Skip to content
SimanticDashboard

Meet your virtual hardware.

Simantic runs your firmware on simulated MCUs, so you can observe and debug your system without a physical board.

Introduction

The Simantic CLI ships as a single binary named sim. It authenticates against the Simantic backend, resolves your MCU's configuration on demand (--mcu), and drives a simulation of your firmware.

Installation

Run the installer, which downloads the zip for your platform, verifies its checksum, extracts the sim binary to ~/.local/bin, and hands off to sim login if the shell is interactive. An existing install can also update itself with sim update (see Updating).

curl -fsSL https://simantic.com/install | sh

Published platforms are linux-x64 and osx-arm64. Pass --check to print the latest version without installing, or -h for the full option list.

Install via Homebrew

brew tap simantic-dev/simantic
brew install simantic

Installs the same sim binary as the script above. Sign in with sim login after installing, and use brew upgrade simantic to update.

Install on Windows (WSL2)

There is no native Windows build. Run the linux-x64 release inside WSL2 (WSL1 is unsupported; ARM Windows is not covered because there is no linux-arm64 release). From an administrator PowerShell, install Ubuntu and confirm wsl -l -v shows VERSION 2:

wsl --install -d Ubuntu-24.04
wsl -l -v

Then, in the Ubuntu shell, install the prerequisites (libicu70 on 22.04). If sim aborts about ICU, run export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1.

sudo apt update && sudo apt install -y curl unzip libicu74
curl -fsSL https://simantic.com/install | sh -s -- --no-login
export PATH="$HOME/.local/bin:$PATH"
sim --version

--no-login skips the installer's browser sign-in, since WSL has no browser. Sign in as described under Authentication.

  • Keep sim, firmware and ~/.sim_cache on the WSL filesystem (~/...), not under /mnt/c: it is slow, exec bits and renames behave differently, and sim update swaps the binary by rename.
  • USB and serial devices need usbipd-win; pure simulation does not.
  • If a large scenario dies silently, WSL2 likely hit its memory cap. Raise memory= in %UserProfile%\.wslconfig, then run wsl --shutdown.

Authentication

Create an API key

  1. Sign in and open Account → API Settings.
  2. Enter a name for the token, then select Create token.
  3. Copy the token immediately. The full key is shown only once.

An API key identifies your account. The MCU models sim pulls from mcu-lib (--mcu) need an account. Ask at contact if you do not have one yet.

Sign in from the CLI

Use the browser sign-in flow for an interactive terminal:

sim login

On a headless host (WSL2, SSH) there is no local browser. sim login --no-browser prints a URL and a one-time code to open in any browser (on WSL2, the Windows one). Alternatively paste a token with sim auth, or pipe it non-interactively:

sim login --no-browser
printf '%s\n%s\n' "$EMAIL" "$SMTC_TOKEN" | sim auth

For manual setup with an API key, use sim auth.

The auth command prompts for your email and personal access token and writes them to ~/.sim_id (mode 0600 on Unix).

sim auth
# Email: you@example.com
# API key: smtc_xxxxxxxxxxxxxxxx
# Credentials saved for you@example.com

The API key prompt hides input on a TTY (echoes nothing while you type). If stdin is redirected, it falls back to a normal line read.

The token must start with smtc_. The file format is:

EMAIL=you@example.com
API_KEY=smtc_xxxxxxxxxxxxxxxx

Replace or revoke a key

Open API Settings and select Revoke for the key, then confirm. If you lose a key, create a replacement and run sim auth again with it.

Keep keys out of source control and shared logs. If authentication fails, check that the key starts with smtc_, has not been revoked, and belongs to the email you entered.

Listing MCUs

The models command calls the backend and prints the supported MCU model names. The list is fetched fresh every time — nothing is cached on disk.

sim models
# Supported models:
#   stm32f401re
#   stm32f401ce
#   stm32f401ve

Running a simulation

The root command runs a simulation. Provide an ELF binary and a backend MCU model (--mcu).

sim --mcu stm32f401re --elf ./firmware.elf
OptionRequiredDefaultNotes
--mcuyes—MCU model returned by models.
--elfyes—Path to the ELF firmware to load.
--timeoutno15Simulation timeout in seconds (≥ 0).
--outputno./output.txtPath to write simulation output.
--configurationno—STM32CubeMX .ioc configuration file used to apply your clock and pin settings before simulation.
--asciinooffRender UART output as ASCII strings (one line per record) instead of wrapped hex bytes.
--only-messagesnooffStrip the [timestamp] (peripheral) prefix from each output row and emit just the payload.
--groupno—Hierarchical group path. Accepts one or more space-separated values; the first is the top-level group and each subsequent value is a subgroup of the previous (e.g. --group team-a board-x rev2).
--use-cachednooffReuse a cached copy of the MCU model from ~/.sim_cache instead of fetching from the backend. On a cache miss the model is fetched and cached for next time. --mcu flows only.
--real-timenooffPin the sim to ~1× wall-clock. By default the sim free-runs as fast as the host allows; pass this only when something needs real-world pacing.

If the token is missing or invalid, or the MCU is unknown, the command exits with status 1 and a descriptive error on stderr.

Output format

By default, each UART record is rendered as one or more rows of hex bytes wrapped at 16 bytes per row, prefixed with the record's virtual timestamp and source peripheral label:

[0.001234s] (usart2) 48 65 6C 6C 6F 2C 20 77 6F 72 6C 64 21 0D 0A

With --ascii, each record becomes a single line of printable ASCII (bytes outside 0x20–0x7E, including CR/LF, collapse to .):

[0.001234s] (usart2) Hello, world!..

With --only-messages, the [timestamp] (label) prefix is dropped and only the payload is written — useful when piping into another tool that expects a clean stream. --ascii and --only-messages compose:

Hello, world!..

Your board on a stock MCU

Your board is a stock MCU model plus the parts you wired to it. Describe it in a scenario file: mcu names a model from sim models, and overlay is your own board file of extra peripherals.

machines:
  p4:
    mcu: ESP32-P4
    overlay: sensors.board
    elf: image.elf
    symbolsElfPath: app.elf   # unstripped ELF, for --trace-symbol and --memory-stats names

Run it with --scenario in place of --mcu/--elf; every other flag works the same.

sim --scenario board.yaml --ascii --only-messages --timeout 30
// sensors.board: a Modbus probe on UART1 and a relay observer on GPIO4
probe: Sensors.ModbusRtuSensor @ uart1
    slaveAddress: 1
    sequence: "745,-35; 300,-35; 600,-35"   // one entry per Modbus poll, holds the last

relay: Scripted.ScriptedPeripheral @ sysbus 0x500E3000
    size: 0x10
    file: "relay_observer.py"

gpio:
    4 -> relay@0
  • The board file is yours. The MCU model is fetched from the server (gated per account) and updates when we ship fixes, so your board file stays small.
  • Add a peripheral with name: Type @ bus, then wire pins with a gpio: block of N -> name@0 lines.
  • overlay: adds peripherals to one machine. nets: connects pins between machines (GPIO only today).
  • symbolsElfPath points at the unstripped build. Without it --trace-symbol needs raw addresses and --memory-stats cannot name tasks.
  • --mcu X on the command line does not take a board file. Use a scenario.

Scripted peripherals

A scripted peripheral is a small Python class that runs inside the emulation on virtual time. Use one to stand in for a part you have no model for, or to observe a pin. There are nine classes:

ClassAttaches toScript class and hooks
Scripted.ScriptedPeripheralA sysbus address (@ sysbus 0x...)Peripheral: on_access(req), on_gpio(line, value), on_timer(), on_uart_rx(byte)
UART.ScriptedUartPeerA free sysbus slot and media: uartPeer: on_byte(b), optional on_timer
I2C.ScriptedI2cSlaveAn I2C controller and 7-bit address (@ i2c1 0x55)Slave: on_write(bytes), on_read(count) returns bytes, optional on_finish
SPI.ScriptedSpiSlaveAn SPI controller (@ spi1)Slave: on_transmit(b) returns a byte, optional on_finish
CAN.ScriptedCanNodeA free sysbus slot and media: canNode: on_frame(frame), optional on_timer
Wireless.ScriptedBlePeerA free sysbus slot and media: blePeer: on_att(pdu), optional on_connect, on_disconnect(reason), on_timer
Network.ScriptedEthernetPeerA free sysbus slot and media: ethPeer: on_frame(frame), optional on_timer
ScriptedNetworkServiceA networkServices entry, args: is the script pathService: on_data(bytes) returns the reply
ScriptedCellularPeerA networkServices entryPeer: on_dns, accepts, on_connect(conn), on_data(conn, data), on_close(conn)

Each script gets a ctx in __init__(self, ctx). On Scripted.ScriptedPeripheral it offers:

  • log(level, msg), noisy, debug, info, warning, error
  • irq(line) returns an object with set(), clear(), pulse()
  • schedule_periodic(us), schedule_oneshot(us), cancel_timer(): virtual-time timers that call on_timer
  • bus_read(addr, length=4), bus_write(addr, value, length=4), bus_read_bytes(addr, n), bus_write_bytes(addr, data)
  • cpu_reg(name), watch(addr, label), watch_to(path): non-halting CPU inspection
  • uart_write(data): serial TX toward the MCU, when registered to a UART

