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.jsonbuild_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 of four pin-map parsers build_boards.js uses — this is a different, code-level concept from the directory family (which is just where the file happens to live):

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, ...).
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.

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.

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.