Skip to content

Latest commit

 

History

13 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

uart-ota

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.

A ZTE H199A opened on the bench with an ESP32-S3 wired to its console header, and the router's OpenWrt boot log on the monitor behind

No authentication, anywhere

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=y plus CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT=y makes 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.


Requirements

Host (Debian/Ubuntu):

sudo apt install git socat picocom netcat-openbsd avahi-daemon libnss-mdns

avahi-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 shell

The 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.


Quick start

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 exit

The ESP32-S3 devkit, with the port used for flashing and the console circled

Flashing 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 back

Turn 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.


Safety

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.


Wiring

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

The router and the bridge side by side, four wires between them

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 console header with each wire labelled: 3V3 blue, TX yellow, RX red, GND brown

The router's TX becomes the ESP's RX, so the two console wires cross:

The same four wires on the ESP32-S3: GND brown, GPIO18 RX yellow, GPIO17 TX red, 3V3 blue

Measure the console voltage first

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.

Why 1.8 V is worse than it looks

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.

Pull-up on the ESP TX line

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.

Pins

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.

Other boards

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.


Power

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 hazard: disturbing the thing you are debugging

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.

  1. 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.)
  2. Radio turned down. CONFIG_ESP_PHY_MAX_WIFI_TX_POWER=13 ships as the default, cutting the peak by 30–40 %. txpower changes it at runtime.
  3. 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.

Never both power sources at once

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.

Brownout is not your margin

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.

Cost one: the head of the boot log

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.

Cost two: the live ring dies with the board

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.


The flash boot log

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.


Attaching

./attach.sh              # console 0 on uart-ota.local
./attach.sh 0 192.168.1.57

attach.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 0

One 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.


Updating the bridge

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.

Layout

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.

Doing it

idf.py build
./push-fw.sh                          # or ./push-fw.sh bench-b.local
printf 'restart\n' | nc -q 1 uart-ota.local 2400

push-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.

The rollback contract

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.

When it goes wrong

  • ota reports 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 revert selects factory, then restart.
  • The bridge is unreachable and rollback did not save it. USB, and the cable. esptool -p /dev/ttyACM0 erase_region 0x590000 0x2000 blanks otadata, which makes the bootloader fall back to factory on the next boot without touching your credentials or boot logs.

When it goes wrong

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.


Console commands

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. quit to 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

Escapes must be doubled

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.

Commands with side effects

  • loopback is 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 — while status shows a perfectly healthy port.
  • selftest still 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.
  • probe blinds 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.

Catching U-Boot

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 indicator

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.


Configuration

idf.py menuconfiguart-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.


Design notes

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.


Layout

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

Known limits

  • 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; status counts 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 factory partition, updates are over USB.
  • Current-draw figures are datasheet arithmetic, not measured.

Contributing

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.

Licence

MIT — see LICENSE.

About

Router serial consoles over Wi-Fi on an ESP32-S3 — TCP bridge with PSRAM scrollback replayed on connect, and every boot mirrored to flash so a power cycle never costs you the log.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages