Hub board (EXO-430): reference, bias, clock, data, battery
One board at the back of the helmet: it feeds the 16 main boards' controllers (contact boards) over the 12-pin FPC,
powers, syncs and controls the 5 cameras over the same FPC, and links the helmet to a host. Source: exo/pcb/hub.py
(circuit as code → out/pcb/hub_circuit.json), exo/pcb/hub_check.py (checks → out/pcb/hub_report.json, priced
BOM → out/pcb/hub/hub-bom-cost.csv), exo/pcb/hub_footprints.py (land patterns), exo/pcb/hub_place.py
(placement, the shell's geometry), exo/pcb/hub_route.py (Freerouting, pours, DRC), pcb/hub.mjs (Gerbers, drill,
pick-and-place), exo/pcb/hub_fab.py (JLC BOM / CPL, zip, render).
Status: laid out and routed, all 412 nets complete, DRC clean (connectivity through the planes and fills, 0.09 mm
clearance, edge, holes, antenna keep-out); Gerbers, drill, BOM and CPL are in out/pcb/hub/. Nothing
has been built. Every number is a datasheet value or a calculation, and says which.
uv run python -m exo.pcb.hub && uv run python -m exo.pcb.hub_check # circuit, checks
uv run python -m exo.pcb.hub_footprints && uv run python -m exo.pcb.hub_place # land patterns, placement, hub_geom.json
uv run python -m exo.pcb.hub_route --dsn # out/pcb/hub/hub.dsn
# route it (hours, one core): java -jar freerouting-2.4.1.jar -de hub.dsn -do hub.ses -mp 60 --gui.enabled=false
uv run python -m exo.pcb.hub_route --import out/pcb/hub/hub.ses # pours, DRC -> circuit.json, route.json
node --stack-size=60000 pcb/hub.mjs && uv run python -m exo.pcb.hub_fab # Gerbers, drill, BOM, CPL, zip, PNG
Block diagram
USB-C (sink, USB 2.0) ──USBLC6── D+/D- ───────────────────────────────┐
│ VBUS ── 2N7002 interlock (RJP1) ─► holds +3V6 off while plugged in │
▼ ▼
BQ25895 charger / NVDC power path ◄── 1S Li-ion 2000 mAh FT2232H (USB 2.0 HS, 480 Mb/s)
│ VSYS 3.5–4.4 V (JST PH: BAT+, NTC, BAT-; ch A async FIFO ch B async FIFO
│ DW01A + 8205A protection, ▲ 8 bit ▲ 8 bit
│ MAX17048 gauge) │ │
├─ TPS63020 ─► +3V3 hub logic RP2350B "A" (ports 1-8) RP2350B "B" (ports 9-16)
│ 8 PIO UART RX, 1 PIO 8-wide UART TX, FIFO, SPI, 8 EN
└─ TPS63020 (EN = TILE_EN · /VBUS) ─► +3V6 ──┬─ 16 × TPS2553-1 (150 mA, latch-off) ─► VIN, ports 1-16
└─ TPS7A20 ─► +3V3A (REF, DRL) │SPI 24 MHz │SPI
▼ ▼
ESP32-S3-MINI-1: Wi-Fi link + system controller
(I2C: charger, gauge; ADC: CC1/CC2, REF/DRL, +3V6;
SWD + RUN to both RP2350s; TILE_EN)
2.048 MHz XO ─star─┬─ LMK1C1108 ─33R×8─► CLK ports 1-8
(OE = tile PG) ├─ LMK1C1108 ─33R×8─► CLK ports 9-16
└─ 74LVC1G17 ─► CLK_MON ─► RP2350 A PIO: launches SYNC on a falling CLK edge
SYNC (RP2350 A) ──► 2 × 74LVC244 ─33R×16─► SYNC ports 1-16 (B listens, for timestamps)
REF contact ─100k─100k─► TLV333 follower ─► REF_BUF ─10k×16─► REF ports 1-16
└─390k─► DRL integrator (1 nF) ◄─ set point = VMID 1.65 V
+ 3.3 mVpp 125 Hz tone (RP2350 A PWM, 1M/1k)
│
BIAS contact ◄─100k─100k──────────────────────────────┘
per port (FPC, pin n → tile pin n): 1-2 VIN, 3 GND, 4 REF, 5 GND, 6 CLK, 7 GND, 8 SYNC,
9 RXD (tile TX), 10 TXD (tile RX), 11-12 GND
VSYS ──► 5 × TPS2553-1 (37.4k: 630 mA min) ──► camera ports C1-C5 SYNC ──► 74LVC244 #3 ──1k×5──► camera SYNC
camera UARTs: C1 RP2350 A UART0 (GPIO30/31), C5 RP2350 B UART0 (GPIO30/31), C2-C4 ESP32 UART1/UART2/UART0
TCA9535 (I2C 0x20, ESP32): camera power EN ×5, CAM_EN ×5, FAULT# ×5, ESP32 LED
per camera port (FPC): 1-3 VSYS (switched), 4-6 GND, 7 SYNC, 8 GND, 9 CAM_TX (camera → hub), 10 CAM_RX (hub → camera),
11 CAM_EN (high = on), 12 GND
Part choices
Data path: 2 × RP2350B and an FT2232H. The hub must receive 16 UARTs at 3 Mbaud, send to 16 more, and move 1.94 MB/s to a host.
| option | 16 RX + 16 TX at 3 Mbaud | host link at 15.5 Mb/s | verdict |
|---|---|---|---|
| 1 × RP2350B | 12 PIO SMs + 2 UARTs = 14 receivers, and nothing left over for TX | USB FS only (12 Mb/s nominal, ~8–9 in practice) | no |
| 2 × RP2040 | 8 SMs each: 6 RX + 2 UART RX, 1 TX, 1 FIFO. Possible, but each chip has only 30 GPIO for 16 UART + 12 FIFO + SPI | USB FS each | no (pins) |
| iCE40UP5K | 39 I/O in SG48, but 32 UART + 15 FIFO lines are needed | needs a bridge | no (pins) |
| iCE40HX4K-TQ144 + FT232H + MCU | yes | 40 MB/s sync FIFO | works, but three chips, a 1.2 V rail, config flash and a second toolchain |
| 2 × RP2350B (48 GPIO) + FT2232H | per chip: 8 PIO RX (PIO0/PIO1), one PIO SM drives 8 TX lines in parallel (out pins, 8), and spare SMs for the FIFO and SYNC |
FT2232H HS, one async FIFO channel per RP2350, 8 MB/s each | chosen: $2.28 of MCUs, same family and PIO as the tiles' RP2040 |
RP2350B runs at 144 MHz (12 MHz × 120 / 5 / 2), so the PIO UART runs at 8 clocks per bit with an integer divider of
6. The GPIO plan (GPIO in hub.py) uses all 48 pins: RX 0–7, TX 8–15, FIFO 16–27, CLK_MON 28, SYNC 29,
SPI 32–36, FAULT 37, per-port EN 38–45, 125 Hz tone 46, LED 47. RP2350B PIO sees a 32-GPIO window, and the plan
respects it. JLC's stock is stepping A4, where the E9 input-latch erratum is fixed.
Host link. USB-C carries USB 2.0 high speed through an FT2232H ($7.80, the most expensive part). The ESP32-S3 handles Wi-Fi. Measured throughput, from Espressif's iperf figures for the ESP32-S3: 20 Mb/s TCP and 30 Mb/s UDP over the air in a lab, 83 Mb/s TCP in a shield box. The ESP32 is also the system controller (charger, gauge, interlock, SWD to both RP2350s for updates over the air), so it is always fitted. The ESP32-C5 (5 GHz) would be better in a congested 2.4 GHz band, but all ~20 LCSC listings are at 0 stock.
Clock. One 2.048 MHz CMOS oscillator (YXC OT32252.048MJBA4SL, ±20 ppm) feeds a star of three equal 22 Ω branches: two TI LMK1C1108 1:8 buffers and a 74LVC1G17 monitor tap. The LMK1C1108 beats the CDCLVC1108 on part-to-part skew (280 ps vs 0.5 ns) and on price ($2.11 vs $2.32). Each output goes through a 33 Ω source terminator, one per port and equal on every port. The oscillator's OE is tied to the tile rail's power-good, so no clock reaches an unpowered tile.
SYNC. RP2350 A's PIO waits for a falling edge on CLK_MON and pulls SYNC low for between 1 and 2047 tCLKIN (ADS131M08: ≥ 2048 is a reset). Two 74LVC244s fan it out through 33 Ω. RP2350 B reads SYNC so that both halves number their frames from the same edge.
REF and bias. The reference contact and the bias contact are the helmet's one spring pin (Mill-Max
0919-0-15-20-89-14-11-0, 9.63 mm free, 3.0 mm stroke) reflowed on 2.2 mm pads on the hub's scalp face, at board
(0, ±9) mm (see Board). A JST-SH lead connector accepts mastoid or ear-clip electrodes instead. Each contact sees
2 × 100 kΩ before silicon, the same as on the tiles.
- The REF follower drives every tile through 10 kΩ per port, so one shorted REF wire does not pull the others.
- The DRL is an integrator. It holds the body at VMID = 1.65 V, the middle of the tiles' rail-to-rail followers,
which are DC-coupled to the scalp. Its unity gain is at 408 Hz (390 kΩ, 1 nF).
- A 125 Hz tone (fs/4, above the EEG band and below Nyquist) is summed into the set point at 3.3 mVpp. Each channel's
125 Hz amplitude is about |Z_contact − Z_ref| · ω · C_in, which gives the contact-quality / skin-oil measurement
that docs/archive/pcb.md assigns to the hub.
- The op-amp is the TI TLV333, an SC70 zero-drift part with 14k in stock. The tile's MCP6V31U has 4 in stock, and the
TLV333 uses the same land pattern with a different pin map (OPA_DCK).
Power. - BQ25895: I²C control and NVDC power path, so the system runs while charging. Its hardware input limit is 2.0 A (180 Ω, KILIM 355). Its D+/D− pins are left open so they don't put a stub on the HS pair. Firmware sets IINLIM from CC1/CC2 and clears HVDCP/MaxCharge so that VBUS stays at 5 V. - Two TPS63020 buck-boosts, because the cell spans 3.0–4.2 V on both sides of 3.6 V. Both run in forced PWM, a fixed 2.4 MHz with no burst noise in the EEG band. - 16 × TPS2553DBVR-1: 180 kΩ sets 129/150/174 mA (min/typ/max) against 33–65 mA per controller; the busiest draws half the minimum limit. The switch latches off on a fault, the RP2350 retries once and then reports the port. Each port has its own EN and FAULT# is wired-OR per chip, so one shorted tile or cable can't take the helmet down. The -1 latch-off part ($0.24) is also cheaper than the auto-retry version. - Battery protection: PUOLOP DW01A plus four HXY 8205A in parallel, because the Fortune originals are at 0 stock and the cameras raise the battery peak to 3.79 A. The DW01A trips at 130-170 mV; four pairs at 2 × 37 mΩ / 4 (max, at VGS 2.5 V) trip at ≥ 7.0 A, 1.8 × the peak. One pair would trip at 1.8 A. - The battery must be a 1S pouch rated ≥ 3.8 A continuous (1.9C of 2000 mAh) with its own PCM: the cameras take 2.25 A straight off it. The MaxAmps Lipo-2000-1S is rated 54 A continuous.
Cameras. Five 12-pin FPC ports, the same HCTL connector as the tile ports, pinout shared with the camera board. - Power: VSYS (the battery, 3.0-4.4 V) through one TPS2553DBVR-1 per port. RILIM 37.4 kΩ (Yageo, C185422) sets 630 / 696 / 771 mA (min / nom / max, SLVS841 eq. 1) against a 450 mA camera. Latch-off on a fault; FAULT# goes to the expander. - SYNC: the converters' SYNC through a third 74LVC244 (one output per camera, three spare inputs grounded) and 1 kΩ, so an unpowered camera back-feeds at most 2.7 mA. CAM_RX (hub → camera) and CAM_EN also go through 1 kΩ. - UARTs, control only (video goes over each camera's own Wi-Fi): C1 on RP2350 A and C5 on RP2350 B, each UART0 on GPIO30 (TX) / GPIO31 (RX), function F11, the RP2350's UART-on-CTS/RTS pins (datasheet table 3). C2 on ESP32 UART1 (IO19 / IO20), C3 on UART2 (IO26 / IO3), C4 on UART0 remapped after boot to IO48 / IO46. IO43/44 stay the ROM download port at the test pads; IO45 (the VDD_SPI strap) is left alone. IO46 idles high from the camera's TX, which is harmless with IO0 high (SPI boot). - Power EN, CAM_EN, FAULT# and the ESP32's LED: a TCA9535 16-bit I2C expander at 0x20 on the ESP32's bus (BQ25895 0x6A, MAX17048 0x36). It has no internal pull-ups and powers up as inputs; every EN has a 100 kΩ pull-down, so every camera stays off until the ESP32 turns it on.
Pinouts checked against datasheets (all of them now by pad number, PIN_SOURCES in hub.py): DW01A (1 OD, 2 CS,
3 OC, 4 TD, 5 VCC, 6 GND), HXY 8205A (1/8 D, 2-3 S1, 4 G1, 5 G2, 6-7 S2), 93LC56B OT (1 DO, 2 VSS, 3 DI, 4 CLK,
5 CS, 6 VCC), MAX17048 (1 CTG, 2 CELL, 3 VDD, 4 GND, 5 ALRT, 6 QSTRT, 7 SCL, 8 SDA, against Adafruit's production
board: Analog's download is blocked), the YXC oscillator (pad 1 = tri-state, high or open = run), TPS2553, TPS7A20,
LMK1C1108, BQ25895, TPS63020, RP2350B (QFN-80 pinout figure), FT2232HL, ESP32-S3-MINI-1. No errors were found. Two
changes came out of the check: the flash is the W25Q16JVSNIQ (C2456211, SOIC-8 150 mil); the -SSIQ listed before is
the 208-mil body and does not fit the land. RT1 (BQ25895 TS divider) is 5.1 kΩ instead of 5.23 kΩ (JLC stock 4-12),
which moves each JEITA edge by under 1 °C. The ESP32-S3-MINI-1-N8 has IO33-IO37 free (only the octal-PSRAM variants
use them) and IO26 free (only -N4R2 uses it).
Isolation and faults (head-worn, IEC 60601-1 type BF intent)
- Battery only while worn, and no mains connection while worn. The hub has no mains input. When VBUS is present, a 2N7002 holds the tile rail's buck-boost EN low, so the helmet cannot measure while a cable is plugged in. Charging and USB data are bench and off-head activities. Removing the 0 Ω link RJP1 (fitted by default) defeats the interlock for bench work on a phantom only, never on a person. This is a functional interlock, not a rated isolation barrier.
- While worn, data goes over Wi-Fi. USB data to a person-worn helmet would need a 2 MOPP USB 2.0 HS isolator between the helmet and the PC. That could be external, or an on-board ADuM4165-class part plus an isolated charger, which would not fit 8 mm creepage on this board. This board's answer: Wi-Fi only while worn, enforced by the interlock; USB on a person needs an external 2 MOPP USB 2.0 HS isolator between the helmet and the PC.
- Contacts. Every scalp contact (1,273 EEG, REF, BIAS) reaches silicon only through ≥ 200 kΩ. The hub's highest
rail while worn is VSYS at 4.4 V, which gives a 22 µA worst single fault (check
faults). The DC limit used is 50 µA single fault for type BF, from IEC 60601-1 table 3; verify against the standard. The tile check uses 3.3 V. - Radio near the head. Point the antenna away from the scalp, at the board edge. Cap the ESP32 TX power at about 13 dBm until SAR is assessed.
- Battery. Use an IEC 62133-2 cell with its own PCM, plus the hub's DW01A protection, the BQ25895 BATFET and NTC (JEITA via the TS divider), and ship mode via /QON. Applied-part and enclosure temperature limits (≈ 41–43 °C) need the built helmet.
Power budget
| load | current | power | basis |
|---|---|---|---|
| 16 controllers, 1,273 ch on 179 ADS131M08 (board LDOs: I_in = I_out) | 0.68 A at 3.6 V | 2.46 W | docs/archive/pcb.md per-part figures: 179 × 2.08 mA ADC + 1,273 × 21 µA follower + 16 × (15 mA RP2040 + 2.8 mA PAT9125) = 684 mA; 33–65 mA per controller (busiest: 21 ADCs, 147 ch), not the 36 mA of an 8-ADC tile |
| 16 × TPS2553 quiescent | 1.9 mA | 7 mW | datasheet |
| 2 × RP2350B at 144 MHz | 60 mA at 3.3 V | estimate | |
| FT2232H, USB HS active | 70 mA | datasheet class value (≈ 0 when unplugged, held in reset) | |
| ESP32-S3 streaming over Wi-Fi (average / peak) | 180 / 350 mA | estimate (TX bursts) | |
| flash, clock tree driving 16 FPC lines, LEDs, op-amps, expander, camera SYNC buffer | 31 mA | 60 pF × 3.3 V × 2.048 MHz × 16 = 6.5 mA for the clock lines | |
| hub logic (+3V3) | 0.34 A | 1.13 W | |
| tiles + hub, from the battery (η = 0.90) | 1.08 A at 3.7 V | 3.99 W | (2.46 + 1.13) / 0.90 |
| 5 cameras streaming, straight off VSYS | 2.25 A (5 × 450 mA) | 8.33 W at 3.7 V | camera board's budget |
| total from the battery | 3.33 A avg, 3.79 A peak at 3.0 V | 12.3 W | peak: 3.99 W / 3.0 V + 2.25 A + radio burst 0.21 A |
Check results (hub_check, power):
- The +3V6 rail runs at 34 % of the TPS63020's 2 A boost rating, or 0.77 A with one port shorted at its limit. The
+3V3 rail peaks at 26 %.
- Battery: 2000 mAh, 7.4 Wh, 6.65 Wh usable (0.90): ≈ 0.54 h (32 min) with all five cameras streaming
(6.65 / 12.3 W), ≈ 1.7 h with the cameras off (6.65 / 3.99 W). The cameras are 68 % of the helmet's power;
streaming them only when needed is what buys runtime.
- The 3.79 A peak is 1.9C on 2000 mAh, but only 7 % of the cell's 54 A continuous rating; the protection trip
(≥ 7.0 A, four 8205A) is 1.8 × the peak, the BQ25895 BATFET over-current limit (9 A) 2.4 ×.
- Each camera switch holds ≥ 630 mA (1.4 × 450 mA); one camera shorted latches off at ≤ 771 mA.
- Charge time is about 1.6 h at 1.5 A (2.0 Ah / 1.5 A × 1.2 for the CV taper).
- WARN: all 16 ports at their limit at once (2.4 A) would exceed the rail, but each switch latches off, so it can't
persist.
- Turning the radio off (USB on the bench) saves about 0.6 W.
Data budget
| value | |
|---|---|
| EEG payload | 1,273 ch × 500 SPS × 24 bit = 15.28 Mb/s |
| controller frame per sample | 3 B × ch EEG + 24 B optics (4 × dx, dy, quality, shutter) + 32 B header + 4 B CRC32 + 2–3 B COBS: 215 B (51 ch) to 504 B (147 ch) |
| busiest controller (T04, 147 ch) | 441 + 24 + 32 + 4 + 3 = 504 B → 252 kB/s → 2.52 Mbaud of 3 Mbaud on the line (8N1): 84 % used, margin 1.19× |
| aggregate | 16 frames, 4,822 B per sample → 2.41 MB/s = 19.3 Mb/s |
| per RP2350 → FT2232H async FIFO | 1.36 MB/s (A, ports 1-8; B 1.05 MB/s) vs 8 MB/s: margin 5.9× |
| USB 2.0 HS (≈ 35 MB/s bulk in practice) | margin 14.5× |
| RP2350 → ESP32 SPI at 24 MHz | 3 MB/s vs 1.36 MB/s: margin 2.2× |
| Wi-Fi, raw | 19.3 Mb/s vs 20 Mb/s in-air TCP: margin 1.04×, not enough |
| Wi-Fi, lossless delta + Rice (assumed 0.55: ~220 LSB of noise in 24 bits) | 10.6 Mb/s: margin 1.9× TCP / 2.8× UDP |
USB FS (an RP2350 on its own) would be 12 Mb/s nominal against 19.3 Mb/s needed, which is why the hub uses the FT2232H. The busiest controller's UART is at 84 %; its line has little room left, and a bigger frame needs a faster line. The Wi-Fi compression ratio is an assumption; measure it on real recordings. Raw Wi-Fi does not fit at all. The fallback is decimating to 250 SPS for the radio link only: 9.6 Mb/s raw, margin 2.1× TCP.
Clock skew budget
| term | ns | basis |
|---|---|---|
| LMK1C1108 part-to-part + output-to-output | 0.28 + 0.05 | datasheet max |
| hub trace mismatch, 20 mm budget (route to 5 mm) | 0.13 | 6.5 ps/mm |
| FPC length mismatch, 0.08–0.35 m | 1.57 | 5.8 ns/m |
| tile 74LVC1G17, tpd 1.0–5.0 ns | 4.0 | datasheet, 3.3 V |
| tile-to-tile sample skew | 6.0 | target tCLKIN/10 = 48.8 ns; 3.8 µrad at 100 Hz |
SYNC capture: CLK and SYNC share each cable, so cable length cancels. SYNC changes between 19.6 ns before and 51.0 ns after the falling CLK edge at the converter. That covers PIO launch latency (0–28 ns), the LVC244 vs LMK delay, the tile buffer spread, edge time (33 Ω × 60 pF) and an assumed 10 ns ADC setup. The result is 193 ns from any rising edge, 79 % of the half period. If the ADS131M08 samples SYNC on the falling edge instead, the PIO launches on the rising edge (firmware).
BOM (one hub, JLC/LCSC prices @100, verified 2026-10-27)
| part | qty | LCSC | unit $ | ext $ |
|---|---|---|---|---|
| FT2232HL-REEL (USB HS) | 1 | C27882 | 7.80 | 7.80 |
| LMK1C1108PWR (1:8 clock buffer) | 2 | C2868241 | 2.11 | 4.22 |
| TPS2553DBVR-1 (port switch) | 16 | C111738 | 0.24 | 3.84 |
| ESP32-S3-MINI-1-N8 | 1 | C2913206 | 3.37 | 3.37 |
| RP2350B | 2 | C42415655 | 1.14 | 2.28 |
| W25Q16JVSSIQ | 2 | C131025 | 0.84 | 1.68 |
| MAX17048G+T10 | 1 | C2682616 | 1.50 | 1.50 |
| HC-FPC-05-10-12RLTAG (ports) | 16 | C5213732 | 0.089 | 1.42 |
| BQ25895RTWR | 1 | C80200 | 1.38 | 1.38 |
| TPS63020DSJR | 2 | C15483 | 0.59 | 1.18 |
| TLV333IDCKR | 3 | C181502 | 0.24 | 0.72 |
| SN74LVC244APWR | 2 | C7668 | 0.20 | 0.40 |
| OT32252.048MJBA4SL (2.048 MHz) | 1 | C2831386 | 0.32 | 0.32 |
| 93LC56BT-I/OT | 1 | C190271 | 0.28 | 0.28 |
| JST S3B-PH-SM4-TB / SM04B-SRSS-TB | 1 + 1 | C265101 / C160404 | 0.21 / 0.17 | 0.38 |
| X322512MSB4SI 12 MHz (basic) | 3 | C9002 | 0.067 | 0.20 |
| WPN4020H1R5MT / FXL0530-2R2-M / MPL2016S3R3MHT | 2 / 1 / 2 | C98360 / C177247 / C98336 | 0.44 | |
| TPS7A2033PDBVR, TYPE-C-31-M-12, USBLC6-2SC6, SN74LVC1G17DCKR | 1 each | C2862740, C165948, C7519, C10425 | 0.45 | |
| DW01A, 8205A, 2N7002 (basic), TS-1187A (basic), KT-0603G × 4, BLM15PD121SN1D × 2, SMT nut × 2 | C351410, C5148694, C8545, C318884, C12624, C76891, C2928168 | 0.43 | ||
| passives (generic 0402/0603/0805: 172 R, 99 C) | 271 | JLC basic | ≈ 1.1 | |
| parts per board | 360 placements, 68 lines | ≈ $33.3 |
Plus, per order: about 29 extended part types at JLC's ~$3 loading fee each (≈ $87 per order, not per board), and a
4-layer 72 × 56 mm board. 4 layers are enough: signal / GND / power / signal, with a 90 Ω differential pair for USB.
Only the 12 MHz crystal, the 2N7002 and the switch are basic parts. Full list: out/pcb/hub/hub-bom-jlc.csv.
Board
80 × 56 mm, 4 layers, 1.6 mm, ENIG, at the back of the head on the midline above the inion, in the shell's pod. The size grew from the proposed 72 × 56 because the edges must carry 21 FPC ports (9 mm pitch, 8.2 mm wide each), the USB-C receptacle and the ESP32 antenna: that is ≥ 75 mm of top and bottom edge once the corner mounting holes are kept clear. 1.6 mm is JLC's standard impedance-controlled 4-layer stack (JLC04161H-7628), which the USB high-speed pair is sized for.
crown
┌─[P01]─[P02]─[P03]─[C1]─[P09]─[P10]─[P11]─┐ top edge: 7 FPC at 9 mm (x = -27 ... 27)
[P04]│ flash A clock tree flash B│[P12]
[P05]│ RP2350 A LMK×2, osc, LVC244×2 RP2350 B│[P13] sides: 5 FPC each (y = 18 ... -18)
[P06]│ +3V6 buck-boost FT2232H +3V3 b-b │[P14]
[P07]│ charger LVC244 #3 TCA9535 │[P15]
[P08]│ battery JST, 4×8205A ESP32-S3-MINI-1 │[P16]
└─[C2]─[C3]──[USB-C]──[antenna, no copper]─[C4]─[C5]─┘ bottom edge
nape
outer face (F, toward the shell): every IC, connector and the power stage
scalp face (B): REF (0, +9) and BIAS (0, -9) spring pins, the analog front end between them, the 21 port switches
and series resistors behind their connectors, decoupling under the ICs
Board frame: seen from the outer face, x to the wearer's right, y up the head, origin at the board centre, mm.
For the shell (out/pcb/hub/hub_geom.json, written by hub_place.py, also in the shell's hub frame,
hub_xy = [board y, board x]): the outline, the four M2 holes at (±37, ±25) with Ø4.6 kept clear on both faces,
every port's position and cable-exit direction, the USB-C mouth, the battery JST, the antenna keep-out, and the
two contacts:
| contact | board (x, y) mm | hub frame (x, y) mm | face | pad |
|---|---|---|---|---|
| REF (J30) | (0.0, +9.0) | (+9.0, 0.0) | scalp (B) | Ø2.2 round, courtyard Ø2.6 |
| BIAS (J31) | (0.0, -9.0) | (-9.0, 0.0) | scalp (B) | Ø2.2 round, courtyard Ø2.6 |
The pin is the Mill-Max 0919-0-15-20-89-14-11-0 (9.63 mm free, 3.0 mm stroke to a hard stop at 6.63 mm), so the shell must hold the hub's scalp face 6.63-9.23 mm from the scalp, measured along the board normal at both pads, aiming at 8.0 mm (Mill-Max's recommended travel, 0.76-2.54 mm, is 7.09-8.87 mm of gap). The previous shell pose (~9.5 mm, set for the 13.0-15.6 mm ball-pins) is 0.27 mm past this pin's range: the hub moves ~1.5 mm toward the head. The tallest parts are the JST PH (4.8 mm) on the outer face and SOT-23-6 (1.45 mm) on the scalp face.
Land patterns (exo/pcb/hub_footprints.py → hub_footprints.json): drawn from the makers' drawings for the
RP2350B QFN-80 (RP2350 datasheet fig. 150: 0.22 × 0.78 pads, rows ±4.897, 3.4 mm exposed pad), the
ESP32-S3-MINI-1 (fig. 11-1, measured off the drawing: 60 × 0.4 × 0.8 pads at 0.8 on a 13.2 mm square, 4 corner
pads, 3 × 3 GND centre), the TPS63020 DSJ (TI land pattern 4210895), the MAX17048 TDFN and the MPL2016; the rest
are KiCad's IPC footprints, parsed and frozen in the module (LQFP-64, TI RTW WQFN-24, SOT/TSSOP/SOIC, USB-C, JST,
crystals, inductors).
Stack-up and rules. Top: signal and the outer-face parts. Inner 1: solid GND plane. Inner 2: the VSYS rail as a wide strip (drawn before routing: the cameras' 2.25 A and both buck-boosts), signals, and a GND fill in what is left. Bottom (scalp face): signal, GND fill. The battery leads, the charger and buck-boost switch nodes, PMID and VBUS are wide copper areas on top, drawn the same way. Trace / space 0.10 / 0.10 mm, fills 0.20 mm from other nets, vias 0.40 / 0.20 mm, power nets 0.25 mm (+3V3, +3V6, +3V3A, cores) and 0.40 mm (camera VSYS, BAT, VBUS), USB pair 0.20 / 0.15 mm; copper ≥ 0.30 mm from the edge and ≥ 0.25 mm from NPTH. All inside JLC's standard 4-layer capability (3.5 mil, 0.15 mm via drill).
Routing. Freerouting 2.4.1 (exo/pcb/hub_route.py), on the peer host, then exo/pcb/repair.py to finish. GND
is not given to the router: before routing every GND pad gets its own via ("dogbone", 414 of them, fixed copper), and
inner1 is the solid GND plane. Freerouting's best run (30 passes) completes 389 of 412 nets (94.4 %); the repair
closes the other 23 on a 0.05 mm grid (A*, 45-degree moves, through vias), ripping up and rerouting Freerouting's
traces where that is the only way through but keeping every via (the fills hang on them) and never tearing up a power
net whole (REPAIR_KEEP_VIAS=1 N_INNER=2 PCB_OUT=... uv run python -m exo.pcb.repair hub). After it the pours are
recomputed (inner1 GND, inner2 VSYS, bottom GND) and cut back from the ESP32 antenna keep-out on every layer.
Result: 10,274 trace segments, 1,658 vias, every net one piece of copper.
DRC (exo.pcb.route.finish, independent of either router; out/pcb/hub/route.json): every net one connected
piece of copper (pads, traces, vias, planes and fills together), no two nets closer than 0.09 mm on any layer, nothing
inside the edge or hole clearance, no copper in the antenna keep-out. Result: no open nets, no shorts, no clearance,
edge, hole or keep-out errors.
Order files (out/pcb/hub/):
- EXO-430-gerbers.zip: F/B copper, In1/In2 copper, F/B mask, paste, silkscreen, Edge_Cuts, plated
(drill-L1-L4.drl, with the USB-C shell slots) and non-plated drill. JLC: 4 layers, 1.6 mm, JLC04161H-7628,
ENIG, 80 × 56 mm.
- hub-bom-jlc.csv (Comment, Designator, Footprint, LCSC Part #, MPN) and hub-cpl-jlc.csv (Designator, Mid X,
Mid Y, Layer, Rotation) for JLC assembly, both faces. The two spring pins have no LCSC stock: order them through
JLC global sourcing (DigiKey ED10766-ND) or consign them; JLC places them by hand (out/sourcing/pogo.json).
Check part rotations in JLC's placement preview before paying, as for any first order.
- hub_routed.png (also docs/images/hub_routed.png): every copper layer.

Bench measurements (need the built board)
- DRL loop on a head: stability with real electrode and cable capacitance (390 kΩ / 1 nF), mains rejection, and the 125 Hz contact-quality tone amplitude against the tiles' input capacitance.
- Wi-Fi throughput inside the helmet next to a head, with the antenna in the pod.
- Lossless compression ratio on real EEG (the Wi-Fi budget assumes 0.55).
- RP2350 PIO RX error rate at 3 Mbaud over 35 cm of FPC.
- Clock edges and ringing on the FPC (33 Ω may need to grow) and EMC from 16 × 2.048 MHz lines.
- MPL2016 VREG inductor (multilayer, in place of RPi's shielded AOTA-B201610S3R3, 0 stock): RP2350 core-supply ripple and EMI.
- Camera inrush: each port's TPS2553 holds ≥ 630 mA; check that the camera board's input capacitance starts without tripping the latch (the switch's soft start is ~1 ms).
- Pod temperature with five cameras streaming (12.3 W from the battery) against the 41-43 °C applied-part limit.
Not needing a measurement but noted for whoever changes the cables: the tile ports and camera ports use the same
FPC connector; the cable must map pin n → pin n (contact type chosen so the two connectors' orientations match).
Per-port ESD is not fitted because the FPCs stay inside the shell; add TPD4E05-class parts if the cables become
user-accessible. For the tile owner (not changed here): circuit.py gives the SN74LVC1G17DCKR as LCSC C7836, which is
the SOT-23-5 (DBV) part; the SC70 part is C10425.