From ef0a5c914710b485ed85c0f5f82ade6150f6ea01 Mon Sep 17 00:00:00 2001 From: R0ck Date: Sun, 25 Oct 2026 01:55:38 +0000 Subject: [PATCH 1/2] renode: watch a board's lamps Each declared lamp pin's output is connected back to MeshBenchInputs, which sends an 'L' message with the pin and level over the inputs socket. The workbench side reads the socket back, keeps each lamp's level and change count, and they reach NodeStat, nodes.stats and the board view, which draws a watched lamp lit or unlit. For #600. --- CHANGELOG.md | 5 ++ internal/app/session/nodestats.go | 10 +++ internal/app/session/statrows.go | 16 ++++- internal/app/state/network.go | 11 ++++ internal/firmware/emulated/emulated_node.go | 3 + internal/firmware/emulated/emulated_panel.go | 13 ++++ .../firmware/emulated/peripheral/buttons.go | 54 +++++++++++++++ internal/firmware/emulated/renode.go | 2 +- .../firmware/emulated/renode/inputs_test.go | 20 ++++-- internal/firmware/emulated/renode/script.go | 12 +++- internal/sim/engine/nodeparts.go | 19 +++++- internal/ui/comp/boardparts.go | 26 +++++--- internal/ui/workbench/boardview/rail.go | 11 +++- internal/ui/workbench/boardview/rows.go | 20 ++++++ .../workbench/nodeview/nodewindowhardware.go | 11 +++- tools/renode/peripherals/MeshBenchInputs.cs | 65 +++++++++++++++++-- 16 files changed, 276 insertions(+), 22 deletions(-) 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..14fd21ea 100644 --- a/internal/firmware/emulated/emulated_node.go +++ b/internal/firmware/emulated/emulated_node.go @@ -121,6 +121,9 @@ type EmulatedNode struct { // to dial a socket file. Exactly one of the two is set. ButtonPort int ButtonPins []int + // 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 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; From 81fa63b40e505d6527f3ef23756e11e758b6932e Mon Sep 17 00:00:00 2001 From: R0ck Date: Sun, 25 Oct 2026 01:55:42 +0000 Subject: [PATCH 2/2] emulated_node: gofmt --- internal/firmware/emulated/emulated_node.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/firmware/emulated/emulated_node.go b/internal/firmware/emulated/emulated_node.go index 14fd21ea..77c78e8d 100644 --- a/internal/firmware/emulated/emulated_node.go +++ b/internal/firmware/emulated/emulated_node.go @@ -124,7 +124,7 @@ type EmulatedNode struct { // 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 + 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.