Running real firmware

Every node in a MeshBench scenario runs an actual MeshCore build. Not a model of one, not a re-implementation of the protocol: the same source, compiled, running as a process, keeping its own state on disk.

There are two ways to do that, and they answer different questions.

nativeemulated
what runsMeshCore compiled for the hostthe published board image
the radioa shim linked in place of the driveran SX1262 model over SPI
speedfaster than real time on small networkswall time, always
deterministicyesno
cost per nodea few MB, a fraction of a core~150 MB, ~1 core
used forstudies, CI gates, everything measuredconformance: does this release work

Every measurement in this project is native. See Emulating a board for the other path.

How a native node is built#

meshcore-native/build.sh <role> [outdir] compiles MeshCore for the host. It does three things that are not obvious:

  1. Replaces the radio driver with a shim that speaks to the channel rather than to hardware over SPI.
  2. Provides the Arduino-shaped environment MeshCore expects: a millisecond clock, an RTC, a filesystem, an RNG. The clock is driven by the engine, not by the host, which is what makes runs reproducible.
  3. Drops board-specific sources that cannot compile for a host, and stops with a clear error if what remains does not link. A build that half-worked would be worse than one that refuses.

Roles map to MeshCore's own examples:

node kindrolewhat it is
simple repeatersimple_repeaterforwards, has a console
advanced repeatersimple_repeatersame application, different profile
companioncompanion_radiowhat a phone attaches to
room serversimple_room_servera console like a repeater, but does not relay
SDR observernonereceives, never transmits, runs no firmware
emitternonea carrier, speaks no protocol

Per-node state, and the trap in it#

Each node gets its own working directory under ~/.cache/meshbench/nodefs, keyed by name. The firmware writes its identity, preferences, channels and contacts there and reads them back at boot, exactly as hardware does.

Saved node state beats a compiled default. A node that has run before loads its stored value and never reaches the changed default. Both arms of a comparison then return identical numbers and the change looks inert. It fails silently, in both arms, which is the worst way for a comparison to fail.

Two ways out:

Provisioning: what a node is told at boot#

An unprovisioned node advertises the firmware's built-in name, has no position, believes the time is zero, and holds no regions. It is not broken, and it will not do anything useful either.

At firmware start each node is given, in order:

set name <the node's name on the map>
time <the scenario's epoch, not the wall clock>
set lat <lat> / set lon <lon>
region put <r> / region allowf <r>   (for each region it holds)
region save
region default <its default scope>
set flood.max.advert <cap>

The clock comes from the scenario rather than the host so that runs stay reproducible. Regions come from the node itself, because they were observed from real traffic and are a fact about that node.

set name identity: what its adverts carry time a shared clock - the scenario’s, so runs reproduce set lat / set lon position: what the physics prices region put / allowf what it relays, observed from real traffic region save persisted, as hardware would region default the scope its own traffic goes out on set flood.max.advert a hop ceiling on adverts
Seven console lines, issued in this order at every firmware start. Before them a node is not broken - and not useful either.

The region half of this is shared code, in internal/app/fixture, used by both the workbench and the headless test runner. It contains the # asymmetry, which this project has paid for twice, and two copies of that rule would eventually disagree.

Talking to a running node#

console.type runs a line on a node's CLI and returns what it said, which is the fastest way to find out what a node actually believes rather than what its configuration was assumed to say.

Open the node's own window on its Console tab, type the command, press Enter.

{"id":1,"method":"console.type","params":{"node":"Bathgate room","command":"get repeat"}}
print(wb.nodes["Bathgate room"].console.ask("get repeat"))
reply, err := wb.Node("Bathgate room").Console().Ask(ctx, "get repeat", 100)

The command reference is at <https://docs.meshcore.io/cli_commands/>. There is no region list and no help; both answer Err - ??, which looks like a broken node and is a command that does not exist.

Console replies come back empty while a sweep is driving the engine. The reply is collected after a 50 ms step and the experiment owns the clock. Stop the experiment first.

The companion interface#

A companion node's serial protocol is offered over TCP or a virtual serial device, byte for byte as the firmware produces it. That is what the Companion bench is for, and what `meshbench test

-endpoint` exposes headlessly.

MeshBench documentation. Built from the running application, not from mock-ups. Screenshots are window-only captures; see CLAUDE.md for the rule that keeps them current. Edit this page.