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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
- **`sim.state` says when the wall clock decided something.** `reproducible`
is false, with the reason, once a node-writing verb ran at a playing clock
without `at_ms`, a client outside the simulation wrote to a served
Expand Down
4 changes: 2 additions & 2 deletions docs/scripting-verbs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
4 changes: 2 additions & 2 deletions docs/verb-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions docs/verbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "?",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 10 additions & 2 deletions internal/app/session/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion internal/app/session/console.verbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions internal/app/session/inputqueue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (#521). 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 {
Expand Down
7 changes: 6 additions & 1 deletion internal/app/session/meshcli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion internal/app/session/meshcli.verbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "?",
Expand Down
61 changes: 61 additions & 0 deletions internal/app/session/pausednote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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
// (#521).
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)
}
}
8 changes: 8 additions & 0 deletions internal/firmware/console/buf.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down