Skip to main content

First Power-On Bring-Up Protocol

When a fresh board arrives, the question is never "does it work" but "which net is wrong". Answering that normally takes a multimeter and an oscilloscope. This project does not have either — the only instruments available are a camera and a USB cable.

So the board measures itself. software/bringup_test/ is an ESP-IDF firmware whose entire output is a serial report: 58 checks, one parseable line each, naming the net and the component behind every failure. The serial log is the instrument — and since the next run, the diagnostic LEDs are its cable-free fallback: the heartbeat LED repeats each failed subsystem as a blink code, so a photo or video carries the verdict even with no serial connection. The step-by-step commissioning sequence (USB first, battery last) is first-boot.md.

It is containment layer 5 of the containment roadmap: the layer that covers what happens after the board is already fabricated and every geometric gate was green.


Before you apply power

The bring-up firmware cannot run on a board that is already damaged, and it cannot see a part that is soldered the wrong way round until that part is powered. Do the photographic checks first — they are cheaper and they are what caught the v4.3.1 rotation failure:

  1. Run the /first-article-check skill, phase A, on the JLCPCB 3D preview before paying. Orientation is checked per package family, not per component.
  2. On arrival, run phase B: photograph each side and compare against the render.
  3. Only then run the unpowered continuity checks (multimeter, USB-C unplugged, battery disconnected) as far as your tools allow, and plug the USB-C cable in.
  4. On a respin board, set SW16 to ON before expecting anything to run. The switch gates every +5V load through Q2: with SW16 OFF the ESP32 has no rail, USB only charges, and the bring-up firmware never boots — a dark board with the switch OFF is not a fault. (On every board fabricated before the respin the switch is inert and either position works.)

If the board draws no current or the USB device never enumerates, the firmware never gets to speak. That failure mode is covered by incident: power short, not by this page.


Flashing and capturing the report

# Build only
make bringup-build

# Flash and watch the report (set ESP_PORT to your device)
ESP_PORT=/dev/cu.usbmodem1101 make bringup-flash

The firmware is generated from software/main/board_config.h, so it can never disagree with the application about a pin number. Regenerate it after any GPIO change:

make bringup-generate     # rewrite software/bringup_test/main/bringup_test.c
make bringup-check # fail if it is stale (no writes)

To keep a copy of the report, capture the monitor output:

ESP_PORT=/dev/cu.usbmodem1101 make bringup-flash 2>&1 | tee bringup-$(date +%F).log
Do not touch the buttons while the report runs

Two of the checks drive button nets and require every other button net to stay high. A finger on a button during those seconds is reported as a short between nets.


What the report looks like

BRINGUP-BEGIN;board=esp32-emu-turbo;module=N16R8;built=Aug  1 2026 08:55:14
BRINGUP-FORMAT;seq;result;id;pins;detail
BRINGUP;01;PASS;bringup.previous_run;-;cold power-on — no previous-run state is retained across it
BRINGUP;02;PASS;sys.reset_reason;-;reset=POWERON(1)
BRINGUP;03;PASS;chip.model;-;model=9 cores=2 rev=0.2 features=0x00000012
BRINGUP;04;PASS;chip.flash_size;-;flash=16MB expected=16MB (N16R8)
BRINGUP;05;PASS;chip.psram_size;-;psram=8MB expected=8MB mode=octal
BRINGUP;06;PASS;config.gpio_unique;-;31 GPIO assignments, all distinct
BRINGUP;07;PASS;usb.serial_jtag;GPIO19/20;D-=GPIO19 D+=GPIO20 txfifo_writable=yes ...
BRINGUP;08;PASS;gpio.strapping;GPIO0/3/45/46;post-boot levels (NOT the reset-time latch): GPIO0/BTN_SELECT=1 GPIO3/BTN_R=1 GPIO45/BTN_L=1 GPIO46/LCD_WR=1
BRINGUP;09;PASS;temp.baseline;-;die=38.4C at idle
BRINGUP;10;PASS;btn.UP.idle;GPIO40;GPIO40 idle=1
...
BRINGUP;22;PASS;btn.UP.rc;GPIO40;GPIO40 rise=1402us (expect ~1390us for 10k x 100nF, window 500-4000us)
...
BRINGUP;34;PASS;btn.isolation;all 12;12 nets, every one isolated from the other 11
BRINGUP;38;PASS;lcd.panel.init;D0-D7+CS/RST/DC/WR;display_init()=ESP_OK 320x480 8-bit i80 @ 20MHz
BRINGUP;40;SKIP;lcd.panel.readback;-;LCD_RD is tied HIGH on the PCB, the panel cannot be read back — confirm the colour bars by eye (R,G,B,white,black,cyan,magenta,yellow)
...
BRINGUP-SKIPPED;lcd.panel.readback,lcd.backlight,audio.audible,power.rail3v3.absolute,power.battery_sense,power.ip5306_i2c
BRINGUP-SUMMARY;total=58;pass=52;fail=0;skip=6;verdict=GREEN
BRINGUP-LED;steady 1 Hz — all subsystems green
BRINGUP-END

