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
12 changes: 11 additions & 1 deletion .claude/skills/meshbench-scripting/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,17 @@ The clients route by node kind, so use `node.console`. If you are driving verbs
directly, pick by kind yourself.

The vocabulary is `advert`, `floodadv`, `public <msg>`, `chan <n> <msg>`,
`infos`, `ver`, `contacts`, `sync_msgs`, `set`, `time`. **There is no `send`.**
`msg <contact> <msg>`, `login <contact> <password>`, `req_status <contact>`,
`scope <name>`, `infos`, `ver`, `contacts`, `sync_msgs`, `set`, `time`.
**There is no `send`.**

`msg`, `login` and `req_status` name a contact the node holds, or a public key
in hex: run `contacts` first, because the session only knows what the node has
listed. Each is answered twice in the transcript (`companion.state`'s
`recent`, not `console.read`): `sent ... by flood|direct` when the firmware
takes it, then `delivered to X after N ms`, `login ok from`, `status from` or
`refused`, each stamped with the simulated time it arrived. A sent line with no
second line is a message whose ack never came back.

`console.read` returns the lines under **`tail`**, capped at the last 200;
`lines` is how many there are. Reading `lines` hands you an integer where you
Expand Down
7 changes: 4 additions & 3 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ meshbench <command> [flags]
```

There are **16**<!--flagdoc:commands--> commands and
**170**<!--flagdoc:flags--> flags between them, of which
**38**<!--flagdoc:capture--> exist only so a panel, a menu or a view can be
**171**<!--flagdoc:flags--> flags between them, of which
**39**<!--flagdoc:capture--> exist only so a panel, a menu or a view can be
reached without a click.

Everything below the first heading is generated from the flag declarations
Expand Down Expand Up @@ -68,7 +68,7 @@ Every flag below carries one of these, because a flag that arranges a screenshot
| `serve` | run a mesh and expose a companion to your app | 8 |
| `test` | run a fixture on real firmware and check its assertions | 10 |
| `headless` | run the verbs over the control socket, with no window | 7 |
| `workbench` | open the desktop workbench: build a scenario on a map and run it | 43 |
| `workbench` | open the desktop workbench: build a scenario on a map and run it | 44 |

## `meshbench link`

Expand Down Expand Up @@ -420,6 +420,7 @@ One panel filling the window, filtered, over a fixed view, closing itself. That
| `-board-tab` | none | capture | which table the board view opens on: Radio or Wiring |
| `-board-view` | none | capture | open this node's board view at startup |
| `-capture` | none | capture | capture the waterfall at this node once the run has traffic |
| `-companion-mode` | none | capture | which way in a companion's tab shows: Client, CLI, TCP |
| `-config-section` | none | capture | open the Configuration page on this section |
| `-control-socket` | none | capture | where the control socket answers: a path for a unix socket, or "tcp" for loopback with a token (the default on Windows, which has no unix socket a Python client can reach). MESHBENCH_CONTROL_SOCKET does the same, and two workbenches need two |
| `-coverage` | none | capture | compute and show coverage from this node at startup |
Expand Down
9 changes: 6 additions & 3 deletions docs/fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,12 @@ outside like a mesh that cannot reach itself. `fixture-fife-strict` shipped that
way: three companions, three `public hello` lines, zero deliveries, and its own
assertion failing on a clean tree with no check anywhere to say so.

A companion's schedule takes meshcore-cli's own spellings - `advert`,
`floodadv`, `public <message>`, `chan <number> <message>` - and anything else is
refused by name rather than swallowed. The same split decides provisioning: a
A companion's schedule takes meshcore-cli's own spellings, the same table
`console.cli` answers - `advert`, `floodadv`, `public <message>`,
`chan <number> <message>`, and in the workbench also `msg <contact> <message>`,
`login`, `req_status` and `scope` - and anything else is refused by name rather
than swallowed. `meshbench check` provisions without a contact list, so it takes
only the first four. The same split decides provisioning: a
companion is told its name, clock, position, radio and **default scope** over
the protocol. The scope is the one that is invisible when it is missing, because
unscoped traffic is carried by a different set of repeaters, and in a strictly
Expand Down
6 changes: 5 additions & 1 deletion docs/scripting-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,11 @@ mesh its own time, and then reads.
repeater reads typed text, over `console.type`. A companion does not: it speaks
the framed companion protocol, and its command line is meshcore-cli's
vocabulary, over `console.cli` — `advert`, `floodadv`, `public <msg>`,
`chan <n> <msg>`, `infos`, `ver`, `contacts`, `sync_msgs`, `set`, `time`.
`chan <n> <msg>`, `msg <contact> <msg>`, `login <contact> <password>`,
`req_status <contact>`, `scope <name>`, `infos`, `ver`, `contacts`,
`sync_msgs`, `set`, `time`. The three that address one node are answered when
the firmware sends them and again when the ack, login reply or status returns,
in the companion's transcript and stamped in simulated time.

Text typed at a companion is echoed locally and goes nowhere, which looks
exactly like a command that ran and did nothing. `node.console` picks the right
Expand Down
2 changes: 1 addition & 1 deletion internal/app/session/companion.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func registerCompanion(st *state.Store, s *Sim) {
w.Endpoints = s.endpoints()
w.Say("took " + node + "'s port back from an idle listener")
}
c := &compSession{node: node}
c := &compSession{node: node, clock: en.Firmware.Bridge.Now}
c.release = en.Firmware.Bridge.Claim(c)
s.comps[node] = c
// AppStart then a device query: the same opening a phone makes, so a
Expand Down
2 changes: 1 addition & 1 deletion internal/app/session/companionport.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func (s *Sim) connectCompanion(node string) error {
if !ok || en.Firmware == nil {
return fmt.Errorf("%s runs no firmware, so it has no companion interface", node)
}
c := &compSession{node: node}
c := &compSession{node: node, clock: en.Firmware.Bridge.Now}
c.release = en.Firmware.Bridge.Claim(c)
s.comps[node] = c
if err := en.Firmware.Bridge.Type(compFrame(proto.AppStart("meshbench"))); err != nil {
Expand Down
83 changes: 83 additions & 0 deletions internal/app/session/companionreceipt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// What became of a direct message, a login or a status request.
//
// The firmware answers each with RESP_CODE_SENT, carrying the code it expects
// back, and later with the ack or the reply itself. None of those name the
// command they answer, so the session keeps what it asked for in order and
// matches: the next RESP_CODE_SENT or error belongs to the oldest request the
// firmware has not yet answered, because a companion handles its serial
// commands one at a time.
package session

import (
"encoding/hex"
"fmt"

"github.com/MeshBench/meshbench/internal/mesh/proto"
)

// asked is one request waiting on the firmware.
type asked struct {
kind, to, text string
// sentMs is the simulated time the firmware took it, once it has.
sentMs uint32
}

// expect records a request so its answers can be matched to it.
func (c *compSession) expect(kind, to, text string) {
c.mu.Lock()
defer c.mu.Unlock()
c.queued = append(c.queued, asked{kind: kind, to: to, text: text})
}

// receipt turns the answer frames into lines that say which request they
// answer, stamped with the simulated time they arrived. Called with c.mu held.
func (c *compSession) receipt(f proto.Frame) bool {
at := fmt.Sprintf("[%.3f s] ", float64(c.simMs())/1000)
switch {
case f.Sent != nil:
if len(c.queued) == 0 {
return false
}
a := c.queued[0]
c.queued = c.queued[1:]
a.sentMs = c.simMs()
how := "direct"
if f.Sent.Flood {
how = "flood"
}
if a.kind == "msg" {
if c.awaiting == nil {
c.awaiting = map[uint32]asked{}
}
c.awaiting[f.Sent.Ack] = a
}
c.note(fmt.Sprintf("%ssent %s to %s by %s, waiting up to %d ms",
at, a.kind, a.to, how, f.Sent.TimeoutMs))
case f.Confirmed != nil:
a, ok := c.awaiting[f.Confirmed.Ack]
if !ok {
// The same ack can arrive more than once; the firmware says so.
return true
}
delete(c.awaiting, f.Confirmed.Ack)
c.note(fmt.Sprintf("%sdelivered to %s after %d ms: %s",
at, a.to, f.Confirmed.RoundTripMs, a.text))
case f.Answer != nil:
from := hex.EncodeToString(f.Answer.From)
switch f.Answer.Push {
case proto.PushLoginSuccess:
c.note(at + "login ok from " + from)
case proto.PushLoginFail:
c.note(at + "login refused by " + from)
default:
c.note(fmt.Sprintf("%sstatus from %s, %d bytes", at, from, len(f.Answer.Status)))
}
case f.Err != "" && len(c.queued) > 0:
a := c.queued[0]
c.queued = c.queued[1:]
c.note(fmt.Sprintf("%s%s to %s refused: %s", at, a.kind, a.to, f.Err))
default:
return false
}
return true
}
77 changes: 77 additions & 0 deletions internal/app/session/companionreceipt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package session

import (
"encoding/binary"
"strings"
"testing"

"github.com/MeshBench/meshbench/internal/mesh/proto"
)

func frameOf(t *testing.T, b []byte) proto.Frame {
t.Helper()
f, err := proto.Decode(b)
if err != nil {
t.Fatal(err)
}
return f
}

// The firmware answers requests in the order it took them, and the answers do
// not say which request they belong to. A delivered line has to name the
// message that was delivered, not the one sent most recently.
func TestReceiptsMatchTheRequestTheyAnswer(t *testing.T) {
var now uint32
c := &compSession{node: "A", clock: func() uint32 { return now }}
c.expect("msg", "Bravo", "first")
c.expect("msg", "Charlie", "second")
sent := func(ack uint32) []byte {
b := binary.LittleEndian.AppendUint32([]byte{byte(proto.RespSent), 0}, ack)
return binary.LittleEndian.AppendUint32(b, 5000)
}
now = 1500
c.apply(frameOf(t, sent(11)))
c.apply(frameOf(t, sent(22)))
conf := binary.LittleEndian.AppendUint32([]byte{byte(proto.PushSendConfirmed)}, 22)
conf = binary.LittleEndian.AppendUint32(conf, 900)
now = 2400
c.apply(frameOf(t, conf))

joined := strings.Join(c.Lines(), "\n")
for _, want := range []string{
"sent msg to Bravo by direct",
"sent msg to Charlie by direct",
"[2.400 s] delivered to Charlie after 900 ms: second",
} {
if !strings.Contains(joined, want) {
t.Errorf("transcript lacks %q:\n%s", want, joined)
}
}
if strings.Contains(joined, "delivered to Bravo") {
t.Errorf("Bravo's message was never acked:\n%s", joined)
}
}

// A refusal belongs to the oldest unanswered request, and says why.
func TestARefusedRequestSaysSo(t *testing.T) {
c := &compSession{node: "A"}
c.expect("login", "Delta", "")
c.apply(frameOf(t, []byte{byte(proto.RespErr), 2}))
if j := strings.Join(c.Lines(), "\n"); !strings.Contains(j, "login to Delta refused") {
t.Errorf("transcript lacks the refusal:\n%s", j)
}
}

// The console of a companion the session holds is the session's transcript,
// not the binary frames its serial port carries.
func TestAHeldCompanionsConsoleIsItsTranscript(t *testing.T) {
s := &Sim{comps: map[string]*compSession{"A": {node: "A"}}}
s.comps["A"].note("> login Bravo hunter2")
got := s.consoleLines("A", []string{">\x01\x02 raw"})
if len(got) != 1 || got[0] != "> login Bravo hunter2" {
t.Fatalf("console shows %q", got)
}
if got := s.consoleLines("B", []string{"kept"}); len(got) != 1 || got[0] != "kept" {
t.Fatalf("a node with nothing to show lost its lines: %q", got)
}
}
38 changes: 37 additions & 1 deletion internal/app/session/companionsession.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ type compSession struct {
// ask "has anything happened" that is cheaper than rebuilding the view
// and comparing it.
rev uint64
// queued and awaiting are the requests the firmware has yet to answer:
// queued until it says it sent one, awaiting its ack after that.
queued []asked
awaiting map[uint32]asked
// clock is the simulated time of the tick the node is running, which is
// when anything it writes was written: the bridge's own record, so the
// goroutine reading frames need not take the engine's lock to ask.
clock func() uint32
}

// simMs is the simulated time a frame arriving now was written at.
func (c *compSession) simMs() uint32 {
if c.clock == nil {
return 0
}
return c.clock()
}

// Write receives whatever the node sends while the claim is held.
Expand Down Expand Up @@ -114,6 +130,9 @@ func (c *compSession) apply(f proto.Frame) {
if f.Push == proto.PushMsgWaiting {
c.waiting = true
}
if c.receipt(f) {
return
}
switch {
case f.SelfInfo != nil:
c.self = f.SelfInfo
Expand Down Expand Up @@ -144,7 +163,7 @@ func (c *compSession) apply(f proto.Frame) {
}
c.unread[f.Message.ChannelIdx]++
}
c.note("message: " + f.Message.Text)
c.note(fmt.Sprintf("[%.3f s] message: %s", float64(c.simMs())/1000, f.Message.Text))
case f.Scope != nil:
c.scope, c.scopeKnown = f.Scope.Name, true
if f.Scope.Name == "" {
Expand Down Expand Up @@ -179,3 +198,20 @@ func (c *compSession) Lines() []string {
// write. The envelope moved down to the transport's own package when the
// headless fixture runner came to need it too, so that there is one of it.
func compFrame(payload []byte) []byte { return companion.Frame(payload) }

// consoleLines is what the console being looked at shows.
//
// A companion the session holds speaks binary frames on its serial port, and
// its raw console buffer is those bytes; drawn as text they are noise with a
// contact's name in the middle. The session's own transcript is the console
// for it: what was typed, and what the decoded frames meant. Anything else
// shows its buffer, as it always has.
func (s *Sim) consoleLines(node string, current []string) []string {
if c, ok := s.comps[node]; ok {
return c.Lines()
}
if buf, ok := s.consoles[node]; ok {
return buf.Snapshot()
}
return current
}
Loading
Loading