From fe0b89f1ae7764735550b095cf6802794035c923 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Mon, 12 Oct 2026 22:39:44 +0100 Subject: [PATCH 1/2] firmware: read how much the chip's IrqMask kept out A chip that applies the firmware's IrqMask counts the events it had to raise and was not allowed to. A native node can append that count to its radio stats record as bytes 43-46; the bridge reads it when the record is at least 47 bytes long. node.stats answers it as irq_suppressed, and the Python client's NodeStat carries it. A record shorter than 47 bytes comes from a chip that does not count, and irq_suppressed is then absent (None in the Python client) rather than zero. --- internal/app/session/nodestats.go | 1 + internal/app/session/statrows.go | 12 +++++ internal/app/state/network.go | 5 ++ internal/firmware/bridge.go | 6 +++ internal/firmware/radiostats.go | 11 +++++ internal/firmware/radiostats_test.go | 64 ++++++++++++++++++++++++++ pkg/client-python/meshbench/types.py | 4 ++ pkg/client-python/tests/test_client.py | 12 +++++ 8 files changed, 115 insertions(+) create mode 100644 internal/firmware/radiostats_test.go diff --git a/internal/app/session/nodestats.go b/internal/app/session/nodestats.go index 540bac12..edd0a142 100644 --- a/internal/app/session/nodestats.go +++ b/internal/app/session/nodestats.go @@ -161,6 +161,7 @@ func (s *Sim) nodeStats(events []state.Event) []state.NodeStat { r := n.Firmware.Bridge.Stats() st.IRQReads, st.BusyReads = r.IRQReads, r.BusyReads st.BusyMs, st.Spurious = r.BusyMs, r.SpuriousUp + st.IRQSuppressed, st.SuppressedReported = r.IRQSuppressed, r.SuppressedReported // The board's own screen, where it has one and has drawn. A board // with no display and one that has drawn nothing both leave this // nil - which of the two it is comes from the board's declaration, diff --git a/internal/app/session/statrows.go b/internal/app/session/statrows.go index 2957ff05..bc405719 100644 --- a/internal/app/session/statrows.go +++ b/internal/app/session/statrows.go @@ -41,11 +41,23 @@ func statRows(stats []state.NodeStat) []map[string]any { // a radio that cries busy too readily. "irq_reads": n.IRQReads, "busy_reads": n.BusyReads, "busy_ms": n.BusyMs, "spurious": n.Spurious, + // Absent from a chip that ignores IrqMask, for the same reason + // the costs above are: it did not count, so there is no zero. + "irq_suppressed": suppressedOrNil(n), }) } return out } +// suppressedOrNil is the chip's masked-event count, or nothing from a chip +// that does not keep one. +func suppressedOrNil(n state.NodeStat) any { + if !n.SuppressedReported { + return nil + } + return n.IRQSuppressed +} + // costOrNil is a reading, or nothing where there was no reading to take. func costOrNil(measured bool, v float64) any { if !measured { diff --git a/internal/app/state/network.go b/internal/app/state/network.go index 959f9ca7..57113260 100644 --- a/internal/app/state/network.go +++ b/internal/app/state/network.go @@ -157,6 +157,11 @@ type NodeStat struct { // a radio that cries busy too readily. IRQReads, BusyReads uint32 BusyMs, Spurious uint32 + // What the firmware's IrqMask kept out, and whether the chip said: + // without the second, a chip too old to count reads as one that counted + // nothing. + IRQSuppressed uint32 + SuppressedReported bool // Board is which hardware this node is, empty for a node running a host // build. It is what decides the shape of the Hardware tab: a board's diff --git a/internal/firmware/bridge.go b/internal/firmware/bridge.go index a9f1f994..13be433b 100644 --- a/internal/firmware/bridge.go +++ b/internal/firmware/bridge.go @@ -247,6 +247,12 @@ func (b *Bridge) read(c net.Conn) { if n >= 43 { st.ConsoleBaud = binary.BigEndian.Uint32(buf[39:]) } + // What the firmware's IrqMask kept out, from a chip new enough + // to apply the mask at all. + if n >= 47 { + st.IRQSuppressed = binary.BigEndian.Uint32(buf[43:]) + st.SuppressedReported = true + } b.mu.Lock() b.stats = st b.mu.Unlock() diff --git a/internal/firmware/radiostats.go b/internal/firmware/radiostats.go index bb2ea22c..b0f84981 100644 --- a/internal/firmware/radiostats.go +++ b/internal/firmware/radiostats.go @@ -85,6 +85,17 @@ type RadioStats struct { // sends a shorter record, and a zero DIO1 mask is a legal thing for a chip to // be holding before the firmware has configured it. DIO1Reported bool + + // IRQSuppressed counts events the chip had to raise and the firmware's + // IrqMask kept out of the status register. A masked event is silence, and + // silence reads exactly like a quiet channel: a firmware that never + // enabled PREAMBLE_DETECTED and one that never heard a preamble report the + // same busy figures, and only this separates them. + // + // SuppressedReported is false for a chip older than the count, which + // ignored the mask and so had nothing to suppress - not zero, unknown. + IRQSuppressed uint32 + SuppressedReported bool } // FemState is whether a board's front-end module was in circuit when the node diff --git a/internal/firmware/radiostats_test.go b/internal/firmware/radiostats_test.go new file mode 100644 index 00000000..2ea83cb5 --- /dev/null +++ b/internal/firmware/radiostats_test.go @@ -0,0 +1,64 @@ +package firmware + +import ( + "encoding/binary" + "testing" + "time" +) + +// statsRecord is a kRadioStats payload of n bytes with IRQReads set to 7 and, +// where the record is long enough to carry it, a suppressed count of 41. +func statsRecord(n int) []byte { + p := make([]byte, n) + binary.BigEndian.PutUint32(p[0:], 7) + if n >= 47 { + binary.BigEndian.PutUint32(p[43:], 41) + } + return p +} + +// statsAfter sends one stats record and waits for the bridge to have read it. +func statsAfter(t *testing.T, record []byte) RadioStats { + t.Helper() + b, err := Listen("127.0.0.1:0", "GB7XYZ") + if err != nil { + t.Fatal(err) + } + defer func() { _ = b.Close() }() + c := fakeRadio(t, b.Addr()) + defer func() { _ = c.Close() }() + + hdr := []byte{kindRadioStats, byte(len(record) >> 8), byte(len(record))} + if _, err := c.Write(append(hdr, record...)); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if st := b.Stats(); st.IRQReads == 7 { + return st + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("the stats record never reached the bridge") + return RadioStats{} +} + +// A chip that applies IrqMask says how much it kept out. A masked event is +// silence, so without the count a firmware that never enabled an interrupt +// and one that never had cause to see it report the same busy figures. +func TestTheSuppressedCountIsReadFromAChipThatKeepsOne(t *testing.T) { + st := statsAfter(t, statsRecord(47)) + if !st.SuppressedReported || st.IRQSuppressed != 41 { + t.Errorf("suppressed = %d, reported %v; want 41, true", + st.IRQSuppressed, st.SuppressedReported) + } +} + +// An older chip ignored the mask and sends the shorter record. Its count is +// unknown rather than zero, because it had nothing to suppress. +func TestAnOlderChipLeavesTheSuppressedCountUnreported(t *testing.T) { + st := statsAfter(t, statsRecord(43)) + if st.SuppressedReported { + t.Errorf("a 43-byte record reported a suppressed count of %d", st.IRQSuppressed) + } +} diff --git a/pkg/client-python/meshbench/types.py b/pkg/client-python/meshbench/types.py index fdaa7c54..6cda3f69 100644 --- a/pkg/client-python/meshbench/types.py +++ b/pkg/client-python/meshbench/types.py @@ -119,6 +119,10 @@ class NodeStat: busy_reads: int = 0 busy_ms: int = 0 spurious: int = 0 + #: Events the firmware's IrqMask kept out of the chip's status register. + #: None from a chip that does not count them, which is not the same as + #: counting none. + irq_suppressed: int | None = None @classmethod def parse(cls, raw: dict[str, Any]) -> NodeStat: diff --git a/pkg/client-python/tests/test_client.py b/pkg/client-python/tests/test_client.py index 4a27c2b9..e931502e 100644 --- a/pkg/client-python/tests/test_client.py +++ b/pkg/client-python/tests/test_client.py @@ -456,6 +456,15 @@ def test_scheduling_and_asserting(wb): assert "best case" in str(report) +def test_a_suppressed_count_is_none_unless_the_chip_kept_one(): + """Absent is unknown, not zero: an older chip ignored the mask entirely.""" + counted = meshbench.types.NodeStat.parse({"name": "a", "irq_suppressed": 41}) + assert counted.irq_suppressed == 41 + uncounted = meshbench.types.NodeStat.parse({"name": "b", "irq_suppressed": None}) + assert uncounted.irq_suppressed is None + assert meshbench.types.NodeStat.parse({"name": "c"}).irq_suppressed is None + + def test_no_assertions_is_not_a_pass(): """A green tick that checked nothing is the worst outcome available.""" empty = meshbench.Report() @@ -513,6 +522,9 @@ def test_node_stats_carry_the_rows(wb): assert stats[0].name assert not stats[0].running assert stats[0].state == "stopped" + # A node with no chip running has counted nothing, and says so as None + # rather than as a zero. + assert stats[0].irq_suppressed is None def test_the_clock_advances_and_stops(wb): From 9334cc366a0452488d7acd5c8ed83a2e21422e02 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Tue, 13 Oct 2026 03:47:20 +0100 Subject: [PATCH 2/2] ci: the python-client job installs the headers cgo needs to build the workbench --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6d80746..dd84e9d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -382,6 +382,16 @@ jobs: with: python-version: "3.11" + # cgo, through Gio: cmd/meshbench links the workbench, so the binary does + # not build without these even though this job opens no window. + - name: Headers cgo needs + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + gcc pkg-config libgl1-mesa-dev xorg-dev \ + libwayland-dev libxkbcommon-dev libxkbcommon-x11-dev \ + libx11-xcb-dev libegl1-mesa-dev libvulkan-dev libffi-dev + # Built once and handed to the tests, rather than left for each of them # to build: the suite starts a process per test and `go build` is not # what is being tested.