From b1b05eca1abd4a04e34681fa981544c61016cddf Mon Sep 17 00:00:00 2001 From: R0ck Date: Fri, 4 Sep 2026 00:07:11 +0100 Subject: [PATCH 1/4] MSIM-560 the emulators hold the chip, and radioserver is gone An emulated node ran in two processes: the emulator, and a radioserver that owned the SX1262 and forwarded SPI to it over a socket. The chip moved into its own repository, MeshBench/virtual-sx1262, and both emulators now load it directly. That leaves one process per node and one socket, to the RF engine, which is genuinely elsewhere because the channel is shared with every node in the scenario. What the socket cost, beyond the round trips: It framed SPI a byte at a time, a path less exercised than the buffered one. That is how GetRssiInst came to answer correctly for a native node and zero for an emulated one for months, which is where the firmware takes its entropy: every emulated board derived its identity from a constant. DIO1 could only be polled, because the protocol was request-response. A millisecond is a long time to hold a receive interrupt, and a packet delivered and acknowledged inside one sampling gap raised the pin never. And it put three clocks in three processes in front of anybody asking what happened when. Protocol implementations go down rather than up: four, counting SPI in QEMU, Renode and radioserver plus the engine's, become two. Renode's peripheral is rewritten around the library it loads, in three files because one was 548 lines and the limit is 500: the loader and the ABI, the line to the engine, and the pins and SPI the firmware can see. It resolves the library by path from MESHBENCH_RADIO_LIB rather than by DllImport name, because Renode may be running on Mono or .NET and NativeLibrary is not on both. Two things found on the way: A release bundle never carried Renode's platform descriptions or our own peripherals, so an nRF52 board could not start from one at all. SupportDir looks beside the binary first, every bundle now copies them there, and verify-bundle refuses a bundle without them. heltec_t096 carries a front-end module and no .repl wires its pin, so its FEM reports as never switched in. That is unchanged here and said out loud at the call site; wiring it means a pin per board profile, read from each variant. Measured, one board at a time on an idle machine, with no radioserver process in either run: Generic_E22_sx1262 under QEMU and RAK_4631 under Renode both pass every row of the board probe, flood included, at 2 of 2 attempts. This must not merge before a QEMU release carrying the new device exists and QEMU_RELEASE names it. The pinned v9.2.2-meshbench-sx1262-10 still has the socket device and does not know radio-bridge, so a fetched QEMU would refuse to start. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- .claude/skills/meshbench-scripting/SKILL.md | 4 +- .claude/skills/meshcoresim/SKILL.md | 5 +- .github/workflows/package.yml | 101 +++-- .golangci-baseline.txt | 2 +- README.md | 2 +- docs/emulated-published-firmware.md | 3 +- docs/install.md | 6 +- docs/native-and-emulated.md | 12 +- docs/packaging-emulation.md | 10 +- docs/repositories.md | 10 +- docs/shortcomings.md | 2 +- internal/app/resource/toolchain_test.go | 26 +- internal/app/resource/toolchaincatalogue.go | 51 ++- internal/app/resource/toolchainpins_test.go | 8 +- internal/app/session/emulatortools_test.go | 2 +- .../session/resources/resourceverbs_test.go | 4 +- internal/app/session/resources/setuptools.go | 2 +- internal/fetchasset_test.go | 5 +- .../emulated/emulated_lifecycle_test.go | 15 +- internal/firmware/emulated/emulated_node.go | 151 +++----- .../firmware/emulated/emulated_node_test.go | 2 +- internal/firmware/emulated/emulated_reap.go | 15 +- internal/firmware/emulated/qemu/machine.go | 14 +- .../firmware/emulated/qemu/machine_test.go | 4 +- internal/firmware/emulated/renode.go | 31 +- internal/firmware/emulated/tools.go | 100 +++-- internal/firmware/emulated/tools_test.go | 37 ++ packaging/emulator-pins.env | 19 +- packaging/verify-bundle.sh | 23 +- packaging/windows-msi.sh | 2 +- tools/renode/README.md | 4 +- tools/renode/armfw-radio.resc | 4 +- tools/renode/peripherals/README.md | 2 +- tools/renode/peripherals/RadioServerSX1262.cs | 268 -------------- tools/renode/peripherals/VirtualSX1262.cs | 280 ++++++++++++++ .../renode/peripherals/VirtualSX1262Engine.cs | 350 ++++++++++++++++++ tools/renode/peripherals/VirtualSX1262Lib.cs | 216 +++++++++++ tools/renode/peripherals/radio-only.repl | 9 + ...{radioserver_test.resc => radio-only.resc} | 24 +- tools/renode/peripherals/radioserver.repl | 8 - tools/renode/radio-legacy.repl | 6 +- tools/renode/radio.repl | 12 +- tools/renode/rak4631-radio.resc | 4 +- tools/renode/spim3.repl | 6 +- 44 files changed, 1273 insertions(+), 588 deletions(-) create mode 100644 internal/firmware/emulated/tools_test.go delete mode 100644 tools/renode/peripherals/RadioServerSX1262.cs create mode 100644 tools/renode/peripherals/VirtualSX1262.cs create mode 100644 tools/renode/peripherals/VirtualSX1262Engine.cs create mode 100644 tools/renode/peripherals/VirtualSX1262Lib.cs create mode 100644 tools/renode/peripherals/radio-only.repl rename tools/renode/peripherals/{radioserver_test.resc => radio-only.resc} (53%) delete mode 100644 tools/renode/peripherals/radioserver.repl diff --git a/.claude/skills/meshbench-scripting/SKILL.md b/.claude/skills/meshbench-scripting/SKILL.md index 34e44b5c..a0257cf4 100644 --- a/.claude/skills/meshbench-scripting/SKILL.md +++ b/.claude/skills/meshbench-scripting/SKILL.md @@ -235,12 +235,12 @@ matched on `floor` as the catch-all now silently sees fewer of them. Group an - Unix socket paths are capped at 104 bytes. The scratchpad path is longer than that, so let the client choose the address. - **An emulated board no longer needs a hand-built toolchain.** - `resource.fetch` downloads `radioserver`, `qemu-system-xtensa` and `renode` + `resource.fetch` downloads `virtual-sx1262`, `qemu-system-xtensa` and `renode` into `~/.cache/meshbench/tools/`, which is where a boot already looks, so no environment variable is needed afterwards. **Pass `kind: "toolchain"`**: the parameter defaults to `softdevice`, and a fetch that omits it asks for the wrong thing. QEMU and Renode are published for linux/amd64 only; macOS gets - `radioserver` alone and Windows nothing, and `resource.list` says which with + the chip model alone and Windows nothing, and `resource.list` says which with a reason. - A killed run can leave an emulator behind. Check `pgrep -f qemu-system`. - Firmware roles on disk: `ls ~/.cache/meshbench/firmware/native/`. diff --git a/.claude/skills/meshcoresim/SKILL.md b/.claude/skills/meshcoresim/SKILL.md index a82c23be..c58c7907 100644 --- a/.claude/skills/meshcoresim/SKILL.md +++ b/.claude/skills/meshcoresim/SKILL.md @@ -342,13 +342,13 @@ one (ADR-0010). A node runs emulated when its `Firmware.Board` is set; empty means the host build. **The toolchain is a download now, not a build.** `resource.fetch` with -`kind: "toolchain"` fetches `radioserver`, `qemu-system-xtensa` and `renode` +`kind: "toolchain"` fetches `virtual-sx1262`, `qemu-system-xtensa` and `renode` into `~/.cache/meshbench/tools/`, which is step three of the lookup a boot already performs, so nothing has to be set afterwards. The `kind` parameter **defaults to `softdevice`**, so a fetch that omits it asks for the wrong thing. `resource.list` says what is present and what it cost; `setup.check` says the same beside everything else that is missing. QEMU and Renode are published for -linux/amd64 only, macOS gets `radioserver` alone, and Windows nothing, each with +linux/amd64 only, macOS gets the chip model alone, and Windows nothing, each with its reason. An emulated nRF52 board additionally needs the Nordic s140 SoftDevice, which is its own `softdevice` resource. @@ -385,7 +385,6 @@ and is not worth subtracting from another arm. | QEMU with our SX1262, GPIO and fixes | `MeshBench/qemu` branch `meshbench-sx1262` | | Renode with the SEVONPEND fix | `MeshBench/renode` and `MeshBench/tlib` | | The chip model | `MeshBench/virtual-sx1262`, MIT, its own repository | -| The socket server that hosts it | `meshcore-native`, `bridge/radioserver.cpp` | | Per-board wiring | `internal/firmware/board/board_.go` | ### The chip model is a submodule, and a submodule does not follow anything diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 29b62359..ac110c1b 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -76,7 +76,7 @@ jobs: with: { go-version: '1.25', cache: false } # Checked before anything is built, because a missing tool used to - # surface eight steps later as "no radioserver-v1 release" - naming a + # surface eight steps later as "no such release" - naming a # release that exists. That was gh, which is not part of a bare Ubuntu # image; the fetches use curl now, which is here and was already used # for the emoji font. @@ -197,15 +197,17 @@ jobs: run: | set -e # No || here. It used to swallow the downloader's own error and - # report "no radioserver release", which was wrong - the release - # existed and gh was not installed - and sent the reader to the wrong - # repository. set -e stops the job; the script says why. + # report "no release", which was wrong - the release existed and gh + # was not installed - and sent the reader to the wrong repository. + # set -e stops the job; the script says why. . packaging/emulator-pins.env - tools/fetch-release-asset.sh MeshBench/meshcore-native "$RADIOSERVER_RELEASE" \ - "/$RADIOSERVER_ASSET_LINUX_AMD64\$" dist/meshbench - mv "dist/meshbench/$RADIOSERVER_ASSET_LINUX_AMD64" dist/meshbench/radioserver - chmod +x dist/meshbench/radioserver - dist/meshbench/radioserver 2>&1 | head -1 || true + tools/fetch-release-asset.sh MeshBench/virtual-sx1262 "$CHIPMODEL_RELEASE" \ + "/$CHIPMODEL_ASSET_LINUX_AMD64\$" dist/meshbench + tar xzf "dist/meshbench/$CHIPMODEL_ASSET_LINUX_AMD64" -C dist/meshbench + mv dist/meshbench/virtual-sx1262-linux-amd64/lib/libvirtualsx1262.so \ + dist/meshbench/ + rm -rf dist/meshbench/virtual-sx1262-linux-amd64 \ + "dist/meshbench/$CHIPMODEL_ASSET_LINUX_AMD64" - name: Fonts and fixtures run: | @@ -217,6 +219,17 @@ jobs: curl -fsSL -o dist/meshbench/fonts/NotoColorEmoji.ttf \ https://github.com/googlefonts/noto-emoji/raw/main/fonts/NotoColorEmoji.ttf \ || echo "::warning::emoji font not fetched, bundle falls back to system fonts" + # Renode's platform descriptions and our own C# peripherals. They are + # ours, they are text, and Renode reads them at runtime rather than + # having them compiled in - so a bundle without them ships two + # emulators and can start a board on only one of them. That reads as a + # broken emulator rather than as a missing file, which is why this is + # here rather than in a note. + mkdir -p dist/meshbench/renode-support/peripherals + cp tools/renode/*.repl dist/meshbench/renode-support/ + cp tools/renode/peripherals/*.cs tools/renode/peripherals/*.repl \ + dist/meshbench/renode-support/peripherals/ + # The shipped fixtures, so the workbench has something to open on a # machine with nothing else on it. cp -r fixtures dist/meshbench/fixtures @@ -268,10 +281,10 @@ jobs: qemu-system-xtensa is a symlink on purpose. QEMU resolves its own path to find its data files, so a bare copy of the binary will not run. - radioserver is the SX1262 model the emulated firmware clocks over a - socket. It is the same chip object a native node uses in process, so - an emulated node and a native one are the same radio. Both emulators - need it; neither works without it. + libvirtualsx1262 is the SX1262 itself, loaded by whichever emulator + runs a board. It is the same model a native node links, so an emulated + node and a native one are the same radio. Both emulators need it; + neither works without it. Native MeshCore builds and board images are downloaded on first use and cached under ~/.cache/meshbench. Nothing else is needed. @@ -330,14 +343,16 @@ jobs: APP=dist/AppDir mkdir -p $APP/usr/bin $APP/usr/share/applications \ $APP/usr/share/metainfo $APP/usr/share/meshbench - # radioserver too: it is 40 KB, and without it an installed - # MeshBench cannot emulate even when the user supplies their own - # QEMU. The emulators themselves stay out of the AppImage and the + # The chip model too: it is a hundred kilobytes, and without it an + # installed MeshBench cannot emulate even when the user supplies their + # own QEMU. The emulators themselves stay out of the AppImage and the # .deb on size grounds - 110 MB against 26 - so those two are the # application, and the tarball is the batteries-included download. - cp dist/meshbench/meshbench dist/meshbench/radioserver $APP/usr/bin/ + cp dist/meshbench/meshbench dist/meshbench/libvirtualsx1262.so $APP/usr/bin/ cp -r dist/meshbench/fixtures dist/meshbench/fonts \ dist/meshbench/LICENCES $APP/usr/share/meshbench/ + # Beside the binary, which is where SupportDir looks first. + cp -r dist/meshbench/renode-support $APP/usr/bin/ cp packaging/meshbench.desktop $APP/usr/share/applications/ cp packaging/io.github.meshbench.meshbench.metainfo.xml $APP/usr/share/metainfo/ for px in 16 24 32 48 64 128 256 512; do @@ -372,7 +387,7 @@ jobs: mkdir -p $d cp packaging/icons/meshbench-${px}.png $d/io.github.meshbench.meshbench.png done - cp $APP/usr/bin/meshbench $APP/usr/bin/radioserver $DEB/usr/bin/ + cp $APP/usr/bin/meshbench $APP/usr/bin/libvirtualsx1262.so $DEB/usr/bin/ cp -r $APP/usr/share/meshbench/* $DEB/usr/share/meshbench/ cp packaging/meshbench.desktop $DEB/usr/share/applications/ cp packaging/io.github.meshbench.meshbench.metainfo.xml $DEB/usr/share/metainfo/ @@ -519,12 +534,22 @@ jobs: if [ -f "$EMU/Release/osx-arm64/Renode" ]; then ln -sf ../Resources/emulators/Release/osx-arm64/Renode "$APP/Contents/MacOS/renode" fi - # The radio model. A real file rather than a link: it is 40 KB and + # The chip model. A real file rather than a link: it is small and # resolves nothing from its own path. - tools/fetch-release-asset.sh MeshBench/meshcore-native "$RADIOSERVER_RELEASE" \ - "/$RADIOSERVER_ASSET_DARWIN_ARM64\$" "$APP/Contents/MacOS" - mv "$APP/Contents/MacOS/$RADIOSERVER_ASSET_DARWIN_ARM64" "$APP/Contents/MacOS/radioserver" - chmod +x "$APP/Contents/MacOS/radioserver" + tools/fetch-release-asset.sh MeshBench/virtual-sx1262 "$CHIPMODEL_RELEASE" \ + "/$CHIPMODEL_ASSET_DARWIN_ARM64\$" "$APP/Contents/MacOS" + tar xzf "$APP/Contents/MacOS/$CHIPMODEL_ASSET_DARWIN_ARM64" -C "$APP/Contents/MacOS" + mv "$APP/Contents/MacOS/virtual-sx1262-macos-arm64/lib/libvirtualsx1262.dylib" \ + "$APP/Contents/MacOS/" + rm -rf "$APP/Contents/MacOS/virtual-sx1262-macos-arm64" \ + "$APP/Contents/MacOS/$CHIPMODEL_ASSET_DARWIN_ARM64" + # Renode's platform descriptions and our own peripherals, beside the + # binary where SupportDir looks first. Without them this bundle can + # start an ESP32 board and not an nRF52 one. + mkdir -p "$APP/Contents/MacOS/renode-support/peripherals" + cp tools/renode/*.repl "$APP/Contents/MacOS/renode-support/" + cp tools/renode/peripherals/*.cs tools/renode/peripherals/*.repl \ + "$APP/Contents/MacOS/renode-support/peripherals/" # What the app actually carries, asked the way a node asks, before it # is signed and sealed into a dmg nobody will open again. packaging/verify-bundle.sh "$APP/Contents/MacOS" darwin-arm64 @@ -686,13 +711,19 @@ jobs: ls -la cd ../.. - # The radio model. It takes the TCP transport - the simulator asks - # for ":0" on Windows for both emulators, because Windows has no - # Unix socket mingw can reach. - tools/fetch-release-asset.sh MeshBench/meshcore-native "$RADIOSERVER_RELEASE" \ - "/$RADIOSERVER_ASSET_WINDOWS_AMD64\$" dist/meshbench - mv "dist/meshbench/$RADIOSERVER_ASSET_WINDOWS_AMD64" dist/meshbench/radioserver.exe - file dist/meshbench/radioserver.exe + # The chip model, which whichever emulator runs a board loads. + tools/fetch-release-asset.sh MeshBench/virtual-sx1262 "$CHIPMODEL_RELEASE" \ + "/$CHIPMODEL_ASSET_WINDOWS_AMD64\$" dist/meshbench + tar xzf "dist/meshbench/$CHIPMODEL_ASSET_WINDOWS_AMD64" -C dist/meshbench + mv dist/meshbench/virtual-sx1262-windows-amd64/lib/libvirtualsx1262.dll \ + dist/meshbench/ + rm -rf dist/meshbench/virtual-sx1262-windows-amd64 \ + "dist/meshbench/$CHIPMODEL_ASSET_WINDOWS_AMD64" + file dist/meshbench/libvirtualsx1262.dll + mkdir -p dist/meshbench/renode-support/peripherals + cp tools/renode/*.repl dist/meshbench/renode-support/ + cp tools/renode/peripherals/*.cs tools/renode/peripherals/*.repl \ + dist/meshbench/renode-support/peripherals/ # What the zip actually carries, asked the way a node asks. packaging/verify-bundle.sh dist/meshbench windows-amd64 cat > dist/meshbench/README.txt <<'TXT' @@ -711,10 +742,10 @@ jobs: a memory stick, a build agent, or anywhere an installer is unwanted. Whichever emulators are published for Windows are in this zip, with - radioserver.exe, the SX1262 model they clock over a socket. They are - found automatically, and they are MeshBench builds rather than - upstream ones - ours carry an SX1262 device and a SEVONPEND fix that - published firmware needs. + libvirtualsx1262.dll, the SX1262 they load. They are found + automatically, and they are MeshBench builds rather than upstream + ones - ours carry an SX1262 device and a SEVONPEND fix that published + firmware needs. Help > Setup cannot download a replacement on Windows - it reads ELF and Mach-O headers rather than PE - so the zip is where they come @@ -739,7 +770,7 @@ jobs: # agent, or anyone who wants no installer. # # What the installer adds is a deliberate location. This bundle finds - # the emulators and radioserver.exe beside meshbench.exe, so a user who + # the emulators and the chip model beside meshbench.exe, so a user who # unpacks the zip into Downloads and then moves the one executable # somewhere tidy - which is the natural thing to do with a single # executable - has broken emulation and will not be told why. The diff --git a/.golangci-baseline.txt b/.golangci-baseline.txt index 31796090..9285b5a2 100644 --- a/.golangci-baseline.txt +++ b/.golangci-baseline.txt @@ -1,5 +1,5 @@ contextcheck 5 dupl 10 -gosec 32 +gosec 31 noctx 10 recvcheck 2 diff --git a/README.md b/README.md index 3f99e125..d7e613e3 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ nobody has watched that board do that thing. ✓ passed ✗ failed – not applicable ? not measurable yet blank not attempted -Measured on 3 September 2026 against `radioserver-v3`, one board at a time on +Measured on 3 September 2026 against `virtual-sx1262` v1.3.0, one board at a time on an idle machine. Every row above is a run from that day except the two blanks. What each board's row means in detail is in diff --git a/docs/emulated-published-firmware.md b/docs/emulated-published-firmware.md index 8c6187cd..366d114e 100644 --- a/docs/emulated-published-firmware.md +++ b/docs/emulated-published-firmware.md @@ -160,7 +160,8 @@ Every `✗` in the **flood** column above, on both MCU families, came from two faults in `VirtualSX1262` rather than from anything about the boards. Both are fixed, in [MeshBench/virtual-sx1262](https://github.com/MeshBench/virtual-sx1262) and -released as `radioserver-v3`. +released as `radioserver-v3`, which was the last of them: the chip moved into +the emulators and that process was retired. **The carrier-detect flags outlived the carrier.** `PREAMBLE_DETECTED` and `HEADER_VALID` were latched and never cleared when the air went quiet. diff --git a/docs/install.md b/docs/install.md index fed7b678..bb164366 100644 --- a/docs/install.md +++ b/docs/install.md @@ -107,7 +107,7 @@ msiexec /i meshbench-0.2.0-windows-x86_64.msi ADDLOCAL=Complete,DesktopShortcut `meshbench-*-windows-x86_64.zip` is the same build with nothing installed. Unzip it anywhere and run `meshbench.exe`. Take this one for a memory stick, a build agent, or anywhere an installer is unwanted. **Keep the folder together**: -`meshbench.exe` finds the emulators and `radioserver.exe` beside itself, so +`meshbench.exe` finds the emulators and the chip model beside itself, so moving the `.exe` out on its own leaves a build that cannot emulate a board and will not say why. That is the main thing the installer exists to prevent. @@ -156,8 +156,8 @@ invisible here and the wrong build. Ours carry an SX1262 device and the SEVONPEND fix respectively; a stock build starts, reports no chip or hangs, and looks like a MeshBench fault. -From a source checkout, `radioserver` can also be built rather than fetched: -`./build.sh radioserver out` in a `MeshBench/meshcore-native` clone, then copy +From a source checkout, the chip model can also be built rather than fetched: +`./build.sh shared` in a `MeshBench/virtual-sx1262` clone, then copy the binary into the tools directory. Everything the application ships under is listed in **Help → Licences & diff --git a/docs/native-and-emulated.md b/docs/native-and-emulated.md index 115e41f8..3db284e7 100644 --- a/docs/native-and-emulated.md +++ b/docs/native-and-emulated.md @@ -155,7 +155,7 @@ looks, so nothing else has to be set. From a script: ``` resource.list # what is here, and what could be -resource.fetch {"kind":"toolchain","name":"radioserver"} +resource.fetch {"kind":"toolchain","name":"virtual-sx1262"} ``` Doing it by hand still works, and is what to do on a platform the page says has @@ -164,7 +164,7 @@ no build: ```bash mkdir -p ~/.cache/meshbench/tools ln -sf /path/to/qemu-system-xtensa ~/.cache/meshbench/tools/ -cp /path/to/radioserver ~/.cache/meshbench/tools/ +cp /path/to/libvirtualsx1262.so ~/.cache/meshbench/tools/ ln -sf /path/to/renode ~/.cache/meshbench/tools/ # nRF52 only ``` @@ -174,8 +174,8 @@ peripherals and platform files live in `tools/renode/` and are loaded from that same tools directory. A symlink is right for QEMU: it finds its own data files by resolving its real -path, so a bare copy of the binary will not run. `radioserver` builds from -`meshcore-native` with `./build.sh radioserver out` — it wants neither a +path, so a bare copy of the binary will not run. The chip model builds from +`MeshBench/virtual-sx1262` with `./build.sh shared` — it wants neither a MeshCore checkout nor Crypto, only the chip model beside it. Then open the firmware library, download a board image, and set a node's role to @@ -248,7 +248,7 @@ described as if it were, and its radio is a stub, so it proves the mesh stack compiles and runs on Cortex-M4 rather than that a node works. All three paths share one chip model. `VirtualSX1262` runs in process for a -native node, and `radioserver` puts the same object behind a socket for QEMU and +native node, and QEMU and Renode load the same library for Renode. That is deliberate: two models of one chip must agree for ever, and the first time they drift, every comparison between an ARM node and an ESP32 node measures our code rather than MeshCore's. @@ -291,7 +291,7 @@ as though it should hold. A native node's clock is *supplied*: the tick carries the instant, the shim runs one MeshCore loop per simulated millisecond, and the acknowledgement means the firmware has been there. An emulated node is ticked through exactly the same code path, and the acknowledgement means something -weaker: it comes from `radioserver`, the chip model on this side of the socket, +weaker: it comes from the chip model on this side of the socket, because the guest is a published image with nothing in it that could receive a tick. Meanwhile the guest executes against QEMU's or Renode's clock, neither of which is under `-icount` or a Renode quantum, so how much firmware runs between diff --git a/docs/packaging-emulation.md b/docs/packaging-emulation.md index 5341fe5f..4ad15b80 100644 --- a/docs/packaging-emulation.md +++ b/docs/packaging-emulation.md @@ -37,7 +37,7 @@ Four things have to be present. Only two can be shipped. | piece | source | ship it? | size | |---|---|---|---| | QEMU with our SX1262 | `MeshBench/qemu`, branch `meshbench-main` | yes | ~17 MB packed, ~79 MB unpacked | -| `radioserver` | `MeshBench/meshcore-native`, `bridge/radioserver.cpp` | yes | ~40 KB | +| `virtual-sx1262` | `MeshBench/virtual-sx1262`, the shared library | yes | ~100 KB | | Renode with our SEVONPEND fix | `MeshBench/renode`, branch `meshbench` | yes | ~60 MB packed | | Nordic SoftDevice | Nordic's own site, fetched at runtime | **no — fetched, not bundled** | 155 KB | @@ -67,7 +67,7 @@ Only `qemu-system-xtensa` is needed, and it finds its own data files by resolving `/proc/self/exe`, so a symlink into the tools directory works and a bare copy of the binary does not. -### radioserver +### The chip model Small, and the important one architecturally. It owns the chip model that a native node reaches in process, and both emulators reach it over a socket — one @@ -142,7 +142,7 @@ an error that read as a missing package. The same shape as the native firmware cache, deliberately. Step 3 is now also where the application *puts* things. The Resources page -carries a row per tool - `radioserver`, `qemu-system-xtensa` and `renode` - +carries a row per tool - `virtual-sx1262`, `qemu-system-xtensa` and `renode` - with its size, its terms and a fetch that downloads from our own forks' releases, verifies the digest, unpacks into that directory and links the binary under the name the lookup asks for. The description of that directory as "where @@ -153,7 +153,7 @@ source build no installer ever does. In rough order of value: -1. **Ship QEMU and `radioserver` beside the binary.** Removes every manual step +1. **Ship QEMU and the chip model beside the binary.** Removes every manual step for the one path that works. A first run then needs only a download of the board image, which the firmware library already does. *Done for a release bundle; and for everything else the Resources page now fetches all three @@ -206,7 +206,7 @@ than proven: - **macOS**: the fork cross-compiles an aarch64 build and the bundle carries it. Untested by us. - **Windows**: the fork cross-compiles a mingw build and the zip carries it, - with Renode's portable package and `radioserver.exe`. A node there asks for + with Renode's portable package and `libvirtualsx1262.dll`. A node there asks for `":0"` and reaches the radio model over TCP for both emulators, because Windows has no Unix socket mingw can reach. Untested by us, and what does *not* work is the runtime fetch: `internal/app/resource` reads ELF and Mach-O diff --git a/docs/repositories.md b/docs/repositories.md index 633fdba2..2b9b7a58 100644 --- a/docs/repositories.md +++ b/docs/repositories.md @@ -17,7 +17,7 @@ A fork nobody outside the organisation can fetch cannot satisfy that. | repository | what it is | licence | public | |---|---|---|---| | `MeshBench/meshbench` | MeshBench itself | GPL-3.0-or-later, `docs/licence.md` | yes, since 1 September 2026 | -| `MeshBench/meshcore-native` | host builds of MeshCore, the bridge and `radioserver` | see its NOTICE | yes | +| `MeshBench/meshcore-native` | host builds of MeshCore, and the bridge | see its NOTICE | yes | | `MeshBench/virtual-sx1262` | the virtual SX1262 itself, with a C ABI, vendored by everything that needs a chip | MIT, and it has to stay permissive | yes | | `MeshBench/meshbench-reports` | the published reports site | — | yes | | `MeshBench/docs` | the documentation site, six of whose pages are generated from here | not stated | yes | @@ -136,7 +136,13 @@ branch is upstream-plus-one-feature so a rebase is a tag away. Forked from Espressif's QEMU fork (`esp-develop`, QEMU 9.2.2). Adds an SX1262 SPI device, a working GPIO implementation, and machine properties for the radio -wiring (`radio-path`, `radio-spi`, `radio-nss`, `radio-busy`). +wiring (`radio-bridge`, `radio-spi`, `radio-nss`, `radio-busy`). + +The `sx1262` device holds the chip itself: it loads `virtual-sx1262` through +GModule, which glib already brings in, so the fork needs no submodule and no +build change and a chip fix is a file to replace rather than an emulator to +rebuild. It used to forward every clocked byte to a `radioserver` process over a +socket. The one socket left goes to the RF engine, which is genuinely elsewhere. Upstream's GPIO write handler is empty, and RadioLib drives NSS as an ordinary GPIO rather than through the SPI controller's chip select — so without that diff --git a/docs/shortcomings.md b/docs/shortcomings.md index f8486ba6..3484fd1f 100644 --- a/docs/shortcomings.md +++ b/docs/shortcomings.md @@ -436,7 +436,7 @@ to it by the engine's tick rather than read off the host. A node running in an emulator is outside all of that, and structurally rather than by oversight. Its firmware is a published image. There is nothing in it that could receive a -tick, so what acknowledges the engine is `radioserver`, the chip model on our +tick, so what acknowledges the engine is the chip model on our side of the socket, and the acknowledgement means the message was handled, not that the guest has reached that instant. The guest is meanwhile running against QEMU's or Renode's own clock, under neither `-icount` nor a Renode quantum, so diff --git a/internal/app/resource/toolchain_test.go b/internal/app/resource/toolchain_test.go index 43d238ed..83cddb3d 100644 --- a/internal/app/resource/toolchain_test.go +++ b/internal/app/resource/toolchain_test.go @@ -86,7 +86,7 @@ func TestTermsAreReadableBeforeAnythingIsFetched(t *testing.T) { } } -// Which board needs which tool. Every emulated node needs radioserver whatever +// Which board needs which tool. Every emulated node needs the chip whatever // its MCU; only an ESP32 needs QEMU and only an nRF52 needs Renode, and a row // that claims otherwise sends somebody to fetch 61 MB they will never run. func TestToolsForMatchesTheMCU(t *testing.T) { @@ -94,10 +94,10 @@ func TestToolsForMatchesTheMCU(t *testing.T) { mcu string want []string }{ - {"ESP32-S3", []string{"radioserver", "qemu-system-xtensa"}}, - {"ESP32", []string{"radioserver", "qemu-system-xtensa"}}, - {"nRF52840", []string{"radioserver", "renode"}}, - {"RP2040", []string{"radioserver"}}, + {"ESP32-S3", []string{"virtual-sx1262", "qemu-system-xtensa"}}, + {"ESP32", []string{"virtual-sx1262", "qemu-system-xtensa"}}, + {"nRF52840", []string{"virtual-sx1262", "renode"}}, + {"RP2040", []string{"virtual-sx1262"}}, } { got := ToolsFor(c.mcu) if strings.Join(got, ",") != strings.Join(c.want, ",") { @@ -114,17 +114,25 @@ func TestToolsForMatchesTheMCU(t *testing.T) { func TestAToolIsOnDiskOnlyWhenTheLookupWouldFindIt(t *testing.T) { dir := t.TempDir() tc := &Toolchain{Dir: dir} - rel, ok := releaseNamed("radioserver") + rel, ok := releaseNamed("virtual-sx1262") if !ok { - t.Fatal("radioserver is not in the catalogue") + t.Fatal("virtual-sx1262 is not in the catalogue") } a, ok := rel.asset() if !ok { - t.Skipf("no radioserver build for %s/%s", runtime.GOOS, runtime.GOARCH) + t.Skipf("no chip model build for %s/%s", runtime.GOOS, runtime.GOARCH) } if got := tc.row(rel); got.State == OnDisk { t.Fatal("an empty tools directory reported a tool on disk") } + // An installation is what the fetcher leaves behind, which for an archive + // is the unpacked tree as well as the link the lookup finds. A link alone + // is the case this test exists for: it is what a half-removed install looks + // like, and a row calling it on disk sends somebody to debug an emulator + // that is not there. + if a.Binary != "" { + writeFake(t, filepath.Join(dir, a.Binary), a.Magic, 4096) + } writeFake(t, filepath.Join(dir, rel.Name), a.Magic, 4096) got := tc.row(rel) if got.State != OnDisk { @@ -133,6 +141,8 @@ func TestAToolIsOnDiskOnlyWhenTheLookupWouldFindIt(t *testing.T) { if got.Estimated { t.Error("a tool on disk was still reporting an estimated size") } + // A plain file measures itself; an archive measures the tree it unpacked + // to, which holds the same one file here. if got.Bytes != 4096 { t.Errorf("measured %d bytes, want 4096", got.Bytes) } diff --git a/internal/app/resource/toolchaincatalogue.go b/internal/app/resource/toolchaincatalogue.go index 41428d1c..60fa108f 100644 --- a/internal/app/resource/toolchaincatalogue.go +++ b/internal/app/resource/toolchaincatalogue.go @@ -23,7 +23,7 @@ package resource const ( qemuBase = "https://github.com/MeshBench/qemu/releases/download/v9.2.2-meshbench-sx1262-10/" renodeBase = "https://github.com/MeshBench/renode/releases/download/meshbench-20260901-ca9f7e3/" - radioBase = "https://github.com/MeshBench/meshcore-native/releases/download/radioserver-v3/" + chipBase = "https://github.com/MeshBench/virtual-sx1262/releases/download/v1.3.0/" ) // qemuArm64LinuxIsUntried is the one platform the fork builds and this does not @@ -60,28 +60,38 @@ const windowsFetchesNoEmulators = "the Windows zip already carries this, and " + "ELF and Mach-O headers, and would refuse a PE binary as not an executable " + "at all. Take the emulators from the Windows release, or put one beside " + "meshbench.exe and point MESHBENCH_QEMU, MESHBENCH_RENODE or " + - "MESHBENCH_RADIO_SERVER at it" + "MESHBENCH_RADIO_LIB at it" // toolReleases is every tool the emulator lookup asks for, in the order they -// are needed: nothing boots without radioserver, and which emulator follows +// are needed: nothing boots without the chip, and which emulator follows // depends on the board. +// +// The chip's version tracks its ABI rather than the library, because a host +// declares the version it needs and refuses one that cannot serve it. 1.3 is +// the one with the byte-at-a-time SPI path, which is the only path an emulator +// can use: its controller clocks one byte and wants the answering byte back +// before it clocks the next. var toolReleases = []toolRelease{{ - Name: "radioserver", - Version: "v3", + Name: "virtual-sx1262", + Version: "v1.3.0", MCU: "", - Why: "the SX1262 model both emulators reach over a socket; every emulated " + - "node needs it, ESP32 or nRF52", - Terms: radioserverTerms, + Why: "the SX1262 itself, which both emulators load and a native node links; " + + "every emulated node needs it, ESP32 or nRF52", + Terms: chipModelTerms, Assets: map[string]toolAsset{ "linux/amd64": { - URL: radioBase + "radioserver-linux-amd64", - SHA256: "8049c3b2649d77206aedd59a6e6b732965b57ab26d4ddc962328b2f5e400b2d5", - Bytes: 42448, Kind: plainFile, Magic: elfAMD64, + URL: chipBase + "virtual-sx1262-linux-amd64.tar.gz", + SHA256: "33f4453c39bfa1b8f0f4706efb387e7f9588217eabc2713887b7fdf9974d9155", + Bytes: 26340, Kind: tarGzip, Magic: elfAMD64, + Root: "virtual-sx1262-linux-amd64", + Binary: "virtual-sx1262-linux-amd64/lib/libvirtualsx1262.so", }, "darwin/arm64": { - URL: radioBase + "radioserver-darwin-arm64", - SHA256: "33250d04783aadbed6f9c2fe2fecbbff92c4139ca370ff8c6a48df92c45dd2b9", - Bytes: 74888, Kind: plainFile, Magic: machARM64, + URL: chipBase + "virtual-sx1262-macos-arm64.tar.gz", + SHA256: "362ed99e8b3b434cc738045531818c8de8e6465f1a05cb057fb7199605f59b6a", + Bytes: 19118, Kind: tarGzip, Magic: machARM64, + Root: "virtual-sx1262-macos-arm64", + Binary: "virtual-sx1262-macos-arm64/lib/libvirtualsx1262.dylib", }, }, Unsupported: map[string]string{"windows/amd64": windowsFetchesNoEmulators}, @@ -143,12 +153,13 @@ var toolReleases = []toolRelease{{ // every release bundle's LICENCES directory; what belongs here is the part // somebody has to have read before pressing Fetch. const ( - radioserverTerms = "radioserver is built from MeshBench/meshcore-native, which " + - "is MIT, and links the SX1262 model from MeshBench/virtual-sx1262, also MIT. " + - "That model is a separate public repository because QEMU and Renode link the " + - "same chip, and it stays permissive because QEMU is GPLv2 and MeshBench is " + - "GPL-3.0-or-later, which no one copyleft licence can serve both of. " + - "radioserver runs as a separate process and is not linked into MeshBench." + chipModelTerms = "The SX1262 model is MeshBench/virtual-sx1262, which is MIT " + + "and public. It is a separate repository because four things link the same " + + "chip - MeshCore built for this host, our QEMU fork, Renode's peripheral, " + + "and eventually MeshBench - and it stays permissive because QEMU is GPLv2 " + + "and MeshBench is GPL-3.0-or-later, which no one copyleft licence can serve " + + "both of. It is loaded by the emulator at runtime and is not linked into " + + "MeshBench." qemuTerms = "QEMU is GPL-2.0, with parts under compatible licences. This build " + "is our own fork - the meshbench-main branch of MeshBench/qemu, which is " + "public, and every release carries the source archive the licence requires " + diff --git a/internal/app/resource/toolchainpins_test.go b/internal/app/resource/toolchainpins_test.go index 397a32a6..989c162b 100644 --- a/internal/app/resource/toolchainpins_test.go +++ b/internal/app/resource/toolchainpins_test.go @@ -62,7 +62,7 @@ func TestCatalogueIsPinnedToATag(t *testing.T) { for _, c := range []struct{ key, base string }{ {"QEMU_RELEASE", qemuBase}, {"RENODE_RELEASE", renodeBase}, - {"RADIOSERVER_RELEASE", radioBase}, + {"CHIPMODEL_RELEASE", chipBase}, } { // "latest" moves under whoever published last, which is the whole // failure this file exists to stop. @@ -82,7 +82,7 @@ func TestCatalogueIsPinnedToATag(t *testing.T) { // project, and qemu-system-xtensa against QEMU is exactly where a derivation // would have to start guessing. var pinPrefix = map[string]string{ - "radioserver": "RADIOSERVER", + "virtual-sx1262": "CHIPMODEL", "qemu-system-xtensa": "QEMU", "renode": "RENODE", } @@ -101,8 +101,8 @@ func TestPinsAndCatalogueNameTheSameAssets(t *testing.T) { continue } // Contains rather than equals: Version is what a person is shown, and - // radioserver's tag prefixes it, so the row reads v3 where the tag reads - // radioserver-v3. Written as a prefix rule rather than naming a number, + // A tag may prefix its version, as radioserver-v3 once did. Written as a + // prefix rule rather than naming a number, // because a version bump should not have to edit this comment. What must // not happen is a row telling somebody it will fetch one release while // the URL beside it fetches another. diff --git a/internal/app/session/emulatortools_test.go b/internal/app/session/emulatortools_test.go index 81407c11..cae74da3 100644 --- a/internal/app/session/emulatortools_test.go +++ b/internal/app/session/emulatortools_test.go @@ -30,7 +30,7 @@ func TestAScenarioSaysWhichEmulatorToolsItNeeds(t *testing.T) { want := map[string]int{ // Every emulated node needs the radio model; the emulator follows the // MCU, and the node with no board is not emulated at all. - "radioserver": 2, "renode": 1, "qemu-system-xtensa": 1, + "virtual-sx1262": 2, "renode": 1, "qemu-system-xtensa": 1, } for name, n := range want { if got[name] != n { diff --git a/internal/app/session/resources/resourceverbs_test.go b/internal/app/session/resources/resourceverbs_test.go index 9c7b6eef..a29c0d1f 100644 --- a/internal/app/session/resources/resourceverbs_test.go +++ b/internal/app/session/resources/resourceverbs_test.go @@ -90,12 +90,12 @@ func TestResourceListReturnsTheRowsAndNotJustACount(t *testing.T) { } // The emulator toolchain is on the page, which it was not: the five providers -// were the SoftDevice and four caches, so there was no path to radioserver, +// were the SoftDevice and four caches, so there was no path to the chip model, // QEMU or Renode from inside the application at all. func TestTheEmulatorToolchainIsListed(t *testing.T) { rows, _ := listResources(t)["resources"].([]map[string]any) want := map[string]bool{ - "radioserver": false, "qemu-system-xtensa": false, "renode": false, + "virtual-sx1262": false, "qemu-system-xtensa": false, "renode": false, } for _, r := range rows { name, _ := r["name"].(string) diff --git a/internal/app/session/resources/setuptools.go b/internal/app/session/resources/setuptools.go index 1cc4b08e..a1e6634f 100644 --- a/internal/app/session/resources/setuptools.go +++ b/internal/app/session/resources/setuptools.go @@ -106,7 +106,7 @@ func toolchainGroup(rows []state.ResourceRow, found map[string]string) state.Set return state.SetupGroup{ Name: "Emulator toolchain", Note: "Each of these is looked for where " + - emulated.EnvRadioServer + ", " + emulated.EnvQEMU + " or " + + emulated.EnvRadioLib + ", " + emulated.EnvQEMU + " or " + emulated.ToolEnv["renode"] + " points, then beside the binary, " + "then in " + emulated.ToolsDir() + ", then on PATH. PATH is the " + "one that will not save you: a desktop application is not launched " + diff --git a/internal/fetchasset_test.go b/internal/fetchasset_test.go index 03e15854..f3675866 100644 --- a/internal/fetchasset_test.go +++ b/internal/fetchasset_test.go @@ -213,9 +213,12 @@ func TestVerifyBundleRefusesABundleWithoutItsEmulators(t *testing.T) { // layouts the emulators actually unpack into. full := t.TempDir() for _, name := range []string{ - "radioserver", + "libvirtualsx1262.so", "qemu-meshbench/bin/qemu-system-xtensa", "renode_1.16.1-portable/renode", + // Renode reads these at runtime, so a bundle without them can start an + // ESP32 board and not an nRF52 one. + "renode-support/peripherals/VirtualSX1262.cs", } { p := filepath.Join(full, name) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { diff --git a/internal/firmware/emulated/emulated_lifecycle_test.go b/internal/firmware/emulated/emulated_lifecycle_test.go index 10c1b57f..7ca3c64b 100644 --- a/internal/firmware/emulated/emulated_lifecycle_test.go +++ b/internal/firmware/emulated/emulated_lifecycle_test.go @@ -24,17 +24,6 @@ func TestStartRefusesASecondCallWhileQEMUIsRunning(t *testing.T) { } } -func TestStartRefusesASecondCallWhileTheRadioModelIsRunning(t *testing.T) { - e := &EmulatedNode{radio: exec.Command("sleep", "1")} - err := e.Start(context.Background(), "") - if err == nil { - t.Fatal("a second Start on a node already running was allowed") - } - if !strings.Contains(err.Error(), "already started") { - t.Errorf("the error does not say the node is already running: %v", err) - } -} - // Two nodes sharing a working directory once corrupted each other silently - // project history's own example is three hundred processes doing exactly // this. A directory already claimed by another process must refuse a second @@ -62,11 +51,11 @@ func TestStartRefusesAWorkDirAlreadyLocked(t *testing.T) { // the lock if Start does not. func TestAFailedStartReleasesTheWorkDirLock(t *testing.T) { dir := t.TempDir() - t.Setenv(EnvRadioServer, filepath.Join(t.TempDir(), "not-here")) + t.Setenv(EnvRadioLib, filepath.Join(t.TempDir(), "not-here")) e := &EmulatedNode{Image: "placeholder", NodeName: "n1", Dir: dir} if err := e.Start(context.Background(), ""); err == nil { - t.Fatal("started with no radio model present") + t.Fatal("started with no chip model present") } lock, err := firmware.LockWorkDir(dir) diff --git a/internal/firmware/emulated/emulated_node.go b/internal/firmware/emulated/emulated_node.go index f90913ad..b70b79a7 100644 --- a/internal/firmware/emulated/emulated_node.go +++ b/internal/firmware/emulated/emulated_node.go @@ -7,25 +7,25 @@ import ( "os" "os/exec" "path/filepath" - "runtime" - "strconv" - "strings" "sync" - "time" "github.com/MeshBench/meshbench/internal/firmware" "github.com/MeshBench/meshbench/internal/firmware/emulated/peripheral" ) -// EnvQEMU overrides the emulator binary, and EnvRadioServer the radio model. +// EnvQEMU overrides the emulator binary, and EnvRadioLib the chip model. // // Both are ours rather than distribution packages: the emulator carries an // SX1262 device and a GPIO implementation that upstream does not have, and the -// radio model is the same one native nodes run against. A released build ships +// chip is the same virtual-sx1262 a native node links. A released build ships // both, and until then these say where to find them. const ( - EnvQEMU = "MESHBENCH_QEMU" - EnvRadioServer = "MESHBENCH_RADIO_SERVER" + EnvQEMU = "MESHBENCH_QEMU" + // EnvRadioLib is read by the emulator, not by us: QEMU's sx1262 device and + // Renode's peripheral both load the library named here. It is passed down + // rather than put on a machine argument because which chip model is + // installed is a property of this machine and not of the board. + EnvRadioLib = "MESHBENCH_RADIO_LIB" // EnvNoiseSeed seeds the radio model's receiver noise, per node. EnvNoiseSeed = "MESHBENCH_NOISE_SEED" ) @@ -42,11 +42,13 @@ const ( // EmulatedNode is one node running a published board image under QEMU. // -// Two processes rather than one: the emulator, and the radio model it talks to -// over a socket. Keeping the model in its own process is what lets an emulated -// node and a native node share the same chip implementation - the alternative -// was a second model inside the emulator, and two models that must agree -// eventually do not. +// One process. The emulator holds the chip itself, as a library it loads, and +// the only socket left is the one to the engine - which is genuinely elsewhere, +// because the channel is shared with every other node. There used to be a third +// process in between, owning the chip and forwarding SPI a byte at a time, and +// it cost more than the round trips: an emulated node and a native one reached +// the same model down different paths, and the paths disagreed for months +// without anything noticing. Now they call the same library. type EmulatedNode struct { // Image is the flash image to boot: a merged .bin, which carries the // bootloader and partition table as well as the application. A bare @@ -180,9 +182,11 @@ type EmulatedNode struct { // renodeStdin holds Renode's monitor open; see startRenode. renodeStdin *os.File - radio *exec.Cmd - sock string - radioPort int + + // radioEnv is the emulator's environment: where the chip model is and what + // this node's receiver noise is seeded with. Built in Start, because the + // seed depends on the run and the node's name. + radioEnv []string // workLock is this process's exclusive claim on Dir, held from Start until // stopLocked has confirmed every process that might still be touching it @@ -197,27 +201,6 @@ type EmulatedNode struct { console *firmware.ConsoleSink } -func waitForPort(ctx context.Context, logPath string) (int, error) { - for i := 0; i < 200; i++ { - if b, err := os.ReadFile(logPath); err == nil { - if i := strings.Index(string(b), "127.0.0.1:"); i >= 0 { - rest := string(b)[i+len("127.0.0.1:"):] - if j := strings.IndexAny(rest, "\r\n"); j > 0 { - if p, err := strconv.Atoi(strings.TrimSpace(rest[:j])); err == nil { - return p, nil - } - } - } - } - select { - case <-ctx.Done(): - return 0, ctx.Err() - case <-time.After(25 * time.Millisecond): - } - } - return 0, fmt.Errorf("firmware: the radio model never said which port it took") -} - func (e *EmulatedNode) Kind() string { return "emulated" } // HasConsole is true once the emulator publishes a serial port we hold open: @@ -245,21 +228,21 @@ func (e *EmulatedNode) ConsoleIn() io.Writer { return e.serial } -// Start brings up the radio model and then the emulator. +// Start brings the emulator up, with the chip inside it. // -// Order matters: the device connects to the socket as it is realized, so a -// QEMU started first fails immediately with "cannot reach the radio model". -// The engine hands over its listener for this node; empty leaves the node deaf -// and mute, booting and then waiting for ever on a transmission that cannot -// complete, which looks like a hang rather than a missing argument. +// bridge is the engine's listener for this node, as host:port. The radio joins +// it as the machine is built, so the engine must already be listening; empty +// leaves the node deaf and mute, booting and then waiting for ever on a +// transmission that cannot complete, which looks like a hang rather than a +// missing argument. func (e *EmulatedNode) Start(ctx context.Context, bridge string) (err error) { e.mu.Lock() defer e.mu.Unlock() - // A second Start on a node already running would overwrite e.qemu and - // e.radio with a new pair, orphaning the first: nothing would hold their - // PIDs any more, and Stop would kill only the second pair. - if e.qemu != nil || e.radio != nil { + // A second Start on a node already running would overwrite e.qemu with a + // new process, orphaning the first: nothing would hold its PID any more, + // and Stop would kill only the second. + if e.qemu != nil { return fmt.Errorf("firmware: emulated node already started") } if e.Image == "" { @@ -284,63 +267,28 @@ func (e *EmulatedNode) Start(ctx context.Context, bridge string) (err error) { _ = e.stopLocked() } }() - // A Unix socket for QEMU on Linux and macOS; a TCP port for Renode - // anywhere, and for either emulator on Windows. + // Where the chip comes from, and what its receiver's noise is seeded with. + // Both reach the emulator through its environment rather than through a + // machine argument: which chip model is installed is a property of this + // machine, not of the board, and the seed is a property of the run. // - // Renode has always used a port: it runs on Mono, whose Unix domain - // socket support is not worth betting a node on. QEMU used a socket file, - // which is the one thing Windows cannot give it - so emulated ESP32 - // boards stopped at the platform rather than at anything technical. The - // device takes either now (it parses the string), and a Unix socket stays - // the default where there is one: it needs no port and cannot collide. - if e.Emulator == Renode || runtime.GOOS == "windows" { - // Port 0 asks the radio model to choose, and it prints what it got. - e.sock = ":0" - } else { - e.sock = filepath.Join(e.Dir, "radio.sock") - _ = os.Remove(e.sock) - } - - radioBin, err := lookupTool("radioserver") + // The seed is where the firmware's entropy comes from. RadioLib reads the + // chip's instantaneous RSSI for random bits and MeshCore derives its + // identity from them, so every node needs its own stream or every node + // comes up with the same keypair - which is what happened, and two + // different nRF52 boards reported the same public key. Derived from the + // node's name rather than from the machine, so a run stays reproducible: + // same scenario, same names, same noise. + radioLib, err := lookupTool(radioLibName) if err != nil { return err } - radioLog, err := os.Create(filepath.Join(e.Dir, "radio.log")) - if err != nil { - return err - } - radioArgs := []string{e.sock} - if bridge != "" { - radioArgs = append(radioArgs, "--bridge", bridge) - } - e.radio = exec.CommandContext(ctx, radioBin, radioArgs...) - // A seed for this node's receiver noise, which is where its firmware gets - // its entropy: RadioLib reads the chip's instantaneous RSSI for random bits - // and MeshCore derives its identity from them. Every node needs its own - // stream or every node comes up with the same keypair, which is what - // happened - two different nRF52 boards reported the same public key. - // - // From the node's name rather than from the machine, so a run stays - // reproducible: same scenario, same names, same noise. - e.radio.Env = append(os.Environ(), + e.radioEnv = append(os.Environ(), + fmt.Sprintf("%s=%s", EnvRadioLib, radioLib), fmt.Sprintf("%s=%d", EnvNoiseSeed, noiseSeedFor(e.RunSeed, e.NodeName))) - e.radio.Stdout, e.radio.Stderr = radioLog, radioLog - e.radio.SysProcAttr = firmware.ChildProcAttr() - if err := e.radio.Start(); err != nil { - return fmt.Errorf("firmware: starting the radio model: %w", err) - } - if e.sock == ":0" { - port, err := waitForPort(ctx, filepath.Join(e.Dir, "radio.log")) - if err != nil { - return err - } - e.radioPort = port - } else if err := waitForSocket(ctx, e.sock); err != nil { - return err - } if e.Emulator == Renode { - if err := e.startRenode(ctx); err != nil { + if err := e.startRenode(ctx, bridge); err != nil { return err } return nil @@ -354,13 +302,7 @@ func (e *EmulatedNode) Start(ctx context.Context, bridge string) (err error) { return err } - // What the device is told to connect to: the socket file, or the port the - // radio model chose when there is no socket file to have. - radioAt := e.sock - if e.radioPort != 0 { - radioAt = fmt.Sprintf("127.0.0.1:%d", e.radioPort) - } - machine := e.machineString(radioAt) + machine := e.machineString(bridge) // The board's own output and the emulator's are two different things, and // separating them is what makes either readable. They shared console.log, @@ -437,6 +379,7 @@ func (e *EmulatedNode) Start(ctx context.Context, bridge string) (err error) { } args = append(args, qemuDebugArgs(e.Dir)...) e.qemu = exec.CommandContext(ctx, qemuBin, args...) + e.qemu.Env = e.radioEnv e.qemu.Stdout, e.qemu.Stderr = emuLog, emuLog // The emulator dies with the simulator. Without this a workbench killed // outright leaves a qemu-system-xtensa and a radioserver per node running, diff --git a/internal/firmware/emulated/emulated_node_test.go b/internal/firmware/emulated/emulated_node_test.go index b1f71716..848bba70 100644 --- a/internal/firmware/emulated/emulated_node_test.go +++ b/internal/firmware/emulated/emulated_node_test.go @@ -92,7 +92,7 @@ func TestMissingToolsExplainThemselves(t *testing.T) { t.Fatal("started with no emulator present") } if !strings.Contains(err.Error(), emulated.EnvQEMU) && - !strings.Contains(err.Error(), emulated.EnvRadioServer) { + !strings.Contains(err.Error(), emulated.EnvRadioLib) { t.Errorf("the error names neither environment variable: %v", err) } } diff --git a/internal/firmware/emulated/emulated_reap.go b/internal/firmware/emulated/emulated_reap.go index 719da20e..421ba497 100644 --- a/internal/firmware/emulated/emulated_reap.go +++ b/internal/firmware/emulated/emulated_reap.go @@ -3,7 +3,6 @@ package emulated import ( "errors" "fmt" - "os" "os/exec" "time" ) @@ -21,14 +20,11 @@ func (e *EmulatedNode) stopLocked() error { e.serial = nil } var procs []*exec.Cmd - for _, c := range []*exec.Cmd{e.qemu, e.radio} { - if c == nil || c.Process == nil { - continue - } - _ = c.Process.Kill() - procs = append(procs, c) + if e.qemu != nil && e.qemu.Process != nil { + _ = e.qemu.Process.Kill() + procs = append(procs, e.qemu) } - e.qemu, e.radio = nil, nil + e.qemu = nil if e.renodeStdin != nil { _ = e.renodeStdin.Close() e.renodeStdin = nil @@ -40,8 +36,6 @@ func (e *EmulatedNode) stopLocked() error { _ = e.GPS.Close() e.GPS = nil } - sock := e.sock - // Reaped with the lock released: a process killed while blocked on a // stalled disk or network write does not die the instant it is killed, it // dies when that write returns, and waiting for it here - as this used to - @@ -52,7 +46,6 @@ func (e *EmulatedNode) stopLocked() error { err := reapAll(procs, reapPeriod) e.mu.Lock() - _ = os.Remove(sock) // Released only once every process that might still be touching Dir is // confirmed gone. A reap that timed out leaves the lock held: a node this // backend can no longer account for might still be writing to it, and diff --git a/internal/firmware/emulated/qemu/machine.go b/internal/firmware/emulated/qemu/machine.go index 2bfbe9ae..b353c4c1 100644 --- a/internal/firmware/emulated/qemu/machine.go +++ b/internal/firmware/emulated/qemu/machine.go @@ -49,11 +49,15 @@ func CoprocAtReset() bool { return v != "" && v != "0" && !strings.EqualFold(v, "false") } -// Machine is the -machine argument for this board, radioAt being where the -// radio device should look for the model. -func (c Config) Arg(radioAt string) string { - machine := fmt.Sprintf("%s,radio-path=%s,radio-spi=%d,radio-nss=%d,radio-busy=%d", - c.Machine, radioAt, c.SPI, c.NSS, c.Busy) +// Machine is the -machine argument for this board, bridge being the host:port +// where the engine is listening for this node. +// +// The chip itself is not named here. It is a library the emulator loads, found +// through MESHBENCH_RADIO_LIB, because which chip model a node runs is a +// property of the installation rather than of the board being emulated. +func (c Config) Arg(bridge string) string { + machine := fmt.Sprintf("%s,radio-bridge=%s,radio-spi=%d,radio-nss=%d,radio-busy=%d", + c.Machine, bridge, c.SPI, c.NSS, c.Busy) // Only when the board records one. Without it the machine leaves the line // unwired, and the firmware never learns a packet arrived - it reads a // received packet solely from the interrupt this pin raises. diff --git a/internal/firmware/emulated/qemu/machine_test.go b/internal/firmware/emulated/qemu/machine_test.go index d5c490ee..07f2b030 100644 --- a/internal/firmware/emulated/qemu/machine_test.go +++ b/internal/firmware/emulated/qemu/machine_test.go @@ -50,12 +50,12 @@ func hasKey(t *testing.T, arg, key string) bool { // The radio is the one thing every board has, and its three pins are what the // firmware talks to it through. func TestTheRadioIsAlwaysWired(t *testing.T) { - arg := base().Arg("/tmp/radio.sock") + arg := base().Arg("127.0.0.1:38217") if got := options(t, arg)[0]; got != "esp32s3" { t.Fatalf("the machine name must come first, got %q", got) } for _, want := range []string{ - "radio-path=/tmp/radio.sock", "radio-spi=3", "radio-nss=9", "radio-busy=13", + "radio-bridge=127.0.0.1:38217", "radio-spi=3", "radio-nss=9", "radio-busy=13", } { if !has(t, arg, want) { t.Errorf("missing %q in %q", want, arg) diff --git a/internal/firmware/emulated/renode.go b/internal/firmware/emulated/renode.go index 359ea20e..bc67229c 100644 --- a/internal/firmware/emulated/renode.go +++ b/internal/firmware/emulated/renode.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "github.com/MeshBench/meshbench/internal/firmware" "github.com/MeshBench/meshbench/internal/firmware/emulated/peripheral" @@ -20,24 +21,33 @@ import ( // per-node: the radio model's port, the console's port, the node's own working // directory, and the image. A shared script would need all four passed in // anyway. -func (e *EmulatedNode) renodeScript(conPort int) (string, error) { +func (e *EmulatedNode) renodeScript(conPort int, bridge string) (string, error) { // The radio's wiring goes in a platform description of its own rather than // into the script: these are declarations, and Renode's monitor does not // take declarations. Inlining them in the .resc left the machine without a // radio and the firmware waiting on a chip that was never there. + // Renode's platform description wants the two halves of the address + // separately. An empty bridge leaves the peripheral with no engine, which + // is a node that answers its own firmware and hears nobody. + engineHost, enginePort := "", 0 + if host, port, err := net.SplitHostPort(bridge); err == nil { + engineHost = host + enginePort, _ = strconv.Atoi(port) + } + repl := filepath.Join(e.Dir, "node.repl") wiring := fmt.Sprintf(`%s radiospi: SPI.NRF52840_SPI @ sysbus 0x%X easyDMA: true -lora: Radio.RadioServerSX1262 @ radiospi - host: "127.0.0.1" - port: %d +lora: Radio.VirtualSX1262 @ radiospi + engineHost: "%s" + enginePort: %d IRQ -> %s@%d %s: %d -> lora@0 -`, renode.EasyDMASPI(e.SPIBase), e.SPIBase, e.radioPort, e.IrqPort, e.IrqPin, e.NssPort, e.NssPin) +`, renode.EasyDMASPI(e.SPIBase), e.SPIBase, engineHost, enginePort, e.IrqPort, e.IrqPin, e.NssPort, e.NssPin) if err := os.WriteFile(repl, []byte(wiring), 0o644); err != nil { return "", err } @@ -58,7 +68,9 @@ lora: Radio.RadioServerSX1262 @ radiospi script := filepath.Join(e.Dir, "node.resc") // The host has to be included before the controller: Renode compiles each // of these on its own, so a file can only see what came before it. - body := fmt.Sprintf(`i @%[1]s/peripherals/RadioServerSX1262.cs + body := fmt.Sprintf(`i @%[1]s/peripherals/VirtualSX1262Lib.cs +i @%[1]s/peripherals/VirtualSX1262Engine.cs +i @%[1]s/peripherals/VirtualSX1262.cs i @%[1]s/peripherals/NRF52840_Temp.cs i @%[1]s/peripherals/NRF52840_Clock.cs i @%[1]s/peripherals/NRF52840_SAADC.cs @@ -88,7 +100,7 @@ machine LoadPlatformDescription @%[1]s/usbd.repl %[5]s %[9]sradiospi.lora Connect %[8]s%[7]sstart -`, ToolsDir(), firmware.SafeNodeName(e.NodeName), e.Platform, repl, flash, +`, SupportDir(), firmware.SafeNodeName(e.NodeName), e.Platform, repl, flash, renode.UnregisterStockSPI(), renode.RenodeTrace(), renode.ConsoleTerminal(conPort, e.ConsoleOnUSB), idle) if err := os.WriteFile(script, []byte(body), 0o644); err != nil { @@ -98,7 +110,7 @@ machine LoadPlatformDescription @%[1]s/usbd.repl } // startRenode boots this node's script, and holds its console open. -func (e *EmulatedNode) startRenode(ctx context.Context) error { +func (e *EmulatedNode) startRenode(ctx context.Context, bridge string) error { renodeBin, err := lookupTool("renode") if err != nil { return err @@ -107,7 +119,7 @@ func (e *EmulatedNode) startRenode(ctx context.Context) error { if err != nil { return err } - script, err := e.renodeScript(conPort) + script, err := e.renodeScript(conPort, bridge) if err != nil { return err } @@ -127,6 +139,7 @@ func (e *EmulatedNode) startRenode(ctx context.Context) error { e.qemu = exec.CommandContext(ctx, renodeBin, "--disable-xwt", "--console", "-e", "include @"+script) e.qemu.Stdin = stdin + e.qemu.Env = e.radioEnv e.qemu.Stdout, e.qemu.Stderr = log, log e.qemu.SysProcAttr = firmware.ChildProcAttr() if err := e.qemu.Start(); err != nil { diff --git a/internal/firmware/emulated/tools.go b/internal/firmware/emulated/tools.go index 273773bd..0726c095 100644 --- a/internal/firmware/emulated/tools.go +++ b/internal/firmware/emulated/tools.go @@ -1,22 +1,20 @@ -// Where the emulator, the radio model and the sockets between them are found. +// Where the emulator, the chip model and Renode's support files are found. // // Kept apart from the node itself because none of it is about a node: it is // about this machine, and what is installed on it. package emulated import ( - "context" "fmt" "os" "os/exec" "path/filepath" "runtime" - "time" "github.com/MeshBench/meshbench/internal/firmware/emulated/renode" ) -// ToolsDir is where the emulator and the radio model are kept. +// ToolsDir is where the emulator and the chip model are kept. // // The same shape as the native build cache, and for the same reason: a desktop // application is not launched from a shell, so it does not inherit one's PATH. @@ -52,6 +50,11 @@ func lookupTool(name string) (string, error) { if runtime.GOOS == "windows" { candidates = append(candidates, name+".exe") } + // A tool with a file name of its own is looked for under that first, and + // under its own name after: the fetcher finishes by linking what it + // unpacked to the tool's name, so both spellings are real on a machine + // that downloaded it. + candidates = append(tool.files, candidates...) subdirs := []string{"", "qemu/bin", "qemu-meshbench/bin"} if self, err := os.Executable(); err == nil { dir := filepath.Dir(self) @@ -83,11 +86,42 @@ func lookupTool(name string) (string, error) { return "", tool.missing(name) } +// SupportDir is where Renode's platform descriptions and our C# peripherals +// are, which is not the same question as where a binary is. +// +// Looked for the way a tool is: an override, then beside the simulator, then +// the tools directory. "renode-support" and not "renode", because the emulator +// binary is called that and a directory sharing its name is a trap for whoever +// reads the bundle next. A release bundle carries them beside the binary, because +// they are ours and they are text - four kilobytes of .repl and the peripherals +// Renode has not got - and a bundle that shipped both emulators and neither of +// these could start an ESP32 board and not an nRF52 one, which reads as a +// broken emulator rather than as a missing file. +func SupportDir() string { + if p := os.Getenv(EnvRenodeSupport); p != "" { + return p + } + // It counts only when it actually holds the files: an empty directory of + // that name beside the binary would otherwise win over a populated tools + // directory and take every nRF52 board down with it. + if self, err := os.Executable(); err == nil { + beside := filepath.Join(filepath.Dir(self), "renode-support") + if fileExists(filepath.Join(beside, "peripherals", "VirtualSX1262.cs")) { + return beside + } + } + return ToolsDir() +} + +// EnvRenodeSupport points at that directory, for a checkout driving a build +// that is not beside its own tree. +const EnvRenodeSupport = "MESHBENCH_RENODE_SUPPORT" + // emulatorTool is what somebody has to be told when one of these is not on the // machine, and it differs per tool. // // One message for all three said "ours carries the SX1262 device", which is -// QEMU's reason. Renode's is the SEVONPEND fix, and radioserver has no +// QEMU's reason. Renode's is the SEVONPEND fix, and the chip model has no // distribution build to be mistaken for at all, so a person missing Renode was // sent to think about a device that had nothing to do with their problem. type emulatorTool struct { @@ -98,6 +132,11 @@ type emulatorTool struct { // ours is why a build from anywhere else will not do, empty where there is // no other build to confuse this one with. ours string + // files are the names to look for on disk, where they are not the tool's + // own name. A shared library is named by its platform rather than by what + // it is, so the chip model is libvirtualsx1262.so, .dylib or .dll and the + // name here has to stay the one a person and the catalogue both use. + files []string } // missing is the error a boot fails with, and the only place that says how to @@ -126,14 +165,32 @@ func fileExists(p string) bool { return err == nil && !st.IsDir() } -// emulatorTools is the three binaries an emulated node starts, and what a -// person is owed when one of them is absent. +// radioLibName is what the chip model is called on this platform, and the name +// under which a released bundle unpacks it. +const radioLibName = "virtual-sx1262" + +// radioLibFiles is what that library is actually called on disk here. A shared +// library is named by its platform rather than by what it is. +var radioLibFiles = func() []string { + switch runtime.GOOS { + case "windows": + return []string{"libvirtualsx1262.dll"} + case "darwin": + return []string{"libvirtualsx1262.dylib"} + default: + return []string{"libvirtualsx1262.so"} + } +}() + +// emulatorTools is the three things an emulated node needs on this machine, and +// what a person is owed when one of them is absent. var emulatorTools = map[string]emulatorTool{ - "radioserver": { - env: EnvRadioServer, - need: "no emulated board can start without it, ESP32 or nRF52", - ours: "There is no distribution build to go looking for: the SX1262 " + - "model is ours", + radioLibName: { + env: EnvRadioLib, + files: radioLibFiles, + need: "no emulated board can start without it, ESP32 or nRF52", + ours: "There is no distribution build to go looking for: the chip is " + + "MeshBench/virtual-sx1262, which the emulator loads", }, "qemu-system-xtensa": { env: EnvQEMU, @@ -175,25 +232,6 @@ var ToolEnv = func() map[string]string { // predicting is worse than no check. func FindTool(name string) (string, error) { return lookupTool(name) } -// waitForSocket blocks until the radio model is listening, or the context ends. -// -// Polled rather than assumed: the device connects to this socket as QEMU -// realizes it, and a race there fails the whole boot with a message about the -// radio being unreachable, which points at configuration rather than at timing. -func waitForSocket(ctx context.Context, path string) error { - for i := 0; i < 200; i++ { - if _, err := os.Stat(path); err == nil { - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(25 * time.Millisecond): - } - } - return fmt.Errorf("firmware: the radio model never opened %s", path) -} - // cardBytes is how big a card a node gets. Small as cards go, because nothing // here fills one and a file per node is a file per node. const cardBytes = 64 << 20 diff --git a/internal/firmware/emulated/tools_test.go b/internal/firmware/emulated/tools_test.go new file mode 100644 index 00000000..d9553159 --- /dev/null +++ b/internal/firmware/emulated/tools_test.go @@ -0,0 +1,37 @@ +package emulated + +import ( + "os" + "path/filepath" + "testing" +) + +// The support directory is where Renode's platform descriptions and our own +// peripherals are, and a release bundle carries them beside the binary. +// +// It counts a directory only when the files are in it. An empty one beside the +// binary winning over a populated tools directory would take every nRF52 board +// down and look like a broken emulator rather than a missing file. +func TestSupportDirIgnoresADirectoryWithNothingInIt(t *testing.T) { + empty := t.TempDir() + if err := os.MkdirAll(filepath.Join(empty, "peripherals"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv(EnvRenodeSupport, "") + if got := SupportDir(); got == empty { + t.Error("an empty directory was accepted as the support directory") + } + + full := t.TempDir() + if err := os.MkdirAll(filepath.Join(full, "peripherals"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(full, "peripherals", "VirtualSX1262.cs"), + []byte("//"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(EnvRenodeSupport, full) + if got := SupportDir(); got != full { + t.Errorf("the override was ignored: got %q, want %q", got, full) + } +} diff --git a/packaging/emulator-pins.env b/packaging/emulator-pins.env index e20cae7d..6e7083f7 100644 --- a/packaging/emulator-pins.env +++ b/packaging/emulator-pins.env @@ -52,10 +52,15 @@ RENODE_ASSET_LINUX_AMD64=renode-1.16.1.linux-portable-meshbench.tar.gz RENODE_ASSET_DARWIN_ARM64=renode-meshbench-macos-arm64.tar.gz RENODE_ASSET_WINDOWS_AMD64=meshbench-renode-1.16.1.windows-portable.zip -# The SX1262 model both emulators clock over a socket. Every emulated node -# needs it before either emulator, so a bundle without it ships two emulators -# nothing can drive. -RADIOSERVER_RELEASE=radioserver-v3 -RADIOSERVER_ASSET_LINUX_AMD64=radioserver-linux-amd64 -RADIOSERVER_ASSET_DARWIN_ARM64=radioserver-darwin-arm64 -RADIOSERVER_ASSET_WINDOWS_AMD64=radioserver-windows-amd64.exe +# The SX1262 itself, which both emulators load and a native node links. Every +# emulated node needs it before either emulator, so a bundle without it ships +# two emulators nothing can drive. +# +# The tag tracks the ABI rather than the library: a host declares the version it +# needs, refuses one that cannot serve it, and the number in the tag is the one +# it is refusing against. This is ABI 1.3, the version with the byte-at-a-time +# SPI path, which is the only one an emulator can use. +CHIPMODEL_RELEASE=v1.3.0 +CHIPMODEL_ASSET_LINUX_AMD64=virtual-sx1262-linux-amd64.tar.gz +CHIPMODEL_ASSET_DARWIN_ARM64=virtual-sx1262-macos-arm64.tar.gz +CHIPMODEL_ASSET_WINDOWS_AMD64=virtual-sx1262-windows-amd64.tar.gz diff --git a/packaging/verify-bundle.sh b/packaging/verify-bundle.sh index bd3e2581..320d89e1 100755 --- a/packaging/verify-bundle.sh +++ b/packaging/verify-bundle.sh @@ -27,9 +27,9 @@ here=$(cd "$(dirname "$0")" && pwd) . "$here/emulator-pins.env" case "$platform" in - linux-amd64) qemu=$QEMU_ASSET_LINUX_AMD64; renode=$RENODE_ASSET_LINUX_AMD64; radio=$RADIOSERVER_ASSET_LINUX_AMD64 ;; - darwin-arm64) qemu=$QEMU_ASSET_DARWIN_ARM64; renode=$RENODE_ASSET_DARWIN_ARM64; radio=$RADIOSERVER_ASSET_DARWIN_ARM64 ;; - windows-amd64) qemu=$QEMU_ASSET_WINDOWS_AMD64; renode=$RENODE_ASSET_WINDOWS_AMD64; radio=$RADIOSERVER_ASSET_WINDOWS_AMD64 ;; + linux-amd64) qemu=$QEMU_ASSET_LINUX_AMD64; renode=$RENODE_ASSET_LINUX_AMD64; chip=$CHIPMODEL_ASSET_LINUX_AMD64; chipfile=libvirtualsx1262.so ;; + darwin-arm64) qemu=$QEMU_ASSET_DARWIN_ARM64; renode=$RENODE_ASSET_DARWIN_ARM64; chip=$CHIPMODEL_ASSET_DARWIN_ARM64; chipfile=libvirtualsx1262.dylib ;; + windows-amd64) qemu=$QEMU_ASSET_WINDOWS_AMD64; renode=$RENODE_ASSET_WINDOWS_AMD64; chip=$CHIPMODEL_ASSET_WINDOWS_AMD64; chipfile=libvirtualsx1262.dll ;; *) echo "verify-bundle: unknown platform $platform" >&2; exit 2 ;; esac @@ -66,7 +66,22 @@ require() { fail=1 } -require radioserver "$radio" "no emulated board can start at all" +# The chip is looked for by its own file name rather than by a tool name: a +# shared library is named by its platform, and that is the name the emulator +# will be handed. +require "$chipfile" "$chip" "no emulated board can start at all" + +# Renode's platform descriptions and our own peripherals. Renode reads them at +# runtime rather than having them compiled in, so a bundle that carries both +# emulators and not these can start an ESP32 board and not an nRF52 one - which +# reads as a broken emulator rather than as a missing file, and is exactly the +# shape of thing a bundle check is for. +if [ -f "$dir/renode-support/peripherals/VirtualSX1262.cs" ]; then + echo "verify-bundle: renode-support -> $(ls "$dir"/renode-support/peripherals/*.cs | wc -l | tr -d ' ') peripherals" +else + echo "::error::$platform bundle has no renode-support, so nRF52 boards cannot emulate" >&2 + fail=1 +fi require qemu-system-xtensa "$qemu" "ESP32 boards cannot emulate" require renode "$renode" "nRF52 boards cannot emulate" diff --git a/packaging/windows-msi.sh b/packaging/windows-msi.sh index 74ba1b89..0e37fc35 100755 --- a/packaging/windows-msi.sh +++ b/packaging/windows-msi.sh @@ -5,7 +5,7 @@ # # is the directory meshbench.exe sits in, the same one the zip is # made from, and everything in it goes into the installer. That is the point: -# the emulators and radioserver.exe are found beside the binary, so an +# the emulators and the chip model are found beside the binary, so an # installer that carried the binary alone would produce a build that cannot # emulate a board and cannot say why. # diff --git a/tools/renode/README.md b/tools/renode/README.md index 96ed8ef8..e1ffe5f0 100644 --- a/tools/renode/README.md +++ b/tools/renode/README.md @@ -153,7 +153,7 @@ The radio model has no way to say so. Its emulator protocol is four tags - chip select, transfer, and read-busy - with no interrupt channel, because the QEMU path did not need one. Closing this needs: - - a read-IRQ tag in radioserver, answering the chip's IRQ line + - the chip's own DIO1 line, pushed to the peripheral - the Renode peripheral polling it and driving a GPIO - that GPIO wired to P_LORA_DIO_1 for the board in question @@ -188,7 +188,7 @@ Two routes from here, and the second is the one we can ship: 2. Build an nRF52 variant in meshcore-native, the way variants/host works: software crypto, real RadioLib over nRF52 SPI, no SoftDevice. Not the flashed bytes, but a real ARM build of the same source that can be handed to anyone, - and the radio path to radioserver is already proven from ARM. + and the radio path to the chip model is already proven from ARM. ### The pattern worth remembering diff --git a/tools/renode/armfw-radio.resc b/tools/renode/armfw-radio.resc index 2f2040da..c905f542 100644 --- a/tools/renode/armfw-radio.resc +++ b/tools/renode/armfw-radio.resc @@ -1,4 +1,6 @@ -i @tools/renode/peripherals/RadioServerSX1262.cs +i @tools/renode/peripherals/VirtualSX1262Lib.cs +i @tools/renode/peripherals/VirtualSX1262Engine.cs +i @tools/renode/peripherals/VirtualSX1262.cs mach create "armfw" machine LoadPlatformDescription @platforms/cpus/nrf52840.repl diff --git a/tools/renode/peripherals/README.md b/tools/renode/peripherals/README.md index ed4a0fc7..a6d1809a 100644 --- a/tools/renode/peripherals/README.md +++ b/tools/renode/peripherals/README.md @@ -24,7 +24,7 @@ ReadBuffer -> 0xAA 0xBB 0xCC ``` ```bash -renode tools/renode/peripherals/radioserver_test.resc +renode tools/renode/peripherals/radio-only.repl ``` ## Why it is tested in isolation first diff --git a/tools/renode/peripherals/RadioServerSX1262.cs b/tools/renode/peripherals/RadioServerSX1262.cs deleted file mode 100644 index 95528fb9..00000000 --- a/tools/renode/peripherals/RadioServerSX1262.cs +++ /dev/null @@ -1,268 +0,0 @@ -// -// The SX1262, as seen from Renode — a wire to the chip, not a model of one. -// -// There was a model here before: sixteen kilobytes of C# implementing the -// command protocol, the status byte, IRQ flags and the buffer. It worked, and -// it was the wrong shape. The QEMU backend needed the same thing and already -// had VirtualSX1262, so finishing this one would have left two implementations -// of one chip that had to agree for ever. They do not agree for ever. The first -// time they drifted, every comparison between an ARM node and an ESP32 node -// would have been measuring our own code rather than MeshCore's. -// -// So this forwards SPI to the same radioserver process the emulated ESP32 talks -// to, which owns the same VirtualSX1262 a native node reaches in process. One -// chip, three ways in. -// -// Renode ---\ -// >--- radioserver --- VirtualSX1262 --- the RF engine -// QEMU ---/ -// -// TCP rather than a Unix socket because Renode runs on Mono, whose Unix domain -// socket support has been unreliable for long enough that betting a node on it -// is a poor trade for one path separator. -// -using System; -using System.Net.Sockets; -using Antmicro.Renode.Core; -using Antmicro.Renode.Core.Structure; -using Antmicro.Renode.Logging; -using Antmicro.Renode.Peripherals.SPI; -using Antmicro.Renode.Peripherals.Timers; -using Antmicro.Renode.Time; - -namespace Antmicro.Renode.Peripherals.Radio -{ - public class RadioServerSX1262 : ISPIPeripheral, IGPIOReceiver - { - public RadioServerSX1262(IMachine machine, string host = "127.0.0.1", int port = 0) - { - this.machine = machine; - this.host = host; - this.port = port; - - // DIO1, polled. The chip is in another process, so there is nothing - // to push an interrupt across - and the alternative, having the - // firmware poll the IRQ register over SPI, is what the ESP32 build - // does but the nRF52 one does not. It waits on the pin. - // - // A kilohertz is far finer than anything the radio times: the - // shortest thing DIO1 signals is a preamble detection, tens of - // milliseconds at these spreading factors. - irqPoll = new LimitTimer(machine.ClockSource, 1000, this, "dio1", - limit: 1, eventEnabled: true, - direction: Direction.Ascending, - enabled: false, workMode: WorkMode.Periodic); - irqPoll.LimitReached += PollIrq; - } - - // DIO1 into the MCU. Wired in the platform description to the pin the - // board uses - P1.15 on a RAK4631. - public GPIO IRQ { get; } = new GPIO(); - - // Connect when the script says so rather than in the constructor: a - // platform description is loaded before the radio model is running, and - // a peripheral that throws while the machine is being built takes the - // whole machine with it. - public void Connect() - { - if(port == 0) - { - this.Log(LogLevel.Error, - "no radio model port given; this node has no radio and will " + - "report chip-not-found rather than say so"); - return; - } - try - { - client = new TcpClient(host, port) { NoDelay = true }; - stream = client.GetStream(); - this.Log(LogLevel.Info, "chip attached via radio model at {0}:{1}", host, port); - irqPoll.Enabled = true; - } - catch(Exception e) - { - // Loud. An unattached radio answers every register read with - // zero, which RadioLib reports as no chip present — a wiring - // error dressed as a hardware fault. - this.Log(LogLevel.Error, "no radio model at {0}:{1} — {2}", host, port, e.Message); - client = null; - stream = null; - } - } - - // Chip select. RadioLib drives NSS as an ordinary GPIO rather than - // letting the SPI controller do it, which is why this is an - // IGPIOReceiver as well: the transaction boundary arrives on a pin, not - // from the bus. Getting this wrong does not fail loudly — the chip sees - // one unframed byte stream and answers nothing that makes sense. - public void OnGPIO(int number, bool value) - { - if(number != NssPin) - { - return; - } - // Active low, as on the part. - if(!value) - { - Send(CsAssert); - selected = true; - } - else if(selected) - { - Send(CsRelease); - selected = false; - } - } - - public byte Transmit(byte data) - { - if(stream == null) - { - return 0; - } - lock(wire) - { - try - { - // The controller may clock bytes without ever touching NSS, if - // the platform wires chip select to the peripheral itself. Open - // a transaction rather than dropping the byte: a silent zero - // here is the hardest kind of fault to find. - if(!selected) - { - Send(CsAssert); - selected = true; - implicitSelect = true; - } - stream.WriteByte(Xfer); - stream.WriteByte(data); - var got = stream.ReadByte(); - if(got < 0) - { - Drop("the radio model closed the connection"); - return 0; - } - return (byte)got; - } - catch(Exception e) - { - Drop(e.Message); - return 0; - } - } - } - - public void FinishTransmission() - { - if(selected && implicitSelect) - { - Send(CsRelease); - selected = false; - implicitSelect = false; - } - } - - // BUSY, read as a GPIO by RadioLib between commands. Always clear, which - // is what the native path and the QEMU path both answer: VirtualSX1262 - // does not model the time a real chip spends digesting a command, and - // answering differently here would make an ARM node a different radio - // from every other node in the scenario. - public bool Busy - { - get { return false; } - } - - public void Reset() - { - selected = false; - implicitSelect = false; - } - - // Ask the chip whether DIO1 is asserted, and drive the pin to match. - private void PollIrq() - { - if(stream == null) - { - return; - } - lock(wire) - { - try - { - stream.WriteByte(ReadIrq); - var got = stream.ReadByte(); - if(got < 0) - { - Drop("the radio model closed the connection"); - return; - } - var asserted = got != 0; - if(asserted != irqLine) - { - irqLine = asserted; - IRQ.Set(asserted); - } - } - catch(Exception e) - { - Drop(e.Message); - } - } - } - - private void Send(byte tag) - { - if(stream == null) - { - return; - } - lock(wire) - { - try - { - stream.WriteByte(tag); - } - catch(Exception e) - { - Drop(e.Message); - } - } - } - - private void Drop(string why) - { - this.Log(LogLevel.Error, "radio model went away — {0}", why); - stream = null; - client = null; - selected = false; - } - - // The emulator side of the radio model's protocol. Four tags, because - // this is on the hot path of every SPI byte. - private const byte CsAssert = 0x01; - private const byte CsRelease = 0x02; - private const byte Xfer = 0x03; - private const byte ReadBusy = 0x04; - private const byte ReadIrq = 0x05; - - // Which GPIO carries chip select into this peripheral. Renode numbers - // the connections a platform declares, so this is the index in the - // .repl rather than a pin on the board. - private const int NssPin = 0; - - private readonly IMachine machine; - private readonly string host; - private readonly int port; - - private TcpClient client; - private NetworkStream stream; - private bool selected; - private bool implicitSelect; - private bool irqLine; - private readonly LimitTimer irqPoll; - // One socket, two threads: SPI arrives on the CPU thread and the DIO1 - // poll on a timer. Interleaving a tag with a transfer would desync the - // stream and read one answer as another. - private readonly object wire = new object(); - } -} diff --git a/tools/renode/peripherals/VirtualSX1262.cs b/tools/renode/peripherals/VirtualSX1262.cs new file mode 100644 index 00000000..9982b26d --- /dev/null +++ b/tools/renode/peripherals/VirtualSX1262.cs @@ -0,0 +1,280 @@ +// +// The SX1262, as seen from Renode. +// +// There was a model here before: sixteen kilobytes of C# implementing the +// command protocol, the status byte, IRQ flags and the buffer. It worked, and +// it was the wrong shape, because the QEMU backend needed the same chip and +// already had one. Two implementations that have to agree for ever do not, and +// the first time they drifted every comparison between an ARM node and an +// ESP32 node would have been measuring our own code rather than MeshCore's. +// +// After that it forwarded SPI to a `radioserver` process which owned the one +// model. That was right about the chip and wrong about the arrangement: every +// clocked byte was a socket round trip, DIO1 could only be polled because the +// protocol was request-response, and three processes had to have their clocks +// reconciled by anybody asking what happened when. +// +// So the chip is in here now, as the same MIT library QEMU loads and a native +// node links. This half is what the firmware can see - chip select, clocked +// bytes, BUSY and DIO1. The simulated air is VirtualSX1262Engine, beside it. +// +// Renode --- calls ---> virtual-sx1262 +// \------------ one socket ----------> the RF engine +// +using System; +using Antmicro.Renode.Core; +using Antmicro.Renode.Logging; +using Antmicro.Renode.Peripherals.SPI; + +namespace Antmicro.Renode.Peripherals.Radio +{ + public class VirtualSX1262 : ISPIPeripheral, IGPIOReceiver, IDisposable + { + public VirtualSX1262(IMachine machine, string engineHost = "127.0.0.1", + int enginePort = 0) + { + this.machine = machine; + this.engineHost = engineHost; + this.enginePort = enginePort; + // Held in a field because the library keeps the pointer: a delegate + // that only exists as an argument is collected, and the chip then + // calls into freed memory the first time a packet arrives. + dio1Callback = new VsxDio1Callback(OnDio1); + } + + // DIO1 into the MCU. Wired in the platform description to the pin the + // board uses - P1.15 on a RAK4631. + public GPIO IRQ { get; private set; } = new GPIO(); + + // Connect when the script says so rather than in the constructor: a + // platform description is loaded before anything else is ready, and a + // peripheral that throws while the machine is being built takes the + // whole machine with it. + public void Connect() + { + // Idempotent, because a script can say Connect twice and the second + // one must not take the first one's place. It would: the engine + // allows one radio per node and closes the newcomer, and this would + // then be holding a closed socket while the reader thread served + // the live one, so the node would go quiet with its connection + // still established and nothing anywhere saying why. + if(chip != IntPtr.Zero) + { + this.Log(LogLevel.Warning, "already connected; ignoring"); + return; + } + var path = Environment.GetEnvironmentVariable("MESHBENCH_RADIO_LIB"); + if(string.IsNullOrEmpty(path)) + { + this.Log(LogLevel.Error, "MESHBENCH_RADIO_LIB is not set, so this " + + "node has no chip and will report chip-not-found rather than say so"); + return; + } + try + { + lib = VirtualSX1262Lib.Open(path); + chip = lib.Create(); + } + catch(Exception e) + { + // Loud. An unattached radio answers every register read with + // zero, which RadioLib reports as no chip present: a + // configuration error dressed as a hardware fault. + this.Log(LogLevel.Error, "no chip model: {0}", e.Message); + return; + } + + // The seed for this node's receiver noise, which is where its + // firmware gets its entropy: RadioLib reads the chip's + // instantaneous RSSI for random bits and MeshCore derives its + // identity from them. Every node needs its own stream, or every + // node comes up with the same keypair. + var seed = Environment.GetEnvironmentVariable("MESHBENCH_NOISE_SEED"); + ulong seedValue; + if(!string.IsNullOrEmpty(seed) && ulong.TryParse(seed, out seedValue)) + { + lib.SetNoiseSeed(chip, seedValue); + } + lib.SetDio1Callback(chip, dio1Callback, IntPtr.Zero); + this.Log(LogLevel.Info, "chip attached from {0}", path); + + if(enginePort == 0) + { + this.Log(LogLevel.Warning, "no engine port given, so this node is " + + "deaf and mute: it will boot and then wait for ever on a " + + "transmission that cannot complete"); + return; + } + engine = new VirtualSX1262Engine(lib, chip, chipLock, SettleIrq, + () => femLevel, + m => this.Log(LogLevel.Warning, m)); + if(engine.Connect(engineHost, enginePort)) + { + this.Log(LogLevel.Info, "joined the engine at {0}:{1}", + engineHost, enginePort); + } + } + + // Chip select. RadioLib drives NSS as an ordinary GPIO rather than + // letting the SPI controller do it, which is why this is an + // IGPIOReceiver as well: the transaction boundary arrives on a pin, not + // from the bus. The rising edge is the only thing that says a command is + // complete, because an SX1262 command carries no length. Getting this + // wrong does not fail loudly - the chip sees one unframed byte stream + // and answers nothing that makes sense. + public void OnGPIO(int number, bool value) + { + if(chip == IntPtr.Zero) + { + return; + } + if(number != NssPin) + { + return; + } + lock(chipLock) + { + if(!value) // active low, as on the part + { + lib.SpiBegin(chip); + selected = true; + } + else if(selected) + { + lib.SpiEnd(chip); + selected = false; + } + } + SettleIrq(); + } + + public byte Transmit(byte data) + { + byte answer; + + if(chip == IntPtr.Zero) + { + return 0; + } + lock(chipLock) + { + // The controller may clock bytes without ever touching NSS, if + // the platform wires chip select to the peripheral itself. Open + // a transaction rather than dropping the byte: a silent zero + // here is the hardest kind of fault to find. + if(!selected) + { + lib.SpiBegin(chip); + selected = true; + implicitSelect = true; + } + answer = lib.SpiByte(chip, data); + } + SettleIrq(); + return answer; + } + + public void FinishTransmission() + { + if(chip == IntPtr.Zero || !selected || !implicitSelect) + { + return; + } + lock(chipLock) + { + lib.SpiEnd(chip); + selected = false; + implicitSelect = false; + } + SettleIrq(); + } + + // BUSY, read as a GPIO by RadioLib between commands. The chip answers, + // rather than this returning a constant, so an ARM node is not a + // different radio from every other node in the scenario. + public bool Busy + { + get { return chip != IntPtr.Zero && lib.Busy(chip) != 0; } + } + + public void Reset() + { + selected = false; + implicitSelect = false; + } + + // Called from inside a chip call, on whichever thread made it, with + // chipLock held. So it records the level rather than driving the line: + // setting a GPIO can enter the CPU, and doing that under our lock while + // the CPU thread waits on the same lock in Transmit is a deadlock. + private void OnDio1(IntPtr user, int asserted) + { + pendingIrq = asserted != 0; + irqPending = true; + } + + private void SettleIrq() + { + if(!irqPending) + { + return; + } + irqPending = false; + var level = pendingIrq; + if(level != irqLine) + { + irqLine = level; + IRQ.Set(level); + } + } + + public void Dispose() + { + if(engine != null) + { + engine.Dispose(); + engine = null; + } + if(chip != IntPtr.Zero) + { + lock(chipLock) + { + lib.Destroy(chip); + chip = IntPtr.Zero; + } + } + } + + // Which GPIO carries chip select into this peripheral. Renode numbers + // the connections a platform declares, so this is the index in the + // .repl rather than a pin on the board. + // + // No front-end module line. heltec_t096 is an nRF52 that carries one, + // and RenodeWiring has no pin for it and no .repl wires it, so this + // reports the module as never switched in - which is what it did before + // as well. Wiring it means a pin per board profile, read from each + // variant, and is worth doing on its own rather than half here. + private const int NssPin = 0; + + private readonly IMachine machine; + private readonly string engineHost; + private readonly int enginePort; + private readonly VsxDio1Callback dio1Callback; + + // SPI arrives on the CPU thread and the engine on its own, and both + // reach the same chip. The library says plainly that it is not thread + // safe and that the host serialises: this is that. + private readonly object chipLock = new object(); + + private VirtualSX1262Lib lib; + private IntPtr chip = IntPtr.Zero; + private VirtualSX1262Engine engine; + + private bool selected; + private bool implicitSelect; + private readonly bool femLevel; + private bool irqLine; + private volatile bool irqPending; + private volatile bool pendingIrq; + } +} diff --git a/tools/renode/peripherals/VirtualSX1262Engine.cs b/tools/renode/peripherals/VirtualSX1262Engine.cs new file mode 100644 index 00000000..669cbe7d --- /dev/null +++ b/tools/renode/peripherals/VirtualSX1262Engine.cs @@ -0,0 +1,350 @@ +// +// The line from an emulated node's radio to the RF engine. +// +// Separate from the peripheral because it is a different job. The peripheral is +// pins and SPI, which is what the firmware can see; this is the simulated air, +// which it cannot: what else is transmitting, when a waveform this node started +// has finished occupying the channel, and what arrived. Only the engine knows +// any of that, because the channel is shared with every other node in the +// scenario. +// +// The chip is not here. It belongs to the peripheral, and this reaches it under +// the lock the peripheral owns, because SPI arrives on the CPU thread and this +// runs on its own. The library says plainly that it is not thread safe and that +// the host serialises; that lock is the host doing so. +// +// Wire format, both directions: [kind:1][length:2 big-endian][payload]. Shared +// with the native firmware bridge and with the simulator's Go half, and frozen +// by both. +// +using System; +using System.IO; +using System.Net.Sockets; +using System.Threading; + +namespace Antmicro.Renode.Peripherals.Radio +{ + public class VirtualSX1262Engine : IDisposable + { + // settle is called after anything that could have moved the DIO1 line, + // outside the chip lock. It is a callback rather than a direct GPIO + // write because setting a Renode GPIO can enter the CPU, and doing that + // under the lock while the CPU thread waits on the same lock in + // Transmit is a deadlock. + public VirtualSX1262Engine(VirtualSX1262Lib lib, IntPtr chip, object chipLock, + Action settle, Func femLevel, + Action log) + { + this.lib = lib; + this.chip = chip; + this.chipLock = chipLock; + this.settle = settle; + this.femLevel = femLevel; + this.log = log; + } + + // Returns false when there is no engine to join, which is a node that + // will boot and then wait for ever on a transmission that cannot + // complete. The caller says so; this only reports. + public bool Connect(string host, int port) + { + try + { + client = new TcpClient(host, port) { NoDelay = true }; + stream = client.GetStream(); + } + catch(Exception e) + { + log(string.Format("no engine at {0}:{1} - {2}", host, port, e.Message)); + return false; + } + // The stream is handed to the thread rather than read from the + // field on every pass: this thread must serve the connection it was + // started for and no other, whatever else happens to the field. + thread = new Thread(Serve) + { + IsBackground = true, + Name = "virtual-sx1262 engine" + }; + thread.Start(stream); + return true; + } + + private void Serve(object opaque) + { + var wire = (NetworkStream)opaque; + var header = new byte[3]; + + while(true) + { + try + { + if(!ReadAll(wire, header, 3)) + { + break; + } + var length = (header[1] << 8) | header[2]; + var payload = new byte[length]; + if(length > 0 && !ReadAll(wire, payload, length)) + { + break; + } + Handle(header[0], payload); + } + catch(Exception e) + { + log("the engine went away - " + e.Message); + break; + } + } + stream = null; + } + + private void Handle(byte kind, byte[] payload) + { + switch(kind) + { + case Frame: + // A packet the channel delivered. Only frames that passed CRC + // arrive here, exactly as on hardware: everything else was + // recorded and withheld. + if(payload.Length > 0) + { + lock(chipLock) + { + lib.DeliverFrame(chip, payload, (UIntPtr)payload.Length); + } + } + break; + + case TxDone: + lock(chipLock) + { + lib.TransmitFinished(chip); + } + break; + + case ChannelBusy: + if(payload.Length >= 1) + { + lock(chipLock) + { + lib.SetChannelBusy(chip, payload[0] != 0 ? 1 : 0); + } + } + break; + + case Tick: + if(payload.Length == 4) + { + Advance(((uint)payload[0] << 24) | ((uint)payload[1] << 16) | + ((uint)payload[2] << 8) | payload[3]); + } + break; + + default: + // Skipped, not fatal. Console traffic reaches an emulated node + // over the emulator's own serial port, so the engine's console + // messages arrive here and are meant to be ignored. Treating an + // unknown kind as fatal once killed the radio the moment + // anybody typed at the fleet, and the node then reported "radio + // init failed: -2", which points at wiring. + break; + } + settle(); + } + + private void Advance(uint toMs) + { + lock(chipLock) + { + // A millisecond at a time, as a native node is stepped. + // Stepping rather than jumping is what keeps the chip's own + // timeouts behaving: a preamble flag that should clear after + // 66 ms does not, if time arrives in 500 ms lumps. + while(simMs < toMs) + { + simMs++; + lib.Tick(chip, simMs); + DrainTx(); + } + lib.Tick(chip, simMs); + DrainTx(); + SendStats(); + } + settle(); + + var ack = new byte[4]; + Put32(ack, 0, toMs); + Send(Ack, ack, ack.Length); + } + + // Anything the firmware handed its radio goes out to the engine now. + // + // A transmission reaches the channel immediately and is not immediately + // complete: the chip stays in transmit until the engine sends TxDone, + // exactly as a native node does, because that is what stops a node + // talking over itself. + // + // Called with chipLock held. + private void DrainTx() + { + var n = (int)lib.TakeTx(chip, txBuffer, (UIntPtr)txBuffer.Length); + if(n <= 0) + { + return; + } + if(n > txBuffer.Length) + { + // Truncated, and said out loud. A frame this long is not + // something MeshCore sends, so it means the chip and this + // disagree about the buffer rather than that a node had a lot + // to say. + log(string.Format("the chip offered a {0} byte frame", n)); + n = txBuffer.Length; + } + Send(Frame, txBuffer, n); + } + + // What this radio has been configured to be, and what it has counted. + // + // The native bridge and QEMU's device write the same payload in the + // same order: an emulated node and a native one reporting different + // shapes would make every comparison between them a comparison of our + // own code. Called with chipLock held. + private void SendStats() + { + VsxState state; + VsxCounters counters; + var sb = new byte[StatsLength]; + + lib.GetState(chip, out state); + lib.GetCounters(chip, out counters); + + Put32(sb, 0, counters.IrqReads); + Put32(sb, 4, counters.BusyReads); + Put32(sb, 8, counters.BusyMs); + Put32(sb, 12, counters.SpuriousRaises); + + sb[16] = state.RxGainReg; + sb[17] = (byte)state.TxPowerDbm; + // The line as it stands now, which the peripheral knows because the + // board drives it there: the module sits beside the chip, not + // inside it, so the chip has no view of it and reports none. + sb[18] = (byte)(femLevel() ? 1 : 0); + sb[19] = state.Mode; + sb[20] = state.SpreadingFactor; + sb[21] = state.CodingRate; + Put32(sb, 22, state.FreqHz); + Put32(sb, 26, state.BandwidthHz); + Put16(sb, 30, state.PreambleSyms); + Put16(sb, 32, state.IrqMask); + Put16(sb, 34, state.IrqFlags); + // Three states, because "has not transmitted" is not "transmitted + // with the module out". + sb[36] = state.FemAtTx; + // The DIO1 routing mask, which is not the enable mask above. + // Reported separately because confusing the two is a fault that has + // already happened: HeaderValid raised DIO1 part-way through a + // carrier, the pin was still high when RxDone arrived, and a driver + // that attaches on the rising edge never learned the packet existed. + Put16(sb, 37, state.Dio1Mask); + + Send(RadioStats, sb, sb.Length); + } + + private static bool ReadAll(NetworkStream wire, byte[] buffer, int count) + { + var at = 0; + while(at < count) + { + var got = wire.Read(buffer, at, count - at); + if(got <= 0) + { + return false; + } + at += got; + } + return true; + } + + private void Send(byte kind, byte[] payload, int length) + { + var wire = stream; + if(wire == null) + { + return; + } + try + { + lock(writeLock) + { + wire.WriteByte(kind); + wire.WriteByte((byte)(length >> 8)); + wire.WriteByte((byte)length); + if(length > 0) + { + wire.Write(payload, 0, length); + } + } + } + catch(IOException e) + { + log("the engine stopped listening - " + e.Message); + stream = null; + } + } + + private static void Put32(byte[] b, int at, uint v) + { + b[at] = (byte)(v >> 24); + b[at + 1] = (byte)(v >> 16); + b[at + 2] = (byte)(v >> 8); + b[at + 3] = (byte)v; + } + + private static void Put16(byte[] b, int at, ushort v) + { + b[at] = (byte)(v >> 8); + b[at + 1] = (byte)v; + } + + public void Dispose() + { + var wire = stream; + stream = null; + if(wire != null) + { + wire.Close(); + } + client = null; + } + + private const byte Frame = 0x01; + private const byte Tick = 0x02; + private const byte Ack = 0x03; + private const byte TxDone = 0x04; + private const byte ChannelBusy = 0x08; + private const byte RadioStats = 0x09; + + // The stats record, whose layout the engine reads on length. + private const int StatsLength = 39; + + private readonly VirtualSX1262Lib lib; + private readonly IntPtr chip; + private readonly object chipLock; + private readonly Action settle; + private readonly Func femLevel; + private readonly Action log; + private readonly byte[] txBuffer = new byte[512]; + private readonly object writeLock = new object(); + + // Held only so it is not collected: a TcpClient with no reference left + // is finalised, and finalising it closes the socket the reader thread + // is still serving. + private TcpClient client; + private NetworkStream stream; + private Thread thread; + private uint simMs; + } +} diff --git a/tools/renode/peripherals/VirtualSX1262Lib.cs b/tools/renode/peripherals/VirtualSX1262Lib.cs new file mode 100644 index 00000000..14bd7a50 --- /dev/null +++ b/tools/renode/peripherals/VirtualSX1262Lib.cs @@ -0,0 +1,216 @@ +// +// Loading virtual-sx1262, and the ABI it exposes. +// +// The chip is a C library, and this is the declaration of it. Two things are +// done the long way round here on purpose. +// +// The library is opened by path rather than by a DllImport name, because the +// path is decided by whoever started this emulator and named in +// MESHBENCH_RADIO_LIB. A DllImport name would need the file on the loader's +// search path, which means an environment variable set around Renode on Linux, +// a different one on macOS and a directory on Windows - three ways to get the +// same thing subtly wrong, for a file whose location we already know. +// +// And the entry points are delegates over dlsym rather than DllImport +// declarations, because Renode compiles this file at load time and may be +// running on Mono or on .NET. NativeLibrary and its resolver are not on both. +// dlopen and LoadLibrary are. +// +// The ABI is a contract, and the other side of it is +// MeshBench/virtual-sx1262's include/virtual_sx1262.h. It is append-only: +// never reorder a struct, never change what an entry point means. This +// declares ABI 1.3, the version that added the byte-at-a-time SPI path, which +// is the only one an emulator can use - Renode's ISPIPeripheral.Transmit is +// called once per clocked byte and must answer that byte before the next. +// +using System; +using System.Runtime.InteropServices; + +namespace Antmicro.Renode.Peripherals.Radio +{ + // Laid out to match vsx_state exactly. Sequential, and no field may be + // reordered or resized: a mismatch here does not fail, it reads one + // setting as another. + [StructLayout(LayoutKind.Sequential)] + public struct VsxState + { + public uint FreqHz; + public uint BandwidthHz; + public ushort PreambleSyms; + public ushort IrqMask; + public ushort IrqFlags; + public byte SpreadingFactor; + public byte CodingRate; + public byte Mode; // 0 standby, 1 rx, 2 tx, 3 cad + public sbyte TxPowerDbm; + public byte RxGainReg; + public byte FemAtTx; // 0 never transmitted, 1 module out, 2 module in + public ushort Dio1Mask; + } + + [StructLayout(LayoutKind.Sequential)] + public struct VsxCounters + { + public uint IrqReads; + public uint BusyReads; + public uint BusyMs; + public uint SpuriousRaises; + public uint PreambleRaises; + public uint FramesDropped; + } + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate void VsxDio1Callback(IntPtr user, int asserted); + + public class VirtualSX1262Lib + { + public const int AbiMajor = 1; + public const int AbiMinor = 3; + + public static VirtualSX1262Lib Open(string path) + { + var handle = NativeOpen(path); + if(handle == IntPtr.Zero) + { + throw new InvalidOperationException( + string.Format("cannot load the chip model at {0}: {1}", path, NativeError())); + } + var lib = new VirtualSX1262Lib(handle, path); + int major, minor; + lib.AbiVersion(out major, out minor); + // A major that does not match means every host has to be rebuilt, + // and the library is saying so; a minor below the floor means an + // entry point this peripheral calls is not there. Refusing is the + // point of asking: an unchecked mismatch is a chip that answers + // plausible nonsense. + if(major != AbiMajor || minor < AbiMinor) + { + throw new InvalidOperationException(string.Format( + "{0} is ABI {1}.{2}, and this peripheral needs {3}.{4} or a later minor", + path, major, minor, AbiMajor, AbiMinor)); + } + return lib; + } + + public string Path { get; private set; } + + public AbiVersionFn AbiVersion; + public CreateFn Create; + public DestroyFn Destroy; + public SetDio1CallbackFn SetDio1Callback; + public SpiBeginFn SpiBegin; + public SpiByteFn SpiByte; + public SpiEndFn SpiEnd; + public BusyFn Busy; + public TickFn Tick; + public SetChannelBusyFn SetChannelBusy; + public DeliverFrameFn DeliverFrame; + public TransmitFinishedFn TransmitFinished; + public TakeTxFn TakeTx; + public SetFemEnabledFn SetFemEnabled; + public GetStateFn GetState; + public GetCountersFn GetCounters; + public SetNoiseSeedFn SetNoiseSeed; + + public delegate void AbiVersionFn(out int major, out int minor); + public delegate IntPtr CreateFn(); + public delegate void DestroyFn(IntPtr chip); + public delegate void SetDio1CallbackFn(IntPtr chip, VsxDio1Callback fn, IntPtr user); + public delegate void SpiBeginFn(IntPtr chip); + public delegate byte SpiByteFn(IntPtr chip, byte outByte); + public delegate void SpiEndFn(IntPtr chip); + public delegate int BusyFn(IntPtr chip); + public delegate void TickFn(IntPtr chip, ulong nowMs); + public delegate void SetChannelBusyFn(IntPtr chip, int busy); + public delegate void DeliverFrameFn(IntPtr chip, byte[] frame, UIntPtr len); + public delegate void TransmitFinishedFn(IntPtr chip); + public delegate UIntPtr TakeTxFn(IntPtr chip, byte[] dst, UIntPtr cap); + public delegate void SetFemEnabledFn(IntPtr chip, int enabled); + public delegate void GetStateFn(IntPtr chip, out VsxState state); + public delegate void GetCountersFn(IntPtr chip, out VsxCounters counters); + public delegate void SetNoiseSeedFn(IntPtr chip, ulong seed); + + private VirtualSX1262Lib(IntPtr handle, string path) + { + this.handle = handle; + Path = path; + + AbiVersion = (AbiVersionFn)Bind("vsx_abi_version", typeof(AbiVersionFn)); + Create = (CreateFn)Bind("vsx_create", typeof(CreateFn)); + Destroy = (DestroyFn)Bind("vsx_destroy", typeof(DestroyFn)); + SetDio1Callback = (SetDio1CallbackFn)Bind("vsx_set_dio1_callback", typeof(SetDio1CallbackFn)); + SpiBegin = (SpiBeginFn)Bind("vsx_spi_begin", typeof(SpiBeginFn)); + SpiByte = (SpiByteFn)Bind("vsx_spi_byte", typeof(SpiByteFn)); + SpiEnd = (SpiEndFn)Bind("vsx_spi_end", typeof(SpiEndFn)); + Busy = (BusyFn)Bind("vsx_busy", typeof(BusyFn)); + Tick = (TickFn)Bind("vsx_tick", typeof(TickFn)); + SetChannelBusy = (SetChannelBusyFn)Bind("vsx_set_channel_busy", typeof(SetChannelBusyFn)); + DeliverFrame = (DeliverFrameFn)Bind("vsx_deliver_frame", typeof(DeliverFrameFn)); + TransmitFinished = (TransmitFinishedFn)Bind("vsx_transmit_finished", typeof(TransmitFinishedFn)); + TakeTx = (TakeTxFn)Bind("vsx_take_tx", typeof(TakeTxFn)); + SetFemEnabled = (SetFemEnabledFn)Bind("vsx_set_fem_enabled", typeof(SetFemEnabledFn)); + GetState = (GetStateFn)Bind("vsx_get_state", typeof(GetStateFn)); + GetCounters = (GetCountersFn)Bind("vsx_get_counters", typeof(GetCountersFn)); + SetNoiseSeed = (SetNoiseSeedFn)Bind("vsx_set_noise_seed", typeof(SetNoiseSeedFn)); + } + + private Delegate Bind(string name, Type type) + { + var symbol = NativeSymbol(handle, name); + if(symbol == IntPtr.Zero) + { + throw new InvalidOperationException( + string.Format("{0} has no {1}: is it virtual-sx1262?", Path, name)); + } + return Marshal.GetDelegateForFunctionPointer(symbol, type); + } + + // The platform's loader, by hand. Windows is the odd one; everywhere + // else is dlopen, and macOS resolves it in libSystem. + private static bool OnWindows + { + get + { + var p = (int)Environment.OSVersion.Platform; + return p != 4 && p != 6 && p != 128; + } + } + + private static IntPtr NativeOpen(string path) + { + // RTLD_NOW | RTLD_LOCAL: every symbol is resolved below anyway, and + // a missing one should be named here rather than crash the machine + // at the first SPI byte. + return OnWindows ? LoadLibraryW(path) : dlopen(path, 2); + } + + private static IntPtr NativeSymbol(IntPtr handle, string name) + { + return OnWindows ? GetProcAddress(handle, name) : dlsym(handle, name); + } + + private static string NativeError() + { + if(OnWindows) + { + return string.Format("error {0}", Marshal.GetLastWin32Error()); + } + var err = dlerror(); + return err == IntPtr.Zero ? "no reason given" : Marshal.PtrToStringAnsi(err); + } + + private readonly IntPtr handle; + + [DllImport("libdl.so.2", EntryPoint = "dlopen")] + private static extern IntPtr dlopen(string path, int flags); + [DllImport("libdl.so.2", EntryPoint = "dlsym")] + private static extern IntPtr dlsym(IntPtr handle, string name); + [DllImport("libdl.so.2", EntryPoint = "dlerror")] + private static extern IntPtr dlerror(); + + [DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr LoadLibraryW(string path); + [DllImport("kernel32", SetLastError = true)] + private static extern IntPtr GetProcAddress(IntPtr handle, string name); + } +} diff --git a/tools/renode/peripherals/radio-only.repl b/tools/renode/peripherals/radio-only.repl new file mode 100644 index 00000000..162c4476 --- /dev/null +++ b/tools/renode/peripherals/radio-only.repl @@ -0,0 +1,9 @@ +// A machine with nothing but the radio, for testing the seam in isolation. +// +// No CPU: what is under test is whether the peripheral and the chip library +// agree about chip select framing and byte order, and a CPU would only add a +// firmware's opinion to a question that does not need one. The engine port is +// nominal; nothing here needs one. +radio: Radio.VirtualSX1262 @ sysbus + engineHost: "127.0.0.1" + enginePort: 7777 diff --git a/tools/renode/peripherals/radioserver_test.resc b/tools/renode/peripherals/radio-only.resc similarity index 53% rename from tools/renode/peripherals/radioserver_test.resc rename to tools/renode/peripherals/radio-only.resc index cf865a3f..1e560de3 100644 --- a/tools/renode/peripherals/radioserver_test.resc +++ b/tools/renode/peripherals/radio-only.resc @@ -1,24 +1,28 @@ -:name: SX1262 over the radio model +:name: SX1262, with no firmware in the way :description: Drives the Renode peripheral directly, before any firmware depends on it. : -: The peripheral is a wire to radioserver rather than a model of a chip, so what -: is under test here is the wire: chip select framing, byte order, and that the -: answers coming back are the ones VirtualSX1262 gives a native node. +: What is under test is the seam between the peripheral and the chip library it +: loads: chip select framing, byte order, and that the answers coming back are +: the ones a native node gets from the same model. : -: Start the radio model first, on a TCP port, with no engine attached: -: radioserver :7777 +: No engine and no CPU. A machine with neither is deliberate: what is being +: asked is whether the peripheral and the model agree, and a firmware would only +: add its own opinion to a question that does not need one. : -: Then: -: renode tools/renode/peripherals/radioserver_test.resc +: MESHBENCH_RADIO_LIB=/path/to/libvirtualsx1262.so \ +: renode tools/renode/peripherals/radio-only.resc mach create "radio" -machine LoadPlatformDescription @tools/renode/peripherals/radioserver.repl +i @tools/renode/peripherals/VirtualSX1262Lib.cs +i @tools/renode/peripherals/VirtualSX1262Engine.cs +i @tools/renode/peripherals/VirtualSX1262.cs +machine LoadPlatformDescription @tools/renode/peripherals/radio-only.repl radio Connect : GetStatus (0xC0) then one byte clocked out to read the answer. A chip in : standby answers 0x20 - mode 2 in bits 6:4 - and a chip that is not there -: answers 0x00, which is how "no radio model" looks from the firmware's side. +: answers 0x00, which is how "no chip model" looks from the firmware's side. echo "GetStatus, expect 0x2x:" radio Transmit 0xC0 radio Transmit 0x00 diff --git a/tools/renode/peripherals/radioserver.repl b/tools/renode/peripherals/radioserver.repl deleted file mode 100644 index d52d628f..00000000 --- a/tools/renode/peripherals/radioserver.repl +++ /dev/null @@ -1,8 +0,0 @@ -// A machine with nothing but the radio, for testing the wire in isolation. -// -// No CPU: what is under test is whether the peripheral and the radio model -// agree about chip select framing and byte order, and a CPU would only add a -// firmware's opinion to a question that does not need one. -radio: Radio.RadioServerSX1262 @ sysbus - host: "127.0.0.1" - port: 7777 diff --git a/tools/renode/radio-legacy.repl b/tools/renode/radio-legacy.repl index ae0d86a2..ac637fd8 100644 --- a/tools/renode/radio-legacy.repl +++ b/tools/renode/radio-legacy.repl @@ -4,6 +4,6 @@ // defines either the legacy TXD/RXD registers or the EasyDMA pointers, not // both. Firmware written against SPIM needs radio.repl; firmware that pokes the // legacy registers needs this one. -lora: Radio.RadioServerSX1262 @ spi2 - host: "127.0.0.1" - port: 7777 +lora: Radio.VirtualSX1262 @ spi2 + engineHost: "127.0.0.1" + enginePort: 7777 diff --git a/tools/renode/radio.repl b/tools/renode/radio.repl index 10a6b369..ee55968b 100644 --- a/tools/renode/radio.repl +++ b/tools/renode/radio.repl @@ -9,9 +9,9 @@ spi2: SPI.NRF52840_SPI @ sysbus 0x40023000 easyDMA: true -> nvic@0x23 -// Not a model of a chip - a wire to radioserver, which owns the same -// VirtualSX1262 a native node reaches in process and an emulated ESP32 reaches -// over a socket. One chip, three ways in. -lora: Radio.RadioServerSX1262 @ spi2 - host: "127.0.0.1" - port: 7777 +// The chip itself, as the library QEMU loads and a native node links. The port +// is the RF engine's, not a chip server's: there is no longer anything between +// the peripheral and the model. +lora: Radio.VirtualSX1262 @ spi2 + engineHost: "127.0.0.1" + enginePort: 7777 diff --git a/tools/renode/rak4631-radio.resc b/tools/renode/rak4631-radio.resc index 9dc567a0..d3b942c7 100644 --- a/tools/renode/rak4631-radio.resc +++ b/tools/renode/rak4631-radio.resc @@ -1,6 +1,8 @@ i @tools/renode/peripherals/NRF52840_TWIM.cs i @tools/renode/peripherals/NRF52840_SAADC.cs -i @tools/renode/peripherals/RadioServerSX1262.cs +i @tools/renode/peripherals/VirtualSX1262Lib.cs +i @tools/renode/peripherals/VirtualSX1262Engine.cs +i @tools/renode/peripherals/VirtualSX1262.cs i @tools/renode/peripherals/NRF52840_Clock.cs i @tools/renode/peripherals/NRF52840_Temp.cs mach create "rak" diff --git a/tools/renode/spim3.repl b/tools/renode/spim3.repl index 019cad94..c9fdd72c 100644 --- a/tools/renode/spim3.repl +++ b/tools/renode/spim3.repl @@ -14,9 +14,9 @@ spi3: SPI.NRF52840_SPI @ sysbus 0x4002F000 easyDMA: true -> nvic@0x2F -lora: Radio.RadioServerSX1262 @ spi3 - host: "127.0.0.1" - port: 7777 +lora: Radio.VirtualSX1262 @ spi3 + engineHost: "127.0.0.1" + enginePort: 7777 // DIO1 into the MCU. The RAK4631 wires it to P1.15, which is pin 47 in the // flat numbering the Adafruit core uses and pin 15 of gpio1 here. Without // it the firmware configures the radio and then waits on a pin nothing From 9fac141b14dc778cfead29a55f95edf35202a3f0 Mon Sep 17 00:00:00 2001 From: R0ck Date: Fri, 4 Sep 2026 00:08:13 +0100 Subject: [PATCH 2/4] firmware: say where the chip actually is The package comment named a Renode file that has never existed under that name and described a socket the SX1262 was on the far end of. The chip is inside the emulator now, on both backends; this socket carries the air. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- docs/emulated-published-firmware.md | 2 +- internal/firmware/bridge.go | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/emulated-published-firmware.md b/docs/emulated-published-firmware.md index 366d114e..1841339d 100644 --- a/docs/emulated-published-firmware.md +++ b/docs/emulated-published-firmware.md @@ -47,7 +47,7 @@ back, plus the `BUSY` and `DIO1` lines. The native path keeps calling it in-process and does not change. The QEMU device is then a forwarder, not a second implementation. Same for -`tools/renode/peripherals/SX1262.cs`, which already assumes a socket and can +`tools/renode/peripherals/VirtualSX1262.cs`, which already assumes a socket and can become a forwarder too, deleting about 380 lines of C#. Its link to the RF engine already exists and stays exactly as it is. diff --git a/internal/firmware/bridge.go b/internal/firmware/bridge.go index 4c86220e..57f6f901 100644 --- a/internal/firmware/bridge.go +++ b/internal/firmware/bridge.go @@ -1,12 +1,14 @@ // Package firmware connects emulated nodes to the RF engine. // -// An emulated node runs inside Renode or QEMU and reaches the simulator over a -// socket: the SX1262 peripheral model (tools/renode/peripherals/SX1262.cs) -// hands transmitted frames out and takes delivered frames back. +// An emulated node runs inside Renode or QEMU, and the chip is inside the +// emulator with it: Renode's tools/renode/peripherals/VirtualSX1262.cs and +// QEMU's hw/ssi/sx1262.c both load the same virtual-sx1262 a native node links. +// This socket carries what is not in there - the air - so the emulator hands +// transmitted frames out over it and takes delivered frames back. // // A socket rather than an emulator-native wireless medium is deliberate. The // physics lives in internal/rf, and an emulated node must share exactly the -// same channel as a native one — otherwise the two backends are not comparable, +// same channel as a native one: otherwise the two backends are not comparable, // which is the entire reason ADR-0010 has both. package firmware From 0f89c58cf3541079e0dc823ffa470f7b89762fe4 Mon Sep 17 00:00:00 2001 From: R0ck Date: Fri, 4 Sep 2026 00:33:36 +0100 Subject: [PATCH 3/4] two emulated nodes are two nodes, and the matrix says what was measured Every emulated board used to come up with the same keypair, so a mesh of them was one node repeated (#556). The mechanism is fixed - per-node receiver noise, and the random-number registers generated rather than stored - but nothing asserted the thing the issue is actually about. noiseSeedFor is unit tested for distinct seeds per name and the chip is tested for distinct noise per seed; the link from noise to the key real firmware derives can only be seen by booting two of them. emu-a 9261FD8CAEED53DF07A62903D0D9D45A7F393E61BFCCE3D7F95E7A377B2D5A3A emu-b A858425E10AD9FA03A84444DCF769CAEE58B2931D9AB59A1B9D29B3A543B07C8 Determinism was checked by hand across two runs and is byte-identical, and the test says plainly that it does not assert that itself: pinning expected keys would break on a MeshCore version rather than on a fault of ours. The README's measurement note also said the matrix was measured against virtual-sx1262 v1.3.0, which an earlier edit of mine made untrue - those rows were measured through the radio server. Nine of them have now been re-measured against the chip loaded inside the emulator, one board at a time on an idle machine, and every one reproduced. LilyGo_TDeck is named as the exception. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- README.md | 7 +- internal/sim/engine/identity_live_test.go | 143 ++++++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 internal/sim/engine/identity_live_test.go diff --git a/README.md b/README.md index d7e613e3..d5e3938a 100644 --- a/README.md +++ b/README.md @@ -205,8 +205,11 @@ nobody has watched that board do that thing. ✓ passed ✗ failed – not applicable ? not measurable yet blank not attempted -Measured on 3 September 2026 against `virtual-sx1262` v1.3.0, one board at a time on -an idle machine. Every row above is a run from that day except the two blanks. +Measured one board at a time on an idle machine. Nine of these rows were +re-measured on 4 September 2026 against `virtual-sx1262` v1.3.0, loaded inside +the emulator, and every one reproduced what it had shown the day before through +the radio server that arrangement replaced. `LilyGo_TDeck` is the exception and +still carries its 3 September run; the two blanks have never been attempted. What each board's row means in detail is in [`docs/emulated-published-firmware.md`](docs/emulated-published-firmware.md). diff --git a/internal/sim/engine/identity_live_test.go b/internal/sim/engine/identity_live_test.go new file mode 100644 index 00000000..123a7374 --- /dev/null +++ b/internal/sim/engine/identity_live_test.go @@ -0,0 +1,143 @@ +package engine_test + +import ( + "context" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/MeshBench/meshbench/internal/firmware" + "github.com/MeshBench/meshbench/internal/firmware/emulated" + "github.com/MeshBench/meshbench/internal/rf/antenna" + "github.com/MeshBench/meshbench/internal/sim/engine" + "github.com/MeshBench/meshbench/internal/world/scenario" +) + +// Two emulated nodes, and they must not be the same node twice. +// +// Every emulated board used to come up with the same keypair, byte for byte, +// across boards and across runs. A mesh of them was one node repeated: every +// packet signed by the same identity, every receiver treating them as one peer, +// and anything keyed on identity - contacts, ACK routing, flood suppression - +// operating on a mesh that did not exist. Their adverts were byte-identical too, +// so a receiver was right to drop the second as a duplicate. +// +// The cause was entropy. Since v1.17.1 MeshCore mixes the radio's own randomness +// into its keypair, and `SX126x::randomByte()` derives that from RSSI noise on a +// receiving radio. Our chip had no noise to give, so three deterministic sources +// xored together stayed deterministic. The chip has per-node receiver noise now, +// seeded from the run's seed and the node's name. +// +// This is the assertion the fix is actually about, and the one nothing else +// makes. `noiseSeedFor` is unit-tested to give distinct seeds per name, and the +// chip model is tested to give distinct noise per seed, but the link from noise +// to the key the firmware derives runs through real firmware and can only be +// seen by running two of them. +// +// Determinism is not traded away, and that is a separate claim this does not +// assert: checking it needs two runs, and pinning the expected keys here would +// break on any MeshCore version rather than on a fault of ours. It was checked +// by hand when this was written - two runs of this test, same seed, same names, +// byte-identical keys both times - and the seed is derived rather than sampled +// precisely so that stays true. +// +// MESHBENCH_LIVE=1 \ +// MESHBENCH_QEMU=~/msim/espqemu-src/build/qemu-system-xtensa \ +// MESHBENCH_RADIO_LIB=~/…/virtual-sx1262/build/libvirtualsx1262.so \ +// go test ./internal/sim/engine -run TestTwoEmulatedNodesAreTwoNodes -v -timeout 600s +func TestTwoEmulatedNodesAreTwoNodes(t *testing.T) { + if os.Getenv("MESHBENCH_LIVE") == "" { + t.Skip("set MESHBENCH_LIVE=1") + } + const board = "Generic_E22_sx1262" + const version = "v1.17.1" + + cache := firmware.DefaultCacheDir() + img := emulated.BoardImage{Board: board, Role: "simple_repeater", + Version: version, Format: "bin"} + if _, err := os.Stat(emulated.BoardImagePath(cache, img)); err != nil { + t.Skipf("no cached %s image for %s; probe that board first", board, version) + } + + keys := identitiesOfTwoEmulatedNodes(t, board, version) + if len(keys) != 2 { + t.Fatalf("only %d of 2 nodes printed an identity: %v", len(keys), keys) + } + if strings.EqualFold(keys["emu-a"], keys["emu-b"]) { + t.Errorf("both emulated nodes came up as %s...; a mesh of them is one "+ + "node repeated", keys["emu-a"][:16]) + } + t.Logf("emu-a %s", keys["emu-a"]) + t.Logf("emu-b %s", keys["emu-b"]) +} + +// repeaterID is what simple_repeater prints as it comes up. +var repeaterID = regexp.MustCompile(`Repeater ID: ([0-9A-Fa-f]{64})`) + +func identitiesOfTwoEmulatedNodes(t *testing.T, board, version string) map[string]string { + t.Helper() + + e := engine.New(flat{}, engine.Config{ + FreqMHz: 869.618, SF: 8, BandwidthHz: 62_500, CodingRate: 4, + NoiseFigDB: 6, StepMs: 10, Seed: 4417, + }) + defer func() { _ = e.Close() }() + + mast := antenna.Mounted{Pattern: antenna.Collinear{GainDBiPeak: 6}, Polarisation: "vertical"} + radio := scenario.RadioConfig{CentreHz: 869.618e6, BandwidthHz: 62_500, + SpreadFactor: 8, CodingRate: 4} + for i, name := range []string{"emu-a", "emu-b"} { + e.Add(scenario.Node{ + Name: name, Kind: scenario.SimpleRepeater, + Position: scenario.LatLon{Lat: 56.70, Lon: -3.90 + float64(i)*0.02}, + HeightAGLm: 10, + Antenna: mast, TxPowerDBm: 20, NoiseFigureDB: 6, Radio: radio, + Firmware: scenario.FirmwareRef{ + Role: "simple_repeater", Version: version, Board: board, + }, + }, nil) + } + + ctx, cancel := context.WithTimeout(context.Background(), 480*time.Second) + defer cancel() + + if err := e.AttachNative(ctx, 4417); err != nil { + t.Fatal(err) + } + t.Log("both emulated nodes attached") + + // Paced to wall time: these are emulators and cannot be run faster than they + // run. Long enough for both to come up and print, and no longer. + deadline := time.Now().Add(90 * time.Second) + for at := uint32(500); time.Now().Before(deadline); at += 500 { + if err := e.Run(ctx, at); err != nil { + t.Fatalf("run to %d ms: %v", at, err) + } + time.Sleep(450 * time.Millisecond) + } + + // Read from each node's own console log, the way the board probe does. The + // identity is printed once, in the first seconds of the boot, so the whole + // log is the only place it is still there to be read afterwards. + out := map[string]string{} + for _, name := range []string{"emu-a", "emu-b"} { + n, ok := e.NodeByName(name) + if !ok || n.Firmware == nil { + t.Fatalf("%s did not attach", name) + } + said, ok := n.Firmware.Backend.(interface{ ConsoleLog() ([]byte, error) }) + if !ok { + t.Fatalf("%s has no console to read", name) + } + log, err := said.ConsoleLog() + if err != nil { + t.Fatalf("%s console: %v", name, err) + } + if m := repeaterID.FindSubmatch(log); m != nil { + out[name] = string(m[1]) + } + } + return out +} From c088dc3538be1a5cc7b190d9da2a9f2dd3f5f946 Mon Sep 17 00:00:00 2001 From: R0ck Date: Fri, 4 Sep 2026 01:03:59 +0100 Subject: [PATCH 4/4] pin QEMU v9.2.2-meshbench-sx1262-11, the release with the chip inside it The device in -10 forwards SPI to a radioserver process and does not know radio-bridge, so a fetched QEMU would refuse to start against this branch even though a locally built one works. That is the whole reason this pin moves in the same change. -11 also carries a portability fix worth naming, because nothing local could have caught it: upstream resolves gmodule only when modules or plugins are enabled, and the sx1262 device calls g_module_open. The release configures with neither, so it linked every target and then failed on qemu-system-xtensa alone, on macOS, with an undefined _g_module_error. This machine had plugins on. Digests taken from the published assets and checked by fetching them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- internal/app/resource/toolchaincatalogue.go | 16 ++++++++-------- packaging/emulator-pins.env | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/app/resource/toolchaincatalogue.go b/internal/app/resource/toolchaincatalogue.go index 60fa108f..108f143c 100644 --- a/internal/app/resource/toolchaincatalogue.go +++ b/internal/app/resource/toolchaincatalogue.go @@ -21,7 +21,7 @@ package resource // downloads have to be the same build, or a bug reproduces on one machine and // not the other. const ( - qemuBase = "https://github.com/MeshBench/qemu/releases/download/v9.2.2-meshbench-sx1262-10/" + qemuBase = "https://github.com/MeshBench/qemu/releases/download/v9.2.2-meshbench-sx1262-11/" renodeBase = "https://github.com/MeshBench/renode/releases/download/meshbench-20260901-ca9f7e3/" chipBase = "https://github.com/MeshBench/virtual-sx1262/releases/download/v1.3.0/" ) @@ -97,22 +97,22 @@ var toolReleases = []toolRelease{{ Unsupported: map[string]string{"windows/amd64": windowsFetchesNoEmulators}, }, { Name: "qemu-system-xtensa", - Version: "v9.2.2-meshbench-sx1262-10", + Version: "v9.2.2-meshbench-sx1262-11", MCU: "ESP32", Why: "the emulator for the ESP32 family, carrying our SX1262 device, its " + "DIO1 line and the GPIO implementation upstream has not got", Terms: qemuTerms, Assets: map[string]toolAsset{ "linux/amd64": { - URL: qemuBase + "qemu-xtensa-softmmu-v9.2.2_meshbench_sx1262_10-x86_64-linux-gnu.tar.xz", - SHA256: "8d5d4cd92ced6a6ebc7fceecbf6da77837beca7ae3ddb3f9706a213761b87cbe", - Bytes: 17089336, Kind: tarXZ, Magic: elfAMD64, + URL: qemuBase + "qemu-xtensa-softmmu-v9.2.2_meshbench_sx1262_11-x86_64-linux-gnu.tar.xz", + SHA256: "05245a554ad9a1bfb1af81146d50c625f1469c41283efbc47f2025920b57a8ac", + Bytes: 17103336, Kind: tarXZ, Magic: elfAMD64, Root: "qemu", Binary: "qemu/bin/qemu-system-xtensa", }, "darwin/arm64": { - URL: qemuBase + "qemu-xtensa-softmmu-v9.2.2_meshbench_sx1262_10-aarch64-apple-darwin.tar.xz", - SHA256: "f6bf3e4d5fd7e9b66b9632d224ea4c22e860ff4eb5175913033d9f0e73c24f01", - Bytes: 4573100, Kind: tarXZ, Magic: machARM64, + URL: qemuBase + "qemu-xtensa-softmmu-v9.2.2_meshbench_sx1262_11-aarch64-apple-darwin.tar.xz", + SHA256: "7930f3704408f6dd74b16bacbf86f0fd07aec7f1df29d6cf0886e231a138030e", + Bytes: 5071740, Kind: tarXZ, Magic: machARM64, Root: "qemu", Binary: "qemu/bin/qemu-system-xtensa", }, }, diff --git a/packaging/emulator-pins.env b/packaging/emulator-pins.env index 6e7083f7..8ee8675c 100644 --- a/packaging/emulator-pins.env +++ b/packaging/emulator-pins.env @@ -36,10 +36,10 @@ # # Built from 14b5e22f9a1ecf271940411859142fca1a88da25, the head of the fork's # meshbench-main branch. -QEMU_RELEASE=v9.2.2-meshbench-sx1262-10 -QEMU_ASSET_LINUX_AMD64=qemu-xtensa-softmmu-v9.2.2_meshbench_sx1262_10-x86_64-linux-gnu.tar.xz -QEMU_ASSET_DARWIN_ARM64=qemu-xtensa-softmmu-v9.2.2_meshbench_sx1262_10-aarch64-apple-darwin.tar.xz -QEMU_ASSET_WINDOWS_AMD64=qemu-xtensa-softmmu-v9.2.2_meshbench_sx1262_10-x86_64-w64-mingw32.tar.xz +QEMU_RELEASE=v9.2.2-meshbench-sx1262-11 +QEMU_ASSET_LINUX_AMD64=qemu-xtensa-softmmu-v9.2.2_meshbench_sx1262_11-x86_64-linux-gnu.tar.xz +QEMU_ASSET_DARWIN_ARM64=qemu-xtensa-softmmu-v9.2.2_meshbench_sx1262_11-aarch64-apple-darwin.tar.xz +QEMU_ASSET_WINDOWS_AMD64=qemu-xtensa-softmmu-v9.2.2_meshbench_sx1262_11-x86_64-w64-mingw32.tar.xz # Renode carries the SEVONPEND fix, without which published nRF52 firmware # sleeps for ever with its wake condition already true. This pin was not