diff --git a/CHANGELOG.md b/CHANGELOG.md index a38e02b4..a94a9f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ had changed in them - which is the gap this file exists to close. ### Added +- **An nRF52 board's lamps are watched.** Under Renode each declared lamp's + output is followed and reported back, so the board view draws it lit or + unlit and says how many times it changed, and `nodes.stats` carries `lamps`. + A RAK4631 repeater's two LEDs flash three times at boot. ESP32 boards under + QEMU still draw lamps as not modelled; that half needs the QEMU fork. - **Script input at an instant of simulated time.** `console.type`, `console.cli` and the `companion.*` verbs that write to a node take `at_ms`: the call is held and runs when the run reaches that instant, so a script diff --git a/internal/app/session/nodestats.go b/internal/app/session/nodestats.go index edd0a142..d56892d4 100644 --- a/internal/app/session/nodestats.go +++ b/internal/app/session/nodestats.go @@ -179,6 +179,16 @@ func (s *Sim) nodeStats(events []state.Event) []state.NodeStat { } } } + if lp, ok := n.Firmware.Backend.(interface { + Lamps() (map[int]bool, map[int]int, bool) + }); ok { + if lit, changes, watched := lp.Lamps(); watched { + st.Lamps = make(map[int]state.Lamp, len(lit)) + for pin, on := range lit { + st.Lamps[pin] = state.Lamp{Lit: on, Changes: changes[pin]} + } + } + } st.Radio = state.RadioState{ Reported: r.Configured, GainReg: r.RxGainReg, Boosted: r.RxBoosted(), TxPowerDBm: r.TxPowerDBm, diff --git a/internal/app/session/statrows.go b/internal/app/session/statrows.go index d700e8e7..833543c4 100644 --- a/internal/app/session/statrows.go +++ b/internal/app/session/statrows.go @@ -7,7 +7,11 @@ // node.radio. package session -import "github.com/MeshBench/meshbench/internal/app/state" +import ( + "strconv" + + "github.com/MeshBench/meshbench/internal/app/state" +) func statRows(stats []state.NodeStat) []map[string]any { out := make([]map[string]any, 0, len(stats)) @@ -45,6 +49,16 @@ func statRows(stats []state.NodeStat) []map[string]any { // the costs above are: it did not count, so there is no zero. "irq_suppressed": suppressedOrNil(n), }) + // The board's lamps, by pin, where something watches them. Absent + // rather than empty where nothing does, because "every lamp off" and + // "not watched" are different answers. + if n.Lamps != nil { + lamps := make(map[string]any, len(n.Lamps)) + for pin, l := range n.Lamps { + lamps[strconv.Itoa(pin)] = map[string]any{"lit": l.Lit, "changes": l.Changes} + } + out[len(out)-1]["lamps"] = lamps + } } return out } diff --git a/internal/app/state/network.go b/internal/app/state/network.go index 994b853e..10a21db1 100644 --- a/internal/app/state/network.go +++ b/internal/app/state/network.go @@ -178,6 +178,17 @@ type NodeStat struct { // report a board with no screen and a board with a blank one as the same // thing. Screen *Screen + + // Lamps is each watched lamp's level, by flat pin, or nil where nothing + // watches this board's lamps - which is not the same as every lamp off. + Lamps map[int]Lamp +} + +// Lamp is one watched light: whether it is lit, and how many times it has +// changed, so a flash between two frames is still seen to have happened. +type Lamp struct { + Lit bool + Changes int } // Screen is one picture from a board's display. diff --git a/internal/firmware/emulated/emulated_node.go b/internal/firmware/emulated/emulated_node.go index 6df13f0e..77c78e8d 100644 --- a/internal/firmware/emulated/emulated_node.go +++ b/internal/firmware/emulated/emulated_node.go @@ -121,7 +121,10 @@ type EmulatedNode struct { // to dial a socket file. Exactly one of the two is set. ButtonPort int ButtonPins []int - Buttons *peripheral.ButtonSender + // LampPins are the board's lamps, whose outputs the machine reports back + // on the same channel - Renode only, for now (#600). + LampPins []int + Buttons *peripheral.ButtonSender // KbdAddr and TouchAddr are where a keyboard and a touch panel answer on // the board's I2C bus, or zero where it carries neither. diff --git a/internal/firmware/emulated/emulated_panel.go b/internal/firmware/emulated/emulated_panel.go index 1daab2b9..892937d2 100644 --- a/internal/firmware/emulated/emulated_panel.go +++ b/internal/firmware/emulated/emulated_panel.go @@ -83,3 +83,16 @@ func (e *EmulatedNode) SetMeter(channel int, raw uint16) error { e.BatRaw = raw return e.Buttons.Analog(channel, raw) } + +// Lamps is each watched lamp pin's level and how many times it changed, or +// false where nothing watches this board's lamps. +func (e *EmulatedNode) Lamps() (lit map[int]bool, changes map[int]int, watched bool) { + if e.Buttons == nil || len(e.LampPins) == 0 { + return nil, nil, false + } + lit, changes = e.Buttons.Lamps() + if lit == nil { + lit, changes = map[int]bool{}, map[int]int{} + } + return lit, changes, true +} diff --git a/internal/firmware/emulated/peripheral/buttons.go b/internal/firmware/emulated/peripheral/buttons.go index c047c25d..81b97077 100644 --- a/internal/firmware/emulated/peripheral/buttons.go +++ b/internal/firmware/emulated/peripheral/buttons.go @@ -2,6 +2,7 @@ package peripheral import ( "context" + "io" "net" "os" "sync" @@ -37,6 +38,12 @@ type ButtonSender struct { // like a working battery meter. atConnect *[msgLen]byte + // lamps is what each watched lamp pin was last driven to, and changes how + // many times it moved, so a flash between two looks is still seen. + // Nil until a machine has said anything about a lamp. + lamps map[int]bool + changes map[int]int + ln net.Listener path string port int @@ -57,6 +64,9 @@ const ( // of a board the simulation drives rather than a person: on these boards, // the divider its cell is measured through. analogTag = 'A' + // lampTag comes the other way: a lamp pin and the level the firmware + // drove it to, from a machine that watches the board's lamps. + lampTag = 'L' ) // ListenButtons starts accepting the emulator's connection at path. @@ -248,7 +258,51 @@ func (b *ButtonSender) accept() { // its first reading is a device the next write will drop anyway. _, _ = conn.Write(first[:]) } + go b.readBack(conn) + } +} + +// readBack takes what the machine says about the board's outputs: its lamps. +// A machine that says nothing - QEMU, which watches none yet - leaves this +// waiting on a read until the connection goes. +func (b *ButtonSender) readBack(conn net.Conn) { + var msg [msgLen]byte + for { + if _, err := io.ReadFull(conn, msg[:]); err != nil { + return + } + if msg[0] != lampTag { + continue + } + pin, lit := int(msg[1]), msg[2] != 0 + b.mu.Lock() + if b.lamps == nil { + b.lamps, b.changes = map[int]bool{}, map[int]int{} + } + if was, seen := b.lamps[pin]; !seen || was != lit { + b.changes[pin]++ + } + b.lamps[pin] = lit + b.mu.Unlock() + } +} + +// Lamps is each watched lamp pin's level and how many times it has changed, +// or nil when nothing has reported a lamp. +func (b *ButtonSender) Lamps() (lit map[int]bool, changes map[int]int) { + b.mu.Lock() + defer b.mu.Unlock() + if b.lamps == nil { + return nil, nil + } + lit, changes = make(map[int]bool, len(b.lamps)), make(map[int]int, len(b.changes)) + for k, v := range b.lamps { + lit[k] = v + } + for k, v := range b.changes { + changes[k] = v } + return lit, changes } var errNoButtons = errNoButtonsError{} diff --git a/internal/firmware/emulated/renode.go b/internal/firmware/emulated/renode.go index 87017859..99b5b3e4 100644 --- a/internal/firmware/emulated/renode.go +++ b/internal/firmware/emulated/renode.go @@ -54,7 +54,7 @@ lora: Radio.VirtualSX1262 @ radiospi e.IrqPin, e.NssPort, e.NssPin) wiring += renode.Panel(e.SPIBase, e.PanelPort, e.PanelWidth, e.PanelHgt, e.PanelCS, e.PanelDC, e.NssPort) - wiring += renode.Inputs(e.ButtonPort, e.HasMeter) + wiring += renode.Inputs(e.ButtonPort, e.HasMeter, e.LampPins) if err := os.WriteFile(repl, []byte(wiring), 0o644); err != nil { return "", err } diff --git a/internal/firmware/emulated/renode/inputs_test.go b/internal/firmware/emulated/renode/inputs_test.go index c9d1acd6..4b47a1a0 100644 --- a/internal/firmware/emulated/renode/inputs_test.go +++ b/internal/firmware/emulated/renode/inputs_test.go @@ -11,17 +11,17 @@ import ( // machine goes away, so one pointed at a port nobody opened would spend the // run failing to connect and logging about it. func TestABoardWithNoInputsGetsNoChannel(t *testing.T) { - if got := Inputs(0, false); got != "" { + if got := Inputs(0, false, nil); got != "" { t.Errorf("a board with no inputs was given a channel:\n%s", got) } - if got := Inputs(0, true); got != "" { + if got := Inputs(0, true, nil); got != "" { t.Errorf("a meter with no channel to arrive on was declared:\n%s", got) } } // The channel names both ports and, where the board has one, its converter. func TestTheInputChannelNamesWhatItDrives(t *testing.T) { - got := Inputs(41235, true) + got := Inputs(41235, true, nil) for _, want := range []string{ "Miscellaneous.MeshBenchInputs @ sysbus", "port: 41235", @@ -36,7 +36,7 @@ func TestTheInputChannelNamesWhatItDrives(t *testing.T) { // A board with no cell to read has no meter to point at, and naming one // that is not in the platform is a description Renode refuses to load - // which takes the whole node down rather than one row of a table. - if bare := Inputs(41235, false); strings.Contains(bare, "meter:") { + if bare := Inputs(41235, false, nil); strings.Contains(bare, "meter:") { t.Errorf("a board with no converter was pointed at one:\n%s", bare) } } @@ -85,3 +85,15 @@ func TestNoDisplayIsDescribedWhereNoneCanBe(t *testing.T) { } } } + +// Each lamp's output is connected back to the inputs peripheral, on the right +// port and numbered by flat pin, which is how it names the lamp it reports +// (#600). +func TestLampOutputsAreConnectedBack(t *testing.T) { + got := Inputs(4242, false, []int{35, 5}) + for _, want := range []string{"gpio1:\n 3 -> inputs@35", "gpio0:\n 5 -> inputs@5"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} diff --git a/internal/firmware/emulated/renode/script.go b/internal/firmware/emulated/renode/script.go index 14f8b531..fd4252be 100644 --- a/internal/firmware/emulated/renode/script.go +++ b/internal/firmware/emulated/renode/script.go @@ -178,7 +178,7 @@ const inputsBase = 0x4F000000 // the same channel as the presses and lands in the converter the firmware // reads - which is what makes an nRF52 board report a voltage rather than the // one constant the model starts with. -func Inputs(port int, hasMeter bool) string { +func Inputs(port int, hasMeter bool, lampPins []int) string { if port == 0 { return "" } @@ -191,5 +191,15 @@ inputs: Miscellaneous.MeshBenchInputs @ sysbus 0x%X if hasMeter { s += " meter: saadc\n" } + // Each lamp's output, connected back to the same peripheral, which + // reports its level to the workbench (#600). Numbered by flat pin, which + // is how the peripheral names it in the message. + for _, pin := range lampPins { + port, local := "gpio0", pin + if pin >= 32 { + port, local = "gpio1", pin-32 + } + s += fmt.Sprintf("\n%s:\n %d -> inputs@%d\n", port, local, pin) + } return s } diff --git a/internal/sim/engine/nodeparts.go b/internal/sim/engine/nodeparts.go index f15e15b2..654df1ed 100644 --- a/internal/sim/engine/nodeparts.go +++ b/internal/sim/engine/nodeparts.go @@ -54,7 +54,17 @@ func withParts(node *emulated.EmulatedNode, board hw.Board, spec scenario.Node, touch = part.Addr } meter, hasMeter := batteryMeter(board) - if len(pins) > 0 || kbd != 0 || touch != 0 || hasMeter { + // Lamps are watched where the machine can follow an output, which is + // Renode; QEMU's half needs a device in its fork (#600). + var lamps []int + if board.Renode != nil { + for _, part := range p.PartsOfKind(hw.Lamp) { + if part.Pin != hw.PinNone { + lamps = append(lamps, part.Pin) + } + } + } + if len(pins) > 0 || kbd != 0 || touch != 0 || hasMeter || len(lamps) > 0 { // Renode cannot dial a socket file, so its boards get the same // channel on a loopback port. Which one a board uses is decided // here and nowhere else: everything above and below this line is @@ -67,6 +77,7 @@ func withParts(node *emulated.EmulatedNode, board hw.Board, spec scenario.Node, node.ButtonPath = bs.Path() node.ButtonPort = bs.Port() node.ButtonPins = pins + node.LampPins = lamps node.KbdAddr, node.TouchAddr = kbd, touch if hasMeter { node.BatChannel, node.BatRaw, node.HasMeter = meter.channel, meter.raw, true @@ -153,6 +164,12 @@ func withParts(node *emulated.EmulatedNode, board hw.Board, spec scenario.Node, return node, nil } +// LampsWatched reports whether the machine that would run this board reports +// its lamps' levels: Renode does, by following each lamp's output; QEMU does +// not yet. Beside ScreenModelled for the same reason: the board view's +// Observed column is the one a person trusts. +func LampsWatched(board hw.Board) bool { return board.Renode != nil } + // ScreenModelled reports whether the machine that would run this board has a // display model to put behind its declared screen. // diff --git a/internal/ui/comp/boardparts.go b/internal/ui/comp/boardparts.go index bb74fc3e..21e68615 100644 --- a/internal/ui/comp/boardparts.go +++ b/internal/ui/comp/boardparts.go @@ -14,6 +14,7 @@ package comp import ( + "github.com/MeshBench/meshbench/internal/app/state" "image" "image/color" @@ -96,9 +97,10 @@ func (c *BoardControls) For(pin int) *widget.Clickable { return b } -// Lamps draws the board's lights, and says where a board has none. +// Lamps draws the board's lights, and says where a board has none. watched +// is each watched lamp's level by pin, or nil where nothing watches them. func (c *BoardControls) Lamps(t *theme.Theme, gtx layout.Context, - panel *hw.Panel) layout.Dimensions { + panel *hw.Panel, watched map[int]state.Lamp) layout.Dimensions { parts := panel.PartsOfKind(hw.Lamp) if len(parts) == 0 { @@ -108,18 +110,26 @@ func (c *BoardControls) Lamps(t *theme.Theme, gtx layout.Context, for i := range parts { part := parts[i] children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return lamp(t, gtx, part) + return lamp(t, gtx, part, watched) })) children = append(children, layout.Rigid(layout.Spacer{Width: t.Sp.S}.Layout)) } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, children...) } -// lamp is one light. Whether it is lit is not modelled: the pin is declared and -// nothing watches it. Drawn as an outline rather than as an unlit lamp, because -// "off" and "not modelled" are different facts and the second is about us. -func lamp(t *theme.Theme, gtx layout.Context, part hw.Part) layout.Dimensions { +// lamp is one light. Where nothing watches its pin it is drawn as an outline +// rather than as an unlit lamp, because "off" and "not modelled" are different +// facts and the second is about us. Watched, it is lit or unlit as the firmware +// last drove it. +func lamp(t *theme.Theme, gtx layout.Context, part hw.Part, watched map[int]state.Lamp) layout.Dimensions { d := gtx.Dp(unit.Dp(10)) + fill := t.P.Panel + if watched != nil { + fill = t.P.Rule + if watched[part.Pin].Lit { + fill = t.P.ScreenLit + } + } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { size := image.Pt(d, d) @@ -129,7 +139,7 @@ func lamp(t *theme.Theme, gtx layout.Context, part hw.Part) layout.Dimensions { }.Op(gtx.Ops)) inner := image.Rect(1, 1, d-1, d-1) ir := (d - 2) / 2 - paint.FillShape(gtx.Ops, t.P.Panel, clip.RRect{ + paint.FillShape(gtx.Ops, fill, clip.RRect{ Rect: inner, NE: ir, NW: ir, SE: ir, SW: ir, }.Op(gtx.Ops)) return layout.Dimensions{Size: size} diff --git a/internal/ui/workbench/boardview/rail.go b/internal/ui/workbench/boardview/rail.go index e7ee7563..b15cdd1f 100644 --- a/internal/ui/workbench/boardview/rail.go +++ b/internal/ui/workbench/boardview/rail.go @@ -56,7 +56,7 @@ func (p *Panel) rail(t *theme.Theme, gtx layout.Context, b hw.Board, } return layout.Inset{Bottom: t.Sp.XXS}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return p.parts.Lamps(t, gtx, b.Hardware) + return p.parts.Lamps(t, gtx, b.Hardware, lampsOf(st)) }) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -229,3 +229,12 @@ func indexItems(rows []Row) []indexItem { } return out } + +// lampsOf is a node's watched lamps, or nil where there is no node or nothing +// watches them. +func lampsOf(st *state.NodeStat) map[int]state.Lamp { + if st == nil { + return nil + } + return st.Lamps +} diff --git a/internal/ui/workbench/boardview/rows.go b/internal/ui/workbench/boardview/rows.go index 996a5c33..0d0e8c4d 100644 --- a/internal/ui/workbench/boardview/rows.go +++ b/internal/ui/workbench/boardview/rows.go @@ -282,6 +282,26 @@ func partRow(b hw.Board, part hw.Part, st *state.NodeStat) Row { r.Verdict, r.Observed = NotModelled, "no watcher on this pin" r.Why = "The pin is declared and nothing watches it, so \"off\" and " + "\"not modelled\" would look the same. This says which it is." + if engine.LampsWatched(b) { + r.Why = "The pin's output is followed from inside the machine, so " + + "this is the level the firmware drove it to, and how often." + switch { + case st == nil || !st.Running: + r.Verdict, r.Observed = Undeclared, "not powered" + case st.Lamps == nil: + r.Verdict, r.Observed = NotModelled, "no watcher on this pin" + default: + l, seen := st.Lamps[part.Pin] + switch { + case !seen: + r.Verdict, r.Observed = Silent, "never driven" + case l.Lit: + r.Verdict, r.Observed = Agrees, fmt.Sprintf("lit, changed %d times", l.Changes) + default: + r.Verdict, r.Observed = Agrees, fmt.Sprintf("off, changed %d times", l.Changes) + } + } + } case hw.Ball: r.Why = "Four lines that pulse as the ball turns; the firmware counts " + "the changes rather than reading a position." diff --git a/internal/ui/workbench/nodeview/nodewindowhardware.go b/internal/ui/workbench/nodeview/nodewindowhardware.go index cd6b3a48..8e98a061 100644 --- a/internal/ui/workbench/nodeview/nodewindowhardware.go +++ b/internal/ui/workbench/nodeview/nodewindowhardware.go @@ -181,7 +181,7 @@ func (p *WindowPanel) device(t *theme.Theme, gtx layout.Context, return comp.Card(t, "", func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return p.board.Lamps(t, gtx, panel) + return p.board.Lamps(t, gtx, panel, lampsOf(st)) }), layout.Rigid(layout.Spacer{Height: t.Sp.S}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -451,3 +451,12 @@ func partFact(part hw.Part) string { } return fmt.Sprintf("pin %d", part.Pin) } + +// lampsOf is a node's watched lamps, or nil where there is no node or nothing +// watches them. +func lampsOf(st *state.NodeStat) map[int]state.Lamp { + if st == nil { + return nil + } + return st.Lamps +} diff --git a/tools/renode/peripherals/MeshBenchInputs.cs b/tools/renode/peripherals/MeshBenchInputs.cs index f9372c5a..78eb00da 100644 --- a/tools/renode/peripherals/MeshBenchInputs.cs +++ b/tools/renode/peripherals/MeshBenchInputs.cs @@ -16,9 +16,10 @@ // TCP rather than a socket file because Renode offers nothing else, which is // the same reason its console is on a port. // -// What it does not do is watch. Lamps are outputs, and reporting one means -// following a GPIO the other way; neither machine does that yet, so both say -// so rather than one of them guessing. +// It also watches the board's lamps (#600). A lamp is an output the firmware +// drives, and the platform description connects each declared lamp pin's +// output to this peripheral; every change of level goes back over the same +// socket as an eight-byte message tagged 'L', with the pin and the level. // using System; using System.Net.Sockets; @@ -42,7 +43,7 @@ public interface IMeshBenchMeter : IPeripheral // .repl registers a peripheral and there is no lighter way to declare one. // Nothing reads or writes it: the guest does not know it is there, which is // correct - a person pressing a button is not something firmware can query. - public class MeshBenchInputs : IDoubleWordPeripheral, IKnownSize, IDisposable + public class MeshBenchInputs : IDoubleWordPeripheral, IKnownSize, IDisposable, IGPIOReceiver { // The two GPIO ports of an nRF52840 and, optionally, the converter its // cell is read through. Ports are required: a board with no buttons @@ -65,6 +66,46 @@ public void WriteDoubleWord(long offset, uint value) { } + // A lamp pin changed level. The number is the flat pin the platform + // description connected it as. Sent as it happens; a level seen before + // the workbench is connected is sent when it connects. + public void OnGPIO(int number, bool value) + { + lock(lampLock) + { + bool was; + if(lamps.TryGetValue(number, out was) && was == value) + { + return; + } + lamps[number] = value; + } + SendLamp(number, value); + } + + private void SendLamp(int pin, bool level) + { + var c = client; + if(c == null) + { + return; + } + var message = new byte[MessageLength]; + message[0] = (byte)'L'; + message[1] = (byte)pin; + message[2] = (byte)(level ? 1 : 0); + lock(sendLock) + { + try + { + c.GetStream().Write(message, 0, message.Length); + } + catch(Exception) + { + } + } + } + public void Reset() { // Deliberately nothing. A reset is the board restarting, and the @@ -120,6 +161,17 @@ private void Serve() continue; } this.Log(LogLevel.Info, "inputs: connected on port {0}", port); + // Whatever the lamps already are, so a light lit at boot is + // not lost to a workbench that connected after it. + var seen = new System.Collections.Generic.List>(); + lock(lampLock) + { + seen.AddRange(lamps); + } + foreach(var kv in seen) + { + SendLamp(kv.Key, kv.Value); + } Read(client.GetStream()); if(running) { @@ -205,6 +257,11 @@ private void Drive(int pin, bool level) line.OnGPIO(pin % PinsPerPort, level); } + private readonly System.Collections.Generic.Dictionary lamps = + new System.Collections.Generic.Dictionary(); + private readonly object lampLock = new object(); + private readonly object sendLock = new object(); + private const int MessageLength = 8; private const int PinsPerPort = 32; private const int RetryMs = 200;