How BoardPilot works
For engineers who want to know what happens under the hood, and how to check it. Everything below is how the code works today, with a link to the file that does it. The project is source-available on GitHub.
The architecture in one minute
BoardPilot is an Electron desktop app written in TypeScript. The main process owns everything that touches the outside world: USB serial ports, the chip tools (esptool, avrdude, picotool, STM32CubeProgrammer, nrfjprog, Teensy Loader), files, API keys and the AI calls. The renderer is the React interface and the three.js 3D view. They talk through a small typed API (the preload bridge); the interface never sees a key, a file path it did not pick, or a serial port.
Almost everything the app knows is data, not code: 13 board files, 383 part files, wizard flows and project templates. The same pure functions (pin choice, wiring checks, code checks, the shopping list, the display renderer) run in the app, in the tests and in the browser demo.
A board is a file, written from its datasheet
Each board (boards/<id>.json) lists every header pin: its id and printed label, its GPIO number, its functions (ADC channel, UART, SPI, I2C…), the highest voltage it accepts, and flags that the rules rely on:
flash: wired to the flash memory (ESP32 GPIO 6 to 11), never usable;input_only: cannot drive an output, often no internal pull-up (ESP32 GPIO 34 to 39);strapping/strapping_critical: the level at reset changes how the chip boots (ESP32 GPIO 0, 2, 5, 12, 15);adc1/adc2: ADC channels; on the ESP32, ADC2 stops working while Wi-Fi is on;uart0,usb,swd,reserved: used by the board itself;five_volt_tolerant: accepts 5 V inputs although the chip runs at 3.3 V.
The board also carries its rules: the default I2C pins (and whether they can be moved), the hardware SPI bus pins, an ordered list of safe pins for general use (output-capable, no side effects at boot, not used by USB or the debugger), the ADC pins in order of preference, and the power pins with the voltage each one supplies. Every fact has a datasheet section in boards/<id>.sources.md, and scripts/check-boards.mjs refuses a board file with a missing pin, a duplicate, or a rule that points at a pin that does not exist.
boards/esp32-devkitc-30.json · boards/esp32-devkitc-30.sources.md · scripts/check-boards.mjs
A part is a file too
Each part (parts/<id>.json) lists its pins in the order printed on the module, and a role for each: power, ground, i2c_sda, i2c_scl, spi_mosi, spi_miso, spi_sck, spi_cs, digital_in (the board drives it), digital_out (the part drives it), analog_out, onewire, int and passive (not for the board: a load, a module's own supply). It also gives the supply voltage range, every I2C address and which pin selects it, whether the module has its own pull-ups, the chip-ID register and the value to expect, known traps (each with its source) and, where a datasheet gives them, the currents. Pin notes carry wiring facts such as “through 10 kΩ to GND” or “add a flyback diode across the coil”.
How the pins are chosen
“Assign safe pins”, the templates and the AI proposals all use one function. It walks the parts in order and, for every part pin that has no wire yet, picks a board pin by the pin's role. Pins already wired, by you or by the assistant, are never moved.
| Part pin role | Board pin picked |
|---|---|
power | A supply pin whose voltage fits the part’s range, the board’s logic voltage first (3V3 for a 3.3–5 V sensor on an ESP32; 5V for a 5 V-only part). |
ground | A GND pin; several parts are spread over the board’s GND pins. |
i2c_sda, i2c_scl | The board’s default I2C pins. All I2C parts share that one bus, and those pins are kept free for it. |
spi_mosi, spi_miso, spi_sck | The board’s hardware SPI pins, shared by every SPI part. |
spi_cs | The bus’s own CS pin for the first SPI part, the next free safe pin for each other one. |
analog_out | The next free ADC pin, in the board’s order of preference (on the ESP32, ADC1 first, because ADC2 stops with Wi-Fi). |
int | An input-only pin first (they are fine for interrupts and otherwise hard to use), then a safe pin. |
| other signals | The next free pin from the board’s safe-pin list. |
passive | Not wired to the board: a load or a module supply is wired part to part, or by hand. |
The result is deterministic: the same drawing on the same board always gets the same pins. If the board runs out of pins, the pin stays unwired and the app says so.
What the wiring checker checks
A pure function runs on every change to the drawing, whether you drew the wire, a template did, or the AI proposed it. Each finding is placed on the pin or wire in 3D, written to the log with its source (the board file or the part file), and shown in the warnings banner with a fix where one exists.
| Rule | What it catches |
|---|---|
unknown_pin | A wire to a pin the board does not have. |
flash_pin | Anything wired to a flash pin. |
voltage_mismatch | A part supplied above its voltage (5 V into a 3.3 V sensor), below it, or a ground wire on a power pin (a short). |
wrong_pin_type | A part powered from a GPIO, a signal wired to a power or ground pin. |
missing_power, missing_ground | A part with a supply or ground pin that is not connected. |
output_on_input_only | A pin the board must drive (an LED, SDA, a chip select) on an input-only pin. |
not_adc | An analog output wired to a pin with no ADC. |
adc2_wifi | An analog reading on an ESP32 ADC2 pin, which fails while Wi-Fi is on. |
strapping_pin | A part that can pull a strapping pin at reset and stop the board from booting. |
reserved_pin, uart0_pin | Pins used by the board itself: the USB serial link, native USB, the debug port. |
shared_pin_conflict | Two parts driving the same board pin (buses and power excepted). |
i2c_swapped | SDA and SCL crossed against the board’s I2C pins. |
logic_level | A 3.3 V part on a 5 V board, or a 5 V output into a pin that is not 5 V tolerant. |
i2c_address_conflict | Two parts on one bus at the same address: a warning when one can move (it names the address pin), an error when neither can. |
The same rules imply a shopping list: two 4.7 kΩ pull-ups when no I2C module has its own, a 1-Wire pull-up when the part’s notes ask for one, a 1 kΩ + 2 kΩ divider for each 5 V output into a 3.3 V pin, a level shifter for lines that go both ways, and, from the parts’ own notes, flyback diodes across coils and divider resistors for bare sensors such as an LDR.
How the AI builds a project
The assistant does not wire anything itself. It is a language model with a set of tools, and the app does the real work behind each tool:
- Context. With every question the model gets the board’s facts and pins (with their flags), your drawing, the library entries of the parts in it, your project code, recent app events and the last log lines.
- Choosing parts.
search_partssearches the 383-part library and returns, for each match, what tells parts apart: what it measures, bus, supply voltage, how it fits this board’s logic level, pin notes, known traps, current.offer_choicesshows you 2 to 4 cards; you pick. - Proposing.
propose_projectsends a list of library part ids, optionally the wires the model wants and part-to-part links (a pump on a MOSFET module, an RFID reader through a level shifter). The app refuses unknown part ids (and suggests the closest real ones), checks every requested wire against the board and the part, places the links first, then lets the safe-pin rules wire everything else, then runs the wiring checker. - Fixing. The checker’s errors, the pins left unwired and the extras to buy go back to the model, which must fix errors and call again before it answers.
- Answering. The final answer is structured: the message, a confidence label (measured, from documentation, suggestion), its sources and the pins or parts to highlight. The code enforces the honesty rules: “measured” is allowed only if a measurement ran this turn or is in the session log; highlights that do not exist in the drawing are dropped.
- Applying. Nothing changes until you press Apply. A new project opens in its own tab if the open one has parts; Undo brings the previous drawing back. Writing to the board is never possible from the chat: the model can only ask, and the app shows its confirmation dialog.
The model also has tools to run the app’s own actions (back up the board, open the monitor, change a setting), to ask for code in the Code panel, to search your project memory, and to design what a display shows.
app/main/ai/tools.ts · shared/projectBuilder.ts · app/main/ai/prompt.ts · app/main/ai/assistant.ts
Code that matches the drawing
Starter code is generated from the drawing: each pin becomes a #define with the board pin it is wired to, buses start on the wired pins, and the Pico SDK, ESP-IDF and STM32 HAL starter projects read each part’s chip ID at start-up when the library has one. The code checker compares any sketch (generated, pasted or written by the AI) with the drawing: Wire.begin() pins that do not match the wiring, pinMode(…, OUTPUT) on an input-only pin, analogRead on a pin with no ADC or on ADC2 while the sketch starts Wi-Fi, an LED wired to one pin while the code drives another, a serial speed that does not match the monitor. Findings point at the code line and the pin in 3D.
shared/sketch.ts · shared/codeCheck.ts · shared/starter/index.ts
Measurements, not guesses
To see a real board, BoardPilot installs a small diagnostic agent firmware (after a confirmation and a full backup of the flash). The app talks to it over serial with one JSON line per request and reply: read every pin, check for external pull-ups (internal pulls off, then read the level), scan the I2C bus, read registers, read an ADC pin, drive a pin (only after your confirmation). The agent itself refuses flash pins and outputs on input-only pins.
The classic debugging trick is the swap test: on chips where I2C pins can be moved in software, the bus is scanned once as wired and once with SDA and SCL exchanged. If a device answers only when exchanged, the wires are crossed, and the app says which ones. A chip-ID read then tells a real BME280 (0x60) from a BMP280 sold as one (0x58). The board can measure voltage only on ADC pins; elsewhere the app reports a digital level and never shows a voltage it did not measure.
firmware/agent · shared/protocol.ts · flows/debug-sensor-not-responding.ts
How the simulator works
The simulated board implements the same driver interface as the real one, so every screen, flow and AI tool runs unchanged without hardware. A simulator scenario holds two things: the drawing you see, and the physical truth on the bench, which can differ, for example with SDA and SCL crossed at the sensor, a BMP280 where a BME280 is drawn, a sensor without power, a busy port or a wrong baud rate. The simulated agent answers commands from the physical truth with the same rules as the real firmware, so the debugging flows find the fault the way they would on a real bench.
Template projects also run as a step-by-step simulation of the program: pins change, values move, displays show text, and each step is linked to the code line. Everything simulated is labelled so, and never mixed with measured data.
app/main/sim/simDriver.ts · app/main/sim/simWorld.ts · app/main/sim/scenarios/weather-station-swapped.json · shared/templates.ts
Display Studio: pixel for pixel
A display design is data: screens of text, values, bars, graphs, gauges, icons and test patterns, with timed or conditional changes and simulated values. The app draws it into a framebuffer at the panel’s real resolution and colour depth (RGB565 on colour TFTs, one bit on OLEDs and e-paper), with Adafruit GFX’s own line and circle algorithms, a 5×7 font and integer pattern maths. The generated Arduino sketch carries the same font, icons and maths and draws with Adafruit GFX.
To prove they match, a test builds the real Adafruit GFX library on the computer with the generated drawing code and compares every pixel of its frames with the app’s, on four panels and two rotations; the live AI evaluation does the same for each design the AI writes. The sketches also compile for ESP32, ESP32-S3, Arduino Uno and Raspberry Pi Pico.
shared/displayRender.ts · shared/displayCode.ts · tests/display.test.ts
How it is tested
- 3,200+ unit tests (Vitest): board and part files, pin choice and wiring rules on every template on every board, the code checker, the flows against the simulator, the agent protocol, the display renderer and code generator.
- End-to-end tests (Playwright) drive the built app in simulator mode through every scenario: connect, new project, flash with backup, each debug flow, monitor, test hardware, report, PDF export, an AI action, a recorded conversation.
- A live AI evaluation sends 26 requests through the real assistant with a small model playing the user, grades every conversation with the app’s own rules, builds the AI’s display designs natively for the pixel comparison, and has a second model read each conversation for unsafe advice. The latest round passed 26 of 26 on Claude Sonnet 5.5; every round, with what it found and what was fixed, is published.