diff --git a/docs/verb-reference.md b/docs/verb-reference.md index 48de1e69..696b20fd 100644 --- a/docs/verb-reference.md +++ b/docs/verb-reference.md @@ -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 diff --git a/docs/verbs.json b/docs/verbs.json index 38e15670..4e730772 100644 --- a/docs/verbs.json +++ b/docs/verbs.json @@ -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", diff --git a/internal/app/session/statrows.go b/internal/app/session/statrows.go index bc405719..d700e8e7 100644 --- a/internal/app/session/statrows.go +++ b/internal/app/session/statrows.go @@ -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 { diff --git a/internal/app/session/statrows_airtime_test.go b/internal/app/session/statrows_airtime_test.go new file mode 100644 index 00000000..a67c7dfd --- /dev/null +++ b/internal/app/session/statrows_airtime_test.go @@ -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"]) + } +} diff --git a/internal/app/session/verbsnodes.go b/internal/app/session/verbsnodes.go index 29a6b8f7..550ab6d5 100644 --- a/internal/app/session/verbsnodes.go +++ b/internal/app/session/verbsnodes.go @@ -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) { diff --git a/internal/app/session/verbsnodes.verbs.json b/internal/app/session/verbsnodes.verbs.json index 0fc490c4..2e447398 100644 --- a/internal/app/session/verbsnodes.verbs.json +++ b/internal/app/session/verbsnodes.verbs.json @@ -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", diff --git a/pkg/client-python/meshbench/types.py b/pkg/client-python/meshbench/types.py index 6cda3f69..d33b2bf7 100644 --- a/pkg/client-python/meshbench/types.py +++ b/pkg/client-python/meshbench/types.py @@ -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: