A Wi-Fi bridge for router serial consoles, on an ESP32-S3.
It holds a bench router's console pins, keeps every byte since power-on in a
PSRAM ring, mirrors each boot into flash, and hands the whole thing to picocom
over TCP.
The part a USB-TTL cable cannot do is the ring. A router's ROM and U-Boot output starts roughly 200 ms after power and the interesting part is over in two seconds; nobody attaches that fast. This listens continuously and replays the backlog the moment you connect.
The name means UART over the air, which is not firmware OTA — but the bridge can now update itself over the network too, because it ends up powered from the router's 3V3 rail inside the router's case. See Updating the bridge.
The console port is an unauthenticated root shell on the bench router. The control port is unauthenticated full control of the bridge: byte injection, Wi-Fi credential overwrite, stored-boot-log readback,
pinscan, which drives GPIO pins as push-pull outputs — a remote physical-damage primitive against whatever is wired to them — and, since OTA was added, firmware replacement. The board advertises itself over mDNS, so it is discoverable rather than obscure.OTA raises the stakes and you should know by how much. Before it, someone who reached port 2400 could abuse the bridge until it was power-cycled. Now they can replace its firmware with their own, permanently, including a version that no longer offers a way to notice. Anything on the network that can open a TCP connection to the bridge can do this; there is no key, no password and no signature check.
The proportionate fix, if this ever leaves an isolated bench, is signed images:
CONFIG_SECURE_SIGNED_APP_NO_SECURE_BOOT=yplusCONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT=ymakes the OTA path verify an ECDSA signature against your key. It burns no eFuses and is fully reversible. It is deliberately not enabled here, because it adds a private key that must be kept and must not be lost, and this build targets an isolated bench.Put this on an isolated VLAN or a dedicated bench AP. A home LAN is not a trust boundary, and this must never be port-forwarded.
Status: verified on hardware — an ESP32-S3-DevKitC-1 N16R8 bridging a ZTE H199A running OpenWrt. Wi-Fi, mDNS, both listeners, capture, the PSRAM ring, the flash boot log and the indicator all confirmed working; an external GPIO17↔GPIO18 loopback round-trips 8 KiB byte-exact at full line rate. The boot-timing cost of router power is measured (below). Current-draw figures are still datasheet arithmetic, not bench measurements.
Host (Debian/Ubuntu):
sudo apt install git socat picocom netcat-openbsd avahi-daemon libnss-mdnsavahi-daemon and libnss-mdns are what make uart-ota.local resolve. Without
them every command below needs a raw IP.
ESP-IDF v5.5.x — developed and tested against 5.5.5:
git clone -b v5.5.5 --recursive https://github.com/espressif/esp-idf.git ~/esp/esp-idf
~/esp/esp-idf/install.sh esp32s3
. ~/esp/esp-idf/export.sh # every new shellThe first build downloads espressif/mdns from the component registry, so it
needs network access.
Hardware: an ESP32-S3 devkit with octal PSRAM and ≥8 MB flash —
developed on the N16R8 DevKitC-1 (16 MB quad flash, 8 MB octal PSRAM, chip
rev v0.2). Confirm yours with esptool flash-id and espefuse summary; sellers
ship mixed batches. See Other boards.
Nothing needs to be wired to a router for this. Bench power over USB.
git clone https://github.com/ADCDS/uart-ota.git && cd uart-ota
. ~/esp/esp-idf/export.sh
idf.py set-target esp32s3 # BEFORE menuconfig — it rewrites sdkconfig
idf.py menuconfig # uart-ota → SSID, password, hostname
idf.py -p /dev/ttyACM0 flash
idf.py -p /dev/ttyACM0 monitor # Ctrl-] to exitFlashing and the console both go to the port marked above.
Press Enter for a prompt, then:
uart-ota> wifi set MySSID hunter2 # skip if you set them in menuconfig
uart-ota> restart
uart-ota> status
uart-ota, up 00:00:12, last reset SW
wifi: MySSID 192.168.1.57 -48 dBm rssi 13.0 dBm tx
psram free: 6094848 B
bootlog: seq 1, 0 B filling, 64 slots
port 0 uart1 tx=17 rx=18 38400 8N1 :2323
captured 0 B, ring holds 0 B of 2.0 MiB, 0 overruns
no client
Prove the firmware path with no wires at all:
uart-ota> selftest 0
sent 30 bytes, captured 30
PASS -- uart, capture task and ring are all fine.
Then the network path, still with no router — internal loopback, and type at yourself over TCP:
uart-ota> loopback 0 on
./attach.sh # anything you type echoes backTurn it off again — loopback 0 off — or the bridge captures nothing from a
real router while status looks perfectly healthy.
Only now wire it to a router, and read the next section first.
Two of these can hurt you; the rest only destroy hardware.
Mains. If the router has an internal power supply — an IEC inlet or a captive mains cord rather than a low-voltage barrel jack — do not open it while plugged in. The primary side and its bulk capacitor sit at ~325 V DC and stay charged after unplugging. Unplug, wait, and verify the capacitor is discharged before touching anything inside.
Hazardous ports. A DSL/VDSL line port carries −48 V DC with up to ~90 V RMS ringing. Coax on a cable modem can sit well away from house earth. PoE carries 48 V. Disconnect these before bonding grounds to anything.
Floating grounds. Most consumer routers ship with a two-prong, non-earthed supply whose output "ground" floats at roughly half mains via the Y-capacitor — commonly 60–120 V AC to earth. Bond that to an earthed PC through USB and that potential appears across your console pins. This is the usual way people destroy a USB-serial adapter and the device's console pins at the same time. Use a laptop on battery, a USB isolator, or power both from the same earthed source.
Wire everything unpowered. Router off, bridge off, ground connected first, then power up. That single habit is the mitigation for floating grounds, capacitor inrush, and back-powering all at once.
Never leave one side powered with the other off. A driven signal into an unpowered pad forward-biases its ESD clamp and back-powers that device's IO rail through the clamp diode — damaging the clamp and producing exactly the intermittent half-boot symptoms you would otherwise blame on the router.
Three wires, and never Vcc for signalling:
router GND ── ESP GND first, always
router TX ── ESP RX
router RX ── ESP TX omit for a listen-only first pass
On the bench router this was developed against, the header is 3V3, TX, RX, GND
and the console is 3.3 V TTL:
The router's TX becomes the ESP's RX, so the two console wires cross:
With the router powered and idle, its TX line rests at the IO domain voltage. Measure it against the router's own ground. (Measure while it is idle — a console mid-print averages well below its true level on a DMM.)
| Reading | What to do |
|---|---|
| Negative, or above 3.6 V | Stop. That is RS-232 (±5 to ±15 V) or 5 V TTL. Connecting it directly destroys the S3 — absolute max on any GPIO is VDD+0.3 V. RS-232 needs a MAX3232-class transceiver. |
| 3.3 V | Wire direct. |
| 1.8 V or 2.5 V | Level shifter, both directions. See below. |
Console voltage varies by SoC generation — 3.3 V on most older MediaTek and Broadcom parts, 1.8 V on newer MediaTek Filogic and many Qualcomm IPQ parts. Do not infer it from the vendor. Measure.
The S3's input thresholds are V_IH(min) = 0.75 × VDD = 2.48 V and V_IL(max) = 0.25 × VDD = 0.83 V. A 1.8 V high lands in the indeterminate band: not guaranteed high, not guaranteed low. In practice it frequently does work at room temperature — which is worse than failing outright, because the fault shows up later as intermittent garbage rather than at bring-up.
For a UART, prefer a fixed-direction buffer — two SN74LVC1T45 with DIR strapped, or a BSS138 discrete shifter — over an auto-direction TXS0108E/TXB0104. A UART's direction is known in advance, and auto-direction parts on a UART are a known source of false direction flips and marginal drive. If you use one anyway: VCCA is the lower rail (router side), VCCB is 3.3 V, and OE must be pulled high, not left floating.
A resistive divider works only ESP → router: 1 kΩ / 1.2 kΩ gives exactly 1.80 V. It is useless in the other direction — it takes a 1.8 V high down to 0.98 V, a guaranteed 0 — so it cannot serve the listen-only first pass.
Between reset and uart_set_pin() the ESP's TX pin floats, and a floating line
into the router's RX can look like a break. A 10 kΩ pull-up holds it at UART
idle through the ESP's own boot — but fit it on the ESP side, to the ESP's
3.3 V. Never pull a 1.8 V router pad up to 3.3 V: that forward-biases its ESD
clamp continuously. Omit it entirely with a TXB0104 (TI specifies no external
pull below 50 kΩ); a TXS0108E already has internal 10 kΩ pull-ups.
GPIO33–37 are consumed by the octal PSRAM, 26–32 by flash, 19/20 by native USB, 43/44 by the onboard USB-UART bridge, 0/3/45/46 are strapping pins, and 48 is the RGB LED. What is left, and what this uses:
| Console | UART | TX (→ router RX) | RX (← router TX) | TCP | Default |
|---|---|---|---|---|---|
| 0 | UART1 | GPIO17 | GPIO18 | 2323 | on |
| 1 | UART2 | GPIO4 | GPIO5 | 2324 | off |
| 2 | UART0 | GPIO6 | GPIO7 | 2325 | off |
Only console 0 is enabled. Raise UART_OTA_PORT_COUNT to 2 or 3 for the others
— they are off by default because an unwired UART's pins float, and the noise
they pick up fills the ring with garbage and costs 2 MiB of PSRAM per port.
UART0 is genuinely free: the ESP console lives on USB-Serial-JTAG, and the GPIO matrix moves UART0 away from the pins wired to the devkit's bridge chip.
This build assumes octal PSRAM. On a board with quad PSRAM or none, the ring
allocation fails and the firmware calls abort() — the symptom is a reboot loop
with no PSRAM for a 2048 KiB ring flashing past.
| Board | Change |
|---|---|
| Quad PSRAM (N16R2, N8R2) | CONFIG_SPIRAM_MODE_QUAD. GPIO33–37 become free. |
| No PSRAM (N8, N16) | Not supported — the ring is PSRAM-only. |
| 8 MB flash | CONFIG_ESPTOOLPY_FLASHSIZE_8MB, and drop ota_1 — with both OTA slots the table ends at 0x8A0000, past the end of an 8 MB chip. One OTA slot plus factory still works; you lose the A/B alternation, not the rollback. |
| 4 MB flash | Shrink or drop bootlog in partitions.csv; you lose boots/replay. |
| Not an S3 | Not supported. |
CONFIG_UART_OTA_LED_GPIO defaults to 48, which is right for DevKitC-1 v1.0.
v1.1 boards and many clones put the RGB LED on GPIO38. Set it to -1 to
disable the indicator.
The bridge can run off the router's own 3.3 V rail. That is a real
simplification — no second supply, no ground loop through the laptop, and
status tells you the router lost power because the bridge reset with it. It
has one hazard and two costs.
The S3 draws roughly 110–140 mA with the radio associated (chip, plus the octal PSRAM and board), peaking near 350 mA on a Wi-Fi transmit burst at 20 dBm — or about 200–240 mA at the 13 dBm this ships with. Pulled through dupont wire out of a router's 3.3 V logic rail, that is how you end up chasing a router bug that is actually your bridge.
- 470–1000 µF low-ESR plus 100 nF at the ESP's 3V3 pin. Size by charge, not RC: a Wi-Fi TX burst is ~100–300 µs, so ΔQ = 250 mA × 200 µs = 50 µC, and 470–1000 µF holds the droop to 50–110 mV. (Millisecond-scale bursts would need ~5000 µF — 1000 µF at 2 ms droops a full volt.)
- Radio turned down.
CONFIG_ESP_PHY_MAX_WIFI_TX_POWER=13ships as the default, cutting the peak by 30–40 %.txpowerchanges it at runtime. - Short, thick power wires — 22 AWG, its own pair. Low series resistance is unambiguously good here: it reduces droop both during and between bursts.
Connect the capacitor with the router unpowered. Hot-plugging a discharged 1000 µF onto a live 3.3 V rail through ~0.13 Ω is a near-short — instantaneous demand around 25 A — which can trip the router's regulator into hiccup and reset it. A 200–500 mA polyfuse in the tap line is cheap insurance against a shorted jumper taking the rail down.
On the DevKitC-1 the 3V3 header pin is the onboard LDO's output net, so
feeding it externally puts two sources on one node. The more destructive
direction is the one that is easy to miss: with USB attached and the router
off, the devkit's LDO back-feeds the router's 3.3 V rail, partially energising
its SoC out of the intended power sequence. Unplug the 3.3 V wire before every
flash.
The same applies to the 5V pin — both USB ports' VBUS and the 5V header
land on the same net, so 5V and USB are equally exclusive. If the router has a
5 V rail ahead of its own buck, feeding 5V is still the better tap (far more
headroom than the 3.3 V logic rail), but note the onboard regulator is linear:
5 V → 3.3 V dissipates ~0.6 W at the 350 mA peak.
A series ORing element would let you leave both connected, but pick the right one: a Schottky drops ~0.27–0.38 V for a rail of 2.9–3.03 V against a 3.0 V minimum — marginal. An ideal-diode controller (LM66100, TPS2116) or a P-FET drops 5–30 mV and removes the question. ORing the power line does nothing about back-powering through the signal pins.
The detector defaults to level 7, a nominal 2.44 V — and IDF's own Kconfig calls these estimates with part-to-part variation. That is far below the S3's 3.0 V minimum recommended supply, so the gap is not headroom. Between 3.0 V and 2.44 V the chip keeps running while the octal PSRAM and the flash operate out of spec, corrupting the ring and risking flash corruption during the bootlog's per-boot erase.
If the rail is marginal, raise the threshold
(CONFIG_ESP_BROWNOUT_DET_LVL_SEL_3 = 2.98 V) rather than treating 2.44 V as
slack. A BROWNOUT reset in status means the rail was already bad for a long
time. And note that a brownout loop erases boot log slots one per reset — 64
resets clears the lot.
Shared power means the bridge boots with the router, and it is slower off the
line. Router ROM and U-Boot start talking around 150–250 ms; the S3's second
stage bootloader, PSRAM init and app start put app_main at roughly 250–400 ms.
You lose the difference.
Most of that gap is closed in sdkconfig.defaults (FLASHMODE_QIO,
BOOTLOADER_LOG_LEVEL_NONE) and by app_main() bringing the UARTs up before
NVS, Wi-Fi and the console REPL.
Measured, against a ZTE H199A: on router power the capture begins at
non secure uboot, losing 212 bytes and 13 lines — the entire SPI-NAND
primary loader stage, Boot SPI NAND through DDR and serial init. Everything
from U-Boot onward survives intact, banner, Starting kernel and the Linux boot
included. With the bridge on its own USB power and already running, the same
boot captures from the very first byte.
So the trade is specific: router power costs you the pre-U-Boot stage and nothing after it. Debugging DDR bring-up or the NAND loader needs separate power; U-Boot interruption, kernel and userspace work do not.
The PSRAM ring is wiped by any bridge reset — a router power cycle, restart,
or simply opening the USB console, since DTR/RTS are wired to GPIO0/EN. That is
what the flash boot log exists to cover.
Each boot claims one slot in a circular set of 64 and fills it once: 64 KiB
per slot, 4 MiB partition. boots lists what survived, replay <seq> prints
one back.
Two limits bound a capture, and the byte limit usually wins:
- 65472 bytes of payload per slot. At 115200 that is only 5.7 seconds of a continuously talking console.
CONFIG_UART_OTA_BOOTLOG_WINDOW_S(default 150 s), which governs only when the console is quiet enough not to hit the byte cap first.
The writer is simply another ring consumer: ring_read() addresses the stream
by absolute offset, so it holds its own cursor exactly the way a TCP client
does. That is what lets the 150–300 ms slot erase happen without losing capture
— but only because CONFIG_UART_ISR_IN_IRAM=y. Without it the UART ISR
lives in flash and is masked for the whole erase, leaving just the 128-byte
hardware FIFO (11 ms at 115200) and dropping 2–3 KiB in the middle of the
router's boot burst. Do not turn that option off.
The slot header is written last, so a boot interrupted mid-write leaves no magic
and reads as an empty slot rather than a truncated log pretending to be whole.
replay verifies the payload CRC and says so if it fails.
Wear is a non-issue, though not for the reason it looks: each boot unconditionally erases its 64 KiB slot regardless of how little it captures, and the erase is what wears NOR flash. 64 slots round-robin means each is erased once per 64 boots — about 6.4 million boots against ~100k cycle endurance.
Console 0 only. Consoles 1 and 2 capture and stream normally, but nothing of theirs survives a power cycle.
./attach.sh # console 0 on uart-ota.local
./attach.sh 0 192.168.1.57attach.sh runs socat to turn the TCP stream into a pty and hands it to
picocom (exit with Ctrl-A Ctrl-X). The pty's baud rate is meaningless —
the one that matters is set on the bridge.
For a quick look, nc uart-ota.local 2323. Telnet is deliberately not spoken:
it would mean IAC escaping, and any 0xFF in a hexdump becomes a bug hunt.
If you changed CONFIG_UART_OTA_BASE_TCP_PORT, tell attach.sh too — the names
differ:
UART_OTA_BASE_PORT=3000 ./attach.sh 0One client at a time per console. A second connection is accepted by TCP and
then waits in the backlog — it is not refused, so attach.sh appears to
succeed and you get a terminal that is silent and echoes nothing. If a console
looks dead, check status for an existing client before suspecting wiring or
baud rate. A peer that dies without closing cleanly is reaped by keepalive
within about a minute.
idf.py flash over USB still works and is still the way to change the partition
table. This is for the other situation: the board is powered from the router's
3V3 rail, inside the router's case, and getting USB onto it means undoing the
bench.
Three app slots. factory is written only over USB and is never an OTA target,
so there is always a copy on the chip that no over-the-air mistake can reach —
it is what the bootloader falls back to whenever otadata is blank.
| partition | offset | size | |
|---|---|---|---|
factory |
0x010000 | 1.5 MB | USB only; the fallback |
bootlog |
0x190000 | 4 MB | unchanged |
otadata |
0x590000 | 8 KB | which slot boots |
ota_0 |
0x5a0000 | 1.5 MB | OTA target |
ota_1 |
0x720000 | 1.5 MB | OTA target |
Everything below 0x590000 kept its old offset, so a board that already had the
single-factory table picks this one up without losing its stored Wi-Fi
credentials or its boot logs. Writing the new table needs one USB flash; after
that, USB is optional. The table ends at 0x8A0000, leaving 7.38 MB of the 16 MB
chip unallocated.
idf.py build
./push-fw.sh # or ./push-fw.sh bench-b.local
printf 'restart\n' | nc -q 1 uart-ota.local 2400push-fw.sh sends ota <bytes> down the control port and then the image on the
same socket. The bridge writes it into whichever of ota_0/ota_1 is not
running, so the image currently serving your console is never the one being
overwritten — losing power mid-write, which is a live risk when the rail belongs
to the router you are debugging, costs a half-written spare slot and nothing
else. esp_ota_end() verifies the image before any of it becomes bootable.
ota on its own shows the slots, their versions and which one boots next.
A freshly received image boots on trial. It is confirmed only when the
bridge gets an IP address — ota_mark_valid() is called from the GOT_IP
handler in net.c, because for a device whose entire job is being reachable,
having an address is the definition of working.
An image that panics, or comes up unable to join Wi-Fi, never reaches that call.
The bootloader restores the previous one on the next reboot, unattended. This
is what makes it safe to do this to a board sealed inside a router, and it is
why CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y is not optional in
sdkconfig.defaults.
Note the failure mode it does not cover: an image that joins Wi-Fi and is broken in some other way confirms itself quite happily. Rollback protects against unreachable, not against wrong.
otareports the image was rejected. Nothing changed; the running slot is still the boot slot. Rebuild and push again.- The new image is bad but reachable.
ota revertselectsfactory, thenrestart. - The bridge is unreachable and rollback did not save it. USB, and the
cable.
esptool -p /dev/ttyACM0 erase_region 0x590000 0x2000blanksotadata, which makes the bootloader fall back tofactoryon the next boot without touching your credentials or boot logs.
idf.py flash says the port is busy. Something else holds it — a running
idf.py monitor, picocom, or a stale attach.sh. fuser -v /dev/ttyACM0
names it.
Monitor is silent. The console lives on the S3's native USB peripheral
(USB-Serial-JTAG), so make sure you are on that port. Otherwise the chip is in
download mode — look for boot:0x2x (DOWNLOAD(USB/UART0)) in the log. Press
RESET, or run esptool -p /dev/ttyACM0 --after hard-reset run. Landing in
download mode after a flash is a known quirk of the native USB port.
Enter does nothing after a reset. A reset re-enumerates the native USB device, so the monitor is holding a dead handle. Quit and reattach.
Nothing at all after the first flash. This build uses
CONFIG_ESPTOOLPY_FLASHMODE_QIO, which requires quad-capable flash. Recover by
holding BOOT, tapping RESET, releasing BOOT, then setting
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y and reflashing.
uart-ota.local does not resolve. Use the IP: ./attach.sh 0 192.168.1.57.
Find it from status, from the up: <ip> as uart-ota.local line in the
monitor, or from your AP's DHCP table — the board registers its hostname there.
The board re-DHCPs on every power cycle, so the IP does change.
Boot loop with no PSRAM for a 2048 KiB ring. Wrong board — see
Other boards.
Capture shows nothing but status looks healthy. loopback is probably
still on.
Two front ends, both live at once, sharing one command registry:
- the devkit's native-USB port (
idf.py monitor) — survives a Wi-Fi outage - the control port (
nc uart-ota.local 2400) — needed once USB is gone, and useful whenever the board is not on your desk.quitto leave.
help |
list everything registered |
status |
ports, ring occupancy, clients, Wi-Fi, bootlog, reset reason |
baud <port> <rate> |
change a console's rate without reflashing |
probe <port> |
try 115200/57600/38400/19200/9600 and pick the one yielding ASCII |
enter <port> |
send a bare CR — what a router console usually wants |
sendhex <port> <hex> |
exact bytes: sendhex 0 0d0a |
send <port> <text> |
inject text — escapes must be doubled, see below |
tail <port> [n] |
last n bytes of scrollback (default 2048) here instead of over TCP |
catch <port> ... |
auto-interrupt U-Boot, see below |
boots |
list boots held in flash |
replay <seq> |
print a stored boot back |
wifi [set <ssid> <pass>] |
show or store credentials |
txpower [dbm] |
2–20; the radio snaps to 11 discrete steps, so status may report less |
ps on|off |
Wi-Fi power save |
led on|off|test |
the indicator |
selftest <port> |
loop TX into RX inside the chip and check the ring grew |
loopback <port> on|off |
leave that internal loopback on, to test over TCP |
pinscan <gpio> |
drive a pin and report which other pin follows it |
ota |
show the firmware slots and which one boots next |
ota <bytes> |
receive that many bytes of firmware — control port only, see Updating the bridge |
ota revert |
select the untouchable factory image for the next boot |
restart |
reboot the bridge, not the router |
esp_console_split_argv() consumes one layer of backslashes before any handler
sees its arguments. So send 0 \r sends nothing, and send 0 \x41 sends the
two characters 4 and 1. The working form is send 0 \\r. This applies to
catch ... match too.
Prefer enter and sendhex, which need no escaping at all.
loopbackis the one to remember to switch off. It feeds RX from TX inside the chip, so the router's real output never reaches the ring — whilestatusshows a perfectly healthy port.selfteststill transmits. The internal loopback bit does not detach the GPIO matrix, so the TX pin keeps driving: the test string is sent to the router at whatever prompt it is sitting at.probeblinds capture for ~6 seconds. Bytes are scored, not stored, so anything the router says during a probe is absent from the scrollback and from the boot log, with no marker. It also injects a CR at each of five rates.pinscan— disconnect the router first. It drives the pin push-pull at 3.3 V, which is exactly what the 1.8 V warning above says not to do to a console, and an RX pin is wired to the router's TX output, so scanning it is output-against-output contention that can damage either side. With a live driver attached it also produces false matches. Every scanned pin is left as a pulled-down input until the next restart. It is a bring-up tool for a bare board and a jumper.
Routers give you a one-second window to interrupt autoboot. Powered from the router, the bridge boots when the router boots, so it can hold that window open.
catch 0 key 3000 spam CR for 3 s, starting now
catch 0 match "stop autoboot" "\\r" wait for a banner, fire once
catch 0 match "autoboot" "tpl\\r" vendor magic strings work the same way
catch 0 off
Note the doubled backslashes — with a single one the catch arms and then fires zero bytes.
Set CONFIG_UART_OTA_CATCH_BOOT_MS to arm spam mode automatically on every
boot; 3000 is a sensible start. A bare CR is usually harmless. A vendor magic
string is not — it drops the device into a bootloader menu whose entries often
include flash erase or environment reset. Know what your string does, and note
that the Kconfig option arms every port on every boot, including ports you
have not wired.
There is no remote power control here on purpose: the bridge is fed by the router, so switching the router's power would cut its own.
The onboard SK6812 (GPIO48) is driven straight off RMT:
| red | no Wi-Fi |
| blue | Wi-Fi up, nobody attached |
| green | a client is attached |
| cyan flash | bytes arrived from the router |
| magenta flash | bytes sent to the router |
Transmit wins the priority race against receive: the router echoes a
keystroke within a millisecond, so if receive won,
the transmit flash would be overwritten before the eye could catch it. led test walks every colour, two seconds each.
Brightness defaults to 12/255 on purpose. These pixels are blinding at full
scale, and on router power a bright white pixel is tens of milliamps drawn from
the rail of the thing you are debugging. led off disables it.
The link colour tracks console 0 only; the activity flashes are global.
idf.py menuconfig → uart-ota. All build-time; baud, txpower, ps and
led are also runtime.
| Option | Default | Notes |
|---|---|---|
WIFI_SSID / WIFI_PASS |
empty | NVS (wifi set) always wins over these |
HOSTNAME |
uart-ota |
mDNS name and DHCP hostname; set per board |
PORT_COUNT |
1 | 1–3. Unwired pins float and fill the ring with noise |
BASE_TCP_PORT |
2323 | Console N listens on base+N; keep clear of the control port |
CTRL_TCP_PORT |
2400 | The bridge's own console over TCP |
RING_KB |
2048 | Per console, in PSRAM |
P0_BAUD |
38400 | Starting rate for console 0. 38400 is the RealTek RLX SDK's stock console rate, which is what both routers this was developed against use; probe finds yours. baud changes it live, but only until the next reboot — the compile-time value is what a board powered from the router's rail comes back on. |
P1/P2_BAUD |
115200 | Starting rate for the other two consoles |
P0/P1/P2_TX / _RX |
17/18, 4/5, 6/7 | Not validated — see the pin table |
LED_GPIO |
48 | -1 disables; v1.1 boards and clones often use 38 |
LED_BRIGHTNESS |
12 | 1–64; activity flashes are 4× this |
BOOTLOG_WINDOW_S |
150 | 5–600, but the 65472-byte cap usually binds first |
CATCH_BOOT_MS |
0 | Arm U-Boot auto-interrupt at every boot |
The pin options accept any integer with no validation. Keep to GPIO 1–2, 4–18, 21, 38–42, 47.
Wi-Fi credentials in NVS win on every boot, including after a reflash —
idf.py flash does not touch NVS. There is no wifi clear: to undo a typo,
wifi set the right values over USB, or idf.py erase-flash, which also wipes
every stored boot log.
The ring is addressed by absolute stream offset, not by index. A reader
holds a uint64_t cursor into the router's console as an infinite stream, and
the ring only decides how far back that stream is still reachable. Replay on
connect and live streaming then become one operation — a new client starts at
the oldest surviving byte, an existing one is already past it. If a slow client
gets lapped, its cursor snaps forward and a [uart-ota: N bytes lost] marker is
spliced into the stream rather than the gap passing silently.
Capture is decoupled from the network. The capture task owns the UART and
runs from the first line of app_main, regardless of Wi-Fi state or whether
anyone is attached. That is what makes the boot log survivable, and it means a
home-AP hiccup punches no hole in the capture — the ring covers it.
TCP_NODELAY is not optional. Without it Nagle adds ~40 ms to every
keystroke echo. The UART side is event-queue driven with the RX timeout set to 2
symbol periods, so a single echoed character does not sit in the FIFO waiting
for 119 friends.
main/uart_ota_main.c |
app_main() — the ordering here is the design |
main/port.c/.h |
One bridged console: UART, capture task, TCP listener, U-Boot catch |
main/ring.c/.h |
The PSRAM scrollback ring, addressed by absolute stream offset |
main/net.c/.h |
Station-mode Wi-Fi, mDNS, credentials in NVS |
main/cmds.c/.h |
Every console command, shared by both front ends |
main/ctrl.c/.h |
The TCP control port — the same commands over the network |
main/bootlog.c/.h |
Mirrors each boot into flash |
main/led.c/.h |
The onboard SK6812 as a bench indicator |
main/ota.c/.h |
Updating the bridge's own firmware; the rollback contract |
partitions.csv |
Custom table; nvs keeps its stock offset so credentials survive, factory is never an OTA target |
sdkconfig.defaults |
Board and timing choices, reasoning inline |
attach.sh |
socat + picocom wrapper |
push-fw.sh |
Pushes a built image at the control port |
- No authentication on either port, and since OTA that includes firmware replacement. See the callout at the top for the signing option.
- No flow control. Fine at 115200 into an 8 KiB driver buffer;
statuscounts overruns. An overrun discards the whole driver buffer, and a[uart-ota: rx overrun]marker is spliced into the stream so the hole is visible. - 8N1 only. Nothing on a router console isn't.
- One client per console, and one on the control port.
- The flash boot log and the indicator's link colour track console 0 only.
- No firmware OTA — a single
factorypartition, updates are over USB. - Current-draw figures are datasheet arithmetic, not measured.
Bug reports and patches welcome. Build clean against ESP-IDF v5.5.x, and say which board and which router you verified on — there is no test suite, and the interesting failures are all hardware-specific.
MIT — see LICENSE.




