diff --git a/CHANGELOG.md b/CHANGELOG.md index f2399c15..8c3a0380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,11 @@ had changed in them - which is the gap this file exists to close. ### Changed +- **A console command on a paused run says it is waiting for play.** The + firmware answers `OK - Advert sent` whether or not the clock is running, and + on a paused run nothing goes on the air until it plays. `console.type` and + `console.cli` now add a line under the reply saying so, and return it as + `paused`. - **The Windows installer asks where to go and says when it has finished.** It is built by the WiX toolset again, now on a Windows runner, which is the only place WiX can build one: a Destination Folder page with a Change button, diff --git a/docs/scripting-verbs.md b/docs/scripting-verbs.md index c0d5d151..907b021d 100644 --- a/docs/scripting-verbs.md +++ b/docs/scripting-verbs.md @@ -228,9 +228,9 @@ Two verbs are **not** in this table: | verb | takes | returns | façade | |---|---|---|---| -| `console.cli` | *a bare string*, `node` string, `command` string, `at_ms` number | `node`, `reply`, `failed`, `queued`, `at_ms`, `now_ms` | `node.companion.cli(line)` | +| `console.cli` | *a bare string*, `node` string, `command` string, `at_ms` number | `node`, `reply`, `failed`, `paused`, `queued`, `at_ms`, `now_ms` | `node.companion.cli(line)` | | `console.read` | *a bare string*, `node` string | `node`, `lines`, `tail` | `node.console.read() / node.console.tail` | -| `console.type` | `node` string, `command` string, `at_ms` number | `node`, `sent`, `note`, `queued`, `at_ms`, `now_ms` | `node.console.send(line)` | +| `console.type` | `node` string, `command` string, `at_ms` number | `node`, `sent`, `note`, `paused`, `queued`, `at_ms`, `now_ms` | `node.console.send(line)` | | `fleet.replies` | — | `replies` | *none* — the reply collector, called only by its own goroutine | | `fleet.send` | *a bare string*, `command` string, `node` string, `kind` string | `command`, `sent_to`, `replies`, `warning` | `wb.fleet.send(command, kind=\|node=)` - *planned*, no client defines `wb.fleet` yet; call the verb | diff --git a/docs/verb-reference.md b/docs/verb-reference.md index 1f064299..ad73e09c 100644 --- a/docs/verb-reference.md +++ b/docs/verb-reference.md @@ -2078,7 +2078,7 @@ Run one meshcore-cli line at a companion node, which has no text console of its | `command` | string | required | the line, in meshcore-cli's own vocabulary; refused when absent or blank, and `?` lists what this build answers | | `at_ms` | number | optional | an instant of simulated time to run this at instead of now: the call is held and runs when the engine reaches it, so it lands at the same instant on any machine. Refused unless it is after now. Without it, a call made while the clock is playing lands wherever the clock happens to be, and sim.state then says the run cannot be reproduced | -**Answers** `node`, `reply`, `failed`, `queued`, `at_ms`, `now_ms`. `failed` is present and true where the line ran and the answer was no: an unknown command, a command meshcore-cli has that this build does not, or a connect that could not be made. That is not an error, because a console refused at the status bar leaves nothing where it was typed. `reply` is what the console prints either way, and the node's own answer to it arrives later in the scrollback. +**Answers** `node`, `reply`, `failed`, `paused`, `queued`, `at_ms`, `now_ms`. `failed` is present and true where the line ran and the answer was no: an unknown command, a command meshcore-cli has that this build does not, or a connect that could not be made. That is not an error, because a console refused at the status bar leaves nothing where it was typed. `reply` is what the console prints either way, and the node's own answer to it arrives later in the scrollback. `paused` is present when the clock is stopped: whatever the command sends is queued and goes on the air when the run plays, and the console says so under the reply. **Example** - list the meshcore-cli commands this build answers @@ -2122,7 +2122,7 @@ Run a line at a node's own firmware console, which answers what the node says ra | `command` | string | required | the line to type, sent with a carriage return and newline after it; refused when it is absent or empty | | `at_ms` | number | optional | an instant of simulated time to run this at instead of now: the call is held and runs when the engine reaches it, so it lands at the same instant on any machine. Refused unless it is after now. Without it, a call made while the clock is playing lands wherever the clock happens to be, and sim.state then says the run cannot be reproduced | -**Answers** `node`, `sent`, `note`, `queued`, `at_ms`, `now_ms`. The node's reply is not in this answer: it lands in the scrollback when the engine next steps, and is read with `console.read`. On a paused clock this steps the engine sixty times so the reply arrives anyway, but while a sweep owns the clock the replies come back empty. +**Answers** `node`, `sent`, `note`, `paused`, `queued`, `at_ms`, `now_ms`. The node's reply is not in this answer: it lands in the scrollback when the engine next steps, and is read with `console.read`. On a paused clock this steps the engine sixty times so the reply arrives anyway, but while a sweep owns the clock the replies come back empty. `paused` is present when the clock is stopped: whatever the command sends is queued and goes on the air when the run plays, and the console says so under the reply. **Example** - ask a node what it believes it is called diff --git a/docs/verbs.json b/docs/verbs.json index 3177a9ea..75a52e69 100644 --- a/docs/verbs.json +++ b/docs/verbs.json @@ -1045,11 +1045,12 @@ "node", "reply", "failed", + "paused", "queued", "at_ms", "now_ms" ], - "answers": "`failed` is present and true where the line ran and the answer was no: an unknown command, a command meshcore-cli has that this build does not, or a connect that could not be made. That is not an error, because a console refused at the status bar leaves nothing where it was typed. `reply` is what the console prints either way, and the node's own answer to it arrives later in the scrollback.", + "answers": "`failed` is present and true where the line ran and the answer was no: an unknown command, a command meshcore-cli has that this build does not, or a connect that could not be made. That is not an error, because a console refused at the status bar leaves nothing where it was typed. `reply` is what the console prints either way, and the node's own answer to it arrives later in the scrollback. `paused` is present when the clock is stopped: whatever the command sends is queued and goes on the air when the run plays, and the console says so under the reply.", "example": { "params": { "command": "?", @@ -1106,11 +1107,12 @@ "node", "sent", "note", + "paused", "queued", "at_ms", "now_ms" ], - "answers": "The node's reply is not in this answer: it lands in the scrollback when the engine next steps, and is read with `console.read`. On a paused clock this steps the engine sixty times so the reply arrives anyway, but while a sweep owns the clock the replies come back empty.", + "answers": "The node's reply is not in this answer: it lands in the scrollback when the engine next steps, and is read with `console.read`. On a paused clock this steps the engine sixty times so the reply arrives anyway, but while a sweep owns the clock the replies come back empty. `paused` is present when the clock is stopped: whatever the command sends is queued and goes on the air when the run plays, and the console says so under the reply.", "example": { "params": { "command": "get name", diff --git a/internal/app/session/console.go b/internal/app/session/console.go index 87f6659d..28196679 100644 --- a/internal/app/session/console.go +++ b/internal/app/session/console.go @@ -75,12 +75,20 @@ func registerConsole(st *state.Store, s *Sim) { // ticker's own goroutine is waiting for yourself, and with // fifty-eight firmware processes it blocked every verb for seconds. s.settleForReply(w) + paused := s.pausedFor(w) + if paused != "" { + buf.Note(paused) + } w.Console, w.ConsoleNode = buf.Snapshot(), name _ = mark - return map[string]any{ + out := map[string]any{ "node": name, "sent": cmd, "note": "the reply lands when the engine next steps; read it with console.read", - }, nil + } + if paused != "" { + out["paused"] = paused + } + return out, nil }) st.Handle("console.read", func(w *state.World, p any) (any, error) { diff --git a/internal/app/session/console.verbs.json b/internal/app/session/console.verbs.json index 1ddeae56..48aeadbb 100644 --- a/internal/app/session/console.verbs.json +++ b/internal/app/session/console.verbs.json @@ -46,11 +46,12 @@ "node", "sent", "note", + "paused", "queued", "at_ms", "now_ms" ], - "answers": "The node's reply is not in this answer: it lands in the scrollback when the engine next steps, and is read with `console.read`. On a paused clock this steps the engine sixty times so the reply arrives anyway, but while a sweep owns the clock the replies come back empty.", + "answers": "The node's reply is not in this answer: it lands in the scrollback when the engine next steps, and is read with `console.read`. On a paused clock this steps the engine sixty times so the reply arrives anyway, but while a sweep owns the clock the replies come back empty. `paused` is present when the clock is stopped: whatever the command sends is queued and goes on the air when the run plays, and the console says so under the reply.", "example": { "params": { "command": "get name", diff --git a/internal/app/session/inputqueue.go b/internal/app/session/inputqueue.go index ebe022c7..e4bf3882 100644 --- a/internal/app/session/inputqueue.go +++ b/internal/app/session/inputqueue.go @@ -152,6 +152,22 @@ func (s *Sim) settleForReply(w *state.World) { w.NowMs = s.eng.NowMs() } +// pausedNote is what a console adds under a command accepted while the clock +// is stopped. The firmware's own "OK - Advert sent" is true from its +// side: it queued the packet. Nothing goes on the air until the engine steps, +// and only MeshBench knows the run is paused, so the note is ours. +const pausedNote = "the run is paused: anything this sends is queued and goes on the air when it plays" + +// pausedFor is pausedNote when it applies to a command running now, or "". A +// call replayed from the input queue runs inside a step, so it is not waiting +// for one. +func (s *Sim) pausedFor(w *state.World) string { + if w.Playing || s.replaying { + return "" + } + return pausedNote +} + func withoutAtMs(p any) any { m, ok := p.(map[string]any) if !ok { diff --git a/internal/app/session/meshcli.go b/internal/app/session/meshcli.go index d773331c..22cc8cc9 100644 --- a/internal/app/session/meshcli.go +++ b/internal/app/session/meshcli.go @@ -278,7 +278,12 @@ func registerMeshCLI(st *state.Store, s *Sim) { // working rather than the clock having stopped. s.settleForReply(w) say(out) - return map[string]any{"node": node, "reply": out}, nil + ans := map[string]any{"node": node, "reply": out} + if paused := s.pausedFor(w); paused != "" { + say("(" + paused + ")") + ans["paused"] = paused + } + return ans, nil } var have []string for _, c := range meshcliCommands { diff --git a/internal/app/session/meshcli.verbs.json b/internal/app/session/meshcli.verbs.json index 867d041a..f1a69eab 100644 --- a/internal/app/session/meshcli.verbs.json +++ b/internal/app/session/meshcli.verbs.json @@ -25,11 +25,12 @@ "node", "reply", "failed", + "paused", "queued", "at_ms", "now_ms" ], - "answers": "`failed` is present and true where the line ran and the answer was no: an unknown command, a command meshcore-cli has that this build does not, or a connect that could not be made. That is not an error, because a console refused at the status bar leaves nothing where it was typed. `reply` is what the console prints either way, and the node's own answer to it arrives later in the scrollback.", + "answers": "`failed` is present and true where the line ran and the answer was no: an unknown command, a command meshcore-cli has that this build does not, or a connect that could not be made. That is not an error, because a console refused at the status bar leaves nothing where it was typed. `reply` is what the console prints either way, and the node's own answer to it arrives later in the scrollback. `paused` is present when the clock is stopped: whatever the command sends is queued and goes on the air when the run plays, and the console says so under the reply.", "example": { "params": { "command": "?", diff --git a/internal/app/session/pausednote_test.go b/internal/app/session/pausednote_test.go new file mode 100644 index 00000000..54894b4f --- /dev/null +++ b/internal/app/session/pausednote_test.go @@ -0,0 +1,60 @@ +package session + +import ( + "strings" + "testing" + + "github.com/MeshBench/meshbench/internal/app/state" + "github.com/MeshBench/meshbench/internal/firmware/console" +) + +// A command accepted on a paused run says so under the firmware's reply, and +// only then: not while playing, and not for a held call replayed inside a step. +func TestThePausedNoteAppliesOnlyWhenPaused(t *testing.T) { + s := &Sim{} + if got := s.pausedFor(&state.World{}); got != pausedNote { + t.Errorf("paused: %q", got) + } + if got := s.pausedFor(&state.World{Playing: true}); got != "" { + t.Errorf("playing: %q", got) + } + s.replaying = true + if got := s.pausedFor(&state.World{}); got != "" { + t.Errorf("replayed from the queue: %q", got) + } +} + +// The note is ours, so it is bracketed and cannot be read as the node's. +func TestANoteIsNotTheFirmwaresOutput(t *testing.T) { + var b console.Buf + b.SetNow(1500) + _, _ = b.Write([]byte(" -> OK - Advert sent\n")) + b.Note(pausedNote) + lines := b.Snapshot() + if len(lines) != 2 || !strings.HasSuffix(lines[1], "("+pausedNote+")") || + !strings.HasPrefix(strings.TrimSpace(lines[1]), "1.500") { + t.Fatalf("lines: %q", lines) + } +} + +// The command line says it too, under the answer, and gives it to a script. +func TestTheCommandLineSaysTheRunIsPaused(t *testing.T) { + st, _, ctx, cancel := cliSim(t) + defer cancel() + saved := meshcliCommands + t.Cleanup(func() { meshcliCommands = saved }) + meshcliCommands = append(append([]cliCommand(nil), saved...), cliCommand{"__ok", "", "", "", + func(*Sim, string, []string) (string, error) { return "advert sent", nil }}) + + res, err := st.Do(ctx, "console.cli", map[string]any{"node": "Alpha", "command": "__ok"}) + if err != nil { + t.Fatal(err) + } + if m, _ := res.(map[string]any); m["paused"] != pausedNote { + t.Errorf("the answer does not say the run is paused: %v", res) + } + lines := st.Snapshot().Console + if len(lines) < 2 || lines[len(lines)-2] != "advert sent" || lines[len(lines)-1] != "("+pausedNote+")" { + t.Errorf("the note is not under the answer: %q", lines) + } +} diff --git a/internal/firmware/console/buf.go b/internal/firmware/console/buf.go index a7e81c03..e98ef085 100644 --- a/internal/firmware/console/buf.go +++ b/internal/firmware/console/buf.go @@ -116,6 +116,14 @@ func (c *Buf) Echo(line string) { c.mu.Unlock() } +// Note adds a line of MeshBench's own under the firmware's output, stamped +// like the rest and bracketed so it cannot be read as something the node said. +func (c *Buf) Note(line string) { + c.mu.Lock() + c.lines = append(c.lines, fmt.Sprintf("%8.3f (%s)", float64(c.nowMs)/1000, line)) + c.mu.Unlock() +} + // drawConsole is the selected node's serial console. // // A real UART. The point of running real firmware is that its own command