The other classes share the log family. The CAN, UART, Ethernet, BLE and network-service contexts add send(...). The CAN, UART, Ethernet and BLE contexts add the timers. SPI and I2C slaves are passive: every byte they emit is a return value, and on_finish is advisory because some controllers never call it, so carry your own framing.

Output at info and above is shown on stdout by default, e.g. [0.532688s] (relay) AERATOR ON. This observer logs each change on GPIO4, which proves the pin moved rather than that the firmware believes it did:

class Peripheral:
    def __init__(self, ctx):
        self.ctx = ctx

    def on_access(self, req):
        pass

    def on_gpio(self, line, value):
        # True = pin driven high = relay energized (assumes an active-high driver)
        if value:
            self.ctx.info("AERATOR ON")
        else:
            self.ctx.info("AERATOR OFF")

Finding out what the firmware is doing

Work down this list and stop when you have your answer. The first four never stop virtual time, so they observe the system instead of changing it. Trace lines are written into the --output file (default ./output.txt) interleaved with console output, not to the terminal.

1. Which functions run: --trace-symbol

Repeatable. The name is resolved through symbolsElfPath. For ROM or vendor code with no symbol, pass 0xADDR; nm app.elf | grep name finds it. Each hit prints the first four argument registers: a0..a3 on RISC-V, r0..r3 on Arm.

sim --scenario board.yaml --timeout 30 --trace-symbol gpio_set_level
(TRACE) gpio_set_level a0=0x4 a1=0x1<errno> a2=0x4FF3C92C a3=0x12C
(TRACE) gpio_set_level a0=0x4 a1=0x0 a2=0xFFFF0000 a3=0x1000

Those are gpio_set_level(4, 1) and gpio_set_level(4, 0). A two-argument function leaves a2/a3 as whatever the caller had there. A <name> suffix means the value happens to equal a symbol's address; for small integers like 0x1 it is a coincidence, ignore it.

2. When interrupts fire: --trace-interrupts

One line per interrupt taken or returned from, on the same clock as the symbol traces.

3. Heap and stack headroom: --memory-stats

sim --scenario board.yaml --timeout 30 --memory-stats 500ms
[1.000000s] (MEM) heap_free=460252 heap_largest_free=391436 heap_frag=0.150 IDLE:stack_remaining=1256 sensor:stack_remaining=3416 ...

heap_frag is 1 - largest_free/free, so 0 means one block. <task>:stack_remaining is the FreeRTOS high-water mark in bytes and needs symbolsElfPath. A task that trends toward 0 is a bug before it is a crash. On an ESP32-P4 the same build reported 3416 bytes for one task in the simulator and on silicon.

4. Every access to a variable: --trace-memory

Takes symbol|0xADDR[:len] and prints each access to that range.

sim --scenario board.yaml --timeout 30 --trace-memory aerator_on
(MEM) aerator_on write addr=0x4FF13178 value=0x1

5. GDB, last

A breakpoint halts every machine in a scenario, so it changes the system instead of observing it. When you need source-level stepping, use --gdb-port 3333 on a single machine or gdbPort: per machine in a scenario. Register values are not yet reliable on ESP32-P4.

Scripted peripherals print their own ctx.info() lines on stdout (see Scripted peripherals).

Updating

The update command updates the installed sim binary in place to the latest released version. It reads the published latest.json manifest, compares it against the running binary's version, and — if newer — downloads the build for your platform, verifies its SHA-256, and atomically swaps the running executable.

sim update          # install the latest release if one is available
sim update --check  # report whether a newer version exists, without installing
  • Replaces the file at the running executable's path.
  • Supported on the published platforms only (linux-x64, osx-arm64); other platforms print a message and exit non-zero.
  • Never downgrades — a release will not replace a newer or equal local version.
  • Makes no backend or credential calls; it only reads the public release bucket. If sim lives in a system location, run with sufficient permissions to overwrite it.

Network behavior

Backend calls happen on --mcu flows (and on auth / models). The backend calls are:

  • validate-token — called before fetching MCU details.
  • list-supported-mcus — called by models.
  • get-mcu-details — called by the root command when --mcu is set.

All three require an Authorization: Bearer <smtc_…> header. By default nothing is cached on disk; if the backend is unreachable, those flows fail.

Passing --use-cached opts into a per-MCU on-disk cache under ~/.sim_cache. The first run for a given --mcu still fetches get-mcu-details and writes the result to ~/.sim_cache/<key>.json. Subsequent --use-cached runs read that file and skip both validate-token and get-mcu-details, so a cached MCU simulates with no network calls. Set SIM_CACHE_DIR to use a different directory, and delete the file (or the whole directory) to force a refresh.