From faf67cd5bc585951d34cbd3a682248c5dad62bbf Mon Sep 17 00:00:00 2001 From: R0ck Date: Sat, 24 Oct 2026 23:37:22 +0100 Subject: [PATCH] boardcheck: fingerprint the emulator the board runs under, and its chip Closes #551. --- CHANGELOG.md | 6 ++ internal/firmware/emulated/tools.go | 3 + internal/sim/boardcheck/boardcheck.go | 67 ++++++++++++++++--- internal/sim/boardcheck/boardcheck_test.go | 77 +++++++++++++++++++++- internal/sim/boardcheck/cache.go | 2 +- internal/sim/boardcheck/probe.go | 2 +- 6 files changed, 143 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a38e02b4..cf25006b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,12 @@ had changed in them - which is the gap this file exists to close. ### Fixed +- **A board's cached capability row names the emulator it ran under.** On a + machine with both emulators set up, every nRF52 row was stamped with QEMU's + binary, so upgrading Renode retired none of them and upgrading QEMU retired + all of them. The fingerprint now follows the board, and also covers the chip + library and, for Renode, our peripherals. Rows cached by an earlier build + read as stale once. - **Adding a scheduled send no longer repeats the ones already said.** Any `schedule.add` made every one-shot send whose moment had passed go out again. diff --git a/internal/firmware/emulated/tools.go b/internal/firmware/emulated/tools.go index a1586144..f81e9b19 100644 --- a/internal/firmware/emulated/tools.go +++ b/internal/firmware/emulated/tools.go @@ -181,6 +181,9 @@ func fileExists(p string) bool { // under which a released bundle unpacks it. const radioLibName = "virtual-sx1262" +// RadioLib is that name for FindTool, from outside this package. +const RadioLib = radioLibName + // 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 { diff --git a/internal/sim/boardcheck/boardcheck.go b/internal/sim/boardcheck/boardcheck.go index 90ee9a8f..6ed6672f 100644 --- a/internal/sim/boardcheck/boardcheck.go +++ b/internal/sim/boardcheck/boardcheck.go @@ -12,6 +12,8 @@ package boardcheck import ( "fmt" "os" + "path/filepath" + "strings" "time" hw "github.com/MeshBench/meshbench/internal/firmware/board" @@ -78,16 +80,45 @@ func (r *BoardReport) set(c Capability, s State, detail string) { r.Results[c] = Result{Capability: c, State: s, Detail: detail} } -// EmulatorFingerprint identifies the emulator build in use, so a cached -// report can tell whether it is still about the binary that produced it. -// The size and mtime of the QEMU or Renode binary this run resolves to - -// cheap, and it changes exactly when a rebuild would invalidate the report. -func EmulatorFingerprint() string { - path := os.Getenv(emulated.EnvQEMU) - if path == "" { - path = os.Getenv("MESHBENCH_RENODE") +// EmulatorFingerprint identifies what a board runs under on this machine, so +// a cached report can tell whether it is still about the binaries that +// produced it. +// +// Which emulator follows from the board, as it does when the board is run: +// QEMU for the ESP32 boards and Renode for the nRF52 ones. It used to read +// MESHBENCH_QEMU first and fall back to Renode, so on a machine set up for +// both every nRF52 row was stamped with QEMU's binary - upgrading Renode +// retired none of them and upgrading QEMU retired all of them (#551). +// +// The chip library is part of it too, because the chip decides what a board +// demonstrates as much as the emulator does, and so for Renode are our C# +// peripherals beside it. Each part is the size and mtime of the file the +// lookup a boot does would find: cheap, and it changes exactly when a rebuild +// or a fetch would invalidate the report. +func EmulatorFingerprint(board string) string { + b, err := hw.BoardByName(board) + if err != nil { + return "unknown board" + } + tool := "qemu-system-xtensa" + if b.Renode != nil { + tool = "renode" + } + parts := []string{ + tool + "=" + fileFingerprint(emulated.FindTool(tool)), + "chip=" + fileFingerprint(emulated.FindTool(emulated.RadioLib)), } - if path == "" { + if b.Renode != nil { + parts = append(parts, "peripherals="+dirFingerprint( + filepath.Join(emulated.SupportDir(), "peripherals"))) + } + return strings.Join(parts, ";") +} + +// fileFingerprint is a file's path, size and mtime, or "unconfigured" when +// the lookup found nothing. +func fileFingerprint(path string, err error) string { + if err != nil { return "unconfigured" } fi, err := os.Stat(path) @@ -97,6 +128,24 @@ func EmulatorFingerprint() string { return fmt.Sprintf("%s@%d-%d", path, fi.Size(), fi.ModTime().Unix()) } +// dirFingerprint is the total size and newest mtime of a directory's files. +func dirFingerprint(dir string) string { + ents, err := os.ReadDir(dir) + if err != nil { + return "unconfigured" + } + var size, newest int64 + for _, e := range ents { + fi, err := e.Info() + if err != nil || fi.IsDir() { + continue + } + size += fi.Size() + newest = max(newest, fi.ModTime().Unix()) + } + return fmt.Sprintf("%s@%d-%d", dir, size, newest) +} + // MatrixReports is the whole board list, agreeing with // hw.EmulatableBoards() on the can-it-run question: a board that // cannot be emulated at all is reported boot-failed with that reason, diff --git a/internal/sim/boardcheck/boardcheck_test.go b/internal/sim/boardcheck/boardcheck_test.go index 3770e4ba..e5876e74 100644 --- a/internal/sim/boardcheck/boardcheck_test.go +++ b/internal/sim/boardcheck/boardcheck_test.go @@ -2,9 +2,13 @@ package boardcheck import ( "os" + "path/filepath" + "strings" "testing" hw "github.com/MeshBench/meshbench/internal/firmware/board" + "github.com/MeshBench/meshbench/internal/firmware/emulated" + "github.com/MeshBench/meshbench/internal/firmware/emulated/renode" ) // A board never run reports "untested" in every column, never blank and @@ -76,9 +80,9 @@ func TestALoadedReportIsStaleWhenTheEmulatorFingerprintDiffers(t *testing.T) { } got := Load("RAK_4631", "repeater-v1.17.0") - // EmulatorFingerprint() with no MESHBENCH_QEMU/RENODE set resolves to - // "unconfigured", which does not equal the saved fingerprint above - so - // this must read as stale, not as still current. + // Whatever EmulatorFingerprint finds on this machine, it is not the + // saved fingerprint above - so this must read as stale, not as still + // current. if !got.Stale { t.Error("a report saved under a different emulator fingerprint did not report stale") } @@ -155,3 +159,70 @@ func TestMatrixAgreesWithEmulatableBoards(t *testing.T) { } } } + +// The fingerprint names the emulator the board runs under, not whichever +// variable happens to be set: on a machine with both, an nRF52 row was stamped +// with QEMU's binary (#551). The chip library is part of it, and for Renode so +// are the peripherals beside it. +func TestTheFingerprintFollowsTheBoardsEmulator(t *testing.T) { + dir := t.TempDir() + file := func(name, body string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p + } + qemu, renodeBin, chip := file("qemu", "q"), file("renode", "r"), file("chip", "c") + if err := os.MkdirAll(filepath.Join(dir, "support", "peripherals"), 0o755); err != nil { + t.Fatal(err) + } + periph := filepath.Join(dir, "support", "peripherals", "VirtualSX1262.cs") + if err := os.WriteFile(periph, []byte("p"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(emulated.EnvQEMU, qemu) + t.Setenv(renode.EnvRenode, renodeBin) + t.Setenv(emulated.EnvRadioLib, chip) + t.Setenv(emulated.EnvRenodeSupport, filepath.Join(dir, "support")) + + nrf := EmulatorFingerprint("RAK_4631") + if !strings.Contains(nrf, renodeBin+"@") || strings.Contains(nrf, qemu+"@") { + t.Errorf("an nRF52 board's fingerprint is not Renode's: %s", nrf) + } + esp := EmulatorFingerprint("LilyGo_TDeck") + if !strings.Contains(esp, qemu+"@") || strings.Contains(esp, renodeBin+"@") { + t.Errorf("an ESP32 board's fingerprint is not QEMU's: %s", esp) + } + for _, fp := range []string{nrf, esp} { + if !strings.Contains(fp, chip+"@") { + t.Errorf("the chip library is not in %s", fp) + } + } + + // Each part moves the rows it should and only those. + touch := func(p, body string) { + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + touch(qemu, "qemu, rebuilt") + if EmulatorFingerprint("RAK_4631") != nrf { + t.Error("a new QEMU retired an nRF52 row") + } + if EmulatorFingerprint("LilyGo_TDeck") == esp { + t.Error("a new QEMU did not retire an ESP32 row") + } + touch(periph, "peripheral, changed") + if EmulatorFingerprint("RAK_4631") == nrf { + t.Error("a changed Renode peripheral did not retire an nRF52 row") + } + esp = EmulatorFingerprint("LilyGo_TDeck") + touch(chip, "chip, new release") + if EmulatorFingerprint("LilyGo_TDeck") == esp { + t.Error("a new chip library did not retire an ESP32 row") + } + if got := EmulatorFingerprint("No_Such_Board"); got != "unknown board" { + t.Errorf("an unknown board: %s", got) + } +} diff --git a/internal/sim/boardcheck/cache.go b/internal/sim/boardcheck/cache.go index e2099f32..681daa19 100644 --- a/internal/sim/boardcheck/cache.go +++ b/internal/sim/boardcheck/cache.go @@ -90,6 +90,6 @@ func Load(board, version string) BoardReport { return untestedReport(board, version) } } - r.Stale = r.EmulatorFP != EmulatorFingerprint() + r.Stale = r.EmulatorFP != EmulatorFingerprint(board) return r } diff --git a/internal/sim/boardcheck/probe.go b/internal/sim/boardcheck/probe.go index 05bfd5d6..ca73b2e4 100644 --- a/internal/sim/boardcheck/probe.go +++ b/internal/sim/boardcheck/probe.go @@ -43,7 +43,7 @@ func (flatEarth) ElevationM(_, _ float64) (float64, bool) { return 0, true } // rather than hanging the probe for whoever is waiting on the column. func Probe(ctx context.Context, board, version string) (report BoardReport) { report = untestedReport(board, version) - report.EmulatorFP = EmulatorFingerprint() + report.EmulatorFP = EmulatorFingerprint(board) report.MeasuredAt = time.Now() cacheDir := firmware.DefaultCacheDir()