feat(modules): port renode module (AUR + arduino-sim + wokwi-cli) to Go
This commit is contained in:
54
internal/modules/renode/assets/arduino-sim.tmpl
Normal file
54
internal/modules/renode/assets/arduino-sim.tmpl
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: arduino-sim <firmware.elf> [platform]"
|
||||||
|
echo ""
|
||||||
|
echo "Launch Renode with a GDB server for debugging firmware."
|
||||||
|
echo "Connect nvim-dap or gdb to localhost:3333"
|
||||||
|
echo ""
|
||||||
|
echo "Platforms:"
|
||||||
|
{{range .Platforms}} echo " {{.Name}} {{.Machine}}"
|
||||||
|
{{end}} exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -lt 1 ]] && usage
|
||||||
|
|
||||||
|
ELF="$1"
|
||||||
|
PLATFORM="${2:-{{(index .Platforms 0).Name}}}"
|
||||||
|
|
||||||
|
[[ ! -f "$ELF" ]] && echo "Error: $ELF not found" && exit 1
|
||||||
|
|
||||||
|
case "$PLATFORM" in
|
||||||
|
{{range .Platforms}} {{.Name}})
|
||||||
|
RENODE_PLATFORM="{{.ReplPath}}"
|
||||||
|
MACHINE="{{.Machine}}"
|
||||||
|
;;
|
||||||
|
{{end}} *)
|
||||||
|
echo "Unknown platform: $PLATFORM"
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
RESC=$(mktemp /tmp/renode-XXXXXX.resc)
|
||||||
|
cat >"$RESC" <<EOF
|
||||||
|
using sysbus
|
||||||
|
|
||||||
|
mach create "$MACHINE"
|
||||||
|
machine LoadPlatformDescription @$RENODE_PLATFORM
|
||||||
|
|
||||||
|
sysbus LoadELF @$(realpath "$ELF")
|
||||||
|
|
||||||
|
machine StartGdbServer 3333
|
||||||
|
|
||||||
|
showAnalyzer sysbus.uart0 Antmicro.Renode.Analyzers.LoggingUartAnalyzer
|
||||||
|
|
||||||
|
start
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Starting Renode ($MACHINE) with GDB server on :3333"
|
||||||
|
echo "Connect debugger: nvim → <leader>ds or gdb → target remote :3333"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
renode --disable-xwt "$RESC"
|
||||||
|
rm -f "$RESC"
|
||||||
187
internal/modules/renode/renode.go
Normal file
187
internal/modules/renode/renode.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
// Package renode installs the Renode emulator (AUR), arm-none-eabi-gdb,
|
||||||
|
// the arduino-sim helper script, and wokwi-cli.
|
||||||
|
package renode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/arduino-sim.tmpl
|
||||||
|
var assetsFS embed.FS
|
||||||
|
|
||||||
|
// Module installs Renode and adjacent emulator tooling.
|
||||||
|
type Module struct {
|
||||||
|
cfg config.RenodeConfig
|
||||||
|
pm platform.PackageManager
|
||||||
|
aur platform.AURHelper
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a renode Module.
|
||||||
|
func New(cfg config.RenodeConfig, pm platform.PackageManager, aur platform.AURHelper) Module {
|
||||||
|
return Module{cfg: cfg, pm: pm, aur: aur}
|
||||||
|
}
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
RenodeInstalled bool
|
||||||
|
GdbInstalled bool
|
||||||
|
SimScriptCurrent bool
|
||||||
|
WokwiInstalled bool
|
||||||
|
RenderedSim []byte
|
||||||
|
SimPath string
|
||||||
|
WokwiPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta returns the module metadata.
|
||||||
|
func (Module) Meta() module.Meta {
|
||||||
|
return module.Meta{ID: "renode", Name: "Renode emulator", Description: "Install Renode, ARM GDB, arduino-sim helper, and Wokwi CLI."}
|
||||||
|
}
|
||||||
|
|
||||||
|
func binPath() (string, error) {
|
||||||
|
h, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(h, ".local", "bin"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Module) renderSim() ([]byte, error) {
|
||||||
|
tmplBytes, err := assetsFS.ReadFile("assets/arduino-sim.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tmpl, err := template.New("sim").Parse(string(tmplBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, m.cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect inspects the system and returns current state.
|
||||||
|
func (m Module) Detect(ctx context.Context) (module.State, error) {
|
||||||
|
bin, err := binPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := state{
|
||||||
|
SimPath: filepath.Join(bin, "arduino-sim"),
|
||||||
|
WokwiPath: filepath.Join(bin, "wokwi-cli"),
|
||||||
|
}
|
||||||
|
if ok, err := m.pm.IsInstalled(ctx, "renode-bin"); err == nil && ok {
|
||||||
|
s.RenodeInstalled = true
|
||||||
|
}
|
||||||
|
if ok, err := m.pm.IsInstalled(ctx, "arm-none-eabi-gdb"); err == nil && ok {
|
||||||
|
s.GdbInstalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := m.renderSim()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.RenderedSim = rendered
|
||||||
|
if cur, err := os.ReadFile(s.SimPath); err == nil && bytes.Equal(cur, rendered) {
|
||||||
|
s.SimScriptCurrent = true
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(s.WokwiPath); err == nil {
|
||||||
|
s.WokwiInstalled = true
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan computes the set of actions needed to converge to desired state.
|
||||||
|
func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) {
|
||||||
|
s, _ := cur.(state)
|
||||||
|
var actions []module.Action
|
||||||
|
|
||||||
|
if !s.RenodeInstalled {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "ensure yay installed",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) },
|
||||||
|
})
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "yay install renode-bin",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.aur.Install(ctx, []string{"renode-bin"}) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !s.GdbInstalled {
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "pacman install arm-none-eabi-gdb",
|
||||||
|
Kind: module.ActionPackageInstall,
|
||||||
|
Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"arm-none-eabi-gdb"}) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !s.SimScriptCurrent {
|
||||||
|
rendered := append([]byte{}, s.RenderedSim...)
|
||||||
|
path := s.SimPath
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "write arduino-sim helper",
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
return platform.WriteUserFile(path, rendered, 0o755)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !s.WokwiInstalled {
|
||||||
|
url := m.cfg.WokwiURL
|
||||||
|
dst := s.WokwiPath
|
||||||
|
actions = append(actions, module.Action{
|
||||||
|
Description: "download wokwi-cli " + m.cfg.WokwiVersion,
|
||||||
|
Kind: module.ActionFileWrite,
|
||||||
|
Exec: func(ctx context.Context) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return platform.Download(ctx, url, dst, 0o755)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return module.Plan{Actions: actions}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply executes the planned actions in order.
|
||||||
|
func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error {
|
||||||
|
for i, a := range p.Actions {
|
||||||
|
r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description)
|
||||||
|
if err := a.Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify re-Detects and errors if state hasn't converged.
|
||||||
|
func (m Module) Verify(ctx context.Context) error {
|
||||||
|
st, err := m.Detect(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if !s.RenodeInstalled {
|
||||||
|
return fmt.Errorf("renode-bin not installed")
|
||||||
|
}
|
||||||
|
if !s.GdbInstalled {
|
||||||
|
return fmt.Errorf("arm-none-eabi-gdb not installed")
|
||||||
|
}
|
||||||
|
if !s.SimScriptCurrent {
|
||||||
|
return fmt.Errorf("arduino-sim script drift")
|
||||||
|
}
|
||||||
|
if !s.WokwiInstalled {
|
||||||
|
return fmt.Errorf("wokwi-cli missing")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
145
internal/modules/renode/renode_test.go
Normal file
145
internal/modules/renode/renode_test.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package renode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||||
|
"github.com/MDietermann/ArduinoArchSetup/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeRunner struct {
|
||||||
|
runCalls, outCalls [][]string
|
||||||
|
installed map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
|
||||||
|
f.runCalls = append(f.runCalls, append([]string{name}, args...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) {
|
||||||
|
f.outCalls = append(f.outCalls, append([]string{name}, args...))
|
||||||
|
if name == "pacman" && len(args) == 2 && args[0] == "-Q" {
|
||||||
|
if f.installed[args[1]] {
|
||||||
|
return []byte("ok"), nil
|
||||||
|
}
|
||||||
|
return nil, errExit1
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exitOne struct{}
|
||||||
|
|
||||||
|
func (exitOne) Error() string { return "exit 1" }
|
||||||
|
|
||||||
|
var errExit1 = exitOne{}
|
||||||
|
|
||||||
|
type nopReporter struct{}
|
||||||
|
|
||||||
|
func (nopReporter) Progress(string, int, int, string) {}
|
||||||
|
func (nopReporter) Log(string, string) {}
|
||||||
|
|
||||||
|
func sampleCfg(url string) config.RenodeConfig {
|
||||||
|
return config.RenodeConfig{
|
||||||
|
WokwiVersion: "0.28.1",
|
||||||
|
WokwiURL: url,
|
||||||
|
Platforms: []config.RenodePlatform{
|
||||||
|
{Name: "stm32f4", ReplPath: "platforms/boards/stm32f4_discovery.repl", Machine: "STM32F4_Discovery"},
|
||||||
|
{Name: "rp2040", ReplPath: "platforms/cpus/rp2040.repl", Machine: "RP2040"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectNothingInstalled(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{}}
|
||||||
|
m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r))
|
||||||
|
st, err := m.Detect(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := st.(state)
|
||||||
|
if s.RenodeInstalled || s.GdbInstalled || s.SimScriptCurrent || s.WokwiInstalled {
|
||||||
|
t.Fatalf("want all absent, got %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectAllInstalled(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
os.MkdirAll(filepath.Join(home, ".local", "bin"), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(home, ".local", "bin", "wokwi-cli"), []byte("x"), 0o755)
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"renode-bin": true, "arm-none-eabi-gdb": true}}
|
||||||
|
m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r))
|
||||||
|
// Apply once to seed the sim script, then re-detect.
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
_ = m.Apply(context.Background(), p, nopReporter{})
|
||||||
|
st2, _ := m.Detect(context.Background())
|
||||||
|
p2, _ := m.Plan(context.Background(), st2)
|
||||||
|
if !p2.IsEmpty() {
|
||||||
|
t.Fatalf("second plan not empty: %+v", p2.Actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyRendersSimScriptAndDownloadsWokwi(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Write([]byte("wokwi-binary"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
r := &fakeRunner{installed: map[string]bool{"yay": true}}
|
||||||
|
m := New(sampleCfg(srv.URL), platform.NewPacman(r), platform.NewYay(r))
|
||||||
|
st, _ := m.Detect(context.Background())
|
||||||
|
p, _ := m.Plan(context.Background(), st)
|
||||||
|
if err := m.Apply(context.Background(), p, nopReporter{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// arduino-sim rendered with both platforms
|
||||||
|
sim, err := os.ReadFile(filepath.Join(home, ".local", "bin", "arduino-sim"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("arduino-sim not written: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(sim), "stm32f4)") {
|
||||||
|
t.Errorf("stm32f4 branch missing: %s", sim)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(sim), "rp2040)") {
|
||||||
|
t.Errorf("rp2040 branch missing: %s", sim)
|
||||||
|
}
|
||||||
|
info, _ := os.Stat(filepath.Join(home, ".local", "bin", "arduino-sim"))
|
||||||
|
if info.Mode().Perm() != 0o755 {
|
||||||
|
t.Errorf("arduino-sim mode %v", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
|
||||||
|
// wokwi-cli downloaded
|
||||||
|
wokwi, err := os.ReadFile(filepath.Join(home, ".local", "bin", "wokwi-cli"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("wokwi-cli missing: %v", err)
|
||||||
|
}
|
||||||
|
if string(wokwi) != "wokwi-binary" {
|
||||||
|
t.Errorf("wokwi-cli content %q", wokwi)
|
||||||
|
}
|
||||||
|
|
||||||
|
// yay install renode-bin called
|
||||||
|
sawYay := false
|
||||||
|
for _, c := range r.runCalls {
|
||||||
|
if c[0] == "yay" && len(c) >= 2 && c[1] == "-S" {
|
||||||
|
sawYay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawYay {
|
||||||
|
t.Fatalf("yay install missing; calls=%v", r.runCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ module.Module = Module{}
|
||||||
Reference in New Issue
Block a user