memlnaut-nisps/firmware/MEMLNaut-NISPS/glue/selftest.hpp
monkey-w1n5t0n 68d4cc4017 build(firmware): migrate to PlatformIO and vendor memllib (plan §5)
One cut, no dual path. Closes ALIGNMENT defect 3 ("Arduino-CLI build
machinery is actively hostile") and vision bullet 4.

platformio.ini carries 16 [env:], one per variant, each passing
-DMEMLNAUT_MODE_TYPE; selftest passes -DNISPS_SELFTEST=1 instead. The env list
IS the registry now — the .ino comment-registry and the NISPS_ST_* token-paste
table are deleted rather than migrated. L12 noted that table was already
silently missing the currently-shipped SLPWorkshop variant, which is the whole
argument against having a second list.

Also deleted: the Python/sed machinery that rewrote the COMMITTED .ino on every
build, the sketch symlink forest, the global TFT_eSPI User_Setup.h mutation
(now -D flags — TFT_eSPI's own documented PlatformIO recipe), the UF2
boot-mount detection stack (upload_protocol=picotool talks to the bootloader
directly), and build-firmware-arch.sh entirely. Scripts 683 -> 435 lines.

memllib is vendored at lib/memllib/ from upstream e291192; no submodules
remain. VENDORED.md records provenance and the re-sync procedure.

S9: a firmware-build CI job compiles three representative envs against a cached
toolchain and reports per-variant flash/RAM. Firmware is in an automated gate
for the FIRST time. The old ci.yml comment justified excluding it as "low
verification value" — an assessment that did not survive contact, since the
SelfTest variant sat broken for an unknown period calling a DisplayDriver
method that did not exist at the pinned memllib commit, and nothing noticed
because nothing built it.

Verified: all 16 envs build from an empty cache, each within ~520 bytes of the
arduino-cli binary it replaces, flash and RAM. Measured as .text+.rodata /
.data+.bss+vector+uninitialized — NOT PlatformIO's console line, which
double-counts .data on this board. This does not prove the hardware boots; no
flash+smoke test was possible and that stays an operator chokepoint.

  slpworkshop 248232/145028   pafsynth 256880/149716   selftest 216228/17960
  (all 16 in the CI log format; none exceeds 2% of a 16 MB flash)

Two traps recorded so nobody rediscovers them: vendoring memllib's subdirs
without a src/ wrapper makes PlatformIO's library builder silently compile
NOTHING while still linking; and project build_flags land BEFORE the
framework's own -std=gnu++17 -Os, so build_unflags is required.

CORRECTION carried in this commit: the firmware sizes in c19d846's message and
the first version of the memllib recon doc were wrong — SLPWorkshop 145348,
PAFSynth 145300, SelfTest 141840. They came from building variants in sequence
through a SHARED incremental arduino-cli build directory, which reused stale
objects and under-reported by ~75 KB. Clean-cache rebuilds of the identical
commit give 216736/18492 for SelfTest. The real cost of the memllib upstream
bump is +216 bytes flash, not +316. Never measure firmware size through a
reused build dir.

HISTORY NOTE: this commit and the docs commit before it were rebuilt (force-push,
2026-07-21) so that each contains only what its message describes. The first
versions had the firmware deletions stranded in the docs commit by a shared-index
race between concurrent agents; content is byte-identical to the originals.

Gates: run-all-tests.sh ALL GREEN (nisps/ untouched by this change beyond
include paths); 16/16 pio envs build.
2026-07-21 20:17:58 +02:00

725 lines
31 KiB
C++

// 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` PlatformIO env (see
// platformio.ini, which defines NISPS_SELFTEST=1; the fork lives in
// src/main.cpp).
//
// 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 "PicoDefs.hpp"
#include "utils/perf.hpp"
#include "audio/AudioDriver.hpp"
#include "interface/MIDIInOut.hpp"
#include "hardware/memlnaut/MEMLNaut.hpp"
#include "hardware/memlnaut/Pins.hpp"
#include "hardware/memlnaut/display/View.hpp"
#include "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");
// AddView appends to the carousel and DisplayDriver::Setup() leaves
// currentViewIndex_ at 0, so this — the selftest's only view — is already
// the one on screen. (There is no NavigateToView in DisplayDriver; a call
// to one used to sit here and had broken this variant's build.)
M->disp->AddView(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