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({
  boardType: 'arduino-uno',
  digitalPinCount: 14,
  analogPinCount: 6,
  i2cBusCount: 1,
  spiBusCount: 1,
  uartCount: 1,
});

SimBoardConfig

OptionTypeDefaultDescription
boardTypestringBoard identifier (e.g., 'arduino-uno', 'arduino-nano'). Known board types carry default PWM/interrupt pin maps; custom/unknown types have no capability pins unless declared via pwmPins/interruptPins
digitalPinCountnumber14Number of digital pins
analogPinCountnumber6Number of analog input pins
i2cBusCountnumber1Number of I2C buses
spiBusCountnumber1Number of SPI buses
uartCountnumber1Number of UART ports
pwmPinsnumber[]Override PWM-capable pin numbers (defaults to the board type’s map, or empty for custom)
interruptPinsnumber[]Override interrupt-capable pin numbers (defaults to the board type’s map, or empty for custom)
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 Package

createSimBoard() hardcodes pin maps and only recognizes a few board types (arduino-uno, arduino-nano); custom/unknown boards get no PWM or interrupt pins. When you already depend on a @typecad/board-* package, use createBoardFromDefinition() to build a SimBoard whose layout matches the real board — reusing the board package’s authoritative pin numbers, capability flags, ADC resolution/reference, and bus counts.

Quick start

import { ArduinoUno } from '@typecad/board-arduino-uno';
import { createBoardFromDefinition } from '@typecad/simulator';

const board = createBoardFromDefinition(ArduinoUno);

// The pin layout matches the real Uno:
board.digital(13).asOutput().high();   // LED on PB5 (pin 13)
board.pwm(9).pwm(50);                  // PWM on PB1 (pin 9)
board.analog(0).injectVoltage(2.5);    // A0, 10-bit ADC, 5V reference

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

Requires the board package to be built (npm run build --workspace @typecad/board-arduino-uno) so its BoardDefinition is importable at runtime.

Note

A couple of terms above: PB5/PB1 are AVR port-bit names (the chip’s own labels for specific pins — PB5 is the Uno’s pin 13, PB1 is pin 9). ADC (analog-to-digital reading) is how a microcontroller measures a voltage and turns it into a number.

⚙️ Advanced details — what's derived from a board package

What gets derived

createBoardFromDefinition(def) reads the board package’s BoardDefinition and configures every relevant field on the resulting SimBoard:

SimBoard fieldDerived from BoardDefinitionUno 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.

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

When to use which

ApproachUse when
createBoardFromDefinition(def)You already depend on a @typecad/board-* package and want the simulator to match the real board exactly. Preferred for board-specific tests.
createSimBoard({ boardType, ... })You want a standalone simulator without a board-package dependency, 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 package: createBoardFromDefinition() is generic — pass an ESP32, Nano, or any future BoardDefinition and the pin layout, buses, and ADC config are derived the same way.

See the Board Packages docs for the list of available @typecad/board-* packages 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(1);      // OUTPUT mode, initial HIGH
pin.asInput();        // INPUT mode
pin.asInputPullUp();  // INPUT_PULLUP mode, value driven to 1

Read / Write

pin.write(1);         // Drive HIGH
pin.write(0);         // 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 and Tone

pin.pulse(100);       // Toggle twice (simulates a pulse; duration not awaited)
pin.tone(440);        // Returns a no-op tone attachment
pin.noTone();         // No-op

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
console.log(a0.readAnalog()); // → 512
console.log(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.setAnalogReference(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
console.log(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
console.log(motor.getPwmPercent()); // → ~50

motor.pwm(0);                   // 0% deactivates PWM
console.log(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);
console.log(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
console.log(serial.available());  // → 4
console.log(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
readBytes(count)Read up to N bytes from RX
readString()Read and clear entire RX buffer
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
onReceive(cb)Callback fired by injectRx()
onTransmitComplete(cb)Callback fired by flush() / flushTx()
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 simply sets isEnabled = false — it does not throw or release mock devices, so firmware cleanup paths run cleanly in tests. This applies to SimSerialPort, SimI2CBus, and SimSPIBus.


Example: Complete Blink Test

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

const board = createSimBoard({ boardType: 'arduino-uno' });
const led = board.digital(13);

let toggleCount = 0;
// Track state changes
led.asOutput(0);
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();