Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions internal/app/session/nodestats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions internal/app/session/statrows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/app/state/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions internal/firmware/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions internal/firmware/radiostats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions internal/firmware/radiostats_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions pkg/client-python/meshbench/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions pkg/client-python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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):
Expand Down
Loading