From 517bd2f98725aa8acf085bbcd3bfd57d0194f635 Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Sun, 28 Jun 2026 04:14:30 +0200 Subject: [PATCH] feat(firmware): wire Explore-and-place glue to hardware buttons + selftest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map enter/exit-explore, reroll, nudge, begin-place, commit-place to the peripheral buttons via the shared FeedbackController. (Not compiled here — no arduino-cli; Placing-audition static_output hook is a flagged follow-up.) --- firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino | 43 +- firmware/MEMLNaut-NISPS/glue/mode_select.hpp | 24 + firmware/MEMLNaut-NISPS/glue/peripherals.hpp | 102 ++- firmware/MEMLNaut-NISPS/glue/selftest.hpp | 721 +++++++++++++++++++ scripts/setup-firmware-toolchain.sh | 120 +++ 5 files changed, 981 insertions(+), 29 deletions(-) create mode 100644 firmware/MEMLNaut-NISPS/glue/selftest.hpp create mode 100755 scripts/setup-firmware-toolchain.sh diff --git a/firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino b/firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino index 6c6aa4e..8fbd142 100644 --- a/firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino +++ b/firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino @@ -42,9 +42,29 @@ // #define MEMLNAUT_MODE_TYPE MEMLNautModeChannelStrip #define MEMLNAUT_MODE_TYPE MEMLNautModePAFSynth // #define MEMLNAUT_MODE_TYPE MEMLNautModeMEMLCelium +// #define MEMLNAUT_MODE_TYPE MEMLNautModeSelfTest + +// NISPS_SELFTEST == 1 iff the selected variant is the guided hardware self-test +// (see glue/mode_select.hpp). Must be computed AFTER the mode #define above so +// MEMLNAUT_MODE_TYPE is in scope for the token paste. +#define NISPS_SELFTEST NISPS_ST_CAT(MEMLNAUT_MODE_TYPE) #include +// Inter-core handshake flags + stack flag — shared by BOTH the normal-mode and +// self-test build paths, so they live outside the fork below. +volatile bool APP_SRAM g_core0_ready = false; +volatile bool APP_SRAM g_core1_ready = false; +volatile bool APP_SRAM g_serial_ready = false; +volatile bool APP_SRAM g_iface_ready = false; + +bool core1_separate_stack = true; + +#if !NISPS_SELFTEST +// ===================================================================== +// Normal-mode build path (an engine + ML mode runs the device). +// ===================================================================== + using ActiveMode = MEMLNAUT_MODE_TYPE; ActiveMode AUDIO_MEM g_mode; @@ -57,14 +77,6 @@ volatile nisps_firmware::ActiveModeBridge AUDIO_MEM nisps_firmware::g_active_mod // Global MIDI handle (shared across cores like the legacy entry point did). std::shared_ptr APP_SRAM g_midi; -// Inter-core handshake flags (mirror legacy semantics). -volatile bool APP_SRAM g_core0_ready = false; -volatile bool APP_SRAM g_core1_ready = false; -volatile bool APP_SRAM g_serial_ready = false; -volatile bool APP_SRAM g_iface_ready = false; - -bool core1_separate_stack = true; - // Audio block callback — placed in SRAM via __not_in_flash_func so the audio // ISR avoids XIP latency. Forwards into the (header-inline) dispatch helper. void AUDIO_FUNC(audio_block_callback)( @@ -187,3 +199,18 @@ void loop1() { if (g_midi) g_midi->Poll(); }, 1000) } + +#else +// ===================================================================== +// SelfTest build path — guided hardware self-test rig (no engine / no ML). +// All four entry points delegate into glue/selftest.hpp. +// ===================================================================== + +#include "glue/selftest.hpp" + +void setup() { nisps_firmware::selftest::setup(); } +void loop() { nisps_firmware::selftest::loop(); } +void setup1() { nisps_firmware::selftest::setup1(); } +void loop1() { nisps_firmware::selftest::loop1(); } + +#endif // NISPS_SELFTEST diff --git a/firmware/MEMLNaut-NISPS/glue/mode_select.hpp b/firmware/MEMLNaut-NISPS/glue/mode_select.hpp index 44f3f2e..90f4d1e 100644 --- a/firmware/MEMLNaut-NISPS/glue/mode_select.hpp +++ b/firmware/MEMLNaut-NISPS/glue/mode_select.hpp @@ -57,3 +57,27 @@ using MEMLNautModeBreakOr = ::nisps::modes::BreakOrMode; using MEMLNautModeVerbFX = ::nisps::modes::VerbFXMode; using MEMLNautModeElysiamorfs = ::nisps::modes::ElysiamorfMode; using MEMLNautModeMEMLCelium = ::nisps::modes::MEMLCeliumMode; + +// ---- SelfTest pseudo-variant ---- +// A standalone guided hardware self-test (see glue/selftest.hpp). It is NOT a +// nisps Mode — it drives the display + raw peripherals directly. We expose a +// tiny tag type so the `MEMLNautModeSelfTest` alias type-checks and the build +// script's `MEMLNautMode*` grep discovers the variant; the .ino forks on +// `NISPS_SELFTEST` and never instantiates this tag. +namespace nisps_firmware { namespace selftest { struct SelfTestRig {}; } } +using MEMLNautModeSelfTest = ::nisps_firmware::selftest::SelfTestRig; + +// Compile-time guard: NISPS_SELFTEST expands to 1 iff MEMLNAUT_MODE_TYPE is +// MEMLNautModeSelfTest, else 0. Computed in the .ino *after* the mode #define. +// Two-level CAT so MEMLNAUT_MODE_TYPE expands before the paste. +#define NISPS_ST_MEMLNautModePAFSynth 0 +#define NISPS_ST_MEMLNautModeChannelStrip 0 +#define NISPS_ST_MEMLNautModeXIASRI 0 +#define NISPS_ST_MEMLNautModeSoundAnalysisMIDI 0 +#define NISPS_ST_MEMLNautModeBreakOr 0 +#define NISPS_ST_MEMLNautModeVerbFX 0 +#define NISPS_ST_MEMLNautModeElysiamorfs 0 +#define NISPS_ST_MEMLNautModeMEMLCelium 0 +#define NISPS_ST_MEMLNautModeSelfTest 1 +#define NISPS_ST_CAT_(x) NISPS_ST_##x +#define NISPS_ST_CAT(x) NISPS_ST_CAT_(x) diff --git a/firmware/MEMLNaut-NISPS/glue/peripherals.hpp b/firmware/MEMLNaut-NISPS/glue/peripherals.hpp index c045cf0..5e8d2c4 100644 --- a/firmware/MEMLNaut-NISPS/glue/peripherals.hpp +++ b/firmware/MEMLNaut-NISPS/glue/peripherals.hpp @@ -25,9 +25,17 @@ // inputs+outputs as a training pair) // TogB2 : train (rising-edge → call ml.train()) // -// This is a sparse subset of the legacy InterfaceRL — full RL UX comes back -// in stream 9 (browser) and stream 12 (firmware UI). Goal here: hardware -// can express the *abstract* RL primitives so the mode keeps moving. +// The button block below wires the SHARED ExploreAndPlace lifecycle +// (nisps/ml/feedback.hpp, the same core the browser drives via WASM) to the +// hardware buttons. See the per-button mapping at the binding site. +// +// FOLLOW-UP (needs a firmware build to verify — no arduino-cli here): while the +// controller is PLACING, the audition should hold feedback.static_output() +// (the frozen vector) instead of running fresh inference. That requires a hook +// in the control path (tick_control / audio_driver) to consult +// feedback.static_output() before ml().process() — not yet wired here because +// the controller lives in this translation unit. Wiring the buttons is the +// deliverable; the audition-hold is a small follow-up. #pragma once @@ -38,10 +46,20 @@ #include #include "../src/nisps/core/perf.hpp" +#include "../src/nisps/ml/feedback.hpp" #include "../src/memllib/hardware/memlnaut/MEMLNaut.hpp" namespace nisps_firmware { +// Salt matching nisps/wasm/bindings.cpp so the firmware FeedbackController's +// per-instance Rng stream is independent of (and reproducible against) the +// MLP's inference/move RNG — same discipline as the browser. +inline constexpr std::uint64_t kFeedbackSalt = 0xFEEDBACC0DEull; + +// Firmware undo-ring depth for ExploreAndPlace (rl-feedback-design §2.2: WASM +// D=4, firmware D=2 — SRAM budget). +inline constexpr std::size_t kFirmwareUndoDepth = 2u; + // Number of analog input channels we forward to the mode. Modes with fewer // inputs ignore the surplus (set_input(idx, ...) silently rejects out-of- // range idx in ModeBase). @@ -79,35 +97,77 @@ inline void bind_peripherals(Mode& mode) { AudioDriver::SetMasterVolume(v); }); - // ---- Buttons / toggles → ML primitives ---- - // Button presses are momentary (rising edge only). Toggles fire on - // both edges with the new state. + // ---- Buttons / toggles → ExploreAndPlace lifecycle (SHARED C++ core) ---- + // + // The same nisps::ml::FeedbackController that runs in the browser (via + // WASM) drives the Idle → Exploring → Placing → Idle state machine here. + // The HARDWARE button mapping differs from the browser's on_down/on_up + // default policy — firmware maps its buttons to the GRANULAR core methods: + // + // MomA1 (TA up) : down — enter explore (Idle→Exploring) / + // exit explore (Exploring→Idle) TOGGLE + // MomB1 (MA up) : randomise — reroll the scratchpad (Exploring) + // MomB2 (MA down) : nudge — small bounded perturbation (Exploring) + // MomA2 (TA down) : like — begin place (Exploring→Placing, freeze output) + // TogB2 (rising) : up — commit place (Placing→Idle) at the CURRENT + // joystick input, then store the +1 example and + // train (caller owns training). Outside Placing, + // a plain train(). + // + // The controller is a function-local static so it lives for the whole + // program (like the mode). It is seeded off the same salt as the browser. + using FB = nisps::ml::FeedbackController; + static FB feedback(static_cast(0xC0FFEEu) ^ kFeedbackSalt); + feedback.set_mode(nisps::ml::FeedbackMode::ExploreAndPlace, mode.ml()); - // Randomise weights (large draw) + const float spread = mode.param_schema().default_spread; + + // MomA1: enter/exit explore toggle. meml->setMomA1Callback([&mode]() { - mode.ml().draw_weights(mode.param_schema().default_spread); + if (feedback.explore_state() == nisps::ml::ExploreState::Idle) { + feedback.enter_explore(mode.ml(), mode.param_schema().default_spread); + } else { + feedback.exit_explore(mode.ml()); + } }); - // Clear training dataset (best-effort: if the MLP exposes reset() - // we use it; otherwise we draw new weights as a degraded fallback). - meml->setMomA2Callback([&mode]() { - mode.ml().reset(); - }); - - // Jolt / move_weights (positive direction) + // MomB1: reroll the scratchpad (only in Exploring; no-op otherwise). meml->setMomB1Callback([&mode]() { - mode.ml().move_weights(0.5f, mode.param_schema().default_spread); + feedback.reroll(mode.ml(), mode.param_schema().default_spread); }); - // Move weights (negative-feedback jolt — same call, different speed sign - // would flip exploration direction; the MLP API takes magnitude). + // MomB2: nudge the scratchpad (small bounded perturbation; undoable). meml->setMomB2Callback([&mode]() { - mode.ml().move_weights(0.25f, mode.param_schema().default_spread); + feedback.nudge(mode.ml(), 0.05f); }); - // Toggle B2: rising edge → train. + // MomA2: like → begin place. Freezes the current scratchpad output so the + // user can move the joystick to choose WHERE to place it. + meml->setMomA2Callback([&mode]() { + feedback.begin_place(mode.ml()); + }); + + // TogB2 (rising): up → commit place at the current joystick input, then + // store the +1 example (input → placed output) and train. Outside Placing, + // just train (legacy behaviour). meml->setTogB2Callback([&mode](bool state) { - if (state) { + if (!state) return; + if (feedback.placing()) { + // Capture the current joystick input BEFORE the restore. + const auto in = mode.input_channels(); + std::array features{}; + for (std::size_t i = 0; i < Mode::ML::kInput; ++i) { + features[i] = (i < in.size()) ? in[i] : 0.f; + } + feedback.commit_place(mode.ml()); // restores the real net + // CALLER owns training: add the +1 example then warm-start train. + const auto label = feedback.committed_output(); // valid post-commit + if (!label.empty()) { + mode.ml().add_example( + std::span(features.data(), Mode::ML::kInput), label); + (void)mode.ml().train(); + } + } else { (void)mode.ml().train(); } }); diff --git a/firmware/MEMLNaut-NISPS/glue/selftest.hpp b/firmware/MEMLNaut-NISPS/glue/selftest.hpp new file mode 100644 index 0000000..fc3674c --- /dev/null +++ b/firmware/MEMLNaut-NISPS/glue/selftest.hpp @@ -0,0 +1,721 @@ +// firmware/glue/selftest.hpp — Guided hardware self-test rig. +// +// A standalone bring-up / soldering-verification firmware. It turns the WHOLE +// device into a test rig: NO audio engine, NO ML. The TFT screen walks the +// operator step-by-step through exercising every control; each step +// AUTO-ADVANCES the moment the firmware detects the expected input. Pressing +// the rotary-encoder switch SKIPS the current step (records SKIPPED) so a dead +// / unsoldered control never traps the sequence. +// +// At the end, two OPTIONAL steps: +// * Headphone test — an L / R / BOTH sine sweep with channel ID, confirmed +// by ear (A1 = PASS, B1 = FAIL). +// * MIDI loopback — patch MIDI OUT -> IN with a DIN cable; the rig sends a +// note on TX and verifies it arrives on RX within a timeout. +// +// This logic is INHERENTLY hardware-coupled (it draws to the TFT and reads raw +// pins), so it lives firmware-side here in glue/, NOT under platform-agnostic +// nisps/. It is selected at build time via the `SelfTest` variant (see +// mode_select.hpp + the NISPS_SELFTEST fork in MEMLNaut-NISPS.ino). +// +// Threading: core 0 owns the step state machine (driven from MEMLNaut's +// loopCallback, ~5 ms) and the display. core 1 owns the audio sweep block +// callback and the MIDI loopback poll. They communicate through a handful of +// single-writer volatile scalars + memory barriers. + +#pragma once + +// Arduino's Common.h leaks min/max/abs/sq/round as macros; nisps headers use +// some of these as identifiers. Undef locally (mirrors mode_select.hpp). +#ifdef sq +# undef sq +#endif +#ifdef min +# undef min +#endif +#ifdef max +# undef max +#endif +#ifdef abs +# undef abs +#endif +#ifdef round +# undef round +#endif + +#include +#include +#include +#include + +#include "../src/memllib/PicoDefs.hpp" +#include "../src/memllib/utils/perf.hpp" +#include "../src/memllib/audio/AudioDriver.hpp" +#include "../src/memllib/interface/MIDIInOut.hpp" +#include "../src/memllib/hardware/memlnaut/MEMLNaut.hpp" +#include "../src/memllib/hardware/memlnaut/Pins.hpp" +#include "../src/memllib/hardware/memlnaut/display/View.hpp" +#include "../src/nisps/dsp/osc.hpp" + +// Inter-core boot-handshake flags. Defined in the .ino (shared by both the +// normal-mode and self-test build paths); declared here so this header is +// order-independent. +extern volatile bool g_serial_ready; +extern volatile bool g_iface_ready; +extern volatile bool g_core0_ready; +extern volatile bool g_core1_ready; + +namespace nisps_firmware { +namespace selftest { + +// ===================================================================== +// Step model +// ===================================================================== + +enum class StepResult : uint8_t { UNTESTED, PASS, SKIP, FAIL }; + +enum class Kind : uint8_t { + INTRO, + JOY_UP, JOY_DOWN, JOY_X, JOY_Z, JOY_SW, + POT_X1, POT_Y1, POT_Z1, POT_GAIN, POT_ADC3, + MOM_A1, MOM_A2, MOM_B1, MOM_B2, + TOG_A1, TOG_A2, TOG_B1, TOG_B2, + ENC_CW, ENC_CCW, + LED, + AUDIO_GATE, AUDIO, + MIDI_GATE, MIDI, + SUMMARY, +}; + +struct Step { + Kind kind; + const char* name; // short label for the summary scorecard + const char* l1; // prompt line 1 + const char* l2; // prompt line 2 ("" = none) + const char* footer; // load-bearing footer: what the encoder/buttons do now +}; + +// Order is the test order. SUMMARY must be last. +static const Step kSteps[] = { + {Kind::INTRO, "intro", "SELF TEST", "Exercise each control", "encoder = skip / next"}, + {Kind::JOY_UP, "Joy Y+", "Push joystick UP (Y)", "then release", "encoder = skip"}, + {Kind::JOY_DOWN, "Joy Y-", "Pull joystick DOWN (Y)", "", "encoder = skip"}, + {Kind::JOY_X, "Joy X", "Push joystick LEFT", "then RIGHT (X)", "encoder = skip"}, + {Kind::JOY_Z, "Joy Z", "Twist joystick (Z)", "fully both ways", "encoder = skip"}, + {Kind::JOY_SW, "Joy SW", "Press joystick DOWN", "(the switch)", "encoder = skip"}, + {Kind::POT_X1, "RV_X1", "Turn RV_X1 fully", "one way then the other", "encoder = skip"}, + {Kind::POT_Y1, "RV_Y1", "Turn RV_Y1 fully", "", "encoder = skip"}, + {Kind::POT_Z1, "RV_Z1", "Turn RV_Z1 fully", "", "encoder = skip"}, + {Kind::POT_GAIN, "RV_GAIN", "Turn RV_GAIN1 fully", "", "encoder = skip"}, + {Kind::POT_ADC3, "ADC3", "Turn the 5th pot", "(ADC3) fully", "encoder = skip"}, + {Kind::MOM_A1, "MOM_A1", "Press + release", "button A1", "encoder = skip"}, + {Kind::MOM_A2, "MOM_A2", "Press + release", "button A2", "encoder = skip"}, + {Kind::MOM_B1, "MOM_B1", "Press + release", "button B1", "encoder = skip"}, + {Kind::MOM_B2, "MOM_B2", "Press + release", "button B2", "encoder = skip"}, + {Kind::TOG_A1, "TOG_A1", "Flip toggle A1", "ON then OFF", "encoder = skip"}, + {Kind::TOG_A2, "TOG_A2", "Flip toggle A2", "ON then OFF", "encoder = skip"}, + {Kind::TOG_B1, "TOG_B1", "Flip toggle B1", "ON then OFF", "encoder = skip"}, + {Kind::TOG_B2, "TOG_B2", "Flip toggle B2", "ON then OFF", "encoder = skip"}, + {Kind::ENC_CW, "Enc CW", "Turn encoder", "CLOCKWISE 5 clicks", "encoder turn = test"}, + {Kind::ENC_CCW, "Enc CCW", "Turn encoder", "COUNTER-CW 5 clicks", "encoder turn = test"}, + {Kind::LED, "LED", "Status LED blinking?", "A1 = PASS B1 = FAIL", "A1 pass / B1 fail"}, + {Kind::AUDIO_GATE,"audio?", "Headphone test?", "A1 = run B1 = skip", "A1 run / B1 skip"}, + {Kind::AUDIO, "Audio", "Listen: L / R / BOTH", "", "A1 pass / B1 fail"}, + {Kind::MIDI_GATE, "midi?", "MIDI loopback?", "patch OUT->IN A1/B1", "A1 run / B1 skip"}, + {Kind::MIDI, "MIDI", "Testing MIDI loop...", "", "A1 retry / B1 skip"}, + {Kind::SUMMARY, "summary", "DONE", "", "turn=scroll press=restart"}, +}; +static constexpr int kNumSteps = static_cast(sizeof(kSteps) / sizeof(kSteps[0])); +static constexpr int kSummaryIdx = kNumSteps - 1; + +// ===================================================================== +// Core 0 peripheral capture state +// +// Analog + momentary callbacks fire from MEMLNaut::loop() on core 0. +// Toggle / joystick-switch / rotary-turn callbacks fire from GPIO ISRs, so the +// fields they write are volatile. +// ===================================================================== + +struct State { + // latest analog values [0, ~0.992] + float joyX = 0.5f, joyY = 0.5f, joyZ = 0.5f; + float potX1 = 0.f, potY1 = 0.f, potZ1 = 0.f, potGain = 0.f, potADC3 = 0.f; + // per-step joystick rest baselines (snapshotted on step entry near centre) + float restJoyX = 0.5f, restJoyY = 0.5f, restJoyZ = 0.5f; + // per-step analog min/max (for pot travel + rail detection) + float aMin = 1e9f, aMax = -1e9f; + // momentary press-edge latches (set in core-0 callbacks, cleared on entry) + bool momA1Pressed = false, momA2Pressed = false, momB1Pressed = false, momB2Pressed = false; + // toggle saw-high / saw-low this step (ISR-written) + volatile bool togA1Hi = false, togA1Lo = false, togA2Hi = false, togA2Lo = false; + volatile bool togB1Hi = false, togB1Lo = false, togB2Hi = false, togB2Lo = false; + volatile bool joySWChanged = false; + // encoder click counters this step (ISR-written) + volatile int encCW = 0, encCCW = 0; +}; +static State APP_SRAM g_state; + +// ===================================================================== +// Cross-core audio + MIDI state +// ===================================================================== + +static constexpr float kSweepLoHz = 200.f; +static constexpr float kSweepHiHz = 4000.f; +// Per-sample log-sweep ratio @48 kHz: ~200 -> 4000 Hz over ~2 s, then wraps. +static constexpr float kSweepRate = 1.00003f; + +enum class SweepPhase : uint32_t { OFF = 0, LEFT, RIGHT, BOTH }; +volatile SweepPhase AUDIO_MEM g_st_phase = SweepPhase::OFF; // core0 writes, core1 reads +volatile bool AUDIO_MEM g_st_freq_reset = false; // core0 one-shot -> core1 resets sweep +nisps::SineOsc AUDIO_MEM g_st_osc; // core1 only +float AUDIO_MEM g_st_freq = kSweepLoHz; // core1 only + +enum class MidiResult : uint32_t { PENDING = 0, PASS, FAIL }; +volatile bool APP_SRAM g_st_midi_run = false; // core0 -> core1: run a loopback probe +volatile MidiResult APP_SRAM g_st_midi_result = MidiResult::PENDING; // core1 -> core0 +volatile bool APP_SRAM g_st_midi_got = false; // core1 internal (RX callback) +std::shared_ptr APP_SRAM g_st_midi; + +// Audio block callback (core 1, ISR context, must live in SRAM). Plain free +// function so its address fits AudioDriver's function-pointer setter. `static` +// (not `inline`) to avoid the documented `inline` + `__not_in_flash` comdat +// conflict; selftest.hpp is only ever included in the single .ino TU. +static void AUDIO_FUNC(selftest_audio_block_callback)( + float in[][kBufferSize], float out[][kBufferSize], size_t n_channels, size_t n_frames) { + (void)in; + const SweepPhase ph = READ_VOLATILE(g_st_phase); + if (READ_VOLATILE(g_st_freq_reset)) { + g_st_freq = kSweepLoHz; + WRITE_VOLATILE(g_st_freq_reset, false); + } + const bool wantL = (ph == SweepPhase::LEFT || ph == SweepPhase::BOTH); + const bool wantR = (ph == SweepPhase::RIGHT || ph == SweepPhase::BOTH); + float f = g_st_freq; + for (size_t i = 0; i < n_frames; ++i) { + const float s = (ph == SweepPhase::OFF) ? 0.f : g_st_osc.sine(f) * 0.8f; + if (n_channels > 0) out[0][i] = wantL ? s : 0.f; + if (n_channels > 1) out[1][i] = wantR ? s : 0.f; + f *= kSweepRate; + if (f > kSweepHiHz) f = kSweepLoHz; + } + g_st_freq = f; // single-writer (core 1) +} + +// ===================================================================== +// The view + state-machine fields +// ===================================================================== + +class SelfTestView : public ViewBase { + public: + explicit SelfTestView(String name) : ViewBase(name) {} + + void OnSetup() override {} + + void OnDraw() override { + scr->fillRect(area.x, area.y, area.w, area.h, TFT_BLACK); + TFT_eSprite line(scr); + line.createSprite(area.w, kLineH); + line.setTextFont(2); + + auto put = [&](const char* s, int row, uint16_t colour) { + line.fillRect(0, 0, area.w, kLineH, TFT_BLACK); + line.setTextColor(colour, TFT_BLACK); + line.drawString(s, 0, 0); + line.pushSprite(area.x + 8, area.y + row * kLineH); + }; + + if (kSteps[stepIdx_].kind == Kind::SUMMARY) { + drawSummary(put); + return; + } + + const Step& st = kSteps[stepIdx_]; + char hdr[48]; + std::snprintf(hdr, sizeof hdr, "step %d / %d %s", stepIdx_, kSummaryIdx, st.name); + put(hdr, 0, TFT_WHITE); + put(st.l1, 1, TFT_WHITE); + if (st.l2 && st.l2[0]) put(st.l2, 2, TFT_WHITE); + + if (liveLabel_[0]) put(liveLabel_, 4, TFT_YELLOW); + if (showBar_) { + const int w = static_cast(barVal_ * (area.w - 16)); + scr->drawRect(area.x + 8, area.y + 5 * kLineH, area.w - 16, 12, 0x7BEF); + scr->fillRect(area.x + 8, area.y + 5 * kLineH, w < 0 ? 0 : w, 12, TFT_CYAN); + } + + if (flash_) { + put(flashFail_ ? "FAIL X" : "PASS OK", 6, flashFail_ ? TFT_RED : TFT_GREEN); + } else if (results_[stepIdx_] == StepResult::SKIP) { + put("SKIPPED", 6, 0x7BEF); + } + + if (advisory_[0]) put(advisory_, 7, TFT_ORANGE); + put(st.footer, 8, 0x7BEF); + } + + void drawSummary(const std::function& put) { + int passes = 0, testable = 0; + for (int i = 1; i < kSummaryIdx; ++i) { + if (kSteps[i].kind == Kind::AUDIO_GATE || kSteps[i].kind == Kind::MIDI_GATE) continue; + ++testable; + if (results_[i] == StepResult::PASS) ++passes; + } + char hdr[48]; + std::snprintf(hdr, sizeof hdr, "DONE %d / %d PASS", passes, testable); + put(hdr, 0, TFT_WHITE); + + // Scrollable scorecard window (rows 1..7 -> 7 visible items). + constexpr int kVisible = 7; + int row = 1; + for (int i = 1 + scrollOff_; i < kSummaryIdx && row <= kVisible; ++i) { + if (kSteps[i].kind == Kind::AUDIO_GATE || kSteps[i].kind == Kind::MIDI_GATE) continue; + const char* r = "----"; + uint16_t c = 0x7BEF; + switch (results_[i]) { + case StepResult::PASS: r = "PASS"; c = TFT_GREEN; break; + case StepResult::FAIL: r = "FAIL"; c = TFT_RED; break; + case StepResult::SKIP: r = "skip"; c = TFT_ORANGE; break; + case StepResult::UNTESTED: r = "----"; c = 0x7BEF; break; + } + char ln[48]; + std::snprintf(ln, sizeof ln, "%-9s %s", kSteps[i].name, r); + put(ln, row++, c); + } + put("RE_SW: ok if skips worked", 8, 0x7BEF); + } + + static constexpr int kLineH = 20; + + int stepIdx_ = 0; + StepResult results_[kNumSteps] = {}; + char liveLabel_[48] = {0}; + bool showBar_ = false; + float barVal_ = 0.f; + bool flash_ = false, flashFail_ = false; + uint32_t passFlashUntil_ = 0; + char advisory_[28] = {0}; + int scrollOff_ = 0; + + // JOY_X/Y direction memory + audio-step bookkeeping + bool joyUpPositive_ = false; + SweepPhase audioStage_ = SweepPhase::LEFT; + uint32_t audioStageDeadline_ = 0; + uint32_t audioSafetyDeadline_ = 0; + uint32_t ledToggleAt_ = 0; +}; + +static std::shared_ptr APP_SRAM g_view; + +// ===================================================================== +// State-machine helpers +// ===================================================================== + +inline void onStepEnter(int i); + +inline void markResult(StepResult r, bool fail = false) { + g_view->results_[g_view->stepIdx_] = r; + if (r == StepResult::PASS || r == StepResult::FAIL) { + g_view->flash_ = true; + g_view->flashFail_ = fail; + g_view->passFlashUntil_ = millis() + 400; + } + g_view->redraw(); +} +inline void markPass() { markResult(StepResult::PASS, false); } +inline void markFail() { markResult(StepResult::FAIL, true); } + +inline void advance() { + if (g_view->stepIdx_ < kNumSteps - 1) onStepEnter(g_view->stepIdx_ + 1); +} + +inline void setAdvisory(const char* s) { + std::snprintf(g_view->advisory_, sizeof g_view->advisory_, "%s", s); +} + +inline void onStepEnter(int i) { + g_view->stepIdx_ = i; + g_view->flash_ = false; + g_view->flashFail_ = false; + g_view->advisory_[0] = 0; + g_view->liveLabel_[0] = 0; + g_view->showBar_ = false; + g_view->barVal_ = 0.f; + g_view->scrollOff_ = 0; + + State& s = g_state; + s.aMin = 1e9f; + s.aMax = -1e9f; + s.momA1Pressed = s.momA2Pressed = s.momB1Pressed = s.momB2Pressed = false; + s.togA1Hi = s.togA1Lo = s.togA2Hi = s.togA2Lo = false; + s.togB1Hi = s.togB1Lo = s.togB2Hi = s.togB2Lo = false; + s.joySWChanged = false; + s.encCW = 0; + s.encCCW = 0; + + const Kind k = kSteps[i].kind; + // Snapshot joystick rest baseline only if the axis is near centre; otherwise + // prompt the operator (via the live label in tick) to release it. + if (k == Kind::JOY_UP && std::fabs(s.joyY - 0.5f) < 0.15f) s.restJoyY = s.joyY; + if (k == Kind::JOY_X && std::fabs(s.joyX - 0.5f) < 0.15f) s.restJoyX = s.joyX; + if (k == Kind::JOY_Z && std::fabs(s.joyZ - 0.5f) < 0.15f) s.restJoyZ = s.joyZ; + + // Stuck-button advisories. + if (k == Kind::MOM_A1 && MEMLNaut::Instance()->getMOMA1State()) setAdvisory("A1 stuck?"); + if (k == Kind::MOM_A2 && MEMLNaut::Instance()->getMOMA2State()) setAdvisory("A2 stuck?"); + if (k == Kind::MOM_B1 && MEMLNaut::Instance()->getMOMB1State()) setAdvisory("B1 stuck?"); + if (k == Kind::MOM_B2 && MEMLNaut::Instance()->getMOMB2State()) setAdvisory("B2 stuck?"); + + if (k == Kind::LED) { + g_view->ledToggleAt_ = millis(); + } + if (k == Kind::AUDIO) { + g_view->audioStage_ = SweepPhase::LEFT; + g_view->audioStageDeadline_ = millis() + 1500; + g_view->audioSafetyDeadline_ = millis() + 30000; + WRITE_VOLATILE(g_st_freq_reset, true); + WRITE_VOLATILE(g_st_phase, SweepPhase::LEFT); + AudioDriver::SetMasterVolume(0.3f); + } + if (k == Kind::MIDI) { + WRITE_VOLATILE(g_st_midi_result, MidiResult::PENDING); + WRITE_VOLATILE(g_st_midi_run, true); + } + g_view->redraw(); +} + +// Convenience: set the live readout + track analog min/max for the bar. +inline void liveAnalog(const char* nm, float val) { + State& s = g_state; + if (val < s.aMin) s.aMin = val; + if (val > s.aMax) s.aMax = val; + g_view->showBar_ = true; + g_view->barVal_ = val; + std::snprintf(g_view->liveLabel_, sizeof g_view->liveLabel_, + "%s %.3f [%.2f..%.2f]", nm, val, s.aMin, s.aMax); +} + +inline bool potPass() { + const State& s = g_state; + return s.aMax > 0.90f || s.aMin < 0.10f || (s.aMax - s.aMin) > 0.70f; +} + +// ===================================================================== +// Per-tick state machine (core 0, ~5 ms via MEMLNaut loopCallback) +// ===================================================================== + +inline void tickStateMachine() { + SelfTestView& v = *g_view; + State& s = g_state; + MEMLNaut* M = MEMLNaut::Instance(); + + // Hold the green/red flash before advancing so fast passes are visible. + if (v.flash_) { + if (millis() > v.passFlashUntil_) { + const bool wasFail = v.flashFail_; + v.flash_ = false; + if (!wasFail) advance(); + } + v.redraw(); + return; + } + + const Kind k = kSteps[v.stepIdx_].kind; + switch (k) { + case Kind::INTRO: + std::snprintf(v.liveLabel_, sizeof v.liveLabel_, "press encoder to begin"); + break; + + case Kind::JOY_UP: + liveAnalog("Y", s.joyY); + if (s.joyY - s.restJoyY > 0.30f) { v.joyUpPositive_ = true; markPass(); } + else if (s.restJoyY - s.joyY > 0.30f) { v.joyUpPositive_ = false; markPass(); } + break; + case Kind::JOY_DOWN: + liveAnalog("Y", s.joyY); + // Opposite direction to the UP step. + if (v.joyUpPositive_ ? (s.restJoyY - s.joyY > 0.30f) : (s.joyY - s.restJoyY > 0.30f)) markPass(); + break; + case Kind::JOY_X: + liveAnalog("X", s.joyX); + if ((s.aMax - s.restJoyX) > 0.30f && (s.restJoyX - s.aMin) > 0.30f) { + // Off-axis bleed: if Y moved more than X, wiring may be swapped. + if (std::fabs(s.joyY - 0.5f) > (s.aMax - s.aMin)) setAdvisory("X/Y swapped?"); + markPass(); + } + break; + case Kind::JOY_Z: + liveAnalog("Z", s.joyZ); + if ((s.aMax - s.aMin) > 0.25f) markPass(); + break; + case Kind::JOY_SW: + std::snprintf(v.liveLabel_, sizeof v.liveLabel_, "%s", + M->getMOMJOYSWState() ? "switch HELD" : "switch released"); + if (s.joySWChanged || M->getMOMJOYSWState()) markPass(); + break; + + case Kind::POT_X1: liveAnalog("X1", s.potX1); if (potPass()) markPass(); break; + case Kind::POT_Y1: liveAnalog("Y1", s.potY1); if (potPass()) markPass(); break; + case Kind::POT_Z1: liveAnalog("Z1", s.potZ1); if (potPass()) markPass(); break; + case Kind::POT_GAIN: liveAnalog("GAIN", s.potGain); if (potPass()) markPass(); break; + case Kind::POT_ADC3: liveAnalog("ADC3", s.potADC3); if (potPass()) markPass(); break; + + // Momentary: press-edge latch + live release detection. + case Kind::MOM_A1: + std::snprintf(v.liveLabel_, sizeof v.liveLabel_, "%s", + M->getMOMA1State() ? "held" : (s.momA1Pressed ? "released" : "waiting")); + if (s.momA1Pressed && !M->getMOMA1State()) markPass(); + break; + case Kind::MOM_A2: + std::snprintf(v.liveLabel_, sizeof v.liveLabel_, "%s", + M->getMOMA2State() ? "held" : (s.momA2Pressed ? "released" : "waiting")); + if (s.momA2Pressed && !M->getMOMA2State()) markPass(); + break; + case Kind::MOM_B1: + std::snprintf(v.liveLabel_, sizeof v.liveLabel_, "%s", + M->getMOMB1State() ? "held" : (s.momB1Pressed ? "released" : "waiting")); + if (s.momB1Pressed && !M->getMOMB1State()) markPass(); + break; + case Kind::MOM_B2: + std::snprintf(v.liveLabel_, sizeof v.liveLabel_, "%s", + M->getMOMB2State() ? "held" : (s.momB2Pressed ? "released" : "waiting")); + if (s.momB2Pressed && !M->getMOMB2State()) markPass(); + break; + + // Toggle: must observe both ON and OFF this step. + case Kind::TOG_A1: if (s.togA1Hi && s.togA1Lo) markPass(); break; + case Kind::TOG_A2: if (s.togA2Hi && s.togA2Lo) markPass(); break; + case Kind::TOG_B1: if (s.togB1Hi && s.togB1Lo) markPass(); break; + case Kind::TOG_B2: if (s.togB2Hi && s.togB2Lo) markPass(); break; + + case Kind::ENC_CW: + std::snprintf(v.liveLabel_, sizeof v.liveLabel_, "CW %d / 5", s.encCW); + if (s.encCCW >= 5 && s.encCW < 5) setAdvisory("encoder reversed?"); + if (s.encCW >= 5) markPass(); + break; + case Kind::ENC_CCW: + std::snprintf(v.liveLabel_, sizeof v.liveLabel_, "CCW %d / 5", s.encCCW); + if (s.encCCW >= 5) markPass(); + break; + + case Kind::LED: { + if (millis() - v.ledToggleAt_ > 250) { + v.ledToggleAt_ = millis(); + digitalWrite(Pins::LED, !digitalRead(Pins::LED)); + } + if (s.momA1Pressed) markPass(); + else if (s.momB1Pressed) markFail(); + break; + } + + case Kind::AUDIO_GATE: + if (s.momA1Pressed) { markResult(StepResult::PASS); onStepEnter(v.stepIdx_ + 1); } + else if (s.momB1Pressed) { markResult(StepResult::SKIP); onStepEnter(v.stepIdx_ + 2); } // skip AUDIO + break; + + case Kind::AUDIO: { + const char* lbl = v.audioStage_ == SweepPhase::LEFT ? "LEFT ear" + : v.audioStage_ == SweepPhase::RIGHT ? "RIGHT ear" : "BOTH"; + std::snprintf(v.liveLabel_, sizeof v.liveLabel_, "%s", lbl); + if (millis() > v.audioStageDeadline_) { + v.audioStage_ = v.audioStage_ == SweepPhase::LEFT ? SweepPhase::RIGHT + : v.audioStage_ == SweepPhase::RIGHT ? SweepPhase::BOTH + : SweepPhase::LEFT; + WRITE_VOLATILE(g_st_phase, v.audioStage_); + v.audioStageDeadline_ = millis() + 1500; + } + if (millis() > v.audioSafetyDeadline_) { // fail-safe: silence after 30 s + WRITE_VOLATILE(g_st_phase, SweepPhase::OFF); + AudioDriver::SetMasterVolume(0.f); + } + if (s.momA1Pressed) { + WRITE_VOLATILE(g_st_phase, SweepPhase::OFF); + AudioDriver::SetMasterVolume(0.f); + markPass(); + } else if (s.momB1Pressed) { + WRITE_VOLATILE(g_st_phase, SweepPhase::OFF); + AudioDriver::SetMasterVolume(0.f); + markFail(); + } + break; + } + + case Kind::MIDI_GATE: + if (s.momA1Pressed) { onStepEnter(v.stepIdx_ + 1); } + else if (s.momB1Pressed) { markResult(StepResult::SKIP); onStepEnter(kSummaryIdx); } + break; + + case Kind::MIDI: { + const MidiResult r = READ_VOLATILE(g_st_midi_result); + if (r == MidiResult::PASS) { + markPass(); + } else if (r == MidiResult::FAIL) { + v.results_[v.stepIdx_] = StepResult::FAIL; + std::snprintf(v.liveLabel_, sizeof v.liveLabel_, "no MIDI - check cable"); + if (s.momA1Pressed) { // retry + s.momA1Pressed = false; + WRITE_VOLATILE(g_st_midi_result, MidiResult::PENDING); + WRITE_VOLATILE(g_st_midi_run, true); + } else if (s.momB1Pressed) { + onStepEnter(kSummaryIdx); + } + } else { + std::snprintf(v.liveLabel_, sizeof v.liveLabel_, "sending note..."); + } + break; + } + + case Kind::SUMMARY: + break; // rendered in OnDraw; navigation via encoder callbacks + } + v.redraw(); +} + +// Encoder PRESS: skip an input step, or restart from the summary. +inline void onEncoderPress() { + SelfTestView& v = *g_view; + const Kind k = kSteps[v.stepIdx_].kind; + if (k == Kind::SUMMARY) { + for (auto& r : v.results_) r = StepResult::UNTESTED; + onStepEnter(0); + return; + } + if (k == Kind::INTRO) { advance(); return; } + if (k == Kind::AUDIO) { // press = jump to next sweep stage now + v.audioStageDeadline_ = 0; + return; + } + // For gate / output steps, A1/B1 are the decision; encoder skips the section. + if (k == Kind::AUDIO_GATE) { markResult(StepResult::SKIP); onStepEnter(v.stepIdx_ + 2); return; } + if (k == Kind::MIDI_GATE) { markResult(StepResult::SKIP); onStepEnter(kSummaryIdx); return; } + // Plain input step: record SKIP and advance. + if (v.results_[v.stepIdx_] != StepResult::PASS && v.results_[v.stepIdx_] != StepResult::FAIL) + v.results_[v.stepIdx_] = StepResult::SKIP; + advance(); +} + +// Encoder TURN: drives the ENC test steps; scrolls the summary scorecard. +inline void onEncoderTurn(int delta) { + if (delta > 0) g_state.encCW++; + else if (delta < 0) g_state.encCCW++; + if (kSteps[g_view->stepIdx_].kind == Kind::SUMMARY) { + g_view->scrollOff_ += (delta > 0 ? 1 : -1); + if (g_view->scrollOff_ < 0) g_view->scrollOff_ = 0; + g_view->redraw(); + } +} + +// ===================================================================== +// Entry points (called from the .ino's SelfTest build path) +// ===================================================================== + +inline void setup() { + Serial.begin(115200); + Serial.println("SelfTest: core 0 boot"); + WRITE_VOLATILE(g_serial_ready, true); + + MEMLNaut::Initialize(false); + pinMode(Pins::LED, OUTPUT); + + MEMLNaut* M = MEMLNaut::Instance(); + // Analog -> state (RV_GAIN1 callback overrides the default volume hijack). + M->setJoyXCallback([](float v) { g_state.joyX = v; }); + M->setJoyYCallback([](float v) { g_state.joyY = v; }); + M->setJoyZCallback([](float v) { g_state.joyZ = v; }); + M->setRVX1Callback([](float v) { g_state.potX1 = v; }); + M->setRVY1Callback([](float v) { g_state.potY1 = v; }); + M->setRVZ1Callback([](float v) { g_state.potZ1 = v; }); + M->setRVGain1Callback([](float v) { g_state.potGain = v; }); + M->setADC3Callback([](float v) { g_state.potADC3 = v; }); + // Momentary press edges. + M->setMomA1Callback([] { g_state.momA1Pressed = true; }); + M->setMomA2Callback([] { g_state.momA2Pressed = true; }); + M->setMomB1Callback([] { g_state.momB1Pressed = true; }); + M->setMomB2Callback([] { g_state.momB2Pressed = true; }); + // Toggles (ISR context — trivial stores only). + M->setTogA1Callback([](bool on) { if (on) g_state.togA1Hi = true; else g_state.togA1Lo = true; }); + M->setTogA2Callback([](bool on) { if (on) g_state.togA2Hi = true; else g_state.togA2Lo = true; }); + M->setTogB1Callback([](bool on) { if (on) g_state.togB1Hi = true; else g_state.togB1Lo = true; }); + M->setTogB2Callback([](bool on) { if (on) g_state.togB2Hi = true; else g_state.togB2Lo = true; }); + M->setJoySWCallback([](bool) { g_state.joySWChanged = true; }); + // Encoder turn + press. + M->setRotaryEncoderCallback([](int d) { onEncoderTurn(d); }); + M->setReSWCallback([] { + static uint32_t last = 0; + const uint32_t now = millis(); + if (now - last > 200) { last = now; onEncoderPress(); } + }); + + g_view = std::make_shared("Self Test"); + M->disp->AddView(g_view); + M->disp->NavigateToView(g_view); + M->setLoopCallback([] { tickStateMachine(); }); + + WRITE_VOLATILE(g_iface_ready, true); + WRITE_VOLATILE(g_core0_ready, true); + while (!READ_VOLATILE(g_core1_ready)) { MEMORY_BARRIER(); delay(1); } + + // NB: do NOT call addSystemInfoView() — it would add a second view and the + // self-test view must stay the active one. + onStepEnter(0); + Serial.println("SelfTest: core 0 ready"); +} + +inline void loop() { + PERIODIC_RUN_US({ MEMLNaut::Instance()->loop(); }, 5000) +} + +inline void setup1() { + while (!READ_VOLATILE(g_serial_ready)) { MEMORY_BARRIER(); delay(1); } + while (!READ_VOLATILE(g_iface_ready)) { MEMORY_BARRIER(); delay(1); } + + g_st_osc.setup(static_cast(AudioDriver::GetSampleRate())); + AudioDriver::SetBlockCallback(&selftest_audio_block_callback); + AudioDriver::Setup(); + AudioDriver::SetMasterVolume(0.f); // silent until the audio step runs + + g_st_midi = std::make_shared(); + g_st_midi->Setup(/*n_outputs=*/1, /*midi_through=*/false); + g_st_midi->SetMIDISendChannel(1); + g_st_midi->SetMIDINoteChannel(1); + g_st_midi->SetNoteCallback([](bool on, uint8_t note, uint8_t vel) { + if (on && note == 60 && vel == 100) WRITE_VOLATILE(g_st_midi_got, true); + }); + + WRITE_VOLATILE(g_core1_ready, true); + while (!READ_VOLATILE(g_core0_ready)) { MEMORY_BARRIER(); delay(1); } + Serial.println("SelfTest: core 1 ready"); +} + +inline void loop1() { + // Non-blocking MIDI loopback probe + continuous RX drain. + PERIODIC_RUN_US({ + static bool sent = false; + static uint32_t deadline = 0; + if (READ_VOLATILE(g_st_midi_run)) { + if (!sent) { + WRITE_VOLATILE(g_st_midi_got, false); + g_st_midi->queueNoteOn(60, 100); + g_st_midi->flushQueue(); + g_st_midi->queueNoteOff(60, 0); + g_st_midi->flushQueue(); + sent = true; + deadline = millis() + 250; + } + g_st_midi->Poll(); + if (READ_VOLATILE(g_st_midi_got)) { + WRITE_VOLATILE(g_st_midi_result, MidiResult::PASS); + WRITE_VOLATILE(g_st_midi_run, false); + sent = false; + } else if (millis() > deadline) { + WRITE_VOLATILE(g_st_midi_result, MidiResult::FAIL); + WRITE_VOLATILE(g_st_midi_run, false); + sent = false; + } + } else { + g_st_midi->Poll(); // keep RX drained when idle + } + }, 1000) +} + +} // namespace selftest +} // namespace nisps_firmware diff --git a/scripts/setup-firmware-toolchain.sh b/scripts/setup-firmware-toolchain.sh new file mode 100755 index 0000000..2fc7333 --- /dev/null +++ b/scripts/setup-firmware-toolchain.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +# setup-firmware-toolchain.sh — one-shot bring-up of the MEMLNaut firmware +# build environment. Idempotent: safe to re-run. +# +# It will: +# 1. Initialise the git submodules (memllib + daisysp). +# 2. Install arduino-cli (to ~/.local/bin) if it is not already on PATH. +# 3. Install the earlephilhower rp2040 core (provides the +# solderparty_rp2350_stamp_xl board + the ARM GCC toolchain). +# 4. Install the Arduino libraries the firmware needs +# (TFT_eSPI, TFT_eWidget, MIDI Library). +# 5. Drop the MEMLNaut-specific TFT_eSPI config (ILI9341, 320x240, MEMLNaut +# pins) into the installed TFT_eSPI library as User_Setup.h, backing up +# the stock one first. memllib `#include ` directly, so this +# is the load-bearing step that makes the display compile + work. +# 6. Optionally compile the SelfTest firmware to verify the whole chain. +# +# Usage: +# scripts/setup-firmware-toolchain.sh # full setup + verify build +# scripts/setup-firmware-toolchain.sh --no-build # setup only, skip the build +# +# Run this on your firmware build machine (Linux or macOS), NOT inside a +# sandbox. The rp2040 core download is large (hundreds of MB: it bundles the +# arm-none-eabi GCC toolchain). + +set -euo pipefail + +# ---- config ------------------------------------------------------------- +RP2040_INDEX_URL="https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json" +ARDUINO_LIBS=("TFT_eSPI" "TFT_eWidget" "MIDI Library") +DO_BUILD=1 + +for arg in "$@"; do + case "$arg" in + --no-build) DO_BUILD=0 ;; + -h|--help) sed -n '2,33p' "$0"; exit 0 ;; + *) echo "unknown arg: $arg" >&2; exit 2 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +TFT_SETUP_SRC="$REPO_ROOT/firmware/MEMLNaut-NISPS/src/memllib/hardware/memlnaut/Setup9999_MEMLNaut.h.renameme" + +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +warn() { printf '\033[1;33mwarning: %s\033[0m\n' "$*" >&2; } +die() { printf '\033[1;31merror: %s\033[0m\n' "$*" >&2; exit 1; } + +# ---- 1. submodules ------------------------------------------------------ +log "Initialising git submodules (memllib + daisysp)" +command -v git >/dev/null 2>&1 || die "git is not installed" +git -C "$REPO_ROOT" submodule update --init --recursive + +[[ -f "$TFT_SETUP_SRC" ]] || die "TFT setup file missing after submodule init: $TFT_SETUP_SRC" + +# ---- 2. arduino-cli ----------------------------------------------------- +if ! command -v arduino-cli >/dev/null 2>&1; then + log "Installing arduino-cli into ~/.local/bin" + mkdir -p "$HOME/.local/bin" + curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh \ + | BINDIR="$HOME/.local/bin" sh + export PATH="$HOME/.local/bin:$PATH" + command -v arduino-cli >/dev/null 2>&1 || die "arduino-cli install failed" + warn "arduino-cli was installed to ~/.local/bin — add it to your shell PATH permanently:" + warn " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.bashrc # or ~/.zshrc" +else + log "arduino-cli already present: $(command -v arduino-cli) ($(arduino-cli version | head -1))" +fi + +# ---- 3. rp2040 core (earlephilhower) ------------------------------------ +# Pass the board index via --additional-urls so we don't mutate the user's +# global arduino-cli config. +log "Installing rp2040:rp2040 core (this downloads the ARM toolchain — be patient)" +arduino-cli core update-index --additional-urls "$RP2040_INDEX_URL" +if arduino-cli core list 2>/dev/null | grep -q '^rp2040:rp2040'; then + log "rp2040:rp2040 already installed — checking for upgrade" + arduino-cli core upgrade rp2040:rp2040 --additional-urls "$RP2040_INDEX_URL" || true +else + arduino-cli core install rp2040:rp2040 --additional-urls "$RP2040_INDEX_URL" +fi + +# ---- 4. Arduino libraries ---------------------------------------------- +log "Installing Arduino libraries: ${ARDUINO_LIBS[*]}" +arduino-cli lib update-index +for lib in "${ARDUINO_LIBS[@]}"; do + arduino-cli lib install "$lib" +done + +# ---- 5. MEMLNaut TFT_eSPI User_Setup ------------------------------------ +log "Configuring TFT_eSPI for the MEMLNaut display" +USER_DIR="$(arduino-cli config get directories.user 2>/dev/null || true)" +[[ -n "$USER_DIR" ]] || USER_DIR="$HOME/Arduino" +TFT_DIR="$USER_DIR/libraries/TFT_eSPI" +[[ -d "$TFT_DIR" ]] || die "TFT_eSPI library dir not found at $TFT_DIR (did lib install fail?)" + +TFT_SETUP_DST="$TFT_DIR/User_Setup.h" +if [[ -f "$TFT_SETUP_DST" && ! -f "$TFT_SETUP_DST.orig" ]]; then + cp "$TFT_SETUP_DST" "$TFT_SETUP_DST.orig" + log "Backed up stock User_Setup.h -> User_Setup.h.orig" +fi +cp "$TFT_SETUP_SRC" "$TFT_SETUP_DST" +log "Installed MEMLNaut User_Setup.h into $TFT_DIR" + +# ---- 6. verify build ---------------------------------------------------- +if [[ "$DO_BUILD" -eq 1 ]]; then + log "Verifying: compiling the SelfTest firmware" + if "$REPO_ROOT/scripts/build-firmware.sh" SelfTest; then + log "SelfTest firmware built successfully ✔" + echo + echo "Next: flash it with" + echo " scripts/flash-firmware.sh" + echo "or build a normal mode, e.g. scripts/build-firmware.sh PAFSynth" + else + die "SelfTest build failed — see the arduino-cli output above. Toolchain/libs are installed; this is a compile error to debug." + fi +else + log "Setup complete (build skipped). Verify with:" + echo " scripts/build-firmware.sh SelfTest" +fi