This document is for anyone maintaining or extending the in-browser Designer's board support — adding a new
board to an existing chip family, adding a whole new chip family, or editing the generated .ino
sketch bodies. It describes how the variants/ directory is laid out, how
pfodWeb_src/build_boards.js turns it into the Designer's per-board data, and how the
per-connection .ino templates are resolved.
Nothing in this document is user-facing — end users never see variants/ at all; it only
exists at build time to generate pfodWeb_src/designer/boards/<Board>/<Board>.json
files, which are what actually gets bundled into pfodWeb.html.
The Designer needs, for every supported board: its display name, which pins exist and what each one can
do (digital I/O, PWM, analog input, DAC, SPI/I2C roles, serial RX/TX), which connection types it supports
(Serial/BLE/TCP/HTTP), and the .ino sketch body to generate for each connection type.
Rather than hand-writing that per-board data as JSON, build_boards.js derives most of it
from the same pins_arduino.h files the Arduino IDE itself uses (via a board's own
hardware package), plus a small hand-written board.json per chip/variant supplying the facts
the .h file doesn't carry (default baud, DAC/PWM/ADC ranges, boot-strapping pin notes, etc.).
This keeps board data trivially updatable — drop in a new pins_arduino.h, write a short
board.json, re-run the build — instead of hand-authoring a full pin map for every board.
variants/ sits at the pfodWeb project root (a sibling of pfodWeb_src/). Two
layouts are supported, matching how different vendors structure their pins_arduino.h files:
variants/
serial.ino # shared Serial .ino template (see section 6)
arduino/
avr/
family.json # "Arduino AVR" family, "Serial" connection types
boards.txt # Arduino-style board-id -> variant-dir mapping
standard/
board.json # shared by every board.json below via ancestor lookup... no --
pins_arduino.h # see note below: board.json sits BESIDE each variant here
mega/
board.json
pins_arduino.h
leonardo/
board.json
pins_arduino.h
...
esp32/
family.json # "ESP32 and related chips", "Serial, BLE, TCP/IP, HTTP"
boards.txt # maps esp32/esp32wrover/esp32cam/... -> esp32/esp32/
ble.ino # family-root BLE .ino template — see section 6
tcp.ino
http.ino
esp32/
board.json # ONE board.json shared by every board under this chip
pins_arduino.h
m5stack_core2/
pins_arduino.h # no board.json here -- inherits esp32/esp32/board.json
lolin32/
pins_arduino.h
...
esp32s3/
board.json
pins_arduino.h
... # one directory per esp32s3 board variant
esp32c3/
board.json
...
ccode/
family.json # "Minimal C code" — no Arduino core, no pins
Ccode/
board.json
pins_arduino.h # stub file, contents never parsed
unlistedBoard/
family.json # "Unlisted Board" -- generic no-pin Arduino target
UnlistedBoard/
board.json
pins_arduino.h # stub file, contents never parsed
The two layouts:
board.json sits beside its
pins_arduino.h — one config per board variant directory.board.json sits at the chip level
(variants/esp32/esp32/board.json) and is shared by every board directory nested under
it (m5stack_core2/, lolin32/, ...), each of which supplies only its own
pins_arduino.h.Both layouts work because of one rule, used throughout build_boards.js:
X
(board.json, family.json, boards.txt, or a
<connectionId>.ino template), the build walks up from a variant's own directory,
through each parent, until it finds a file called X or reaches variants/
itself. The closest one found wins. This is what lets ESP32 boards share one chip-level
board.json while AVR boards each have their own right next to the header, with no special-
casing in the build script.Every field below is required on every board.json — build_boards.js
reports both missing and unknown fields as configuration errors, so a typo or a stale schema is
caught immediately rather than silently ignored. Use the sentinel shown ([], {},
empty string) for anything that doesn't apply to a particular family. The file is JSONC — //
and /* */ comments are allowed and stripped before parsing.
| Field | Type | Meaning |
|---|---|---|
boardName | string | Fallback output id (directory + filename basename). Overridden per-board when a
boards.txt entry matches (see section 5). |
displayName | string | Fallback human-readable name shown in the Designer UI. Also overridden by
boards.txt when present. |
family | string | Which pin-map parser to use: "avr", "esp32", "ccode", or
"unlistedBoard" (see section 4). |
inputOnlyGpios | number[] | GPIO numbers with no output driver (ESP32-style chips only) — drops
digital_output/pwm_output from those pins. [] for AVR (no such
concept — every AVR pin is bidirectional). |
connections | string[] | Transports this board can carry pfod over, lowercase:
"serial", "ble", "tcp", "http". AVR boards
typically list only ["serial"]; ESP32 chips with WiFi/BLE list all four. |
pinNotes | object | Map of "<gpio>" → plain-text warning shown under that pin in the picker (e.g.
ESP32 strapping-pin cautions). {} when nothing to flag. |
chipGpios | object | Map of "<gpio>" → { capabilities: [...], note?: "..." } describing
every GPIO the chip silicon exposes (from the datasheet) — used to fill in GPIOs the board's
pins_arduino.h doesn't itself declare, tagged "Check if available on board". {}
for AVR (no GPIO concept). |
pwm | object | { min, max, method } for analogWrite()-style output, e.g.
{ "min": 0, "max": 255, "method": "analogWrite" }. {} if the target has no
pins at all (ccode/unlistedBoard). |
dac | object | { min, max, minVolts, maxVolts, method } for a hardware DAC, e.g. ESP32's
{ "min":0, "max":255, "minVolts":"0.0", "maxVolts":"3.3", "method":"dacWrite" }.
{} when the chip has no DAC. |
adc | object | { bits, max, defaultRefVolts }, e.g. AVR's { "bits":10, "max":1023,
"defaultRefVolts":"5.0" }. |
boards.txt has an entry whose <id>.build.variant= matches the variant
directory's name, that entry's <id> and <id>.name= win instead — see
section 5. This is what lets one chip-level ESP32 board.json still produce dozens of
differently-named output boards.family parsersOne family.json sits at the root of each family's directory tree and supplies the
human-readable family name + connection-type summary shown in the Designer's target picker. Resolved by
the same nearest-ancestor lookup as board.json, so it's optional — a brand-new family
directory builds fine before its family.json is added (falling back to the raw
family id and a default sort position).
{
"familyDisplayName": "ESP32 and related chips",
"connectionTypes": "Serial, BLE, TCP/IP, HTTP"
}
| Field | Required | Meaning |
|---|---|---|
familyDisplayName | yes | Shown as the family name in the target picker. |
connectionTypes | yes | Shown after the display name, e.g. "ESP32 - Serial, BLE, TCP/IP, HTTP". |
sortOrder | no | Lower sorts earlier in the family picker. Omit to use the default (sorts after any family with an explicit value). |
board.json's family field picks which pin-map parser
build_boards.js uses — this is a different, code-level concept from the directory
family (which is just where the file happens to live). Several family values map to the
same parser function: esp32, esp8266, rp2040,
nrf52 and adafruit_nrf52 are unrelated silicon/vendors, but all declare pins via the
identical static const uint8_t NAME = value; convention, so they all route through
buildEsp32Board() — adding a new chip family this way costs zero new parser code, only new data
files (see section 8).
family value | Parser | Notes |
|---|---|---|
avr | buildAvrBoard() |
Reads #define-based pin macros + PROGMEM arrays with USART#_RX/TX
trailing comments. |
esp32 | buildEsp32Board() |
Reads static const/constexpr uint8_t NAME = value; pin aliases
(TX, RX, SDA, A0..A19, DAC1, ...). |
esp8266 | buildEsp32Board() |
Same alias convention as esp32, kept as a separate family/directory family
so the Designer's target picker lists ESP8266 on its own. |
rp2040 | buildEsp32Board() |
Same alias convention (arduino-pico core), covers both RP2040 and RP2350 chip buckets. |
nrf52 | buildEsp32Board() |
Same alias convention (Adafruit_nRF52_Arduino-derived "Seeed nRF52 Boards" core). Serial is native
USB-CDC on every current board in this family, not a UART — see the warning below about a vendor
header's own generic TX/RX aliases naming an optional secondary UART instead of
the real pfod connection. |
adafruit_nrf52 | buildEsp32Board() |
Adafruit's own upstream Adafruit_nRF52_Arduino core (the core Seeed forked for nrf52
above) — deliberately kept as a SEPARATE family/directory tree from nrf52 even though most
of its boards share the same nRF52840 silicon, so the two vendors' boards list separately in the target
picker. Covers nRF52832 (no native USB — Serial is a real dedicated UART on this chip, forced via
chipGpios) as well as nRF52833/nRF52840 (native USB, same optional-secondary-UART situation as
nrf52). |
ccode | buildCcodeBoard() |
"Minimal C Code" target — no Arduino core, no pins at all.
pins_arduino.h is a stub whose contents are never parsed; always emits pins: [].
Routes code generation to generateCcode.js (plain C), not the Arduino/C++ generator. |
unlistedBoard | buildCcodeBoard() |
Same no-pin structure as ccode (also emits pins: []), but
board.family stays "unlistedBoard" so generateCode.js routes
to the normal Arduino/C++ generator instead. This is the family that lets a user still generate a full
sketch for a board that isn't in the picker, using the ? placeholder-pin toggle (see
editMenuItem.js/editChart.js's canPlaceholder logic) instead of a
real pin list. |
buildEsp32Board() unconditionally treats whatever GPIO a pins_arduino.h aliases as
bare TX/RX as the board's dedicated primary serial port — correct for ESP32/ESP8266,
where that alias really is the UART0 pair the Serial object uses. It is NOT automatically correct
for a chip whose Serial object is native USB-CDC (no GPIO at all) and whose vendor header still
declares a generic TX/RX alias for some other, optional UART — that alias will
wrongly get excluded from every other capability. Fix it in your own copy of the file by deleting (not
commenting out — this parser has no C-comment awareness, see the pin-audit playbook's parser-gotchas section)
the offending alias declaration; see variants/nrf52/nrf52840/Seeed_XIAO_nRF52840_Plus/pins_arduino.h
for a worked example.Arduino hardware packages routinely map several distinct board ids onto one shared
pins_arduino.h (e.g. esp32, esp32wrover, esp32cam and
kb32 all declare build.variant=esp32). build_boards.js reads the
real Arduino-style boards.txt next to (or above) each variant to recover this, and emits
one output JSON per matching board id — not one per pins_arduino.h file.
# boards.txt format build_boards.js looks for: esp32.name=ESP32 Dev Module esp32.build.variant=esp32 esp32wrover.name=ESP32 Wrover Module esp32wrover.build.variant=esp32
Both lines above share build.variant=esp32, so both esp32 and
esp32wrover get their own designer/boards/<id>/<id>.json, each using
the SAME pins_arduino.h + chip-level board.json, but with
boardName/displayName taken from their own .name= line instead of
the fallback in board.json.
Entries without a matching .name= line (e.g. Arduino IDE menu sub-options like
board.menu.Revision.V2.build.variant=...) are silently skipped. A variant directory with no
boards.txt anywhere on its ancestor path (e.g. AVR's standard/) just uses
board.json's own boardName/displayName directly.
Each board's connections array lists which transports it supports; for every one of them,
the Designer needs an actual .ino sketch body to emit when the user clicks Generate
Code. Rather than hard-coding these bodies inside generateCode.js, they live as plain
.ino files under variants/, resolved by the same nearest-ancestor lookup
used for board.json/family.json:
| File | Covers |
|---|---|
variants/serial.ino | Every board's Serial connection — sits at the
variants/ root, so it's the fallback for any board/chip that doesn't override it. |
variants/esp32/ble.ino | BLE connection for every board under the ESP32 family (any chip: esp32, esp32s3, esp32c3, ...) — sits at the family root so all of them inherit it. |
variants/esp32/tcp.ino | TCP/IP Socket connection, same family-root scope. |
variants/esp32/http.ino | HTTP connection, same family-root scope. |
variants/nrf52/ble.ino | BLE connection for the nRF52 family, using the
Adafruit Bluefruit BLEUart class — which already implements the Stream interface directly, unlike
ESP32's BLE library, so this template needs no custom Stream wrapper class. |
variants/adafruit_nrf52/ble.ino | BLE connection for the adafruit_nrf52 family —
same Bluefruit BLEUart approach as variants/nrf52/ble.ino above (same underlying
core), duplicated rather than shared since the two are separate family-root directory trees. |
The filename (minus .ino) is the connection id — it must match one of the strings that can
appear in a board.json's connections array: serial, ble,
tcp, http.
ble.ino deeper in the tree — e.g. variants/esp32/esp32c3/ble.ino — and every board
under that chip will resolve to it instead, while every other ESP32 chip keeps using the family-root
version. Same rule for a single board directory overriding its chip's default. Closest file wins.For every board and every connection id it supports, build_boards.js's
registerInoTemplate() walks up from the variant's own directory to find the nearest matching
<connectionId>.ino, then stores the file's contents once — keyed by its
path relative to variants/ (e.g. "serial.ino", "esp32/ble.ino") — in
a shared registry written out as designer/boards/shared/inoTemplates.js
(const INO_TEMPLATES = {...}). Each board's own JSON only carries a small
connections.<proto>.inoTemplateId string referencing that registry — not a copy of the
file content — so hundreds of boards sharing serial.ino only store it once in the bundle.
serial is required to resolve for every board that lists it (which is effectively every
board) — build_boards.js reports a config error and fails the build if
variants/serial.ino is ever missing entirely.
designer/menus/generateCode.js's _generateIno() looks up
state.board.connections[state.connection].inoTemplateId, pulls the matching string out of
INO_TEMPLATES, and emits it verbatim — no placeholder substitution of any
kind. This is deliberate: every template is expected to be real, directly
compilable Arduino code you could open and build as-is, not a text template with holes to fill in. Only the
// Board: ... // Connection: ... /* Code generated by pfodWeb <version> */ header lines
are generated dynamically in JS and prepended — see _fileHeader() in
generateCode.js.
.ino file under
variants/ directly, then re-run build_boards.js (see section 8) to refresh
inoTemplates.js, then re-run build-bundle.js (or one of the top-level
build-* scripts) to fold the refreshed data into pfodWeb.html. No JS code changes
needed for an edit to an existing template file.variants/arduino/avr/
(or under whichever family you're extending) containing that board's pins_arduino.h
(copied from the Arduino core's own hardware package) and its own board.json (copy a sibling
board's and adjust the fields in section 3).variants/esp32/esp32s3/my_new_board/) containing only
pins_arduino.h — no board.json needed, it inherits the chip-level one via
nearest-ancestor lookup.displayName/id than the chip-level fallback, add (or
extend) a boards.txt entry per section 5 instead of writing a whole new board.json.build_boards.js (section 8) and check its output for the new board's pin/connection
summary line, and for any validation errors.family parsers fits (section 4). If neither avr nor
esp32's pin-declaration style matches the new chip's pins_arduino.h convention,
a new parser function needs to be added to build_boards.js and registered in
SUPPORTED_FAMILIES — this is the one case that genuinely requires a code change, not just new
data files.variants/ (e.g. variants/newfamily/),
with a family.json at its root (section 4).board.json file(s) per section 3, and a
boards.txt if the vendor's hardware package ships one and you want its per-board id/name
mapping honoured..ino templates should live (mirroring section 6's ESP32 example — usually
variants/newfamily/ble.ino etc.) and write them as real, compilable sketches.build_boards.js; fix any validation errors it reports (missing/unknown
board.json fields, unsupported family value, missing serial.ino
resolution, etc.) until it completes cleanly.build-bundle.js (or a top-level build-pfodWeb.* script) to fold the new
per-board JSONs + template registry into pfodWeb.html, then open the Designer and confirm the
new family/chip/board appears correctly in the target picker.From pfodWeb_src/:
node build_boards.js
(or double-click build_boards.bat on Windows). This walks ../variants/,
regenerates every designer/boards/<Board>/<Board>.json file and
designer/boards/shared/inoTemplates.js, and prints one line per board summarising its pin
count, serial port count, and supported transports. Any configuration problem (missing field, bad JSON,
unresolvable board.json/serial.ino) is printed with the offending file's path and
a non-zero exit code — nothing is silently skipped.
build_boards.js only regenerates the per-board JSON/template data; it does not rebuild
pfodWeb.html itself. Follow it with node build-bundle.js (from
pfodWeb_src/) or one of the top-level build-pfodWeb.bat/.sh scripts
to fold the refreshed data into the bundled HTML.
variants/ ships in pfodWeb.html
directly. It's a build-time-only input — the generated
designer/boards/<Board>/<Board>.json files and
designer/boards/shared/inoTemplates.js are what actually get inlined into the bundle.