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
| Option | Type | Default | Description |
|---|---|---|---|
digitalPinCount | number | 14 | Number of digital pins |
analogPinCount | number | 6 | Number of analog input pins |
i2cBusCount | number | 1 | Number of I2C buses |
spiBusCount | number | 1 | Number of SPI buses |
uartCount | number | 1 | Number of UART ports |
pwmPins | number[] | [] | PWM-capable pin numbers (empty unless you declare them, or derive them with createBoardFromDefinition()) |
interruptPins | number[] | [] | Interrupt-capable pin numbers (empty unless you declare them, or derive them with createBoardFromDefinition()) |
uartRxBufferSize | number | 256 | RX buffer size for UART |
uartTxBufferSize | number | 256 | TX 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); // SimSPIBusOr 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 stateSimulating 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.jsonexists.
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 field | Derived from the board manifest | Uno value |
|---|---|---|
digitalPinCount | pins.digital.length | 20 |
analogPinCount | pins.analog.length | 6 |
pwmPins | pins.all entries with capabilities.pwm | [3, 5, 6, 9, 10, 11] |
interruptPins | pins.all entries with capabilities.interrupt (excluding unsafe) | [2, 3] |
i2cBusCount | peripherals.i2c.length | 1 |
spiBusCount | peripherals.spi.length | 1 |
uartCount | peripherals.uart.length | 1 |
| ADC resolution | peripherals.adc[0].resolution | 10 bits |
| ADC reference | peripherals.adc[0].referenceVoltage | 5.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
| Approach | Use 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.interruptflag frompins.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 tocreateSimBoard({ ..., 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 1Read / 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 signalState 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 historyPulse
pin.pulse(100); // No observable transition in simulation — duration is not awaitedAnalog 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()); // → falseInterrupt 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
| Method | Description |
|---|---|
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();On This Page