Skip to content

SDK

Build a board, flash real firmware, and run it on the Simulator86 cloud from TypeScript.

Provision simulated hardware the way you provision cloud infrastructure — from TypeScript. You build the board, flash the exact firmware you’d put on real hardware, run it on the Simulator86 cloud, and stream what it does. No lab, no board, same binary.

Terminal window
npm i @sim86/sdk # zero runtime dependencies

Node ≥ 22, browsers, Bun, and Deno work out of the box. Get an API key in the editor under Cloud → API keys (sim86_live_…) and set it as SIM86_API_KEY.

import { createClient, Components } from "@sim86/sdk";
const client = createClient(process.env.SIM86_API_KEY!);
const project = await client.getProject("your-project-id");
const graph = project.graph;
const mcu = graph.addComponent(Components.STM32F405_EXPRESS, {
id: "mcu1",
});
const led = graph.addComponent(Components.LED, { id: "led1" });
graph.connect(mcu.pin("13"), led.pin("+"));
graph.connect(mcu.pin("GND"), led.pin("-"));
await mcu.setFlash("./firmware.elf"); // path or Uint8Array
const run = await graph.run({ duration: 30_000 });
run.stream(led.id).subscribe(({ t, value }) => {
console.log(`${t}ms`, value); // { intensity: 100 } … blinking
});
const { status, reason } = await run.done; // ends with a truthful reason

For this STM32 board, flash the ELF your linker produced — not an objcopy’d .bin/.hex. Firmware formats are board-specific; the XIAO ESP32-C3 instead takes the merged flash .bin that esptool would write.

Firmware that already lives in the project

Section titled “Firmware that already lives in the project”

setFlash() uploads the bytes with every run. When the same image is started over and over — a soak test, a CI matrix, an agent iterating — put it in the project’s workspace once and reference it by path instead:

await project.putFile("build/merged.bin", "./merged-flash.bin");
c3.setFlashPath("build/merged.bin");

putFile() replaces whatever is at that path, and the file is part of the project, so it also shows up in the editor’s file tree. A firmware you build in the workspace (from the editor, or with the project’s build command) needs no upload at all — just the path.

A run lives on the cloud, not in your process. Close your laptop or kill the CI job — it keeps going until it hits your limits, the firmware exits, or you stop it. Unbounded means soak tests measured in days, on purpose.

const run = await graph.run(); // no duration → runs until stopped
const runId = run.id;
run.detach(); // your process can exit
// later, anywhere:
const again = await client.attach(runId);

stream() gives you a component’s current state — an LED’s brightness, a sensor reading. Logs are the other half: an append-only record of what the firmware printed and when.

const lines = await run.logs();
// [{ t: 120, source: "mcu1:rtt", line: "boot ok" }, …]

t is simulated milliseconds, not wall clock, so timings are reproducible across machines. source is component:channel — one component can have several channels (a UART, an RTT link, a state change like the onboard LED):

await run.logs({ from: "mcu1" }); // every channel on that board
await run.logs({ from: "mcu1:uart0" }); // just that UART
await run.sources(); // e.g. ["mcu1:uart0", …]

Follow them as they happen. While you’re attached the lines are pushed to you; detached, it polls — either way the loop ends when the run does:

for await (const l of run.logs({ follow: true, from: "mcu1:uart0" })) {
console.log(l.line);
}

Logs outlive the run, so a CI job can start a simulation, exit, and something else can read what happened afterwards.

Firmware output and state changes are always recorded. Deeper instrumentation is opt-in, because recording it unconditionally would slow the simulation for data nobody reads — a busy board changes pins millions of times a second.

mcu.record("pins"); // before the run
const run = await graph.run({ duration: 5_000 });
await run.logs({ from: `${mcu.id}:@pins` });
// [{ t: 12, source: "mcu1:@pins", line: "13 = 1" },
// { t: 12, source: "mcu1:@pins", line: "SDA = z" }, …]

@pins records every level each pin is driven to, and distinguishes a pin released to high-impedance (z) from one actively driven low (0) — the difference that usually explains bus contention.

Channels you asked for are prefixed @ so they never collide with a board’s own output, and turning one on changes only what is recorded, never what is simulated: the same seed still replays identically.

For robustness tests, inject arbitrary 802.11 MPDU bytes into the same RF path as a real radio. This can represent a rogue AP, deauthentication traffic, an imported capture, or a deliberately malformed frame:

const room = graph.addComponent(Components.RF_ENVIRONMENT);
const c3 = graph.addComponent(Components.XIAO_ESP32C3);
room.joinRfEnvironment(c3);
await c3.setFlash("./merged-flash.bin");
const run = await graph.run({ seed: 42, duration: 10_000 });
await run.at(2_000).injectWifiFrame(room, {
sourceId: "rogue-ap",
sequence: 1,
channel: 6,
txPowerDbm: -20,
payloadHex: "c000", // exact bytes; malformed frames are allowed
});

sourceId plus sequence is the stable event identity used by deterministic RF decisions. The rate defaults to robust 1 Mbit/s DSSS; set rate or durationNs when the test needs exact airtime.

A Wi-Fi Network is a pinless infrastructure AP, DHCP/DNS service, and browser point of view in one component. Put it in the same RF room as a XIAO ESP32-C3:

const room = graph.addComponent(Components.RF_ENVIRONMENT);
const c3 = graph.addComponent(Components.XIAO_ESP32C3);
const network = graph.addComponent(Components.WIFI_NETWORK);
room.joinRfEnvironment(c3, network);
await c3.setFlash("./softap-web-server.bin");
const run = await graph.run({ duration: 60_000 });
const page = await network.fetch({
run,
ssid: "ESP32-Setup",
url: "http://192.168.4.1/status",
});
console.log(page.status, page.text());
const browser = await network.openBrowser({
run,
ssid: "ESP32-Setup",
url: "http://192.168.4.1/",
});
console.log(browser.url); // one-use URL for a real browser tab

This is not a direct server request to 192.168.4.1. The browser client inside the network scans, associates over the simulated air, gets a DHCP lease, then uses the simulated DNS/TCP stack. RF loss, interference and collisions still apply. The firmware SoftAP is the access point.

Firmware station mode can connect directly to the SSID configured on the same Wi-Fi Network. Hardware MAC identities are generated, so the basic config is normally just { ssid: "myhome", internetAccess: true }.

The initial browser release supports open networks and plain HTTP. WPA2/CCMP and device-side HTTPS fail explicitly until those layers are modeled. See the Wi-Fi Network component for both traffic flows and the complete configuration.

Every MCU, sensor, display, and peripheral you can place is in the Components overview, including its wiring, configuration, and limitations. If you can drag it onto a diagram, you can add it from the SDK with the same name.


Have a project open in the editor? Grab a key from Cloud → API keys, paste its ID above, and run the snippet. The first simulation tells you more than any more docs would.