Every line is BRINGUP;<seq>;<PASS|FAIL|SKIP>;<id>;<pins>;<detail>, six ;-separated fields. A field never contains a ; — the firmware rewrites it to a comma before printing. A FAIL line appends implicates: <net or component> to its detail, so the log alone is enough to start reworking.

The verdict is RED if anything failed. Skips do not turn it red, because every skip in this firmware is a permanent property of the board rather than a test that was not written — see below.

If the log stops in the middle

A rail that collapses under load does not print a FAIL line. It reboots the chip, and the only symptom is a truncated log followed by a fresh BRINGUP-BEGIN. The firmware handles this: the check it was executing is written to RTC memory that survives the reset, and the next run reports it:

BRINGUP;01;FAIL;bringup.previous_run;-;previous run died during check 'power.load' | implicates: ...
BRINGUP;02;FAIL;sys.reset_reason;-;reset=BROWNOUT(9) | implicates: U3 SY8089A output, its feedback divider R25/R26, or the input path

That pair is the signature of a 3V3 rail that cannot hold under current.


What each failure implicates

Each FAIL line already carries this text; the table is here so you can plan rework before flashing.

CheckA failure points at
bringup.previous_runThe board reset during the named check on the previous run — almost always a rail collapse in that subsystem
sys.reset_reasonBROWNOUT or PWR_GLITCH: U3 SY8089A output, its R25/R26 feedback divider, or the input path
chip.model, chip.flash_sizeWrong module fitted at U1, or a module carrying less flash than N16R8
chip.psram_sizeModule variant mismatch, or VDD_SPI strapped to 1.8 V at reset by a pull-up on GPIO45 that must be DNP
config.gpio_uniqueboard_config.h itself assigns one GPIO twice — a firmware bug, not a board fault
usb.serial_jtagJ1 USB-C, the D+/D− pair, or the 5.1 k CC resistors
btn.<X>.idleThat button's net: a shorted switch, a solder bridge to GND, or a switch stuck closed
btn.<X>.rcThe 10 k pull-up or the 100 nF debounce cap on that net — compare the printed rise time against the other eleven buttons
btn.isolationA solder bridge between two button nets, or D1 (the BAT54C that makes SW13 press START and SELECT together) fitted backwards
lcd.data.stuckAn ILI9488 data pin shorted to +3V3 or GND at J4 or in the FPC fan-out
lcd.data.shortsA solder bridge between two adjacent data lines at J4
lcd.ctrl.toggleA control line shorted at J4, or the pin loaded by the panel
lcd.panel.initJ4 FPC seating, the ILI9488 panel, or panel power
lcd.panel.patternThe i80 DMA path, or the panel losing its configuration mid-transfer
sd.lines.stuckAn SD line shorted to a rail at the TF-01A socket pads
sd.cmd0The card answers but not with idle state: marginal CLK, or CS not reaching the socket
sd.cmd8Signal integrity on the ~150 mm SD run, or a card that cannot take 3.3 V
sd.acmd41Card power: the socket's VDD pin, or the 3V3 rail sagging while the card starts
sd.ocrMISO integrity — the command went out but the reply came back wrong
sd.fat.mountThe same wiring at 20 MHz rather than 400 kHz: trace length, via count, or missing pull-ups
audio.pdm.init, audio.toneThe DMA path only. Nothing after GPIO17 can fail these — see audio.audible
psram.patternThe module's internal Octal PSRAM, or VDD_SPI at the wrong voltage
power.loadU3 SY8089A cannot hold 3V3 under load: L1, the output capacitor, or the feedback divider
power.temp_deltaExcess current somewhere on the board — a partial short

The two checks that measure rather than poll

btn.<X>.rc measures the debounce network without an instrument. It drives the button node to 0 V, releases it to a floating input, and times how long the 10 k pull-up takes to charge the 100 nF cap through the input threshold. With τ = 1 ms and VIH = 0.75·VDD, the crossing should land near τ·ln 4 = 1.39 ms. A rise far below that means the debounce cap is missing or open; far above means the pull-up is missing or the cap is oversized. The measured microseconds are always printed, so the numbers stay useful even where the pass window is not.

BTN_L is the inverted case. board_config.h records that its pull-up is DNP on purpose — an external pull-up on GPIO45 would strap VDD_SPI to 1.8 V and break the Octal PSRAM — so for that one button, never rising is the pass condition and a fast rise is the failure.

power.load is the closest thing to a rail measurement. It runs both cores, PSRAM, the display bus and the audio DMA flat out for three seconds and checks the board is still alive afterwards.


The six permanent skips

None of these is an unwritten test. Each is something this board revision physically cannot report, and the reason is printed on the skip line:

