pfodWeb Designer — variants/ Guide

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.

1. Why variants/ exists

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.

2. Directory layout

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:

Both layouts work because of one rule, used throughout build_boards.js:

Nearest-ancestor lookup. For a file named 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.

3. board.json reference

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.

FieldTypeMeaning
boardNamestring Fallback output id (directory + filename basename). Overridden per-board when a boards.txt entry matches (see section 5).
displayNamestring Fallback human-readable name shown in the Designer UI. Also overridden by boards.txt when present.
familystring Which pin-map parser to use: "avr", "esp32", "ccode", or "unlistedBoard" (see section 4).
inputOnlyGpiosnumber[] 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).
connectionsstring[] 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.
pinNotesobject Map of "<gpio>" → plain-text warning shown under that pin in the picker (e.g. ESP32 strapping-pin cautions). {} when nothing to flag.
chipGpiosobject 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).
pwmobject { 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).
dacobject { 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.
adcobject { bits, max, defaultRefVolts }, e.g. AVR's { "bits":10, "max":1023, "defaultRefVolts":"5.0" }.
boardName / displayName are fallbacks only. When a sibling-or-ancestor 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.

4. family.json and the four family parsers

One 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"
}
FieldRequiredMeaning
familyDisplayNameyesShown as the family name in the target picker.
connectionTypesyesShown after the display name, e.g. "ESP32 - Serial, BLE, TCP/IP, HTTP".
sortOrdernoLower 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 valueParserNotes
avrbuildAvrBoard() Reads #define-based pin macros + PROGMEM arrays with USART#_RX/TX trailing comments.
esp32buildEsp32Board() Reads static const/constexpr uint8_t NAME = value; pin aliases (TX, RX, SDA, A0..A19, DAC1, ...).
esp8266buildEsp32Board() Same alias convention as esp32, kept as a separate family/directory family so the Designer's target picker lists ESP8266 on its own.
rp2040buildEsp32Board() Same alias convention (arduino-pico core), covers both RP2040 and RP2350 chip buckets.
nrf52buildEsp32Board() 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_nrf52buildEsp32Board() 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).
ccodebuildCcodeBoard() "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.
unlistedBoardbuildCcodeBoard() 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.
A vendor's generic "TX"/"RX" alias may not name the board's real serial pins. 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.

5. boards.txt — one variant, many board ids

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.

6. The .ino template system

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:

FileCovers
variants/serial.inoEvery 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.inoBLE 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.inoTCP/IP Socket connection, same family-root scope.
variants/esp32/http.inoHTTP connection, same family-root scope.
variants/nrf52/ble.inoBLE 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.inoBLE 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.

Overriding for one chip/board. If a specific chip needs different BLE code than the rest of its family (a different BLE library, different GATT setup, etc.), drop a 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.

How resolution happens at build time

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.

How the template gets used at runtime

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.

Editing an existing template. Just edit the .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.

7. Adding a new board to an existing chip/family

  1. AVR-style family: create a new directory under 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).
  2. ESP32-style chip, board sharing the chip's existing board.json: just add a new directory under the chip (e.g. 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.
  3. If the board needs a different 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.
  4. Re-run build_boards.js (section 8) and check its output for the new board's pin/connection summary line, and for any validation errors.

8. Adding a whole new chip or family

  1. Decide which of the four 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.
  2. Create the new directory tree under variants/ (e.g. variants/newfamily/), with a family.json at its root (section 4).
  3. Add the chip-level (or per-board) 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.
  4. If the family will support connection types beyond Serial (BLE/TCP/HTTP), decide where the family-root .ino templates should live (mirroring section 6's ESP32 example — usually variants/newfamily/ble.ino etc.) and write them as real, compilable sketches.
  5. Run 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.
  6. Run 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.

9. Running the build

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.

Nothing under 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.