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
| Option | Type | Default | Description |
|---|---|---|---|
boardType | string | — | Board 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 |
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[] | — | Override PWM-capable pin numbers (defaults to the board type’s map, or empty for custom) |
interruptPins | number[] | — | Override interrupt-capable pin numbers (defaults to the board type’s map, or empty for custom) |
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 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 itsBoardDefinitionis importable at runtime.
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 field | Derived from BoardDefinition | 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.
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
| Approach | Use 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.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 package:
createBoardFromDefinition()is generic — pass an ESP32, Nano, or any futureBoardDefinitionand 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 1Read / 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 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 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-opAnalog 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()); // → 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);
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
| 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 |
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();On This Page