From ac05d8fa85b6fd4751475916c92e168cec71ef32 Mon Sep 17 00:00:00 2001 From: R0ck Date: Wed, 14 Oct 2026 23:45:52 +0100 Subject: [PATCH 1/6] companion: meshcore-cli's msg, login, req_status and scope, with what the firmware answered meshcore-cli's own commands for talking to one node, which the CLI table already listed as known but not answered. Each is answered twice in the transcript, matched to the request: when the firmware says it sent it, and when the ack, login reply or status comes back, stamped in simulated time. RESP_CODE_SENT, PUSH_CODE_SEND_CONFIRMED and the login and status pushes are decoded for the first time. --- internal/app/session/companionreceipt.go | 83 ++++++++++++ internal/app/session/companionreceipt_test.go | 62 +++++++++ internal/app/session/companionsession.go | 13 +- internal/app/session/companionview.go | 4 + internal/app/session/meshclidirect.go | 125 ++++++++++++++++++ internal/mesh/proto/decode.go | 6 + internal/mesh/proto/delivery.go | 95 +++++++++++++ internal/mesh/proto/delivery_test.go | 68 ++++++++++ internal/mesh/proto/proto.go | 9 +- 9 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 internal/app/session/companionreceipt.go create mode 100644 internal/app/session/companionreceipt_test.go create mode 100644 internal/app/session/meshclidirect.go create mode 100644 internal/mesh/proto/delivery.go create mode 100644 internal/mesh/proto/delivery_test.go diff --git a/internal/app/session/companionreceipt.go b/internal/app/session/companionreceipt.go new file mode 100644 index 00000000..41daa867 --- /dev/null +++ b/internal/app/session/companionreceipt.go @@ -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.Load())/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.Load() + 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 +} diff --git a/internal/app/session/companionreceipt_test.go b/internal/app/session/companionreceipt_test.go new file mode 100644 index 00000000..59c840ad --- /dev/null +++ b/internal/app/session/companionreceipt_test.go @@ -0,0 +1,62 @@ +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) { + c := &compSession{node: "A"} + 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) + } + c.simMs.Store(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) + c.simMs.Store(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) + } +} diff --git a/internal/app/session/companionsession.go b/internal/app/session/companionsession.go index 84521211..74dd2dbb 100644 --- a/internal/app/session/companionsession.go +++ b/internal/app/session/companionsession.go @@ -12,6 +12,7 @@ import ( "encoding/binary" "fmt" "sync" + "sync/atomic" "github.com/MeshBench/meshbench/internal/mesh/companion" "github.com/MeshBench/meshbench/internal/mesh/proto" @@ -49,6 +50,13 @@ 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 + // simMs is the simulated time as of the last tick. Frames arrive on the + // bridge's goroutine, which must not take the engine's lock to ask. + simMs atomic.Uint32 } // Write receives whatever the node sends while the claim is held. @@ -114,6 +122,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 @@ -144,7 +155,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.Load())/1000, f.Message.Text)) case f.Scope != nil: c.scope, c.scopeKnown = f.Scope.Name, true if f.Scope.Name == "" { diff --git a/internal/app/session/companionview.go b/internal/app/session/companionview.go index a2f6f9bf..f901268b 100644 --- a/internal/app/session/companionview.go +++ b/internal/app/session/companionview.go @@ -143,6 +143,10 @@ func (s *Sim) refreshCompanions(w *state.World) { if len(s.comps) == 0 && s.servedCount() == 0 { return } + now := s.eng.NowMs() + for _, c := range s.comps { + c.simMs.Store(now) + } s.collectWaiting() if rev := s.companionRev(); rev != s.compRev { s.publishCompanions(w) diff --git a/internal/app/session/meshclidirect.go b/internal/app/session/meshclidirect.go new file mode 100644 index 00000000..836de8c7 --- /dev/null +++ b/internal/app/session/meshclidirect.go @@ -0,0 +1,125 @@ +// meshcore-cli's commands for talking to one node rather than to a channel: +// a direct message, a login and a status request, plus the scope those and +// every flood go out under. +// +// Each names its recipient the way meshcore-cli does, by contact name or by +// public key in hex, and each is answered twice: once when the firmware says +// it sent the request, once when the ack or the reply comes back. Both land in +// the transcript, matched to the request (companionreceipt.go), so "sent" and +// "delivered" are never confused. +package session + +import ( + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/MeshBench/meshbench/internal/mesh/proto" + "github.com/MeshBench/meshbench/internal/world/provider" +) + +var directCommands = []cliCommand{ + {"msg", "m", " ", "send a direct message", func(s *Sim, node string, a []string) (string, error) { + if len(a) < 2 { + return "", fmt.Errorf("msg ") + } + to, key, err := s.contactKey(node, a[0], 6) + if err != nil { + return "", err + } + text := strings.Join(a[1:], " ") + frame, err := proto.SendContactText(key, time.Now(), 0, text) + if err != nil { + return "", err + } + return s.request(node, "msg", to, text, frame) + }}, + {"login", "l", " ", "log in to a repeater or room", func(s *Sim, node string, a []string) (string, error) { + if len(a) < 1 { + return "", fmt.Errorf("login ") + } + to, key, err := s.contactKey(node, a[0], 32) + if err != nil { + return "", err + } + return s.request(node, "login", to, "", proto.Login(key, strings.Join(a[1:], " "))) + }}, + {"req_status", "rs", "", "ask a repeater or room for its status", func(s *Sim, node string, a []string) (string, error) { + if len(a) < 1 { + return "", fmt.Errorf("req_status ") + } + to, key, err := s.contactKey(node, a[0], 32) + if err != nil { + return "", err + } + return s.request(node, "req_status", to, "", proto.StatusRequest(key)) + }}, + {"scope", "", "", "the region floods go out under; * for none", func(s *Sim, node string, a []string) (string, error) { + if len(a) != 1 { + return "", fmt.Errorf("scope , or scope * to send unscoped") + } + name := CanonicalScope(strings.TrimSpace(a[0])) + frame := proto.ClearDefaultScope() + if name != "" && name != "#*" { + frame = proto.SetDefaultScope(name, provider.RegionKey(name)) + } else { + name = "" + } + if err := s.compFrame(node, frame); err != nil { + return "", err + } + _ = s.compFrame(node, proto.GetDefaultScope()) + return "scope set to " + orUnscoped(name), nil + }}, +} + +func init() { + meshcliCommands = append(meshcliCommands, directCommands...) + for _, c := range directCommands { + delete(meshcliRest, c.name) + } +} + +// request sends one frame that the firmware answers with RESP_CODE_SENT, and +// remembers it so the answer can be matched. +func (s *Sim) request(node, kind, to, text string, frame []byte) (string, error) { + c, _, err := s.companionFor(node) + if err != nil { + return "", err + } + c.expect(kind, to, text) + if err := s.compFrame(node, frame); err != nil { + return "", err + } + return fmt.Sprintf("%s to %s queued; the firmware says when it is sent and when it is answered", kind, to), nil +} + +// contactKey finds a contact by name, or by key in hex, among those the node +// has reported, and returns the first n bytes of its key. +// +// A key long enough to address the request is taken as given even when the +// node has not listed it, because the firmware decides whether it holds the +// contact and answers "not found" itself - the honest place for that refusal. +func (s *Sim) contactKey(node, who string, n int) (string, []byte, error) { + c, _, err := s.companionFor(node) + if err != nil { + return "", nil, err + } + c.mu.Lock() + defer c.mu.Unlock() + low := strings.ToLower(who) + for _, ct := range c.contacts { + if ct.Name == who || strings.HasPrefix(hex.EncodeToString(ct.PublicKey), low) && len(low) >= 2 { + if len(ct.PublicKey) < n { + return "", nil, fmt.Errorf("%s's key is %d bytes", ct.Name, len(ct.PublicKey)) + } + return ct.Name, ct.PublicKey[:n], nil + } + } + if b, err := hex.DecodeString(low); err == nil && len(b) >= n { + return low[:min(len(low), 12)], b[:n], nil + } + return "", nil, fmt.Errorf("%s holds no contact %q - contacts lists what it holds; "+ + "a key needs %d hex characters", node, who, 2*n) +} diff --git a/internal/mesh/proto/decode.go b/internal/mesh/proto/decode.go index 6d147aac..81c40c6f 100644 --- a/internal/mesh/proto/decode.go +++ b/internal/mesh/proto/decode.go @@ -73,6 +73,12 @@ func Decode(frame []byte) (Frame, error) { return f, err } f.Message = m + case RespSent: + f.Sent = decodeSent(body) + case Response(PushSendConfirmed): + f.Confirmed = decodeConfirmed(body) + case Response(PushLoginSuccess), Response(PushLoginFail), Response(PushStatusResp): + f.Answer = decodeAnswer(f.Push, body) } return f, nil } diff --git a/internal/mesh/proto/delivery.go b/internal/mesh/proto/delivery.go new file mode 100644 index 00000000..f5d7071a --- /dev/null +++ b/internal/mesh/proto/delivery.go @@ -0,0 +1,95 @@ +// Whether a direct message got there. +// +// A channel message has no receipt: it is flooded, and nobody owes the sender +// an answer. A direct message does. The firmware answers the send with +// RESP_CODE_SENT, carrying the acknowledgement code it expects back and how +// long it will wait, then pushes PUSH_CODE_SEND_CONFIRMED with that same code +// and the round trip once the recipient's ack arrives. Matching the two is the +// only way a client can say "delivered" rather than "sent". +package proto + +import "encoding/binary" + +// Sent is the firmware's answer to a direct send (RESP_CODE_SENT). +type Sent struct { + // Flood is true when the firmware had no path to the contact and flooded + // the message, false when it went direct along a known path. + Flood bool + // Ack is the acknowledgement code the recipient will send back. + Ack uint32 + // TimeoutMs is how long the firmware will wait for that ack. + TimeoutMs uint32 +} + +// Confirmed is the push that says an ack arrived (PUSH_CODE_SEND_CONFIRMED). +type Confirmed struct { + Ack uint32 + RoundTripMs uint32 +} + +// decodeSent reads RESP_CODE_SENT's body. MeshCore's MyMesh.cpp writes the +// flood flag, then the expected ack, then the estimated timeout, each ack and +// timeout four bytes little-endian. +func decodeSent(b []byte) *Sent { + if len(b) < 5 { + return nil + } + s := &Sent{Flood: b[0] == 1, Ack: binary.LittleEndian.Uint32(b[1:5])} + if len(b) >= 9 { + s.TimeoutMs = binary.LittleEndian.Uint32(b[5:9]) + } + return s +} + +// decodeConfirmed reads PUSH_CODE_SEND_CONFIRMED's body: the ack code, then +// the round trip in milliseconds. +func decodeConfirmed(b []byte) *Confirmed { + if len(b) < 4 { + return nil + } + c := &Confirmed{Ack: binary.LittleEndian.Uint32(b[0:4])} + if len(b) >= 8 { + c.RoundTripMs = binary.LittleEndian.Uint32(b[4:8]) + } + return c +} + +// Answer is a repeater or room server replying to a login or a status +// request: PUSH_CODE_LOGIN_SUCCESS, PUSH_CODE_LOGIN_FAIL or +// PUSH_CODE_STATUS_RESPONSE, each led by a reserved or permissions byte and +// the six-byte key prefix of whoever answered. +type Answer struct { + Push Push + From []byte + Status []byte +} + +func decodeAnswer(p Push, b []byte) *Answer { + if len(b) < 7 { + return nil + } + a := &Answer{Push: p, From: b[1:7]} + if p == PushStatusResp { + a.Status = b[7:] + } + return a +} + +// Login sends CMD_SEND_LOGIN: the whole key, then the password. The firmware +// looks the key up among its contacts, so the recipient has to be one. +func Login(pubKey []byte, password string) []byte { + b := append([]byte{byte(CmdSendLogin)}, pubKey...) + return append(b, password...) +} + +// StatusRequest sends CMD_SEND_STATUS_REQ to a contact, by its whole key. +func StatusRequest(pubKey []byte) []byte { + return append([]byte{byte(CmdSendStatusReq)}, pubKey...) +} + +// The two request commands, numbered as MeshCore's companion_radio numbers +// them. +const ( + CmdSendLogin Command = 26 + CmdSendStatusReq Command = 27 +) diff --git a/internal/mesh/proto/delivery_test.go b/internal/mesh/proto/delivery_test.go new file mode 100644 index 00000000..2af926b0 --- /dev/null +++ b/internal/mesh/proto/delivery_test.go @@ -0,0 +1,68 @@ +package proto_test + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/MeshBench/meshbench/internal/mesh/proto" +) + +// The two halves of a direct message's receipt, laid out as MeshCore's +// companion_radio writes them: RESP_CODE_SENT is the flood flag, the expected +// ack and the timeout; PUSH_CODE_SEND_CONFIRMED is the ack and the round trip. +func TestSentAndConfirmedDecode(t *testing.T) { + sent := []byte{byte(proto.RespSent), 1} + sent = binary.LittleEndian.AppendUint32(sent, 0xA1B2C3D4) + sent = binary.LittleEndian.AppendUint32(sent, 12000) + f, err := proto.Decode(sent) + if err != nil || f.Sent == nil { + t.Fatalf("RESP_CODE_SENT did not decode: %v %+v", err, f) + } + if !f.Sent.Flood || f.Sent.Ack != 0xA1B2C3D4 || f.Sent.TimeoutMs != 12000 { + t.Fatalf("sent = %+v", *f.Sent) + } + + conf := []byte{byte(proto.PushSendConfirmed)} + conf = binary.LittleEndian.AppendUint32(conf, 0xA1B2C3D4) + conf = binary.LittleEndian.AppendUint32(conf, 2345) + f, err = proto.Decode(conf) + if err != nil || f.Confirmed == nil { + t.Fatalf("PUSH_CODE_SEND_CONFIRMED did not decode: %v %+v", err, f) + } + if f.Confirmed.Ack != 0xA1B2C3D4 || f.Confirmed.RoundTripMs != 2345 { + t.Fatalf("confirmed = %+v", *f.Confirmed) + } +} + +// A login or status answer names who answered by the six bytes after the +// leading byte, and a status reply carries the rest as the status itself. +func TestAnswersNameWhoAnswered(t *testing.T) { + from := []byte{1, 2, 3, 4, 5, 6} + frame := append(append([]byte{byte(proto.PushStatusResp), 0}, from...), 9, 9, 9) + f, err := proto.Decode(frame) + if err != nil || f.Answer == nil { + t.Fatalf("status response did not decode: %v %+v", err, f) + } + if !bytes.Equal(f.Answer.From, from) || len(f.Answer.Status) != 3 { + t.Fatalf("answer = %+v", *f.Answer) + } + frame = append(append([]byte{byte(proto.PushLoginFail), 0}, from...)) + if f, _ = proto.Decode(frame); f.Answer == nil || f.Answer.Push != proto.PushLoginFail { + t.Fatalf("login refusal did not decode: %+v", f) + } +} + +// The request commands carry the whole key, which is what the firmware looks +// the contact up by. +func TestRequestLayouts(t *testing.T) { + key := bytes.Repeat([]byte{0xAB}, 32) + l := proto.Login(key, "hunter2") + if proto.Command(l[0]) != proto.CmdSendLogin || !bytes.Equal(l[1:33], key) || string(l[33:]) != "hunter2" { + t.Fatalf("login = %x", l) + } + s := proto.StatusRequest(key) + if proto.Command(s[0]) != proto.CmdSendStatusReq || len(s) != 33 { + t.Fatalf("status request = %x", s) + } +} diff --git a/internal/mesh/proto/proto.go b/internal/mesh/proto/proto.go index eeeb57b8..51b7495a 100644 --- a/internal/mesh/proto/proto.go +++ b/internal/mesh/proto/proto.go @@ -223,8 +223,6 @@ type Message struct { // and the node transmitted it - but nothing comes back to echo it, so // without this the operator sends into a conversation that stays empty. Mine bool - // Confirmed is set when the firmware acknowledges the send. - Confirmed bool } // Frame is a decoded reply or push. @@ -238,7 +236,12 @@ type Frame struct { Contact *Contact Scope *ScopeInfo Device *DeviceInfo - Err string + // Sent and Confirmed are the two halves of a direct message's receipt. + Sent *Sent + Confirmed *Confirmed + // Answer is a login or status reply from a repeater or room server. + Answer *Answer + Err string // Raw is the whole frame, kept so anything this package does not decode // is still inspectable rather than lost. Raw []byte From d3353c0d1446ecd43bdaa596f24422f59e53fb1c Mon Sep 17 00:00:00 2001 From: R0ck Date: Wed, 14 Oct 2026 23:48:29 +0100 Subject: [PATCH 2/6] docs: the companion command line's msg, login, req_status and scope --- .claude/skills/meshbench-scripting/SKILL.md | 12 +++++++++++- docs/fixtures.md | 9 ++++++--- docs/scripting-api.md | 6 +++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.claude/skills/meshbench-scripting/SKILL.md b/.claude/skills/meshbench-scripting/SKILL.md index 506d1dbc..1390bb36 100644 --- a/.claude/skills/meshbench-scripting/SKILL.md +++ b/.claude/skills/meshbench-scripting/SKILL.md @@ -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 `, `chan `, -`infos`, `ver`, `contacts`, `sync_msgs`, `set`, `time`. **There is no `send`.** +`msg `, `login `, `req_status `, +`scope `, `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 diff --git a/docs/fixtures.md b/docs/fixtures.md index c074f25e..72589834 100644 --- a/docs/fixtures.md +++ b/docs/fixtures.md @@ -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 `, `chan ` - 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 `, +`chan `, and in the workbench also `msg `, +`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 diff --git a/docs/scripting-api.md b/docs/scripting-api.md index 91c1ff25..918a9479 100644 --- a/docs/scripting-api.md +++ b/docs/scripting-api.md @@ -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 `, -`chan `, `infos`, `ver`, `contacts`, `sync_msgs`, `set`, `time`. +`chan `, `msg `, `login `, +`req_status `, `scope `, `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 From 4ed11043fdba828be22f1883d4b09f5cdc219607 Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 15 Oct 2026 00:19:37 +0100 Subject: [PATCH 3/6] companion: stamp receipts with the tick the firmware wrote them in The bridge records the time of each tick it sends, and a frame the node writes while running that tick arrives before the tick's ack, so that is when the frame was said. Read without the engine's lock, from the goroutine reading the node's output. --- internal/app/session/companion.go | 2 +- internal/app/session/companionport.go | 2 +- internal/app/session/companionreceipt.go | 4 ++-- internal/app/session/companionreceipt_test.go | 7 ++++--- internal/app/session/companionsession.go | 18 +++++++++++++----- internal/app/session/companionview.go | 4 ---- internal/firmware/bridge.go | 10 ++++++++++ internal/firmware/lockstep.go | 1 + 8 files changed, 32 insertions(+), 16 deletions(-) diff --git a/internal/app/session/companion.go b/internal/app/session/companion.go index a17f6c6d..af66e4e6 100644 --- a/internal/app/session/companion.go +++ b/internal/app/session/companion.go @@ -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 diff --git a/internal/app/session/companionport.go b/internal/app/session/companionport.go index b6dae8ca..5700adbe 100644 --- a/internal/app/session/companionport.go +++ b/internal/app/session/companionport.go @@ -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 { diff --git a/internal/app/session/companionreceipt.go b/internal/app/session/companionreceipt.go index 41daa867..7369a540 100644 --- a/internal/app/session/companionreceipt.go +++ b/internal/app/session/companionreceipt.go @@ -32,7 +32,7 @@ func (c *compSession) expect(kind, to, text string) { // 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.Load())/1000) + at := fmt.Sprintf("[%.3f s] ", float64(c.simMs())/1000) switch { case f.Sent != nil: if len(c.queued) == 0 { @@ -40,7 +40,7 @@ func (c *compSession) receipt(f proto.Frame) bool { } a := c.queued[0] c.queued = c.queued[1:] - a.sentMs = c.simMs.Load() + a.sentMs = c.simMs() how := "direct" if f.Sent.Flood { how = "flood" diff --git a/internal/app/session/companionreceipt_test.go b/internal/app/session/companionreceipt_test.go index 59c840ad..de205b4a 100644 --- a/internal/app/session/companionreceipt_test.go +++ b/internal/app/session/companionreceipt_test.go @@ -21,19 +21,20 @@ func frameOf(t *testing.T, b []byte) proto.Frame { // 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) { - c := &compSession{node: "A"} + 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) } - c.simMs.Store(1500) + 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) - c.simMs.Store(2400) + now = 2400 c.apply(frameOf(t, conf)) joined := strings.Join(c.Lines(), "\n") diff --git a/internal/app/session/companionsession.go b/internal/app/session/companionsession.go index 74dd2dbb..9da1e529 100644 --- a/internal/app/session/companionsession.go +++ b/internal/app/session/companionsession.go @@ -12,7 +12,6 @@ import ( "encoding/binary" "fmt" "sync" - "sync/atomic" "github.com/MeshBench/meshbench/internal/mesh/companion" "github.com/MeshBench/meshbench/internal/mesh/proto" @@ -54,9 +53,18 @@ type compSession struct { // queued until it says it sent one, awaiting its ack after that. queued []asked awaiting map[uint32]asked - // simMs is the simulated time as of the last tick. Frames arrive on the - // bridge's goroutine, which must not take the engine's lock to ask. - simMs atomic.Uint32 + // 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. @@ -155,7 +163,7 @@ func (c *compSession) apply(f proto.Frame) { } c.unread[f.Message.ChannelIdx]++ } - c.note(fmt.Sprintf("[%.3f s] message: %s", float64(c.simMs.Load())/1000, 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 == "" { diff --git a/internal/app/session/companionview.go b/internal/app/session/companionview.go index f901268b..a2f6f9bf 100644 --- a/internal/app/session/companionview.go +++ b/internal/app/session/companionview.go @@ -143,10 +143,6 @@ func (s *Sim) refreshCompanions(w *state.World) { if len(s.comps) == 0 && s.servedCount() == 0 { return } - now := s.eng.NowMs() - for _, c := range s.comps { - c.simMs.Store(now) - } s.collectWaiting() if rev := s.companionRev(); rev != s.compRev { s.publishCompanions(w) diff --git a/internal/firmware/bridge.go b/internal/firmware/bridge.go index 13be433b..cbbc6f60 100644 --- a/internal/firmware/bridge.go +++ b/internal/firmware/bridge.go @@ -19,6 +19,7 @@ import ( "io" "net" "sync" + "sync/atomic" ) // Message kinds on the wire. @@ -105,8 +106,17 @@ type Bridge struct { // with no way to hear about that hangs for as long as its deadline allows - // or for ever, if it was given none. done chan struct{} + + // tickAt is the simulated time of the last tick sent. A frame the node + // writes while running that tick arrives before its ack, so this is the + // moment anything the node says was said - read from whichever goroutine + // is handling the output, without the engine's lock. + tickAt atomic.Uint32 } +// Now is the simulated time of the tick the node is running, or last ran. +func (b *Bridge) Now() uint32 { return b.tickAt.Load() } + // Listen starts a bridge for one emulated node. Addr is typically // "127.0.0.1:0"; the emulator connects to Addr(). func Listen(addr, node string) (*Bridge, error) { diff --git a/internal/firmware/lockstep.go b/internal/firmware/lockstep.go index d809d6ed..244b2457 100644 --- a/internal/firmware/lockstep.go +++ b/internal/firmware/lockstep.go @@ -34,6 +34,7 @@ func (b *Bridge) Advance(ctx context.Context, atMs uint32) error { // CPU actually went — three hundred serialised round trips per step, at twelve // steps a frame. func (b *Bridge) BeginAdvance(atMs uint32) error { + b.tickAt.Store(atMs) var payload [4]byte binary.BigEndian.PutUint32(payload[:], atMs) if err := b.send(kindTick, payload[:]); err != nil { From b317f2bfc354fb48d2f9231f03dc901ecc21daeb Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 15 Oct 2026 14:19:18 +0100 Subject: [PATCH 4/6] workbench: -companion-mode opens a companion's tab on Client, CLI or TCP The CLI, where meshcore-cli's answers land, could only be reached by a click, so nothing could photograph or check it. --- docs/cli-reference.md | 7 ++-- internal/ui/workbench/main.go | 4 ++- .../ui/workbench/nodeview/companionopen.go | 33 +++++++++++++++++++ .../workbench/nodeview/companionopen_test.go | 28 ++++++++++++++++ .../ui/workbench/nodeview/companiontab.go | 1 + internal/ui/workbench/panellist.go | 1 + internal/ui/workbench/panelsmesh.go | 7 ++++ tools/flagdoc/flags.json | 1 + 8 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 internal/ui/workbench/nodeview/companionopen.go create mode 100644 internal/ui/workbench/nodeview/companionopen_test.go diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b1a57d43..d86d6138 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -12,8 +12,8 @@ meshbench [flags] ``` There are **16** commands and -**170** flags between them, of which -**38** exist only so a panel, a menu or a view can be +**171** flags between them, of which +**39** 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 @@ -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` @@ -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 | diff --git a/internal/ui/workbench/main.go b/internal/ui/workbench/main.go index a6deb5d9..8a94d230 100644 --- a/internal/ui/workbench/main.go +++ b/internal/ui/workbench/main.go @@ -97,6 +97,8 @@ func Run(args []string) { nodeTabFlag := flag.String("node-tab", "", "which tab a node window opens on, by name: "+ strings.Join(nodeview.TabNames(), ", ")) + compModeFlag := flag.String("companion-mode", "", + "which way in a companion's tab shows: "+strings.Join(nodeview.CompanionModeNames(), ", ")) coverFlag := flag.String("coverage", "", "compute and show coverage from this node at startup") energyFlag := flag.Bool("energy", false, "run the site study for the selected node at startup") @@ -301,7 +303,7 @@ func Run(args []string) { boardWinFlag: boardWinFlag, boardTabFlag: boardTabFlag, boardDecodeFlag: boardDecodeFlag, openFwFlag: openFwFlag, openMenuFlag: openMenuFlag, - packetTabFlag: packetTabFlag, nodeTabFlag: nodeTabFlag, + packetTabFlag: packetTabFlag, nodeTabFlag: nodeTabFlag, compModeFlag: compModeFlag, }) // After the panels are registered, because it opens one of them. Not when // a single panel fills the window: that is a capture, and a page docking diff --git a/internal/ui/workbench/nodeview/companionopen.go b/internal/ui/workbench/nodeview/companionopen.go new file mode 100644 index 00000000..9a931013 --- /dev/null +++ b/internal/ui/workbench/nodeview/companionopen.go @@ -0,0 +1,33 @@ +package nodeview + +import "strings" + +// OpenOnCompanionMode is which way in a companion's tab shows when it is +// first drawn: Client, except when a capture is being taken of one of the +// others. The CLI is where meshcore-cli's answers land, and a mode that only +// a click can reach is one no screenshot or check can show. +var OpenOnCompanionMode companionMode + +// CompanionModeByName is a mode named the way its switch is labelled, ignoring +// case; an empty name is the default. +func CompanionModeByName(name string) (companionMode, bool) { + if strings.TrimSpace(name) == "" { + return modeClient, true + } + for _, m := range companionModes { + if strings.EqualFold(m.Label, name) { + return m.Mode, true + } + } + return modeClient, false +} + +// CompanionModeNames is every mode, for saying what was possible when a name +// matched none. +func CompanionModeNames() []string { + out := make([]string, 0, len(companionModes)) + for _, m := range companionModes { + out = append(out, m.Label) + } + return out +} diff --git a/internal/ui/workbench/nodeview/companionopen_test.go b/internal/ui/workbench/nodeview/companionopen_test.go new file mode 100644 index 00000000..7cd1e5d7 --- /dev/null +++ b/internal/ui/workbench/nodeview/companionopen_test.go @@ -0,0 +1,28 @@ +package nodeview + +import "testing" + +// A mode is found by the label on its switch, in any case, and a name that +// matches none is refused rather than quietly opening Client. +func TestCompanionModesAreFoundByTheirLabel(t *testing.T) { + if m, ok := CompanionModeByName("cli"); !ok || m != modeCLI { + t.Fatalf("cli resolved to %v, %v", m, ok) + } + if m, ok := CompanionModeByName(""); !ok || m != modeClient { + t.Fatalf("an empty name resolved to %v, %v", m, ok) + } + if _, ok := CompanionModeByName("terminal"); ok { + t.Fatal("a name no switch carries was accepted") + } +} + +// A tab built after the flag is set opens on that mode. +func TestATabOpensOnTheChosenMode(t *testing.T) { + OpenOnCompanionMode = modeCLI + defer func() { OpenOnCompanionMode = modeClient }() + c := &companionTab{} + c.build() + if c.mode != modeCLI { + t.Fatalf("the tab opened on %v", c.mode) + } +} diff --git a/internal/ui/workbench/nodeview/companiontab.go b/internal/ui/workbench/nodeview/companiontab.go index 8ed576d9..68454a59 100644 --- a/internal/ui/workbench/nodeview/companiontab.go +++ b/internal/ui/workbench/nodeview/companiontab.go @@ -141,6 +141,7 @@ func (c *companionTab) build() { c.railList.Axis, c.convoList.Axis, c.cliList.Axis = layout.Vertical, layout.Vertical, layout.Vertical c.buildRadio() c.pathHash = 3 + c.mode = OpenOnCompanionMode c.clicks_ = map[string]*widget.Clickable{} c.built = true } diff --git a/internal/ui/workbench/panellist.go b/internal/ui/workbench/panellist.go index c9e36dab..c60f2012 100644 --- a/internal/ui/workbench/panellist.go +++ b/internal/ui/workbench/panellist.go @@ -63,6 +63,7 @@ type panelDeps struct { openMenuFlag *string packetTabFlag *int nodeTabFlag *string + compModeFlag *string } // addPanels registers every panel. diff --git a/internal/ui/workbench/panelsmesh.go b/internal/ui/workbench/panelsmesh.go index cac11c4b..8c7b40c5 100644 --- a/internal/ui/workbench/panelsmesh.go +++ b/internal/ui/workbench/panelsmesh.go @@ -50,6 +50,13 @@ func addMeshPanels(d panelDeps) { *d.nodeTabFlag, strings.Join(nodeview.TabNames(), ", ")) os.Exit(2) } + if m, ok := nodeview.CompanionModeByName(*d.compModeFlag); ok { + nodeview.OpenOnCompanionMode = m + } else { + fmt.Fprintf(os.Stderr, "-companion-mode %q: no such mode. There is: %s\n", + *d.compModeFlag, strings.Join(nodeview.CompanionModeNames(), ", ")) + os.Exit(2) + } packetview.OpenOnTab = *d.packetTabFlag if *d.nodeWinFlag != "" { go func() { diff --git a/tools/flagdoc/flags.json b/tools/flagdoc/flags.json index 68201ef5..20b618d6 100644 --- a/tools/flagdoc/flags.json +++ b/tools/flagdoc/flags.json @@ -254,6 +254,7 @@ "board-tab": "capture", "board-view": "capture", "capture": "capture", + "companion-mode": "capture", "config-section": "capture", "control-socket": "capture", "coverage": "capture", From aabf8991d5ad3e05b77212338c0bbacaaa7f4a6d Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 15 Oct 2026 14:22:48 +0100 Subject: [PATCH 5/6] companion: the console of a held companion is its transcript, not its serial bytes Every tick replaced the console lines with the node's raw serial buffer, which for a companion the session holds is binary frames; the CLI pane showed noise with a contact's name in it and never the answers. --- internal/app/session/companionreceipt_test.go | 14 ++++++++++++++ internal/app/session/companionsession.go | 17 +++++++++++++++++ internal/app/session/verbs.go | 4 +--- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/internal/app/session/companionreceipt_test.go b/internal/app/session/companionreceipt_test.go index de205b4a..1662e50b 100644 --- a/internal/app/session/companionreceipt_test.go +++ b/internal/app/session/companionreceipt_test.go @@ -61,3 +61,17 @@ func TestARefusedRequestSaysSo(t *testing.T) { 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) + } +} diff --git a/internal/app/session/companionsession.go b/internal/app/session/companionsession.go index 9da1e529..8c158b0a 100644 --- a/internal/app/session/companionsession.go +++ b/internal/app/session/companionsession.go @@ -198,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 +} diff --git a/internal/app/session/verbs.go b/internal/app/session/verbs.go index fb88fb86..65d22619 100644 --- a/internal/app/session/verbs.go +++ b/internal/app/session/verbs.go @@ -246,9 +246,7 @@ func Register(st *state.Store, s *Sim) { // published only on the next console.type, every answer appeared // one command late, which reads as a console that does not answer. if w.ConsoleNode != "" { - if buf, ok := s.consoles[w.ConsoleNode]; ok { - w.Console = buf.Snapshot() - } + w.Console = s.consoleLines(w.ConsoleNode, w.Console) } // And the output pane, for the same reason. Its source is a file // the emulator is still writing, so a pane that read it once shows From fb1a5889e28f19bc63f922a576ea1d76e26dc619 Mon Sep 17 00:00:00 2001 From: R0ck Date: Fri, 16 Oct 2026 09:40:20 +0100 Subject: [PATCH 6/6] proto: a login refusal frame is one append, not an append of nothing --- internal/mesh/proto/delivery_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mesh/proto/delivery_test.go b/internal/mesh/proto/delivery_test.go index 2af926b0..067c58d2 100644 --- a/internal/mesh/proto/delivery_test.go +++ b/internal/mesh/proto/delivery_test.go @@ -47,7 +47,7 @@ func TestAnswersNameWhoAnswered(t *testing.T) { if !bytes.Equal(f.Answer.From, from) || len(f.Answer.Status) != 3 { t.Fatalf("answer = %+v", *f.Answer) } - frame = append(append([]byte{byte(proto.PushLoginFail), 0}, from...)) + frame = append([]byte{byte(proto.PushLoginFail), 0}, from...) if f, _ = proto.Decode(frame); f.Answer == nil || f.Answer.Push != proto.PushLoginFail { t.Fatalf("login refusal did not decode: %+v", f) }