From 66f02edfb2dca89593fc788e61b7a247672bb07a Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 12:13:42 +0200 Subject: [PATCH] feat(modules): port renode module (AUR + arduino-sim + wokwi-cli) to Go --- .../modules/renode/assets/arduino-sim.tmpl | 54 +++++ internal/modules/renode/renode.go | 187 ++++++++++++++++++ internal/modules/renode/renode_test.go | 145 ++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 internal/modules/renode/assets/arduino-sim.tmpl create mode 100644 internal/modules/renode/renode.go create mode 100644 internal/modules/renode/renode_test.go diff --git a/internal/modules/renode/assets/arduino-sim.tmpl b/internal/modules/renode/assets/arduino-sim.tmpl new file mode 100644 index 0000000..ef5c0ec --- /dev/null +++ b/internal/modules/renode/assets/arduino-sim.tmpl @@ -0,0 +1,54 @@ +#!/bin/bash +set -euo pipefail + +usage() { + echo "Usage: arduino-sim [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" <ds or gdb → target remote :3333" +echo "" + +renode --disable-xwt "$RESC" +rm -f "$RESC" diff --git a/internal/modules/renode/renode.go b/internal/modules/renode/renode.go new file mode 100644 index 0000000..385f9e3 --- /dev/null +++ b/internal/modules/renode/renode.go @@ -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 +} diff --git a/internal/modules/renode/renode_test.go b/internal/modules/renode/renode_test.go new file mode 100644 index 0000000..2d37b85 --- /dev/null +++ b/internal/modules/renode/renode_test.go @@ -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{}