Custom Pin Facts

Cuttlefish knows a pin’s abilities from its facts: the silicon routing harvested from your Zephyr tree (which pad carries ADC channel 3, which timer channel reaches PB6). The facts are what make new ADC(PA1) validate — and what makes an unmapped pin a build error instead of a silent misread.

Sometimes the facts are wrong or missing. Your board’s family may not be harvested yet, or you know a routing the catalog doesn’t. For those cases there are two ways to supply the routing yourself. Both are facts, not bypasses — they flow through the same validation, lowering, and overlay generation as harvested data.

  • A facts file (cuttlefish.facts.json) covers a whole board. Commit it with the project.
  • Construction overrides cover one pin at one construction site, inline.

Use the file when the routing is a property of your board. Use the inline form when it’s a property of one line of code you’re prototyping.

The Facts File

Create cuttlefish.facts.json next to cuttlefish.config.ts:

{
  "boards": {
    "myboard/mysoc": {
      "adc": {
        "device": "adc1",
        "channels": [{ "pin": 0, "channel": 1, "pinctrl": "adc1_in1_pa1" }]
      },
      "pwm": {
        "specs": [{ "pin": 15, "controller": "pwm0", "channel": 2 }]
      },
      "dac": {
        "device": "dac1",
        "channels": [{ "pin": 4, "channel": 1 }]
      }
    }
  }
}
  1. Put your board’s qualified target as the key under boards — bare board ids and prefixes work too, same as everywhere else.
  2. Pins are the global pin numbers the generated module and the diagnostics use. .cuttlefish/board.ts prints them, and an error message names the number it wants.
  3. adc.device is the devicetree nodelabel of the ADC peripheral. Omit it to reuse the board’s primary.
  4. pinctrl is the pinmux token the build overlay composes — a pinctrl node label on STM32 (adc1_in1_pa1), a pinmux macro on RP2040 (ADC_CH0_P26). Omit it on families whose analog pads need no mux (nRF, ESP32, SAM).
  5. Rebuild. Watch for one line per shadowed route in the build output, like cuttlefish: adc pin 1: user channel 9 shadows the harvested channel 1.

Your routes win per pin. A pin you don’t mention keeps its harvested answer, and every takeover is named in the output — the file is auditable by grep.

The file participates in regeneration: editing it (or deleting it) regenerates the board module on the next build. Nothing goes stale.

⚙️ Advanced details — precedence and the fingerprint

The merge runs at board-module generation, before anything else: generateBoard(target, { factsJson }) merges your section into the manifest, so exports, capability flags, validation, lowering, and the overlay all see your routes as first-class facts. User routes replace harvested routes per pin — across every source (silicon pinctrl harvest, RP2 header matrices, Atmel pinconfigs, nRF family tables, connector io-channel wiring). Supplying any pwm.specs for a board also suppresses that board’s auto-generated nRF PWM matrix: you own PWM on that board. The raw file text is hashed into the module’s source.fingerprint (+<12 hex>), which is what makes edits regenerate.

Construction Overrides

Add the routing to the constructor options of the class you’re already using:

import { ADC, PWM } from '@typecad/hal';
import { PA0, PB7 } from '@typecad/board';

// PA0, channel 3, on the ADC peripheral labeled adc1 — because I say so
const sense = new ADC(PA0, { channel: 3, device: 'adc1' });

// PB7 as PWM channel 2 of the pwm0 peripheral
const servo = new PWM(PB7, { periodNs: 20_000_000, controller: 'pwm0', channel: 2 });

ADC takes channel (required for the override to mean anything), device, and pinctrl. PWM takes controller and channel. Everything else about the classes is unchanged — gain, reference, periodNs work as always.

The scope is the single construction. A second new ADC(PA0) without overrides gets the manifest’s answer again, and if the manifest has none, that construction errors exactly as before.

⚙️ Advanced details — how the routing reaches the build

The overrides ride the HAL op as channelOverride / deviceOverride / pinctrlOverride (ADC) and controllerOverride / channelOverride (PWM). The lowering emits them verbatim, the “pin is not an analog channel” diagnostics stand down for overridden pins, and the shims declare the device handles and alias variables the emitted C++ references. The parts only devicetree can carry (the pad mux, the pwms cell) travel as /* cuttlefish-user-facts: … */ marker comments in the emitted source; the compile-time overlay regeneration parses them back out and synthesizes the DT nodes before west build runs.

Which One to Reach For

SituationReach for
The board’s family isn’t harvested yet (NXP i.MX, Renesas RA, …)Facts file — fill the board’s map once, every construction validates
The catalog’s channel for one pin is wrongFacts file — fix the board, keep the audit trail
Trying a pad on the bench, not sure yetConstruction override — one line, no file
Same pad needs two different routings in two placesConstruction overrides — the file is per board, the override is per construction

And when a routing genuinely cannot be expressed as facts, rawCpp() remains the full bypass — no validation, no help, on purpose.