Real MeshCore firmware, compiled for desktop architectures, with its radio and its console on a socket.
These are not models of MeshCore. They are MeshCore's own applications —
examples/simple_repeater, examples/companion_radio and the rest — built
unmodified against a host variant, so the forwarding policy, the CSMA timing,
the CLI and the preferences are the firmware's and not a reimplementation of it.
A simulator that decides for itself which packets get relayed is answering a
different question from the one anybody asked.
Worth being exact about, because the interesting bugs live on the boundary.
| Layer | What runs |
|---|---|
| MeshCore application and mesh logic | real, unmodified |
MeshCore radio driver — CustomSX1262, RadioLibWrapper |
real, unmodified |
| RadioLib 7.6.0 — the version MeshCore pins | real, vendored in vendor/, unmodified |
| The SX1262 chip | ours, vendor/virtual-sx1262 (shared with QEMU and Renode) |
| Arduino, board, filesystem, RTC, sensors, RNG | ours — variants/host/ |
| The air | whatever drives the bridge |
Nothing in MeshCore is patched. The build points at a checkout and compiles it as
it stands, with the flags its own platformio.ini sets — RADIOLIB_GODMODE
among them, because its driver reaches into RadioLib's internals.
The radio driver used to be ours: HostRadio, a hand-written stand-in for the
whole stack. It worked, and it quietly answered questions the firmware should
have answered. MeshCore asks its radio "is another station transmitting?" before
it sends; HostRadio decided that, so listen-before-talk was the simulator's
behaviour rather than the firmware's — and two MeshCore versions differing only
in their radio driver produced bit-identical results.
Now RadioLib talks to a virtual SX1262 over a RadioLibHal:
SimHal— pins, SPI, time and interrupts. Sixteen methods, all trivial butspiTransfer, because RadioLib is written to be ported.VirtualSX1262— the chip: command interpreter, registers, data buffer, and the IRQ register, which is the point.PREAMBLE_DETECTEDandHEADER_VALIDare raised from what is actually on the air, at the instant each becomes true, andCustomSX1262::isReceiving()reads and times them exactly as on hardware.
Two deliberate departures from silicon:
- Time is the node's, not the machine's.
millis()andmicros()come from simulated time, because a lockstep run is reproducible only if everything the firmware can observe comes from the simulation. - BUSY is never asserted. Command latency is modelled; the handshake is not. RadioLib spins on that line, and a spin that does not advance simulated time never ends — a hang the firmware cannot tell apart from a fast chip, and worth removing outright.
Alongside each release there is a -faultyirq build: the same firmware, on a
virtual radio that misbehaves the way real ones do.
Real SX1262s sometimes latch their detection interrupts and refuse to clear them. A driver that trusts those flags then believes the channel is permanently busy and stops transmitting — the "4 second lock-up" MeshCore's own release notes describe, and the reason its 1.17 driver stopped trusting the flags and started timing them out instead.
On a chip that behaves, 1.16 and 1.17 are indistinguishable. That is not a guess: twelve runs across a 154-node mesh produced byte-identical results, because the recovery path 1.17 added never had anything to recover from.
So the fault is a build:
repeater-v1.17.0 a chip that behaves
repeater-v1.17.0-faultyirq a chip that latches its detection flags
Making it a variant rather than a runtime switch means an experiment can select it exactly like any other firmware version, and "does this release survive a radio that sticks?" becomes a question you can put in a matrix and measure.
The chip also reports what the firmware asked it: how many times the interrupt register was read, how many of those reads found a busy flag set, how long the flags were up, and how many preambles it raised. That is what separates "the mesh is genuinely busy" from "our chip cries busy too readily" — and the second would look exactly like a finding about the firmware.
There is no list of supported node types here, and that is deliberate. A
MeshCore node is a radio in a place running an application; whether it is a
repeater, a companion, a room server or a sensor is settled entirely by which
application is linked. So the pipeline reads examples/ at build time and builds
whatever it finds. When upstream ships a new kind of node, it appears in the next
nightly release without this repository changing.
The same goes for versions: "every previous release" is not a fixed list, it is whatever upstream has tagged by the time the workflow runs.
| release | tracks |
|---|---|
main |
upstream's main branch, rebuilt when it moves |
dev |
upstream's dev branch, marked pre-release |
<tag> |
each upstream tag, built once and never rebuilt |
Each release carries one binary per role per platform, named
meshcore-<role>-<os>-<arch>:
| x86 | x64 | arm64 | |
|---|---|---|---|
| Linux | ✓ | ✓ | ✓ |
| Windows | ✓ | ✓ | ✓ |
| macOS | — | ✓ | ✓ |
macOS has had no 32-bit userland since Catalina, so there is nothing to build there.
A release body contains a meshcore-commit: line. That is not decoration — the
next run reads it back to decide whether the ref still needs building, which is
what stops a nightly schedule recompiling three tags that have not changed since
they were cut.
meshcore-simple_repeater-linux-amd64 --bridge 127.0.0.1:9000 --seed 4417 --sf 10 --bw-khz 250 --cr 1
The binary connects to the simulator and then does nothing on its own clock. The
simulator owns time: it sends a tick, the node runs loop() once per simulated
millisecond and acknowledges. That is what makes a run reproducible from its
seed, and it is why delay() here does nothing — a firmware that busy-waited for
millis() to move would wait forever.
--print-airtime N reports the firmware's own getEstAirtimeFor(N) and exits,
so a caller can check that its channel model and this build still agree about how
long a packet occupies the air. Two copies of a formula that nothing compares are
two formulas.
variants/host/ is a MeshCore board like any other: it provides target.h,
the four globals every variant declares, and the platform underneath them.
Nothing in it is role-aware.
What it stands in for, and why it answers the way it does:
- The radio is a socket. Transmission reaches the wire immediately and is
not immediately complete —
isSendComplete()stays false until the simulator says the waveform ended, because how long a signal occupied the channel is a property of the samples the simulator generated, not of a formula this end could evaluate. - The clock is the simulator's.
millis()reads a variable the bridge writes. - The filesystem is real files under a directory the simulator owns, with the
block geometry of an nRF52840's
InternalFS. The identity, preferences and ACL a repeater writes are genuinely persisted, so a CLI that changes a setting changes something. - Randomness is seeded. Identity generation on hardware samples radio noise, which is correct there and the one thing that cannot be replayed.
- Sensors and I²C report absent. That is a case the firmware already handles, and it is a truthful answer rather than a plausible invented reading.
roles.d/<role>.flags is optional per-role build flags. Anything without a file
there builds with the defaults, which is the point.
MESHCORE=path/to/MeshCore CRYPTO=path/to/arduinolibs/libraries/Crypto \
./build.sh simple_repeater out
Cross-compiling is TARGET_OS, TARGET_ARCH, CXX, CC and EXTRA_FLAGS.
Sources that will not compile for a host are dropped rather than failing the build — a helper that wants an SPI bus has nothing to say here. If dropping one leaves the link short of a symbol, that role does not build on that platform for that version of MeshCore, and the build says so instead of papering over it.
MIT. MeshCore is MIT, this links MeshCore, and that is why these builds are
released from here rather than from the simulator that consumes them — see
NOTICE.md.