Software-Defined Hardware

Build a virtual microcontroller in code and run your firmware against it on your computer, with no board plugged in. The @typecad/simulator package lets you create pins, buses, and other parts on demand, set their values by hand, and check how your firmware reacts.


Reasons to Simulate

  • Test firmware logic without a board connected
  • Verify bus protocols (I2C register reads, SPI transfers) with mock devices
  • Catch logic errors before uploading to hardware
  • Run in CI — no serial port or device needed

SimBoard

SimBoard creates a virtual microcontroller with configurable pins and buses. Use createSimBoard() to create one:

import { createSimBoard } from '@typecad/simulator';

const board = createSimBoard({
  digitalPinCount: 14,
  analogPinCount: 6,
  i2cBusCount: 1,
  spiBusCount: 1,
  uartCount: 1,
});

SimBoardConfig

OptionTypeDefaultDescription
digitalPinCountnumber14Number of digital pins
analogPinCountnumber6Number of analog input pins
i2cBusCountnumber1Number of I2C buses
spiBusCountnumber1Number of SPI buses
uartCountnumber1Number of UART ports
pwmPinsnumber[][]PWM-capable pin numbers (empty unless you declare them, or derive them with createBoardFromDefinition())
interruptPinsnumber[][]Interrupt-capable pin numbers (empty unless you declare them, or derive them with createBoardFromDefinition())
uartRxBufferSizenumber256RX buffer size for UART
uartTxBufferSizenumber256TX buffer size for UART

Accessing Pins and Buses

Use typed accessor methods (they throw if the pin/bus doesn’t exist):

const led = board.digital(13);       // SimDigitalPin
const sensor = board.analog(0);      // SimAnalogPin (A0)
const motor = board.pwm(9);          // SimPWMPin
const intPin = board.interrupt(2);   // SimInterruptPin
const serial = board.serial(0);      // SimSerialPort
const i2c = board.i2c(0);            // SimI2CBus
const spi = board.spi(0);            // SimSPIBus

Or access the underlying maps directly:

board.digitalPins   // ReadonlyMap<number, SimDigitalPin>
board.analogPins    // ReadonlyMap<number, SimAnalogPin>
board.pwmPins       // ReadonlyMap<number, SimPWMPin>
board.interruptPins // ReadonlyMap<number, SimInterruptPin>
board.serialPorts   // ReadonlyMap<number, SimSerialPort>
board.i2cBuses      // ReadonlyMap<number, SimI2CBus>
board.spiBuses      // ReadonlyMap<number, SimSPIBus>

Reset

board.reset();  // Reset every pin, port, and bus to initial state

Simulating a Real Board

createSimBoard() gives you plain pin and bus counts; its PWM and interrupt maps start empty unless you declare capability pins via pwmPins/interruptPins. When your project carries a generated board module, use createBoardFromDefinition() to build a SimBoard whose layout matches the real board — reusing the generated manifest’s pin numbers, capability flags, and bus counts.

Quick start

import boardDef from '../.cuttlefish/board.json';
import { createBoardFromDefinition } from '@typecad/simulator';

const board = createBoardFromDefinition(boardDef);

// The pin layout matches the real board:
board.digital(48).asOutput().high();   // the onboard LED
board.pwm(1).pwm(50);
board.analog(0).injectVoltage(2.5);

// Buses match the board's peripheral count:
board.serial(0).begin(9600);
board.i2c(0).attachDevice(0x76, mockSensor);

Requires the board module to be generated (any build, or npx cuttlefish board regen) so .cuttlefish/board.json exists.

Note

A couple of terms above: port-bit names are the chip’s own labels for specific pins (for example, on an STM32 board PB5 is port B bit 5). ADC (analog-to-digital reading) is how a microcontroller measures a voltage and turns it into a number.

⚙️ Advanced details — what's derived from the board manifest

What gets derived

createBoardFromDefinition(def) reads the generated board manifest (.cuttlefish/board.json) and configures every relevant field on the resulting SimBoard:

SimBoard fieldDerived from the board manifestUno value
digitalPinCountpins.digital.length20
analogPinCountpins.analog.length6
pwmPinspins.all entries with capabilities.pwm[3, 5, 6, 9, 10, 11]
interruptPinspins.all entries with capabilities.interrupt (excluding unsafe)[2, 3]
i2cBusCountperipherals.i2c.length1
spiBusCountperipherals.spi.length1
uartCountperipherals.uart.length1
ADC resolutionperipherals.adc[0].resolution10 bits
ADC referenceperipherals.adc[0].referenceVoltage5.0 V

The ADC resolution and reference are applied to every SimAnalogPin, so readVoltage() produces board-accurate values without manual setResolution()/setAnalogReference() calls.

Generated-module Pin objects vs. this helper: the Pin exports from the generated board module (D13, A0, …) are compile-time tokens with .number === -1 at runtime — they can’t drive a simulator directly. createBoardFromDefinition() reads the board manifest (which does carry real pin numbers), not those Pin tokens.

When to use which

ApproachUse when
createBoardFromDefinition(def)Your project carries a generated board module and you want the simulator to match the real board exactly. Preferred for board-specific tests.
createSimBoard({ ... })You want a standalone simulator without a board module, or need a fully custom pin layout via pwmPins / interruptPins overrides.

Notes

  • Interrupt pins: the helper uses the per-pin capabilities.interrupt flag from pins.all. If your board’s flagging doesn’t match the interrupts you want to exercise (e.g. you want pin-change interrupts beyond INT0/INT1), drop down to createSimBoard({ ..., interruptPins: [...] }) and declare them explicitly.
  • Works for any board target: createBoardFromDefinition() is generic — pass any generated board manifest and the pin layout, buses, and ADC config are derived the same way.

