Recovery Playbook
The chip is in production firmware mode: USB-OTG is owned by TinyUSB (so it can be a USB-NCM ethernet device), which means the ROM USB-JTAG isn't visible to the host. You cannot esptool-flash this chip without holding BOOT during reset. That defeats the no-buttons contract.
So every operation has to go over the network. This playbook is the set of patterns that has worked reliably across hundreds of OTA cycles this session.
Pre-flight check (do this first, every session)
# 1. Is the chip reachable on USB-NCM?
curl -m 3 -s http://10.42.0.80/state.json | jq '.t0, .ml_state, .heap_free'
# Expected: a number, 0-4, a number. If you get nothing:
# - is the USB cable plugged in?
# - `ip addr show | grep 10.42.0.1` — does the host have the NCM
# interface up? If not, NetworkManager is probably handling it;
# `nmcli c | grep enx` should show a connection.
# - if no NCM interface at all, the chip's TinyUSB stack didn't
# enumerate. Power-cycle the chip (yank+reinsert USB) once.
# 2. Is the admin UI reachable?
curl -m 3 -s -o /dev/null -w "%{http_code}\n" \
-u admin:microlink http://10.42.0.80/admin/
# Expected: 200. If 401: password mismatch (default is `microlink`).
# If 000: chip is wedged; see "Recover from wedge" below.
OTA deploy pattern (the canonical one)
As of v15.20+ the lwIP core-locking fix makes OTA-while-Tailscale- active safe. No need to pause first — just push:
curl -m 120 -u admin:microlink \
-H "Content-Type: application/octet-stream" \
--data-binary "@build/microlink_dual_core_safety.bin" \
http://10.42.0.80/admin/api/ota
Expected response:
{"ok":true,"message":"Firmware updated. Rebooting..."}
Bench-verified: 16 s for ~1.1 MB binary under sustained 5 Hz Tailscale
ping + 10 Hz pstop load. Chip back ml_state=4 in ~8 s post-reboot.
If you'd rather pause Tailscale anyway (e.g. to avoid the 30 s heartbeat blip during the reboot), the historical two-step is still valid:
# Optional: pause TS first so OTA doesn't compete with WG traffic
curl -m 5 -X POST http://10.42.0.80/api/derp
curl -m 5 -X POST http://10.42.0.80/api/wg
# ...upload...
# Resume not required — chip reboots into clean TS state after OTA.
If the OTA POST hangs past 90 s or returns HTTP 000, the chip is
either (a) crashing during the OTA write (look for a panic on
/dev/ttyUSB1 UART debug) or (b) unreachable. Wait 3 min for the
safety chain to fire, then retry. Do not assume a partial OTA
landed — the bootloader either accepts the full image atomically
or rejects it; there is no half-updated state.
Recover from wedge
If the chip is unreachable for >30 s:
# Wait for liveness watchdog to fire (max 180 s post-boot)
# You can monitor by polling and seeing when /state.json comes back:
while ! curl -m 2 -s http://10.42.0.80/state.json > /dev/null; do
echo "$(date +%H:%M:%S) unreachable"
sleep 5
done
echo "back up"
# After it's back, check boot_count to confirm the chain fired:
curl -s http://10.42.0.80/state.json | jq '.boot_count, .reset_reason'
# `boot_count` of 1 = first crash, normal recovery
# `boot_count` of 3+ = approaching rollback threshold, ship a fix soon
# `reset_reason` should be "PANIC" or "TASK_WDT" after a wedge
If boot_count ever hits 4: the next reboot will roll back to the
previous OTA slot. That's by design — the chain is doing its job.
Force a rollback (i.e. recover from a botched OTA you just pushed)
If you OTA'd a build that wedges in pre-Tailscale init and you don't want to wait for 3 crash cycles:
# Option A: just wait. The chain triggers rollback after 3 reboots
# (~6 min worst case).
# Option B: force-trigger if the chip is still responsive between crashes
curl -u admin:microlink -X POST http://10.42.0.80/admin/api/rollback
Option B calls esp_ota_mark_app_invalid_rollback_and_reboot() immediately.
Useful if Layer 2 (liveness) is firing slowly and you want to skip ahead.
Wholly bricked? (chain failed somehow)
This shouldn't happen, but if both OTA slots are bad:
- Hold BOOT, tap RESET — chip enters ROM USB-JTAG.
idf.py -p /dev/ttyACM0 flash(oresptool.pydirectly).- After flash, chip auto-reboots into normal mode.
You can do this without disconnecting the chip from the host's NetworkManager session — the JTAG and USB-NCM are different USB endpoints on the S3.
We have NOT had to do this once in this entire session. The safety chain has caught every failure mode.
Useful one-liners
# Watch state.json live (5 s refresh)
watch -n 5 -d "curl -m 3 -s http://10.42.0.80/state.json | jq"
# Monitor pstop liveness
while true; do
curl -m 2 -s http://10.42.0.80/state.json \
| jq -c '{t:.t0, ml:.ml_state, sent:.pstop_sent, rep:.pstop_replies, fail:.pstop_send_fail}'
sleep 1
done
# Pause Tailscale (e.g. before a long debug session)
curl -m 5 -X POST http://10.42.0.80/api/derp
curl -m 5 -X POST http://10.42.0.80/api/wg
# Resume Tailscale (same endpoints toggle)
curl -m 5 -X POST http://10.42.0.80/api/derp
curl -m 5 -X POST http://10.42.0.80/api/wg
# Repoint pstop client at a new responder
curl -u admin:microlink -X POST \
"http://10.42.0.80/api/pstop_peer?ip=10.42.0.1&port=8890"
# Toggle USB tether on/off (NVS-persisted)
curl -u admin:microlink -X POST http://10.42.0.80/api/usb_enable
Auto-OTA-and-monitor pattern (for iterative debugging)
When iterating on a fix, this loop builds, OTAs, and watches liveness in one go. v15.20+ no longer needs the Tailscale pause — included below as a comment for the historical case (large tailnets where DISCO chatter still saturates lwIP, only if you actually hit it).
#!/bin/bash
set -e
cd examples/dual_core_safety
source ~/esp-idf-5.5/export.sh
idf.py build
# Optional (pre-v15.20 holdout): pause TS to free lwIP for OTA upload.
# curl -m 5 -X POST http://10.42.0.80/api/derp || true
# curl -m 5 -X POST http://10.42.0.80/api/wg || true
# sleep 1
curl -m 300 -u admin:microlink \
-H "Content-Type: application/octet-stream" \
--data-binary "@build/microlink_dual_core_safety.bin" \
http://10.42.0.80/admin/api/ota
# Wait for reboot
sleep 12
# Watch for 5 min, log to file
for i in $(seq 1 60); do
line=$(curl -m 2 -s http://10.42.0.80/state.json \
| jq -c '{t:.t0, ml:.ml_state, bc:.boot_count, rr:.reset_reason, sent:.pstop_sent, rep:.pstop_replies}' \
|| echo "UNREACHABLE")
echo "$(date +%H:%M:%S) $line"
sleep 5
done | tee "/tmp/ota_attempt_$(date +%s).log"
If after 5 min boot_count is still 0 and pstop_sent is growing
monotonically, the wedge is gone. Promote that attempt log file with
a meaningful name and commit it.
Required tools on the bench host
curl(any modern version)jq- IDF 5.5:
source ~/esp-idf-5.5/export.shbefore anyidf.pycommand - Python 3 with stdlib
socketandstruct(forpstop_responder.py)
No proprietary tools, no Anthropic-specific dependencies. Anyone with a fresh Linux box and the source checked out should be able to follow this playbook end-to-end.