Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions internal/app/session/nodestats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion internal/app/session/statrows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
}
Expand Down
11 changes: 11 additions & 0 deletions internal/app/state/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion internal/firmware/emulated/emulated_node.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions internal/firmware/emulated/emulated_panel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
54 changes: 54 additions & 0 deletions internal/firmware/emulated/peripheral/buttons.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package peripheral

import (
"context"
"io"
"net"
"os"
"sync"
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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{}
Expand Down
2 changes: 1 addition & 1 deletion internal/firmware/emulated/renode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
20 changes: 16 additions & 4 deletions internal/firmware/emulated/renode/inputs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}
}
12 changes: 11 additions & 1 deletion internal/firmware/emulated/renode/script.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}
Expand All @@ -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
}
19 changes: 18 additions & 1 deletion internal/sim/engine/nodeparts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
//
Expand Down
26 changes: 18 additions & 8 deletions internal/ui/comp/boardparts.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
package comp

import (
"github.com/MeshBench/meshbench/internal/app/state"
"image"
"image/color"

Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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}
Expand Down
11 changes: 10 additions & 1 deletion internal/ui/workbench/boardview/rail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Loading