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/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/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 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 new file mode 100644 index 00000000..3730a5e7 --- /dev/null +++ b/internal/app/session/companionreceipt.go @@ -0,0 +1,98 @@ +// 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, and + // timeoutMs how long the firmware said it would wait for the ack. + sentMs, timeoutMs 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) + c.expire(at) + switch { + case f.Sent != nil: + if len(c.queued) == 0 { + return false + } + a := c.queued[0] + c.queued = c.queued[1:] + a.sentMs, a.timeoutMs = c.simMs(), f.Sent.TimeoutMs + 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 +} + +// expire gives up on a message whose ack is past the time the firmware said it +// would wait, as the firmware does, and says so. Without it a message that was +// never acked stayed waiting for the whole session. Called with c.mu held. +func (c *compSession) expire(at string) { + now := c.simMs() + for ack, a := range c.awaiting { + if now > a.sentMs+a.timeoutMs { + delete(c.awaiting, ack) + c.note(fmt.Sprintf("%sno ack from %s within %d ms: %s", at, a.to, a.timeoutMs, a.text)) + } + } +} diff --git a/internal/app/session/companionreceipt_test.go b/internal/app/session/companionreceipt_test.go new file mode 100644 index 00000000..930ba0ff --- /dev/null +++ b/internal/app/session/companionreceipt_test.go @@ -0,0 +1,106 @@ +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) + } +} + +// A message whose ack never comes is given up on once the firmware's own wait +// is over, and the transcript says so, rather than waiting all session. +func TestAnUnackedMessageIsGivenUpOn(t *testing.T) { + var now uint32 + c := &compSession{node: "A", clock: func() uint32 { return now }} + c.expect("msg", "Bravo", "hello") + sent := binary.LittleEndian.AppendUint32([]byte{byte(proto.RespSent), 0}, 11) + sent = binary.LittleEndian.AppendUint32(sent, 5000) + now = 1000 + c.apply(frameOf(t, sent)) + // Something else arrives after the wait is over. + c.expect("msg", "Charlie", "later") + later := binary.LittleEndian.AppendUint32([]byte{byte(proto.RespSent), 0}, 22) + later = binary.LittleEndian.AppendUint32(later, 5000) + now = 7000 + c.apply(frameOf(t, later)) + + if _, ok := c.awaiting[11]; ok { + t.Fatal("the message to Bravo is still waiting after its 5000 ms") + } + if !strings.Contains(strings.Join(c.Lines(), "\n"), "no ack from Bravo within 5000 ms: hello") { + t.Fatalf("the transcript does not say the message went unacked:\n%s", + strings.Join(c.Lines(), "\n")) + } + if _, ok := c.awaiting[22]; !ok { + t.Fatal("the message to Charlie, still inside its wait, was given up on") + } +} diff --git a/internal/app/session/companionsession.go b/internal/app/session/companionsession.go index 84521211..8c158b0a 100644 --- a/internal/app/session/companionsession.go +++ b/internal/app/session/companionsession.go @@ -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. @@ -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 @@ -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 == "" { @@ -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 +} 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/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 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 { 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..067c58d2 --- /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([]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 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",