Skip to content

A firmware

A controller simulated in the editor, then the same controller on a development board on your desk, reading the board’s own sensors. This page is that loop and nothing more: how to watch generated code run on silicon, not a production deployment story. What the board reads and what the controller answers can be plotted live over the debug probe, beside whatever the firmware drives.

The model is an ordinary package. The model that ships is one model in it, say controller::Controller in src/controller.mag, and the simulation root is another, stepping a plant and the controller together. Give the controller its own clock with @every(10ms), so its dt is the same number in the simulation and on the board.

If the model that ships steps a source (a sine, a step, anything whose step takes no input), the canvas outlines it with a warning. A signal made from time alone belongs to the simulation root, and on the board it would run too, in place of what the board should read.

The firmware is your crate, not ours. It owns the board:

  • the clocks;
  • the peripherals;
  • the sensor reads;
  • the timer the controller runs on.

Its .cargo/config.toml says what it builds for, as it would for any embedded crate. It steps the model one of two ways.

By hand, depending on the generated crate by path and calling step:

firmware/Cargo.toml
[dependencies]
rig = { path = "../target/codegen/release/rig" }
use rig::controller::{Controller, ControllerParameters};
use rig::{Ctx, Model};
const PARAMS: ControllerParameters = ControllerParameters { /* … */ };
let mut controller = Controller::init(&PARAMS);
let ctx = Ctx { dt: core::time::Duration::from_millis(10) };
loop {
let inputs = read_sensors();
let outputs = controller.step(&ctx, &PARAMS, inputs);
drive_outputs(outputs);
wait_for_next_tick();
}

The parameters are a struct literal because on the board they are yours to own: the same values the params item states, written by the code that calls the model.

Through the embassy glue, which magnet build --runtime embassy writes to target/codegen/embassy/<package>/ as the crate <package>-embassy. It writes no deliverable of its own: the glue crate is it, and the firmware depends on that one crate and on nothing else magnet writes:

firmware/Cargo.toml
[dependencies]
rig-embassy = { path = "../target/codegen/embassy/rig" }

Implement Io over your sensors and actuators, and await run in a task:

use rig_embassy::{Driver, Inputs, Io, Outputs, Settings, Tick, row, run};
struct Board { /* peripherals, the RTT channel */ }
impl Io for Board {
fn read(&mut self) -> Inputs {
Inputs { accel: self.read_accel() }
}
fn write(&mut self, tick: Tick, inputs: &Inputs, outputs: &Outputs) {
self.drive(outputs);
let _ = row(&mut self.telemetry, tick, inputs, outputs);
}
}
#[embassy_executor::task]
async fn control(mut board: Board) {
run(Driver::new(&Settings::default()), &mut board).await
}

Settings are the constructor’s arguments, with a Default when every one has a default. Driver::recalibrate takes new ones from the next tick on.

run steps the model at its own period, PERIOD, which is the one it declares and not a constant the firmware repeats. One task runs every rate inside it, each child on the ticks its period falls on, exactly as the simulation steps it. A model with no rate anywhere has no period for the loop and is refused.

row writes a tick as the telemetry row Watch plots, under the columns HEADER lists, so what the firmware sends is what the plot reads.

The manifest says which is which, in one table:

[deploy]
model = "controller::Controller"
chip = "…" # as `probe-rs chip list` prints it
firmware = "firmware"
runtime = "embassy" # only with the glue: flash regenerates it before building

With runtime = "embassy", magnet flash writes and builds the glue before it builds the firmware, so the firmware’s dependency is regenerated for the model on every flash. Without the key, flash writes the model crate alone, for a firmware that steps it by hand.

The model line can be written from the editor too: the Deployment layout’s Model row lists every model the package can ship, and a .mag file’s context menu in the explorer has Set as Deploy Model. Either writes the line into Magnet.toml, keeping its comments, and the explorer marks the chosen file with a star.

The board is chosen there as well. The Firmware row lists every crate in the package that builds a binary, each with the chip its .cargo/config.toml runner line hands probe-rs (runner = "probe-rs run --chip …"). Picking one writes both firmware and chip. A firmware whose runner specifies no chip keeps the chip already written, and the Chip field takes one typed by hand. With both rows set, nothing about a flash needs a terminal.

  1. magnet gui, open the simulation root, and Run. Probe what the controller estimates against what the plant knows, and change the model until the plot is right.

  2. Plug the board in by its debug USB port.

  3. magnet flash, or, in the editor, the Deployment layout’s Flash, which shows the [deploy] table beside the button. It then:

    1. writes the release crate to target/codegen/release/rig/ (and the glue, with runtime = "embassy");
    2. builds the firmware against it with cargo;
    3. writes the binary to the chip with probe-rs.

    Each tool’s own output streams as it runs.

Nothing about the controller changes between step 1 and step 3. target/codegen/ is generated, so the firmware’s path dependency only resolves once magnet build or magnet flash has run, which is why flash is the one command to use. A simulation writes its own crate, in target/codegen/sim/, and never the release/ one the firmware reads.

Once a flash has written the controller, the Deployment layout’s Watch plots it live: what the board read and what the controller answered, streamed over the same debug probe while the board runs on its own sensors. The plot is the simulation view’s, so the same probes and plot cards read the board and the simulation alike. Stop watching detaches and leaves the board running; a flash stops a watch first, since one tool holds the probe at a time.

For the plot to have rows, the firmware writes one per step on an RTT up channel named telemetry, which is what the glue’s row does:

tick,t,<the step's inputs…>,<the step's outputs…>

Arrays are flattened as the simulation flattens them (accel[0], accel[1], …), t is the board’s clock in seconds, and each row is written in one call, so a busy probe drops rows whole rather than cells of them.

Watch reads the rows against the model the last flash wrote: after an edit that changes the model’s inputs or outputs, it asks you to flash again rather than plotting the board’s numbers under the wrong names.

  • probe-rs on the PATH: cargo install probe-rs-tools.
  • The firmware’s target installed: rustup target add <triple>.
  • The board connected. probe-rs list shows what is.

Each of those, missing, is a refusal that describes the fix. flash refuses everything it can before cargo starts, so a flash with the cable out fails at once rather than after a release build. That includes:

  • no chip or firmware from either place;
  • a firmware directory with no crate in it;
  • a model the package does not declare.

See [deploy] in the manifest reference.