SkipWhy it can never pass or fail
lcd.panel.readbackLCD_RD is tied HIGH on the PCB. With no read line the panel's status and ID registers are unreachable, so no software check can confirm the pixels arrived — confirm the colour bars by eye
lcd.backlightThe backlight is hardwired — LED-A is fed from +5V through R27 (20 Ω) with no GPIO control. A dark panel after a passing lcd.panel.pattern is R27, the LED_BLA net at J4 pad 8, or FPC seating — never the firmware
audio.audibleThere is no feedback path from the PAM8403 back to any GPIO. If audio.tone passed and you heard nothing, the fault is after GPIO17: the coupling cap, the PAM8403 supply or its SD pin, or the speaker
power.rail3v3.absoluteThe ESP32-S3 exposes no ADC channel on VDD3P3. Absolute rail voltage is not a measurable quantity in software; rail health is inferred from the brownout detector and power.load
power.battery_senseboard_config.h defines no battery-sense net. There is no divider from BAT+ into any ADC pin on this revision, so battery voltage is not observable from firmware
power.ip5306_i2cIP5306 I²C is not routed — GPIO33/34 are reserved for the Octal PSRAM. The charger runs on power-on defaults and reports nothing to the MCU; charge state is the on-board LED only

Two more skips are conditional rather than permanent, and say so in their detail field: sd.cmd0 skips when nothing answers on SD_MISO, because no card-detect line is read on this revision and therefore nothing in software can tell an empty socket from a broken MISO net. Re-run with a known-good card inserted; if it still reads 0xFF, the fault is on the board. Every later SD check then skips naming sd.cmd0 as the reason, so a missing card never looks like six independent failures.

The socket itself is not the reason. The TF-01A does have a card-detect contact — its drawing labels the pad row (1)…(8) and then Cd, so pad 9 is the socket's detect contact, not a card contact and not DAT2. What this revision lacks is a route for it: pad 9 is deliberately off-net (the BTN_R track used to cross it and was rerouted east of the pad row in R31-HIGH-2 — a Cd blade is a switch to the grounded shell, so no signal may share it), and no firmware reads it. See the BTN_R regression check below.


Strapping pins

Four ESP32-S3 strapping pins carry board nets:

GPIONetLatched at reset
0BTN_SELECTBoot mode — LOW at reset enters serial download
3BTN_RJTAG source select
45BTN_LVDD_SPI voltage — LOW at reset selects 3.3 V, which the Octal PSRAM requires
46LCD_WRROM message printing

The firmware cannot assert anything about their boot-time state. Those latches are sampled once, on the rising edge of reset, and are not exposed to software afterwards. gpio.strapping therefore only prints the post-boot level and says so on the line; it is informational.

The one that matters is GPIO45, and it is checked indirectly: if VDD_SPI had been strapped to 1.8 V, chip.psram_size and psram.pattern would fail. That is the evidence, not the strapping line.

GPIO46 carrying LCD_WR is benign — a high level at reset only suppresses ROM boot messages — but it is worth knowing when a boot log looks emptier than expected. The reset-time expectation for it is LOW, held by the pad's internal pull-down, and that is enforced on the PCB side by scripts/verify_strapping_pins.py, not here. The firmware derives its strapping table independently from the ESP32-S3 datasheet set, so the two agreeing is a cross-check rather than a copy.

note

software/main/board_config.h documents the GPIO0, GPIO3 and GPIO45 straps in prose but does not mention GPIO46. The pin is handled correctly everywhere it matters; only the header comment is silent about it.

U6 pad 9 (Cd) and BTN_R — reroute regression check

One extra reading belongs to the GPIO3 line above, and it costs nothing: read BTN_R once with the socket empty and once with a card inserted.

U6 pad 9 is the TF-01A's own card-detect contact (Cd on the socket drawing) — a blade that mates with the grounded shell, i.e. a switch to GND, not a data line. Up to R31 the BTN_R track crossed that pad and the generator declared the overlap same-net, which tied the R shoulder button to that switch: pressed-forever in one card state. R31-HIGH-2 fixed it in copper — the BTN_R riser detours east of the whole pad row and pad 9 carries no net. The two readings verify the board in your hands was built from post-R31 gerbers:

BTN_R, card inserted, button not pressedVerdict
stays HIGHReroute present — BTN_R is independent of card state, as designed
goes LOWThis board was fabbed from pre-R31 gerbers (BTN_R still on pad 9 and the Cd blade grounds it). The R shoulder button is dead whenever a card is inserted; do not ship, refab from the current release

Boot is unaffected either way. GPIO3's strap selects the JTAG signal source, and per table 8 (p.15) of the module datasheet the pin is ignored unless EFUSE_STRAP_JTAG_SEL is burned — which the factory default leaves unselected. Even on a pre-R31 board a card pulling the pad low cannot change how the board boots. It would stop being harmless only if someone burned that eFuse, which is another reason not to.