Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/verb-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1160,7 +1160,7 @@ Recompute every node's counters now rather than at the next tick, and answer wit

**Takes** nothing.

**Answers** `nodes`, `stats`. `nodes` is how many rows there are and `stats` is the rows. A session with no engine built has no counters to report and answers with none, which is not a failure.
**Answers** `nodes`, `stats`. `nodes` is how many rows there are and `stats` is the rows. Each row carries `airtime_ms`, cumulative over the run, and `duty_cycle_pct` over the run's whole length, the same numbers as the Scores panel; a node the engine has no score for has neither. A session with no engine built has no counters to report and answers with none, which is not a failure.

**Example** - read every node's state and counters

Expand Down
2 changes: 1 addition & 1 deletion docs/verbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -3775,7 +3775,7 @@
"nodes",
"stats"
],
"answers": "`nodes` is how many rows there are and `stats` is the rows. A session with no engine built has no counters to report and answers with none, which is not a failure.",
"answers": "`nodes` is how many rows there are and `stats` is the rows. Each row carries `airtime_ms`, cumulative over the run, and `duty_cycle_pct` over the run's whole length, the same numbers as the Scores panel; a node the engine has no score for has neither. A session with no engine built has no counters to report and answers with none, which is not a failure.",
"example": {
"params": {},
"what": "read every node's state and counters",
Expand Down
23 changes: 23 additions & 0 deletions internal/app/session/statrows.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,29 @@ func statRows(stats []state.NodeStat) []map[string]any {
return out
}

// withAirtime adds each node's time on the air, as the Scores panel shows it.
//
// The engine has always counted it, and the panel has always drawn it, but a
// script could only get the whole mesh's total out of an experiment cell - so
// "which repeater is over its duty cycle" had an answer on screen and none
// outside it. airtime_ms is cumulative over the run, so a caller wanting a
// window subtracts two readings; duty_cycle_pct is over the whole run, boot
// included, exactly as the panel's column is. A node the engine has no score
// for has neither, rather than a zero.
func withAirtime(rows []map[string]any, scores []state.Score) []map[string]any {
by := make(map[string]state.Score, len(scores))
for _, sc := range scores {
by[sc.Name] = sc
}
for _, r := range rows {
name, _ := r["name"].(string)
if sc, ok := by[name]; ok {
r["airtime_ms"], r["duty_cycle_pct"] = sc.AirtimeMs, sc.DutyCyclePct
}
}
return rows
}

// suppressedOrNil is the chip's masked-event count, or nothing from a chip
// that does not keep one.
func suppressedOrNil(n state.NodeStat) any {
Expand Down
20 changes: 20 additions & 0 deletions internal/app/session/statrows_airtime_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package session

import (
"testing"

"github.com/MeshBench/meshbench/internal/app/state"
)

// The row carries the node's own airtime, matched by name, and a node with no
// score carries none rather than a zero that reads as "never transmitted".
func TestStatRowsCarryAirtimeByName(t *testing.T) {
rows := withAirtime(statRows([]state.NodeStat{{Name: "Alpha"}, {Name: "Bravo"}}),
[]state.Score{{Name: "Alpha", AirtimeMs: 1234.5, DutyCyclePct: 2.5}})
if rows[0]["airtime_ms"] != 1234.5 || rows[0]["duty_cycle_pct"] != 2.5 {
t.Errorf("Alpha's row = %+v", rows[0])
}
if _, ok := rows[1]["airtime_ms"]; ok {
t.Errorf("Bravo has no score but its row says %v", rows[1]["airtime_ms"])
}
}
3 changes: 2 additions & 1 deletion internal/app/session/verbsnodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ func registerNodeFirmwareVerbs(st *state.Store, s *Sim) {
// was no way to ask whether a node was running, which is the first
// thing any script wants to know and the thing every wait is built
// on. The count stays for whoever is already reading it.
return map[string]any{"nodes": len(w.Stats), "stats": statRows(w.Stats)}, nil
return map[string]any{"nodes": len(w.Stats),
"stats": withAirtime(statRows(w.Stats), s.scores())}, nil
})

st.Handle("node.stop", func(w *state.World, p any) (any, error) {
Expand Down
2 changes: 1 addition & 1 deletion internal/app/session/verbsnodes.verbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@
"nodes",
"stats"
],
"answers": "`nodes` is how many rows there are and `stats` is the rows. A session with no engine built has no counters to report and answers with none, which is not a failure.",
"answers": "`nodes` is how many rows there are and `stats` is the rows. Each row carries `airtime_ms`, cumulative over the run, and `duty_cycle_pct` over the run's whole length, the same numbers as the Scores panel; a node the engine has no score for has neither. A session with no engine built has no counters to report and answers with none, which is not a failure.",
"example": {
"params": {},
"what": "read every node's state and counters",
Expand Down
6 changes: 6 additions & 0 deletions pkg/client-python/meshbench/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ class NodeStat:
#: None from a chip that does not count them, which is not the same as
#: counting none.
irq_suppressed: int | None = None
#: Time on the air over the whole run, as the Scores panel shows it.
#: Cumulative, so a window is the difference of two readings. None for a
#: node the engine has no score for.
airtime_ms: float | None = None
#: airtime_ms over the run's whole length, boot included.
duty_cycle_pct: float | None = None

@classmethod
def parse(cls, raw: dict[str, Any]) -> NodeStat:
Expand Down
Loading