See the Board Targets docs for the supported target list and the Peripheral Mocking page for attaching mock I2C/SPI devices to the resulting board.


Digital Pin Simulation

SimDigitalPin

Simulates a digital GPIO pin with state change history tracking.

Mode Configuration

const pin = board.digital(13);

pin.asOutput(true);   // OUTPUT mode, initial HIGH
pin.asInput();        // INPUT mode
pin.asInputPullUp();  // INPUT_PULLUP mode, value driven to 1

Read / Write

pin.write(true);      // Drive HIGH
pin.write(false);     // Drive LOW
pin.high();           // Drive HIGH
pin.low();            // Drive LOW
pin.toggle();         // Flip current value

pin.read();           // → true (HIGH) or false (LOW)
pin.isHigh();         // → boolean
pin.isLow();          // → boolean
pin.getBitValue();    // → 0 or 1 (plain number)

Simulating External Signals

Tests drive pins from outside (simulating button presses, sensor outputs):

pin.injectValue(1);   // Simulate an external HIGH signal
pin.injectValue(0);   // Simulate an external LOW signal

State Change History

All value transitions are recorded with timestamps:

pin.high();
pin.low();
pin.high();

const history = pin.getHistory();
// [{ from: 0, to: 1, timestamp: ... }, { from: 1, to: 0, ... }, { from: 0, to: 1, ... }]

pin.clearHistory();   // Clear the history

Pulse

pin.pulse(100);       // No observable transition in simulation — duration is not awaited

Analog Pin Simulation

SimAnalogPin

Simulates a continuous analog value (0-1023 for 10-bit ADC):

const a0 = board.analog(0);

a0.injectValue(512);          // Simulate sensor reading of 512
UART0.writeLine(a0.readAnalog()); // → 512
UART0.writeLine(a0.readVoltage()); // → ~2.5 (based on 5V reference)

You can also inject a voltage directly, which is converted to an ADC value based on the configured resolution and reference:

a0.injectVoltage(2.5);        // → readAnalog() ≈ 512 on a 10-bit / 5V ADC
a0.setResolution(12);         // Change ADC resolution (default 10-bit)
a0.setReferenceVoltage(3.3);  // Change reference voltage (default 5.0V)

PWM Pin Simulation

SimPWMPin

Simulates PWM duty cycle as a percentage (0-100%):

const motor = board.pwm(9).asOutput();

motor.pwm(50);                  // 50% duty cycle
UART0.writeLine(motor.getPwmPercent()); // → 50

// Writing a raw value > 1 is treated as an analog/PWM value and scaled
motor.write(128);               // ~50% on an 8-bit (0-255) resolution
UART0.writeLine(motor.getPwmPercent()); // → ~50

motor.pwm(0);                   // 0% deactivates PWM
UART0.writeLine(motor.isPwmActive()); // → false

Interrupt Pin Simulation

SimInterruptPin

Simulates edge detection events. Register handlers with onRising/onFalling/onChange, then drive them by firing a transition or an explicit interrupt:

const intPin = board.interrupt(2);

let edges = 0;
intPin.onRising(() => { edges++; });

// Fire an explicit interrupt edge
intPin.fireInterrupt('rising', 1);
UART0.writeLine(edges); // → 1

// Or simulate a full 0→1 / 1→0 transition, which fires the matching edge
intPin.simulateTransition(0, 1); // fires onRising (and onChange if set)
intPin.simulateTransition(1, 0); // fires onFalling (and onChange if set)

Serial Port Simulation

SimSerialPort

Simulates UART with buffered TX/RX:

Firmware Writes (TX)

const serial = board.serial(0);
serial.begin(9600);
serial.print("Hello ");
serial.println("World");

// Inspect what firmware wrote
const text = serial.peekTxAsString();     // → "Hello World\r\n" (doesn't clear)
const output = serial.flushTx();          // → [72, 101, 108, ...] (clears buffer)

Simulating Received Data (RX)

// Simulate receiving data from another device
serial.injectRx("OK\r\n");

// Firmware can now read it
UART0.writeLine(serial.available());  // → 4
UART0.writeLine(serial.readLine());   // → "OK" (trailing \r\n stripped)

Full API

MethodDescription
begin(baud)Enable the port at the given baud rate
print(...args)Write strings to TX buffer
println(...args)Write strings + \r\n to TX buffer
printf(format, ...args)Format and write (%d/%i, %s, %f, {} placeholders, %%)
write(data)Write raw bytes to TX
read()Read and remove next byte from RX (-1 if empty)
readLine()Read up to next \n from RX
available()Bytes waiting in RX buffer
injectRx(data)Inject bytes into RX (simulates received data)
flushTx()Return TX bytes and clear buffer
peekTx()Read TX bytes without clearing
peekTxAsString()Read TX as string without clearing
reset()Clear buffers, errors, reset state

Lifecycle: begin() and end()

begin(baud) enables a port or bus; end() gracefully disables it. Under simulation, calling end() on any bus or port never throws or releases mock devices — SimI2CBus and SimSPIBus just set isEnabled = false, and SimSerialPort.end() is a no-op — so firmware cleanup paths run cleanly in tests.


Example: Complete Blink Test

import { createSimBoard } from '@typecad/simulator';
import { describe, done } from '@typecad/expect';

const board = createSimBoard({});
const led = board.digital(13);

let toggleCount = 0;
// Track state changes
led.asOutput(false);
for (let i = 0; i < 10; i++) {
  led.toggle();
}
const history = led.getHistory();

describe('Blink')
  .it('toggles 10 times')
    .expect(history.length).toBe(10)
  .it('alternates HIGH and LOW')
    .expect(history[0].to).toBe(1)
    .expect(history[1].to).toBe(0);

done();