feat(firmware): wire Explore-and-place glue to hardware buttons + selftest

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.)
This commit is contained in:
monkey-w1n5t0n 2026-06-28 04:14:30 +02:00
parent fbc68ebfab
commit 517bd2f987
5 changed files with 981 additions and 29 deletions

View file

@ -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 <memory>
// 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<MIDIInOut> 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

View file

@ -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)

View file

@ -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 <cstdint>
#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<typename Mode::ML, kFirmwareUndoDepth>;
static FB feedback(static_cast<std::uint64_t>(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<float, Mode::ML::kInput> 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<const float>(features.data(), Mode::ML::kInput), label);
(void)mode.ml().train();
}
} else {
(void)mode.ml().train();
}
});

View file

@ -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 <memory>
#include <cmath>
#include <cstdio>
#include <functional>
#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<int>(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<MIDIInOut> 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<int>(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<void(const char*, int, uint16_t)>& 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<SelfTestView> 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<SelfTestView>("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<float>(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<MIDIInOut>();
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

View file

@ -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 <User_Setup.h>` 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