From 7fc74e99b8c1807349b6f67958e792bc36a8a03b Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Mon, 6 Jul 2026 13:58:37 -0400 Subject: [PATCH 1/9] docs: add FIRST_BOOT_QC design spec Factory QC + per-unit throttle calibration design: automatic POST (bus-level I2C/SPI/CAN checks), screen-guided calibration capture, interactive operator checks, JSON traceability record, NVS gating. Targeting v8.1 (phases 1-3 + interactive checks + serial record); live throttle-mapping switch is v8.2, data-gated on calibration records collected by this release. Co-Authored-By: Claude Opus 4.8 --- FIRST_BOOT_QC.md | 160 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 FIRST_BOOT_QC.md diff --git a/FIRST_BOOT_QC.md b/FIRST_BOOT_QC.md new file mode 100644 index 0000000..cd9a4fd --- /dev/null +++ b/FIRST_BOOT_QC.md @@ -0,0 +1,160 @@ +# First-Boot Hardware QC + Throttle Calibration + +Design doc for a per-unit hardware self-test and throttle calibration that runs on the +first flash/boot of each controller. Target: **post-8.0** (built on branch `first-boot-qc`). + +## Goals + +1. **Catch bad hardware before it ships.** Every unit verifies its own peripherals on + first boot and shows a per-component checklist on screen. +2. **Calibrate each throttle to its own pot.** Capture this unit's real ADC endpoints so + every controller gets the full throttle range and a consistent feel — instead of the + fixed `0..4095` assumption that silently under/over-ranges individual units. +3. **Production traceability.** Emit a structured per-unit QC record over USB serial so a + bench rig can log results across a whole run and flag outliers. +4. **Be invisible after it passes.** Once a unit passes, a flag in NVS makes it boot + straight to normal operation. Re-runnable on demand for field service / returns. + +Non-goal: replacing bench/HIL testing. This is automated first-line QC + calibration. + +## Why per-unit calibration matters (variability sources) + +The throttle is read as a 12-bit ADC value (`0..4095`). Today the code maps a fixed +`POT_MIN_VALUE=0 .. POT_MAX_VALUE=4095` to `ESC_MIN_PWM..ESC_MAX_PWM`. Real units vary: + +| Source | Effect on raw ADC | Consequence with fixed 0..4095 | +|---|---|---| +| Hall/resistive zero offset, 3V3 rail | released ≈ 80–250, not 0 | small; absorbed by the 5% deadband — **unless** offset drifts above engagement → phantom throttle / arming blocked | +| Mechanical end-stop + sensor span | full press ≈ 3850–4095, not always 4095 | unit **never reaches ESC_MAX_PWM** → loses top-end power | +| Spring/lever slop, plastic tolerance | return-to-rest wanders | inconsistent deadband feel unit-to-unit | +| Temperature / supply drift, aging | endpoints move over time | calibration must keep a safety margin, and be re-runnable | + +Per-unit calibration captures this unit's `raw_min` (released) and `raw_max` (full press) +and maps **`[raw_min', raw_max'] → [ESC_MIN_PWM, ESC_MAX_PWM]`**, where the primed values +include deadband margins. Result: full range on every unit, consistent feel, reliable idle. + +## Trigger & gating + +- **First boot:** if NVS key `qc_passed` is absent or `false`, enter the QC flow + automatically. (Mirrors the existing first-boot detection in `refreshDeviceData()`.) +- **Manual re-run:** hold the button at boot → force QC/recalibration (field service, + returns, pot drift after years). +- **After pass:** write `qc_passed=true` + `qc_fw=`; subsequent boots skip QC. +- **FW bump policy (optional):** if `qc_fw` major < current major, re-run the *automatic* + checks but keep existing calibration. + +## Automatic checks (no operator — mostly aggregates existing signals) + +Collected ~2–3 s after boot, reusing flags/state the firmware already maintains: + +| Check | Signal that already exists | Pass criteria | +|---|---|---| +| Display | reached render path | implicit (you see the screen) | +| Barometer (I2C) | `bmpPresent` + reading | present, pressure in 800–1100 hPa | +| CPU temp | `getCachedCpuTemperature()` | reading in -20..90 °C | +| ESC / CAN (TWAI) | `escTwaiInitialized` + `escTelemetryData.escState` | driver up + `CONNECTED` + telemetry seen | +| BMS / CAN | `bmsCanInitialized` + `bmsTelemetryData.bmsState` | up + `CONNECTED` + pack voltage sane | +| NVS / settings | `preferences.begin()` + read-back | write+read round-trips | +| Throttle ADC | `readThrottleRaw()` | reads, and idle within expected band | + +## Interactive checks (operator-confirmed — no electrical readback) + +Output-only / input devices need a human in the loop: + +- **Throttle calibration** (the important one) — full-range sweep, see below. +- **Button** — "press the button" → detect press. +- **Buzzer** — play a tone → operator confirms audible. +- **Vibration** — pulse → operator confirms felt. +- **NeoPixel** — cycle R/G/B → operator confirms colors. + +## Throttle calibration procedure + +Guided on-screen, with live raw value shown: + +1. **"Release throttle fully"** → sample until stable (variance < ε over ~500 ms) → + capture `raw_min` (median of the window). +2. **"Squeeze throttle fully"** → sample until stable → capture `raw_max`. +3. **"Release again"** → confirm it returns within tolerance of `raw_min` (hysteresis / + stuck-lever check). +4. **Sanity-check** (reject → FAIL, do not save, fall back to defaults): + - `span = raw_max − raw_min ≥ MIN_SPAN` (e.g. 2000) — else bad pot/wiring. + - `raw_min ≤ MAX_IDLE` (e.g. 800) — else miswired/stuck-high. + - `raw_max ≥ MIN_FULL` (e.g. 3200) — else never reaches full. +5. **Save** `pot_min=raw_min`, `pot_max=raw_max`, `pot_calibrated=true` to NVS. + +### Mapping change (the safety-critical part — lands last, see phasing) + +Centralized in `throttle.cpp` (`potRawToPwm`, `potRawToModePwm`) and the engagement/cruise +helpers. Replace fixed endpoints with calibrated effective endpoints: + +``` +bottom_db = max(FLOOR_DB, BOTTOM_PCT * span) // keep a small deadband (drift/slop) +top_margin = TOP_PCT * span // ensure full press hits ESC_MAX_PWM +eff_min = pot_min + bottom_db +eff_max = pot_max - top_margin +pwm = map(constrain(raw, eff_min, eff_max), eff_min, eff_max, ESC_MIN_PWM, mode_max) +engagement = eff_min + ENGAGE_PCT * (eff_max - eff_min) // was 5% of 4095 +``` + +Suggested starting values (tune on hardware): `BOTTOM_PCT≈3%`, `TOP_PCT≈2%`, +`FLOOR_DB≈50 counts`, `ENGAGE_PCT≈5%`. You keep "a slight deadband" via `bottom_db`. + +### Safety analysis + +- **Uncalibrated = today's behavior.** If `pot_calibrated` is false/absent or values fail + sanitize, fall back to `0..4095` + existing 5% deadband. No regression for existing units. +- **Validate on every load**, not just at capture — extend `sanitizeDeviceData()` so a + corrupted `pot_min/max` can never produce a non-idle command at rest. +- **Idle always maps to ESC_MIN_PWM**; output always `constrain`ed to `[ESC_MIN_PWM, mode_max]`. +- **Arming gate** (`throttleSafe`) must use the calibrated zero so "throttle released" is + honored; a bad calibration that read idle as engaged would *block* arming (fail-safe). +- **Re-cal is deliberate only** (button-hold at boot) — never automatic mid-use. +- Cache `pot_min/max` into `throttle.cpp` statics at init/disarm to avoid per-tick reads of + `deviceData` from the 50 Hz loop (sidesteps the known settings-concurrency concern). + +## On-screen UX + +Dedicated LVGL QC screen: vertical list, one row per component = label + live value + +status icon (spinner → green ✓ / red ✗) updating as each check completes. Throttle row +expands into the release/squeeze sub-flow with live raw + captured min/max. Final banner: +**QC PASSED ✓** (green) or **FAILED ✗** listing failed checks. (Could later get +screenshot-test coverage via the existing emulator harness.) + +## Production QC record (traceability) + +On completion, emit one structured JSON line over USB serial for a bench rig to log: +`{ fw, esc_hw_id, esc_serial, bms_id, pot_min, pot_max, span, baro_hpa, cpu_c, +pack_v, checks:{...}, result }`. Across a run this surfaces outliers (e.g. a batch of pots +with low span) — directly the "variability between controllers" visibility you want. + +## NVS schema additions (per-key, same pattern as existing settings) + +| Key | Type | Default | Meaning | +|---|---|---|---| +| `qc_passed` | uchar | 0 | overall QC pass flag (gates auto-run) | +| `qc_fw` | ushort | 0 | firmware version that last passed QC | +| `pot_calibrated` | uchar | 0 | throttle calibration valid | +| `pot_min` | ushort | 0 | calibrated raw min (released) | +| `pot_max` | ushort | 4095 | calibrated raw max (full press) | + +Add `pot_min`/`pot_max`/`pot_calibrated`/`qc_passed` to `STR_DEVICE_DATA_140_V1`; load in +`refreshDeviceData()`, persist in `writeDeviceData()`, validate in `sanitizeDeviceData()`. + +## Phased implementation (risk increases down the list) + +1. **Scaffold + gating + automatic POST + serial report.** NVS keys, `qc_passed` gate, + aggregate init flags + liveness, print report. No throttle change. *Low risk.* +2. **On-screen QC checklist UI** (LVGL). +3. **Throttle calibration capture + storage** — capture/store/log endpoints, but **do not + yet change the live mapping**. *Still safe.* +4. **Switch throttle mapping to calibrated endpoints** behind sanitize+fallback. *The + safety-critical change — most review + HIL testing; lands last.* +5. **Interactive output checks** (buzzer/vibe/LED) + production serial record + BLE surfacing. + +## Open decisions (need your call) + +- Deadband split: keep a fixed floor + percentage as above, or pure percentage? +- Save endpoints only, or also a measured center/curve (if any unit is non-linear)? +- QC screen: auto-pass output checks after the cue, or require an explicit button confirm per device? +- Re-QC on every major FW bump, or only on demand? +- Store calibration in `deviceData` (simplest) or a separate factory namespace (cleaner separation of factory vs user data)? From 9cd39bdfb895381a7de30da68ec03837c71bb0cc Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Mon, 6 Jul 2026 14:06:46 -0400 Subject: [PATCH 2/9] feat(qc): factory settings namespace + pure QC logic with native tests - factory_settings: "openppg-factory" NVS namespace (survives user factory reset), mutex-guarded single-commit writes mirroring device_settings. Keys: qc_passed, qc_fw, pot_cal, pot_min, pot_max, qc_rerun, qc_record. - qc_logic: dependency-free gate decision (fresh-NVS or serial rerun only; legacy units back-filled, never auto-calibrated), calibration sanity gates, stability-window detector, v8.2 calibrated-mapping scaffold (unwired), QC record + one-line JSON serializer. - test_qc: 29 native tests incl. exhaustive gate decision table proving the installed fleet can never enter QC. Co-Authored-By: Claude Opus 4.8 --- inc/sp140/factory_settings.h | 56 ++++++ inc/sp140/qc_logic.h | 142 ++++++++++++++ src/sp140/factory_settings.cpp | 211 +++++++++++++++++++++ src/sp140/qc_logic.cpp | 237 ++++++++++++++++++++++++ test/test_qc/test_qc.cpp | 327 +++++++++++++++++++++++++++++++++ 5 files changed, 973 insertions(+) create mode 100644 inc/sp140/factory_settings.h create mode 100644 inc/sp140/qc_logic.h create mode 100644 src/sp140/factory_settings.cpp create mode 100644 src/sp140/qc_logic.cpp create mode 100644 test/test_qc/test_qc.cpp diff --git a/inc/sp140/factory_settings.h b/inc/sp140/factory_settings.h new file mode 100644 index 0000000..86b173b --- /dev/null +++ b/inc/sp140/factory_settings.h @@ -0,0 +1,56 @@ +// Copyright 2026 +// OpenPPG +// +// Factory-persistent settings ("openppg-factory" NVS namespace — exactly the +// 15-char NVS limit). Separate from the user "openppg" namespace on purpose: +// resetDeviceData() / user factory reset must NEVER wipe factory calibration +// or QC state. See FIRST_BOOT_QC.md. + +#ifndef INC_SP140_FACTORY_SETTINGS_H_ +#define INC_SP140_FACTORY_SETTINGS_H_ + +#include +#include + +struct FactoryCal { + bool calibrated; + uint16_t potMin; + uint16_t potMax; +}; + +// Create the module mutex + probe the namespace. Call once, single-threaded, +// early in setup() (before any other factory* call). +void factorySettingsInit(); + +// --- QC gate state --- +bool factoryQcPassed(); +bool factoryRerunRequested(); // qc_rerun flag (set by the run_qc command) +void factorySetRerunFlag(); // called by the "run_qc" serial command +void factoryClearRerunFlag(); // consumed at boot by the QC gate + +// --- Results --- +// qc_passed + qc_fw in one commit. +void factoryWriteQcResult(bool passed, uint16_t fwEncoded); +// pot_min/pot_max + pot_calibrated=1 in one commit. +void factoryWriteCal(uint16_t potMin, uint16_t potMax); +// Migration guard: existing (pre-QC firmware) unit — back-fill qc_passed +// without calibration so the installed fleet never sees the QC flow. +void factoryMarkLegacyUnit(uint16_t fwEncoded); + +FactoryCal factoryGetCal(); + +// POST helper: write+read+erase a scratch key in the factory namespace. +// Proves NVS is healthy end-to-end. Returns true on round-trip success. +bool factoryNvsRoundTrip(); + +// --- QC record blob (BLE fleet-sync surface reads this) --- +bool factoryWriteQcRecordBlob(const void* data, size_t len); +// Returns bytes read (0 if absent/too large for the buffer). +size_t factoryReadQcRecordBlob(void* out, size_t maxLen); + +// Encode VERSION_MAJOR/VERSION_MINOR into the u16 stored as qc_fw. +inline uint16_t factoryEncodeFw(uint8_t major, uint8_t minor) { + return (uint16_t)((uint16_t)major << 8 | minor); +} + +#endif // INC_SP140_FACTORY_SETTINGS_H_ diff --git a/inc/sp140/qc_logic.h b/inc/sp140/qc_logic.h new file mode 100644 index 0000000..52e862c --- /dev/null +++ b/inc/sp140/qc_logic.h @@ -0,0 +1,142 @@ +// Copyright 2026 +// OpenPPG +// +// FIRST_BOOT_QC pure logic — no Arduino/FreeRTOS/NVS dependencies so every +// decision that matters (gate, calibration gates, stability detection, the +// v8.2 mapping scaffold, record serialization) is natively unit-testable. +// See FIRST_BOOT_QC.md for the full design. + +#ifndef INC_SP140_QC_LOGIC_H_ +#define INC_SP140_QC_LOGIC_H_ + +#include +#include + +// --------------------------------------------------------------------------- +// Boot gate decision +// --------------------------------------------------------------------------- + +// QC entry has exactly two paths: fresh factory NVS, or the serial-command +// rerun flag. Existing units (any pre-QC firmware data in the "openppg" +// namespace) are back-filled as passed and NEVER auto-calibrated — the QC +// target is factory PCB/IC defects on new boards, not the installed fleet. +enum class QcGateAction : uint8_t { + SKIP_NORMAL_BOOT = 0, // QC already passed — boot normally + MARK_LEGACY_AND_SKIP, // existing unit: back-fill qc_passed, no calibration + RUN_QC, // fresh factory unit — run the full flow + RUN_QC_RERUN, // deliberate bench/field re-entry via serial command +}; + +QcGateAction qcGateDecision(bool factoryQcPassed, + bool factoryRerunRequested, + bool userSettingsPresent); + +// --------------------------------------------------------------------------- +// Throttle calibration sanity gates +// --------------------------------------------------------------------------- + +struct QcCalGates { + uint16_t minSpan; // reject if raw_max - raw_min below this + uint16_t maxIdle; // reject if raw_min above this (miswired/stuck) + uint16_t minFull; // reject if raw_max below this (never reaches full) + uint16_t releaseTolerance; // re-release must land within this of raw_min +}; + +enum class QcCalResult : uint8_t { + OK = 0, + SPAN_TOO_SMALL, + IDLE_TOO_HIGH, + FULL_TOO_LOW, + RELEASE_MISMATCH, +}; + +QcCalResult qcValidateCalibration(uint16_t rawMin, uint16_t rawMax, + uint16_t releaseRecheck, + const QcCalGates& gates); + +// --------------------------------------------------------------------------- +// Stability window detector (release/squeeze capture) +// --------------------------------------------------------------------------- + +// Fixed-capacity ring buffer over raw ADC samples. "Stable" when the buffer is +// full and (max - min) <= epsilon across the whole window. Capture value is +// the window median (robust to single-sample glitches). +class QcStabilityWindow { + public: + static const size_t kMaxWindow = 64; + + QcStabilityWindow(uint16_t epsilon, size_t windowSize); + + void push(uint16_t raw); + void reset(); + bool isFull() const { return count_ >= size_; } + bool isStable() const; + uint16_t median() const; // only meaningful when isFull() + + private: + uint16_t buf_[kMaxWindow]; + size_t size_; + size_t count_; + size_t head_; + uint16_t epsilon_; +}; + +// --------------------------------------------------------------------------- +// v8.2 scaffold: calibrated raw->PWM mapping (pure function, wired to NOTHING +// in v8.1 — the live throttle path still uses the fixed 0..4095 mapping). +// --------------------------------------------------------------------------- + +int qcPotRawToPwmCalibrated(uint16_t raw, + uint16_t potMin, uint16_t potMax, + int escMinPwm, int escMaxPwm, + float bottomPct, float topPct, + uint16_t floorDb); + +// --------------------------------------------------------------------------- +// QC record + one-line JSON serialization +// --------------------------------------------------------------------------- + +enum class QcCheckStatus : uint8_t { + NOT_RUN = 0, + PASS, + FAIL, + SKIP, // operator button-confirmed deliberately-absent device (e.g. no ESC) +}; + +const char* qcCheckStatusStr(QcCheckStatus s); + +struct QcRecord { + char fw[8]; // "8.1" + char build[24]; // build date string + uint16_t potMin; + uint16_t potMax; + bool calSaved; + float baroHpa; + float cpuC; + float packV; + char escHwId[12]; // empty => null in JSON + char escSn[36]; // empty => null in JSON + char bmsId[36]; // empty => null in JSON + QcCheckStatus display; + QcCheckStatus i2cBaro; + QcCheckStatus spiBms; + QcCheckStatus canEsc; + QcCheckStatus canBms; + QcCheckStatus cpu; + QcCheckStatus nvs; + QcCheckStatus throttle; + QcCheckStatus cal; + QcCheckStatus buzzer; + QcCheckStatus vibe; + QcCheckStatus button; +}; + +// PASSED iff every check is PASS or SKIP (a NOT_RUN check means the flow was +// interrupted and must not count as a pass). +bool qcRecordAllPassed(const QcRecord& r); + +// Serialize as a single JSON line (no trailing newline). Returns bytes +// written (excluding NUL), or 0 if the buffer was too small. +size_t qcRecordToJson(const QcRecord& r, char* out, size_t outLen); + +#endif // INC_SP140_QC_LOGIC_H_ diff --git a/src/sp140/factory_settings.cpp b/src/sp140/factory_settings.cpp new file mode 100644 index 0000000..8af2b59 --- /dev/null +++ b/src/sp140/factory_settings.cpp @@ -0,0 +1,211 @@ +// Copyright 2026 +// OpenPPG +// +// Factory NVS namespace ("openppg-factory"). Mirrors the device_settings.cpp +// pattern: a module mutex serializes writers, and multi-key writes go through +// the raw NVS API so each logical update is ONE commit (no torn saves). + +#include "sp140/factory_settings.h" + +#include "Arduino.h" +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "sp140/globals.h" + +// 15 chars — the NVS namespace limit. House rule: never abbreviate "openppg". +static const char* FACTORY_NAMESPACE = "openppg-factory"; + +// Factory keys +static const char* KEY_QC_PASSED = "qc_passed"; // u8 +static const char* KEY_QC_FW = "qc_fw"; // u16 (major<<8 | minor) +static const char* KEY_POT_CALIBRATED = "pot_cal"; // u8 +static const char* KEY_POT_MIN = "pot_min"; // u16 +static const char* KEY_POT_MAX = "pot_max"; // u16 +static const char* KEY_QC_RERUN = "qc_rerun"; // u8 (consumed at boot) +static const char* KEY_QC_RECORD = "qc_record"; // blob (JSON line) +static const char* KEY_NVS_SCRATCH = "nvs_scratch"; // u16 (POST round-trip) + +static SemaphoreHandle_t s_factoryMutex = nullptr; + +static void factoryEnsureMutex() { + if (s_factoryMutex == nullptr) { + s_factoryMutex = xSemaphoreCreateMutex(); + } +} + +static void factoryLock() { + factoryEnsureMutex(); + if (s_factoryMutex != nullptr) { + xSemaphoreTake(s_factoryMutex, portMAX_DELAY); + } +} + +static void factoryUnlock() { + if (s_factoryMutex != nullptr) { + xSemaphoreGive(s_factoryMutex); + } +} + +void factorySettingsInit() { + // Create the mutex while still single-threaded in setup(), like + // prefsEnsureMutex() in device_settings.cpp. + factoryEnsureMutex(); +} + +// --- reads (Preferences wrapper, read-only) -------------------------------- + +static Preferences& factoryPrefs() { + static Preferences prefs; + return prefs; +} + +bool factoryQcPassed() { + factoryLock(); + Preferences& p = factoryPrefs(); + bool passed = false; + if (p.begin(FACTORY_NAMESPACE, true)) { + passed = p.getUChar(KEY_QC_PASSED, 0) == 1; + p.end(); + } + factoryUnlock(); + return passed; +} + +bool factoryRerunRequested() { + factoryLock(); + Preferences& p = factoryPrefs(); + bool rerun = false; + if (p.begin(FACTORY_NAMESPACE, true)) { + rerun = p.getUChar(KEY_QC_RERUN, 0) == 1; + p.end(); + } + factoryUnlock(); + return rerun; +} + +FactoryCal factoryGetCal() { + FactoryCal cal = {false, 0, 4095}; + factoryLock(); + Preferences& p = factoryPrefs(); + if (p.begin(FACTORY_NAMESPACE, true)) { + cal.calibrated = p.getUChar(KEY_POT_CALIBRATED, 0) == 1; + cal.potMin = p.getUShort(KEY_POT_MIN, 0); + cal.potMax = p.getUShort(KEY_POT_MAX, 4095); + p.end(); + } + factoryUnlock(); + return cal; +} + +// --- writes (raw NVS, single commit per logical update) -------------------- + +// Open + apply `fn` + commit + close, under the module mutex. +template +static bool factoryBatchedWrite(Fn fn) { + factoryLock(); + nvs_handle_t handle = 0; + esp_err_t err = nvs_open(FACTORY_NAMESPACE, NVS_READWRITE, &handle); + if (err != ESP_OK) { + factoryUnlock(); + USBSerial.println(F("factory_settings: failed to open NVS for writing")); + return false; + } + bool success = fn(handle); + success &= (nvs_commit(handle) == ESP_OK); + nvs_close(handle); + factoryUnlock(); + if (!success) { + USBSerial.println(F("factory_settings: write may not have been saved")); + } + return success; +} + +void factoryWriteQcResult(bool passed, uint16_t fwEncoded) { + factoryBatchedWrite([&](nvs_handle_t h) { + bool ok = (nvs_set_u8(h, KEY_QC_PASSED, passed ? 1 : 0) == ESP_OK); + ok &= (nvs_set_u16(h, KEY_QC_FW, fwEncoded) == ESP_OK); + return ok; + }); +} + +void factoryWriteCal(uint16_t potMin, uint16_t potMax) { + factoryBatchedWrite([&](nvs_handle_t h) { + bool ok = (nvs_set_u16(h, KEY_POT_MIN, potMin) == ESP_OK); + ok &= (nvs_set_u16(h, KEY_POT_MAX, potMax) == ESP_OK); + ok &= (nvs_set_u8(h, KEY_POT_CALIBRATED, 1) == ESP_OK); + return ok; + }); +} + +void factoryMarkLegacyUnit(uint16_t fwEncoded) { + // Existing unit detected at boot: back-fill qc_passed WITHOUT calibration. + factoryBatchedWrite([&](nvs_handle_t h) { + bool ok = (nvs_set_u8(h, KEY_QC_PASSED, 1) == ESP_OK); + ok &= (nvs_set_u16(h, KEY_QC_FW, fwEncoded) == ESP_OK); + return ok; + }); +} + +void factorySetRerunFlag() { + factoryBatchedWrite([](nvs_handle_t h) { + return nvs_set_u8(h, KEY_QC_RERUN, 1) == ESP_OK; + }); +} + +void factoryClearRerunFlag() { + factoryBatchedWrite([](nvs_handle_t h) { + return nvs_set_u8(h, KEY_QC_RERUN, 0) == ESP_OK; + }); +} + +bool factoryNvsRoundTrip() { + const uint16_t magic = 0xA5C3; + bool wrote = factoryBatchedWrite([&](nvs_handle_t h) { + return nvs_set_u16(h, KEY_NVS_SCRATCH, magic) == ESP_OK; + }); + if (!wrote) { + return false; + } + factoryLock(); + Preferences& p = factoryPrefs(); + uint16_t readBack = 0; + if (p.begin(FACTORY_NAMESPACE, true)) { + readBack = p.getUShort(KEY_NVS_SCRATCH, 0); + p.end(); + } + factoryUnlock(); + return readBack == magic; +} + +bool factoryWriteQcRecordBlob(const void* data, size_t len) { + if (data == nullptr || len == 0) { + return false; + } + return factoryBatchedWrite([&](nvs_handle_t h) { + return nvs_set_blob(h, KEY_QC_RECORD, data, len) == ESP_OK; + }); +} + +size_t factoryReadQcRecordBlob(void* out, size_t maxLen) { + if (out == nullptr || maxLen == 0) { + return 0; + } + factoryLock(); + size_t readLen = 0; + nvs_handle_t handle = 0; + if (nvs_open(FACTORY_NAMESPACE, NVS_READONLY, &handle) == ESP_OK) { + size_t required = 0; + if (nvs_get_blob(handle, KEY_QC_RECORD, nullptr, &required) == ESP_OK && + required > 0 && required <= maxLen) { + if (nvs_get_blob(handle, KEY_QC_RECORD, out, &required) == ESP_OK) { + readLen = required; + } + } + nvs_close(handle); + } + factoryUnlock(); + return readLen; +} diff --git a/src/sp140/qc_logic.cpp b/src/sp140/qc_logic.cpp new file mode 100644 index 0000000..e61fb13 --- /dev/null +++ b/src/sp140/qc_logic.cpp @@ -0,0 +1,237 @@ +// Copyright 2026 +// OpenPPG +// +// FIRST_BOOT_QC pure logic. Deliberately free of Arduino/FreeRTOS/NVS so the +// native GoogleTest suite (test/test_qc) exercises the real implementation. + +#include "sp140/qc_logic.h" + +#include +#include + +// --------------------------------------------------------------------------- +// Boot gate decision +// --------------------------------------------------------------------------- + +QcGateAction qcGateDecision(bool factoryQcPassed, + bool factoryRerunRequested, + bool userSettingsPresent) { + // A deliberate serial-command rerun overrides everything (bench/service). + if (factoryRerunRequested) { + return QcGateAction::RUN_QC_RERUN; + } + // Already QC'd — normal boot. + if (factoryQcPassed) { + return QcGateAction::SKIP_NORMAL_BOOT; + } + // Existing unit (settings written by v8.0-or-prior firmware): back-fill the + // pass flag and never auto-calibrate. The installed fleet must never see QC. + if (userSettingsPresent) { + return QcGateAction::MARK_LEGACY_AND_SKIP; + } + // Truly fresh NVS: brand-new factory controller. + return QcGateAction::RUN_QC; +} + +// --------------------------------------------------------------------------- +// Throttle calibration sanity gates +// --------------------------------------------------------------------------- + +QcCalResult qcValidateCalibration(uint16_t rawMin, uint16_t rawMax, + uint16_t releaseRecheck, + const QcCalGates& gates) { + if (rawMax <= rawMin || (uint16_t)(rawMax - rawMin) < gates.minSpan) { + return QcCalResult::SPAN_TOO_SMALL; + } + if (rawMin > gates.maxIdle) { + return QcCalResult::IDLE_TOO_HIGH; + } + if (rawMax < gates.minFull) { + return QcCalResult::FULL_TOO_LOW; + } + const uint16_t diff = (releaseRecheck > rawMin) + ? (releaseRecheck - rawMin) + : (rawMin - releaseRecheck); + if (diff > gates.releaseTolerance) { + return QcCalResult::RELEASE_MISMATCH; + } + return QcCalResult::OK; +} + +// --------------------------------------------------------------------------- +// Stability window detector +// --------------------------------------------------------------------------- + +QcStabilityWindow::QcStabilityWindow(uint16_t epsilon, size_t windowSize) + : size_(windowSize == 0 ? 1 : (windowSize > kMaxWindow ? kMaxWindow : windowSize)), + count_(0), + head_(0), + epsilon_(epsilon) { + memset(buf_, 0, sizeof(buf_)); +} + +void QcStabilityWindow::push(uint16_t raw) { + buf_[head_] = raw; + head_ = (head_ + 1) % size_; + if (count_ < size_) { + count_++; + } +} + +void QcStabilityWindow::reset() { + count_ = 0; + head_ = 0; +} + +bool QcStabilityWindow::isStable() const { + if (count_ < size_) { + return false; + } + uint16_t lo = buf_[0]; + uint16_t hi = buf_[0]; + for (size_t i = 1; i < size_; i++) { + if (buf_[i] < lo) lo = buf_[i]; + if (buf_[i] > hi) hi = buf_[i]; + } + return (uint16_t)(hi - lo) <= epsilon_; +} + +uint16_t QcStabilityWindow::median() const { + if (count_ == 0) { + return 0; + } + const size_t n = (count_ < size_) ? count_ : size_; + uint16_t sorted[kMaxWindow]; + memcpy(sorted, buf_, n * sizeof(uint16_t)); + // Insertion sort — n <= 64. + for (size_t i = 1; i < n; i++) { + const uint16_t key = sorted[i]; + size_t j = i; + while (j > 0 && sorted[j - 1] > key) { + sorted[j] = sorted[j - 1]; + j--; + } + sorted[j] = key; + } + return sorted[n / 2]; +} + +// --------------------------------------------------------------------------- +// v8.2 scaffold: calibrated raw->PWM mapping (unwired in v8.1) +// --------------------------------------------------------------------------- + +int qcPotRawToPwmCalibrated(uint16_t raw, + uint16_t potMin, uint16_t potMax, + int escMinPwm, int escMaxPwm, + float bottomPct, float topPct, + uint16_t floorDb) { + if (potMax <= potMin) { + return escMinPwm; // degenerate calibration — always safe idle + } + const float span = static_cast(potMax - potMin); + + float bottomDb = bottomPct * span; + if (bottomDb < static_cast(floorDb)) { + bottomDb = static_cast(floorDb); + } + const float topMargin = topPct * span; + + const float effMin = static_cast(potMin) + bottomDb; + const float effMax = static_cast(potMax) - topMargin; + if (effMax <= effMin) { + return escMinPwm; // margins collapsed the range — safe idle + } + + float r = static_cast(raw); + if (r < effMin) r = effMin; + if (r > effMax) r = effMax; + + const float frac = (r - effMin) / (effMax - effMin); + const int pwm = escMinPwm + + static_cast(frac * static_cast(escMaxPwm - escMinPwm) + 0.5f); + if (pwm < escMinPwm) return escMinPwm; + if (pwm > escMaxPwm) return escMaxPwm; + return pwm; +} + +// --------------------------------------------------------------------------- +// QC record + JSON +// --------------------------------------------------------------------------- + +const char* qcCheckStatusStr(QcCheckStatus s) { + switch (s) { + case QcCheckStatus::PASS: return "pass"; + case QcCheckStatus::FAIL: return "fail"; + case QcCheckStatus::SKIP: return "skip"; + case QcCheckStatus::NOT_RUN: + default: + return "not_run"; + } +} + +static bool checkOk(QcCheckStatus s) { + return s == QcCheckStatus::PASS || s == QcCheckStatus::SKIP; +} + +bool qcRecordAllPassed(const QcRecord& r) { + return checkOk(r.display) && checkOk(r.i2cBaro) && checkOk(r.spiBms) && + checkOk(r.canEsc) && checkOk(r.canBms) && checkOk(r.cpu) && + checkOk(r.nvs) && checkOk(r.throttle) && checkOk(r.cal) && + checkOk(r.buzzer) && checkOk(r.vibe) && checkOk(r.button); +} + +// Append helper: writes either "null" or a quoted string. +static int appendIdField(char* out, size_t remaining, const char* key, + const char* value, bool trailingComma) { + if (value[0] == '\0') { + return snprintf(out, remaining, "\"%s\":null%s", key, trailingComma ? "," : ""); + } + return snprintf(out, remaining, "\"%s\":\"%s\"%s", key, value, trailingComma ? "," : ""); +} + +size_t qcRecordToJson(const QcRecord& r, char* out, size_t outLen) { + if (out == nullptr || outLen == 0) { + return 0; + } + const uint16_t span = (r.potMax > r.potMin) ? (r.potMax - r.potMin) : 0; + size_t pos = 0; + + int n = snprintf(out + pos, outLen - pos, + "{\"qc\":1,\"fw\":\"%s\",\"build\":\"%s\",\"result\":\"%s\"," + "\"pot_min\":%u,\"pot_max\":%u,\"span\":%u,\"cal_saved\":%s," + "\"baro_hpa\":%.1f,\"cpu_c\":%.1f,\"pack_v\":%.1f,", + r.fw, r.build, + qcRecordAllPassed(r) ? "PASSED" : "FAILED", + r.potMin, r.potMax, span, r.calSaved ? "true" : "false", + static_cast(r.baroHpa), + static_cast(r.cpuC), + static_cast(r.packV)); + if (n < 0 || (size_t)n >= outLen - pos) return 0; + pos += (size_t)n; + + n = appendIdField(out + pos, outLen - pos, "esc_hw_id", r.escHwId, true); + if (n < 0 || (size_t)n >= outLen - pos) return 0; + pos += (size_t)n; + n = appendIdField(out + pos, outLen - pos, "esc_sn", r.escSn, true); + if (n < 0 || (size_t)n >= outLen - pos) return 0; + pos += (size_t)n; + n = appendIdField(out + pos, outLen - pos, "bms_id", r.bmsId, true); + if (n < 0 || (size_t)n >= outLen - pos) return 0; + pos += (size_t)n; + + n = snprintf(out + pos, outLen - pos, + "\"checks\":{\"display\":\"%s\",\"i2c_baro\":\"%s\",\"spi_bms\":\"%s\"," + "\"can_esc\":\"%s\",\"can_bms\":\"%s\",\"cpu\":\"%s\",\"nvs\":\"%s\"," + "\"throttle\":\"%s\",\"cal\":\"%s\",\"buzzer\":\"%s\",\"vibe\":\"%s\"," + "\"button\":\"%s\"}}", + qcCheckStatusStr(r.display), qcCheckStatusStr(r.i2cBaro), + qcCheckStatusStr(r.spiBms), qcCheckStatusStr(r.canEsc), + qcCheckStatusStr(r.canBms), qcCheckStatusStr(r.cpu), + qcCheckStatusStr(r.nvs), qcCheckStatusStr(r.throttle), + qcCheckStatusStr(r.cal), qcCheckStatusStr(r.buzzer), + qcCheckStatusStr(r.vibe), qcCheckStatusStr(r.button)); + if (n < 0 || (size_t)n >= outLen - pos) return 0; + pos += (size_t)n; + + return pos; +} diff --git a/test/test_qc/test_qc.cpp b/test/test_qc/test_qc.cpp new file mode 100644 index 0000000..792ee22 --- /dev/null +++ b/test/test_qc/test_qc.cpp @@ -0,0 +1,327 @@ +// Copyright 2026 +// OpenPPG +// +// Native tests for FIRST_BOOT_QC pure logic (gate decision, calibration +// sanity gates, stability window, v8.2 mapping scaffold, JSON record). + +#include +#include + +// Include the real implementation under test (pure — no Arduino deps). +#include "../../inc/sp140/qc_logic.h" +#include "../../src/sp140/qc_logic.cpp" + +// --------------------------------------------------------------------------- +// Gate decision table — the fleet-safety contract. +// --------------------------------------------------------------------------- + +TEST(QcGate, FreshFactoryUnitRunsQc) { + // No factory state, no rerun flag, no user settings => brand-new unit. + EXPECT_EQ(qcGateDecision(false, false, false), QcGateAction::RUN_QC); +} + +TEST(QcGate, ExistingFleetUnitNeverSeesQc) { + // Settings from v8.0-or-prior exist but factory namespace is empty: + // this is an OTA'd customer device. Must back-fill and skip — never QC. + EXPECT_EQ(qcGateDecision(false, false, true), + QcGateAction::MARK_LEGACY_AND_SKIP); +} + +TEST(QcGate, PassedUnitBootsNormally) { + EXPECT_EQ(qcGateDecision(true, false, false), QcGateAction::SKIP_NORMAL_BOOT); + EXPECT_EQ(qcGateDecision(true, false, true), QcGateAction::SKIP_NORMAL_BOOT); +} + +TEST(QcGate, SerialRerunOverridesEverything) { + EXPECT_EQ(qcGateDecision(true, true, true), QcGateAction::RUN_QC_RERUN); + EXPECT_EQ(qcGateDecision(false, true, true), QcGateAction::RUN_QC_RERUN); + EXPECT_EQ(qcGateDecision(true, true, false), QcGateAction::RUN_QC_RERUN); +} + +// There is deliberately NO input that maps a button state to QC entry — the +// gate takes only {factory passed, rerun flag, user settings present}. This +// test documents that contract at compile time by exhaustively covering the +// full input space. +TEST(QcGate, ExhaustiveInputSpace) { + for (int passed = 0; passed <= 1; passed++) { + for (int rerun = 0; rerun <= 1; rerun++) { + for (int user = 0; user <= 1; user++) { + const QcGateAction a = qcGateDecision(passed, rerun, user); + if (rerun) { + EXPECT_EQ(a, QcGateAction::RUN_QC_RERUN); + } else if (passed) { + EXPECT_EQ(a, QcGateAction::SKIP_NORMAL_BOOT); + } else if (user) { + EXPECT_EQ(a, QcGateAction::MARK_LEGACY_AND_SKIP); + } else { + EXPECT_EQ(a, QcGateAction::RUN_QC); + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Calibration sanity gates +// --------------------------------------------------------------------------- + +static const QcCalGates kGates = {2000, 800, 3200, 100}; // span/idle/full/tol + +TEST(QcCalGatesTest, TypicalUnitPasses) { + // Design-doc typical unit: idle ~142, full ~3987, recheck near idle. + EXPECT_EQ(qcValidateCalibration(142, 3987, 150, kGates), QcCalResult::OK); +} + +TEST(QcCalGatesTest, SpanTooSmall) { + // Bad pot / wiring: barely moves. + EXPECT_EQ(qcValidateCalibration(1000, 2500, 1000, kGates), + QcCalResult::SPAN_TOO_SMALL); + // Inverted / equal values are also span failures, never underflow. + EXPECT_EQ(qcValidateCalibration(3000, 3000, 3000, kGates), + QcCalResult::SPAN_TOO_SMALL); + EXPECT_EQ(qcValidateCalibration(3000, 2000, 3000, kGates), + QcCalResult::SPAN_TOO_SMALL); +} + +TEST(QcCalGatesTest, IdleTooHigh) { + // Miswired / stuck-high: never returns near zero. + EXPECT_EQ(qcValidateCalibration(900, 3987, 910, kGates), + QcCalResult::IDLE_TOO_HIGH); +} + +TEST(QcCalGatesTest, FullTooLow) { + // Never reaches full press band. + EXPECT_EQ(qcValidateCalibration(100, 3100, 110, kGates), + QcCalResult::FULL_TOO_LOW); +} + +TEST(QcCalGatesTest, ReleaseMismatch) { + // Sticky lever: re-release lands far from captured idle. + EXPECT_EQ(qcValidateCalibration(142, 3987, 400, kGates), + QcCalResult::RELEASE_MISMATCH); + // Tolerance is symmetric. + EXPECT_EQ(qcValidateCalibration(400, 3987, 150, kGates), + QcCalResult::RELEASE_MISMATCH); +} + +TEST(QcCalGatesTest, BoundaryValues) { + // Exactly at gates: span == minSpan passes, idle == maxIdle passes, + // full == minFull passes, recheck at exact tolerance passes. + EXPECT_EQ(qcValidateCalibration(800, 3200, 800 + kGates.releaseTolerance, + QcCalGates{2400, 800, 3200, 100}), + QcCalResult::OK); +} + +// --------------------------------------------------------------------------- +// Stability window +// --------------------------------------------------------------------------- + +TEST(QcStabilityTest, NotStableUntilWindowFull) { + QcStabilityWindow w(30, 25); + for (int i = 0; i < 24; i++) { + w.push(100); + EXPECT_FALSE(w.isStable()); + } + w.push(100); + EXPECT_TRUE(w.isStable()); +} + +TEST(QcStabilityTest, NoisySignalNotStable) { + QcStabilityWindow w(30, 25); + for (int i = 0; i < 25; i++) { + // Alternate +/- 50 counts around 100 — beyond epsilon 30. + w.push(static_cast((i % 2 == 0) ? 150 : 50)); + } + EXPECT_FALSE(w.isStable()); +} + +TEST(QcStabilityTest, SmallJitterWithinEpsilonIsStable) { + QcStabilityWindow w(30, 25); + for (int i = 0; i < 25; i++) { + w.push(static_cast(100 + (i % 3))); // 100..102 jitter + } + EXPECT_TRUE(w.isStable()); + EXPECT_NEAR(w.median(), 101, 1); +} + +TEST(QcStabilityTest, BecomesStableAfterSettling) { + QcStabilityWindow w(30, 10); + // Operator moving the lever... + for (int i = 0; i < 10; i++) { + w.push(static_cast(500 + i * 100)); + } + EXPECT_FALSE(w.isStable()); + // ...then holds still: the moving window flushes out the ramp. + for (int i = 0; i < 10; i++) { + w.push(3990); + } + EXPECT_TRUE(w.isStable()); + EXPECT_EQ(w.median(), 3990); +} + +TEST(QcStabilityTest, MedianRobustToSingleGlitch) { + QcStabilityWindow w(4095, 25); // wide epsilon; testing median only + for (int i = 0; i < 24; i++) { + w.push(140); + } + w.push(4000); // one glitch sample + EXPECT_EQ(w.median(), 140); +} + +TEST(QcStabilityTest, ResetClears) { + QcStabilityWindow w(30, 5); + for (int i = 0; i < 5; i++) w.push(100); + EXPECT_TRUE(w.isStable()); + w.reset(); + EXPECT_FALSE(w.isStable()); + EXPECT_FALSE(w.isFull()); +} + +// --------------------------------------------------------------------------- +// v8.2 mapping scaffold (pure math — NOT wired into the live throttle path) +// --------------------------------------------------------------------------- + +static const int kEscMin = 1035; +static const int kEscMax = 1950; + +TEST(QcCalibratedMapping, IdleAlwaysMapsToEscMin) { + // Anything at/below the effective minimum is idle. + EXPECT_EQ(qcPotRawToPwmCalibrated(0, 142, 3987, kEscMin, kEscMax, + 0.03f, 0.02f, 50), + kEscMin); + EXPECT_EQ(qcPotRawToPwmCalibrated(142, 142, 3987, kEscMin, kEscMax, + 0.03f, 0.02f, 50), + kEscMin); +} + +TEST(QcCalibratedMapping, FullPressReachesEscMax) { + // The design-doc fix: units whose pot never reaches 4095 still get full + // power once calibrated. + EXPECT_EQ(qcPotRawToPwmCalibrated(3987, 142, 3987, kEscMin, kEscMax, + 0.03f, 0.02f, 50), + kEscMax); + EXPECT_EQ(qcPotRawToPwmCalibrated(4095, 142, 3987, kEscMin, kEscMax, + 0.03f, 0.02f, 50), + kEscMax); +} + +TEST(QcCalibratedMapping, MonotonicThroughRange) { + int last = kEscMin; + for (uint16_t raw = 0; raw <= 4095; raw = static_cast(raw + 64)) { + const int pwm = qcPotRawToPwmCalibrated(raw, 142, 3987, kEscMin, kEscMax, + 0.03f, 0.02f, 50); + EXPECT_GE(pwm, last); + EXPECT_GE(pwm, kEscMin); + EXPECT_LE(pwm, kEscMax); + last = pwm; + } +} + +TEST(QcCalibratedMapping, DegenerateCalibrationIsSafeIdle) { + // Corrupted/backwards calibration can never produce a non-idle command. + EXPECT_EQ(qcPotRawToPwmCalibrated(2000, 3000, 3000, kEscMin, kEscMax, + 0.03f, 0.02f, 50), + kEscMin); + EXPECT_EQ(qcPotRawToPwmCalibrated(2000, 3000, 1000, kEscMin, kEscMax, + 0.03f, 0.02f, 50), + kEscMin); +} + +TEST(QcCalibratedMapping, FloorDeadbandApplies) { + // With a tiny span, the fixed floor deadband dominates the percentage. + const int justAboveMin = qcPotRawToPwmCalibrated( + 1049, 1000, 3200, kEscMin, kEscMax, 0.0f, 0.0f, 50); + EXPECT_EQ(justAboveMin, kEscMin); // inside the 50-count floor deadband +} + +// --------------------------------------------------------------------------- +// QC record + JSON +// --------------------------------------------------------------------------- + +static QcRecord makePassingRecord() { + QcRecord r = {}; + snprintf(r.fw, sizeof(r.fw), "8.1"); + snprintf(r.build, sizeof(r.build), "Jul 4 2026"); + r.potMin = 142; + r.potMax = 3987; + r.calSaved = true; + r.baroHpa = 1002.1f; + r.cpuC = 41.2f; + r.packV = 98.7f; + snprintf(r.escHwId, sizeof(r.escHwId), "0x1A2B"); + snprintf(r.escSn, sizeof(r.escSn), "A1B2C3"); + snprintf(r.bmsId, sizeof(r.bmsId), "BAT-001"); + r.display = r.i2cBaro = r.spiBms = r.canEsc = r.canBms = QcCheckStatus::PASS; + r.cpu = r.nvs = r.throttle = r.cal = QcCheckStatus::PASS; + r.buzzer = r.vibe = r.button = QcCheckStatus::PASS; + return r; +} + +TEST(QcRecordTest, AllPassIsPassed) { + EXPECT_TRUE(qcRecordAllPassed(makePassingRecord())); +} + +TEST(QcRecordTest, SkipDoesNotFailTheUnit) { + QcRecord r = makePassingRecord(); + r.canEsc = QcCheckStatus::SKIP; // bare-controller bench QC, no ESC + r.canBms = QcCheckStatus::SKIP; // no pack attached + EXPECT_TRUE(qcRecordAllPassed(r)); +} + +TEST(QcRecordTest, AnyFailFailsTheUnit) { + QcRecord r = makePassingRecord(); + r.i2cBaro = QcCheckStatus::FAIL; + EXPECT_FALSE(qcRecordAllPassed(r)); +} + +TEST(QcRecordTest, NotRunCountsAsFailure) { + // An interrupted flow must never report PASSED. + QcRecord r = makePassingRecord(); + r.button = QcCheckStatus::NOT_RUN; + EXPECT_FALSE(qcRecordAllPassed(r)); +} + +TEST(QcRecordTest, JsonGolden) { + QcRecord r = makePassingRecord(); + r.canEsc = QcCheckStatus::SKIP; + char buf[512]; + const size_t n = qcRecordToJson(r, buf, sizeof(buf)); + ASSERT_GT(n, 0u); + + const std::string json(buf); + EXPECT_NE(json.find("\"qc\":1"), std::string::npos); + EXPECT_NE(json.find("\"fw\":\"8.1\""), std::string::npos); + EXPECT_NE(json.find("\"result\":\"PASSED\""), std::string::npos); + EXPECT_NE(json.find("\"pot_min\":142"), std::string::npos); + EXPECT_NE(json.find("\"pot_max\":3987"), std::string::npos); + EXPECT_NE(json.find("\"span\":3845"), std::string::npos); + EXPECT_NE(json.find("\"can_esc\":\"skip\""), std::string::npos); + EXPECT_NE(json.find("\"buzzer\":\"pass\""), std::string::npos); + // Must be exactly one JSON object on one line. + EXPECT_EQ(json.front(), '{'); + EXPECT_EQ(json.back(), '}'); + EXPECT_EQ(json.find('\n'), std::string::npos); +} + +TEST(QcRecordTest, JsonNullIds) { + QcRecord r = makePassingRecord(); + r.escHwId[0] = '\0'; + r.escSn[0] = '\0'; + r.bmsId[0] = '\0'; + char buf[512]; + ASSERT_GT(qcRecordToJson(r, buf, sizeof(buf)), 0u); + const std::string json(buf); + EXPECT_NE(json.find("\"esc_hw_id\":null"), std::string::npos); + EXPECT_NE(json.find("\"esc_sn\":null"), std::string::npos); + EXPECT_NE(json.find("\"bms_id\":null"), std::string::npos); +} + +TEST(QcRecordTest, JsonBufferTooSmallReturnsZero) { + char tiny[32]; + EXPECT_EQ(qcRecordToJson(makePassingRecord(), tiny, sizeof(tiny)), 0u); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 7e8144e7334e55eca514b106099deea89dbbd9e5 Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Mon, 6 Jul 2026 14:13:24 -0400 Subject: [PATCH 3/9] feat(qc): boot gate + automatic POST engine + JSON record (serial-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - qcCaptureBootContext() runs BEFORE refreshDeviceData() so a fresh factory board is distinguishable from an existing fleet unit (which gets qc_passed back-filled and never sees QC). - POST as bus-communication tests: I2C (baro probe + sane pressure), SPI (MCP2515 init), CAN-ESC (active DroneCAN hardware-info request/response — never a throttle command), CAN-BMS (passive telemetry listen), CPU temp, factory-NVS round-trip, throttle ADC. - Absent-device skip-confirm: ESC/BMS not detected => operator button press records "skip" (bare-controller bench QC passes); no press => fail. Cosmetic LED cycle only — no operator LED check. - Emits one-line JSON QC record over USBSerial + persists blob for the upcoming BLE fleet-sync characteristic. qc_passed only written when every check passes; cal/interactive checks are NOT_RUN until the next commits, so this intermediate stage can never stamp a pass. - QC tuning constants centralized in shared-config.h. Co-Authored-By: Claude Opus 4.8 --- inc/sp140/first_boot_qc.h | 29 ++++ inc/sp140/shared-config.h | 14 ++ src/sp140/first_boot_qc.cpp | 308 ++++++++++++++++++++++++++++++++++++ src/sp140/main.cpp | 15 ++ 4 files changed, 366 insertions(+) create mode 100644 inc/sp140/first_boot_qc.h create mode 100644 src/sp140/first_boot_qc.cpp diff --git a/inc/sp140/first_boot_qc.h b/inc/sp140/first_boot_qc.h new file mode 100644 index 0000000..a09d88e --- /dev/null +++ b/inc/sp140/first_boot_qc.h @@ -0,0 +1,29 @@ +// Copyright 2026 +// OpenPPG +// +// FIRST_BOOT_QC — factory self-test + per-unit throttle calibration capture. +// Runs as a blocking guided flow inside setup() at the Phase 4/5 boundary +// (display + hardware up, no app tasks running). See FIRST_BOOT_QC.md. +// +// Entry has exactly two paths: truly fresh NVS (new factory unit) or the +// serial "run_qc" command flag. The installed fleet is back-filled as passed +// at first boot on this firmware and never sees the flow. + +#ifndef INC_SP140_FIRST_BOOT_QC_H_ +#define INC_SP140_FIRST_BOOT_QC_H_ + +// Capture boot context BEFORE refreshDeviceData() runs. refreshDeviceData() +// writes defaults into the "openppg" namespace on a fresh unit, which would +// make a brand-new board indistinguishable from an existing fleet unit — so +// the fresh-vs-legacy probe must happen first. Single-threaded setup() only. +void qcCaptureBootContext(); + +// Evaluate the gate (and perform the legacy back-fill / rerun-flag consume +// side effects). Returns true if the QC flow should run this boot. +bool qcShouldRun(); + +// Run the blocking QC flow. Call at the Phase 4/5 boundary in setup(). +// Never arms and never sends throttle/setpoint commands to the ESC. +void runFirstBootQc(); + +#endif // INC_SP140_FIRST_BOOT_QC_H_ diff --git a/inc/sp140/shared-config.h b/inc/sp140/shared-config.h index bea64ad..bbaa92c 100644 --- a/inc/sp140/shared-config.h +++ b/inc/sp140/shared-config.h @@ -21,4 +21,18 @@ #define POT_MIN_VALUE 0 // 12 bit ADC //TODO: use calibration and store in EEPROM #define POT_MAX_VALUE 4095 // 12 bit ADC //TODO: use calibration and store in EEPROM +// FIRST_BOOT_QC tuning (see FIRST_BOOT_QC.md). The live throttle mapping does +// NOT use the calibration in v8.1 — capture only; the mapping switch is v8.2. +#define QC_MIN_SPAN 2000 // reject cal if raw span below this +#define QC_MAX_IDLE 800 // reject cal if released raw above this +#define QC_MIN_FULL 3200 // reject cal if full-press raw below this +#define QC_RELEASE_TOLERANCE 100 // re-release must land within this of raw_min +#define QC_STABLE_EPSILON 30 // max-min counts across a stable window +#define QC_STABLE_WINDOW_SAMPLES 25 // ~500 ms at 50 Hz sampling +#define QC_CONFIRM_TIMEOUT_MS 10000 // pot-confirm window for interactive checks +#define QC_SKIP_CONFIRM_TIMEOUT_MS 15000 // button window to confirm an absent device +#define QC_CAN_POLL_ITERATIONS 10 // POST CAN polls (x interval = ~2 s) +#define QC_CAN_POLL_INTERVAL_MS 200 +#define QC_RECORD_JSON_MAX 512 // QC record JSON buffer size + #endif // INC_SP140_SHARED_CONFIG_H_ diff --git a/src/sp140/first_boot_qc.cpp b/src/sp140/first_boot_qc.cpp new file mode 100644 index 0000000..943983b --- /dev/null +++ b/src/sp140/first_boot_qc.cpp @@ -0,0 +1,308 @@ +// Copyright 2026 +// OpenPPG +// +// FIRST_BOOT_QC flow. Design: FIRST_BOOT_QC.md. Pure decision logic lives in +// qc_logic.cpp (natively tested); this file owns hardware sequencing and runs +// single-threaded inside setup() — no app tasks exist yet, so all polling is +// direct (readESCTelemetry/updateBMSData/readThrottleRaw) and the UI is +// pumped inline. + +#include "sp140/first_boot_qc.h" + +#include "Arduino.h" +#include + +#include "sp140/qc_logic.h" +#include "sp140/factory_settings.h" +#include "sp140/globals.h" +#include "sp140/altimeter.h" +#include "sp140/system_monitors.h" +#include "sp140/throttle.h" +#include "sp140/esc.h" +#include "sp140/bms.h" +#include "sp140/shared-config.h" +#include "../../inc/version.h" +#include "../../inc/sp140/esp32s3-config.h" + +extern const char* buildDate; +extern HardwareConfig board_config; + +// --------------------------------------------------------------------------- +// Boot context (captured before refreshDeviceData() writes defaults) +// --------------------------------------------------------------------------- + +static bool s_userSettingsPresentAtBoot = false; +static bool s_factoryQcPassedAtBoot = false; +static bool s_rerunRequestedAtBoot = false; +static bool s_contextCaptured = false; + +void qcCaptureBootContext() { + factorySettingsInit(); + + // Probe the user namespace read-only with a LOCAL Preferences instance — + // the global one is owned by device_settings and not yet initialized. + Preferences userProbe; + if (userProbe.begin("openppg", true)) { + s_userSettingsPresentAtBoot = userProbe.isKey("ver_major"); + userProbe.end(); + } else { + // Namespace doesn't exist yet => genuinely fresh unit. + s_userSettingsPresentAtBoot = false; + } + + s_factoryQcPassedAtBoot = factoryQcPassed(); + s_rerunRequestedAtBoot = factoryRerunRequested(); + s_contextCaptured = true; +} + +bool qcShouldRun() { + if (!s_contextCaptured) { + // Defensive: without a captured context, never run QC (fail safe for the + // fleet; a factory unit can always be re-triggered via run_qc). + return false; + } + + const QcGateAction action = qcGateDecision( + s_factoryQcPassedAtBoot, s_rerunRequestedAtBoot, + s_userSettingsPresentAtBoot); + + switch (action) { + case QcGateAction::RUN_QC: + USBSerial.println(F("QC: fresh factory unit - entering QC flow")); + return true; + case QcGateAction::RUN_QC_RERUN: + USBSerial.println(F("QC: rerun requested via serial command")); + factoryClearRerunFlag(); // consume so the next boot is normal + return true; + case QcGateAction::MARK_LEGACY_AND_SKIP: + // Existing unit (settings from v8.0-or-prior): back-fill and never + // calibrate. The installed fleet must never see this flow. + USBSerial.println(F("QC: existing unit detected - back-filling qc_passed")); + factoryMarkLegacyUnit(factoryEncodeFw(VERSION_MAJOR, VERSION_MINOR)); + return false; + case QcGateAction::SKIP_NORMAL_BOOT: + default: + return false; + } +} + +// --------------------------------------------------------------------------- +// View seam — commit 2 is serial-only; the LVGL QC screen hooks in via these +// (lvgl_qc_screen.cpp provides rich rendering; serial output stays for the +// bench log either way). +// --------------------------------------------------------------------------- + +static void viewPump() { + // Keep the system responsive / yield to IDLE so the task WDT stays fed. + vTaskDelay(pdMS_TO_TICKS(5)); +} + +static void viewCheckResult(const char* name, QcCheckStatus status) { + USBSerial.printf("QC: %-10s %s\n", name, qcCheckStatusStr(status)); +} + +static void viewPrompt(const char* line1, const char* line2) { + USBSerial.print(F("QC: ")); + USBSerial.print(line1); + if (line2 != nullptr && line2[0] != '\0') { + USBSerial.print(F(" - ")); + USBSerial.print(line2); + } + USBSerial.println(); +} + +// --------------------------------------------------------------------------- +// Direct-poll helpers (no app tasks are running) +// --------------------------------------------------------------------------- + +// Poll the top button (INPUT_PULLUP — LOW = pressed) with debounce. Returns +// true if a debounced press is seen within timeoutMs. +static bool qcWaitButtonPress(uint32_t timeoutMs) { + const uint32_t start = millis(); + uint32_t lowSince = 0; + while (millis() - start < timeoutMs) { + if (digitalRead(board_config.button_top) == LOW) { + if (lowSince == 0) { + lowSince = millis(); + } else if (millis() - lowSince >= 50) { // 50 ms debounce, matches main + // Wait for release so one press can't confirm two prompts. + while (digitalRead(board_config.button_top) == LOW && + millis() - start < timeoutMs) { + viewPump(); + } + return true; + } + } else { + lowSince = 0; + } + viewPump(); + } + return false; +} + +// Absent-device skip-confirm pattern: the operator explicitly acknowledges a +// deliberately missing device (bare-controller bench QC). Button press within +// the window => SKIP; no press => FAIL (a dead attached device must not be +// silently skippable). +static QcCheckStatus qcSkipConfirm(const char* deviceName) { + char line[64]; + snprintf(line, sizeof(line), "%s NOT DETECTED", deviceName); + viewPrompt(line, "press button to confirm testing without it"); + return qcWaitButtonPress(QC_SKIP_CONFIRM_TIMEOUT_MS) ? QcCheckStatus::SKIP + : QcCheckStatus::FAIL; +} + +// --------------------------------------------------------------------------- +// POST checks (bus-communication focus: I2C / SPI / CAN) +// --------------------------------------------------------------------------- + +static QcCheckStatus postCheckI2cBaro(QcRecord* rec) { + if (!bmpPresent) { + return QcCheckStatus::FAIL; + } + const float hpa = getBaroPressure(); + rec->baroHpa = hpa; + return (hpa >= 800.0f && hpa <= 1100.0f) ? QcCheckStatus::PASS + : QcCheckStatus::FAIL; +} + +static QcCheckStatus postCheckSpiBms() { + // MCP2515 responding over SPI at init proves the SPI leg; the CAN traffic + // itself is judged separately in the BMS CAN check. + return bmsCanInitialized ? QcCheckStatus::PASS : QcCheckStatus::FAIL; +} + +// Active DroneCAN request/response — this is the end-to-end CAN TX/RX proof. +// Never a throttle/setpoint command. +static QcCheckStatus postCheckCanEsc(QcRecord* rec) { + if (!escTwaiInitialized) { + return qcSkipConfirm("ESC"); + } + requestEscHardwareInfo(); + for (int i = 0; i < QC_CAN_POLL_ITERATIONS; i++) { + readESCTelemetry(); + if (escTelemetryData.escState == TelemetryState::CONNECTED) { + // Capture identifiers for the QC record (may need a few more polls for + // the hardware-info response; non-fatal if absent). + for (int j = 0; j < QC_CAN_POLL_ITERATIONS && + escTelemetryData.hardware_id == 0; j++) { + readESCTelemetry(); + vTaskDelay(pdMS_TO_TICKS(QC_CAN_POLL_INTERVAL_MS)); + } + if (escTelemetryData.hardware_id != 0) { + snprintf(rec->escHwId, sizeof(rec->escHwId), "0x%04X", + escTelemetryData.hardware_id); + } + bool snNonZero = false; + for (size_t b = 0; b < sizeof(escTelemetryData.sn_code); b++) { + if (escTelemetryData.sn_code[b] != 0) { + snNonZero = true; + break; + } + } + if (snNonZero) { + size_t pos = 0; + for (size_t b = 0; b < sizeof(escTelemetryData.sn_code) && + pos + 2 < sizeof(rec->escSn); b++) { + pos += snprintf(rec->escSn + pos, sizeof(rec->escSn) - pos, "%02X", + escTelemetryData.sn_code[b]); + } + } + return QcCheckStatus::PASS; + } + vTaskDelay(pdMS_TO_TICKS(QC_CAN_POLL_INTERVAL_MS)); + viewPump(); + } + return qcSkipConfirm("ESC"); +} + +// Passive listen for BMS broadcast telemetry. +static QcCheckStatus postCheckCanBms(QcRecord* rec) { + if (!bmsCanInitialized) { + return qcSkipConfirm("BMS"); + } + for (int i = 0; i < QC_CAN_POLL_ITERATIONS; i++) { + updateBMSData(); + if (bmsTelemetryData.bmsState == TelemetryState::CONNECTED) { + const float packV = bmsTelemetryData.battery_voltage; + rec->packV = packV; + snprintf(rec->bmsId, sizeof(rec->bmsId), "%s", + bmsTelemetryData.battery_id); + return (packV >= 20.0f && packV <= 102.0f) ? QcCheckStatus::PASS + : QcCheckStatus::FAIL; + } + vTaskDelay(pdMS_TO_TICKS(QC_CAN_POLL_INTERVAL_MS)); + viewPump(); + } + return qcSkipConfirm("BMS"); +} + +static QcCheckStatus postCheckCpu(QcRecord* rec) { + const float cpuC = getCachedCpuTemperature(); + rec->cpuC = cpuC; + return (cpuC >= -20.0f && cpuC <= 90.0f) ? QcCheckStatus::PASS + : QcCheckStatus::FAIL; +} + +static QcCheckStatus postCheckThrottleAdc() { + const uint16_t raw = readThrottleRaw(); + return (raw <= QC_MAX_IDLE) ? QcCheckStatus::PASS : QcCheckStatus::FAIL; +} + +// --------------------------------------------------------------------------- +// The flow +// --------------------------------------------------------------------------- + +void runFirstBootQc() { + USBSerial.println(F("QC: ===== FACTORY QC START =====")); + + QcRecord rec = {}; + snprintf(rec.fw, sizeof(rec.fw), "%d.%d", VERSION_MAJOR, VERSION_MINOR); + snprintf(rec.build, sizeof(rec.build), "%s", buildDate); + rec.potMin = 0; + rec.potMax = 4095; + + // --- Automatic POST (bus-communication focus) --- + rec.display = QcCheckStatus::PASS; // implicit: the UI is rendering + viewCheckResult("display", rec.display); + + rec.i2cBaro = postCheckI2cBaro(&rec); + viewCheckResult("i2c_baro", rec.i2cBaro); + + rec.spiBms = postCheckSpiBms(); + viewCheckResult("spi_bms", rec.spiBms); + + rec.canEsc = postCheckCanEsc(&rec); + viewCheckResult("can_esc", rec.canEsc); + + rec.canBms = postCheckCanBms(&rec); + viewCheckResult("can_bms", rec.canBms); + + rec.cpu = postCheckCpu(&rec); + viewCheckResult("cpu", rec.cpu); + + rec.nvs = factoryNvsRoundTrip() ? QcCheckStatus::PASS : QcCheckStatus::FAIL; + viewCheckResult("nvs", rec.nvs); + + rec.throttle = postCheckThrottleAdc(); + viewCheckResult("throttle", rec.throttle); + + // --- Guided calibration + interactive checks (later commits) --- + // NOT_RUN counts as failure, so a partially-implemented flow can never + // stamp qc_passed. + + // --- Persist + report --- + const bool passed = qcRecordAllPassed(rec); + if (passed) { + factoryWriteQcResult(true, factoryEncodeFw(VERSION_MAJOR, VERSION_MINOR)); + } + + char json[QC_RECORD_JSON_MAX]; + if (qcRecordToJson(rec, json, sizeof(json)) > 0) { + factoryWriteQcRecordBlob(json, strlen(json) + 1); // include NUL + USBSerial.println(json); + } + + USBSerial.printf("QC: ===== FACTORY QC %s =====\n", + passed ? "PASSED" : "FAILED"); +} diff --git a/src/sp140/main.cpp b/src/sp140/main.cpp index ee43dc8..e9843a7 100644 --- a/src/sp140/main.cpp +++ b/src/sp140/main.cpp @@ -42,6 +42,7 @@ #include "../../inc/sp140/buzzer.h" #include "../../inc/sp140/device_state.h" #include "../../inc/sp140/diagnostics.h" +#include "../../inc/sp140/first_boot_qc.h" #include "../../inc/sp140/led.h" #include "../../inc/sp140/mode.h" #include "../../inc/sp140/throttle.h" @@ -666,6 +667,11 @@ void setup() { USBSerial.println(buildDate); diagnosticsInit(); + // Capture QC boot context BEFORE refreshDeviceData(): on a fresh unit + // refreshDeviceData() writes defaults into the "openppg" namespace, which + // would make a brand-new factory board look like an existing fleet unit. + qcCaptureBootContext(); + // Load device config from EEPROM first - may contain pin mappings refreshDeviceData(); printBootMessage(); @@ -784,6 +790,15 @@ void setup() { xSemaphoreGive(lvglMutex); } + // ========================================================================= + // PHASE 4.5: Factory QC (fresh factory units / serial-requested rerun ONLY; + // the installed fleet is back-filled and never enters this flow). Blocking + // guided flow — display + hardware are up, no app tasks are running yet. + // ========================================================================= + if (qcShouldRun()) { + runFirstBootQc(); + } + // ========================================================================= // PHASE 5: Services Initialization // ========================================================================= From bca89e50126af2f7a199123713e04518bf0bc8da Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Mon, 6 Jul 2026 14:28:06 -0400 Subject: [PATCH 4/9] feat(qc): LVGL QC screen with checklist, guided prompt, and result banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 160x128 three-view screen: POST checklist (8 rows, name + colored status), guided prompt (big instruction, live value in montserrat_28, progress/countdown bar), and full-screen PASS/FAIL banner with failed-check detail. Wired into the QC flow's view seam (serial output kept for the bench log); button-wait now shows a live countdown. Screenshot suite gains 4 QC states (checklist-in-progress, squeeze prompt, passed and failed banners) with generated references, via both the CMake harness (CI path) and the pio native-screenshot filter. Note: SplashScreen_Light/Dark screenshot failures and the pio native-screenshot env linker error on macOS are pre-existing on the base branch (verified via stash) — untouched here. Co-Authored-By: Claude Opus 4.8 --- inc/sp140/lvgl/lvgl_qc_screen.h | 49 ++++ platformio.ini | 1 + src/sp140/first_boot_qc.cpp | 76 +++++- src/sp140/lvgl/lvgl_qc_screen.cpp | 250 ++++++++++++++++++ test/test_screenshots/CMakeLists.txt | 1 + .../reference/qc_banner_failed.bmp | Bin 0 -> 61494 bytes .../reference/qc_banner_passed.bmp | Bin 0 -> 61494 bytes .../reference/qc_checklist_progress.bmp | Bin 0 -> 61494 bytes .../reference/qc_prompt_squeeze.bmp | Bin 0 -> 61494 bytes test/test_screenshots/test_screenshots.cpp | 73 +++++ 10 files changed, 448 insertions(+), 2 deletions(-) create mode 100644 inc/sp140/lvgl/lvgl_qc_screen.h create mode 100644 src/sp140/lvgl/lvgl_qc_screen.cpp create mode 100644 test/test_screenshots/reference/qc_banner_failed.bmp create mode 100644 test/test_screenshots/reference/qc_banner_passed.bmp create mode 100644 test/test_screenshots/reference/qc_checklist_progress.bmp create mode 100644 test/test_screenshots/reference/qc_prompt_squeeze.bmp diff --git a/inc/sp140/lvgl/lvgl_qc_screen.h b/inc/sp140/lvgl/lvgl_qc_screen.h new file mode 100644 index 0000000..9e19486 --- /dev/null +++ b/inc/sp140/lvgl/lvgl_qc_screen.h @@ -0,0 +1,49 @@ +// Copyright 2026 +// OpenPPG +// +// Factory QC screen (FIRST_BOOT_QC). Three views on one screen object: +// - checklist: title + up to 8 POST rows (name left, status right) +// - prompt: big instruction + live value + progress bar (guided steps) +// - banner: full-screen PASSED / FAILED result +// Driven single-threaded from the QC flow in first_boot_qc.cpp; also compiled +// into the native screenshot harness. + +#ifndef INC_SP140_LVGL_LVGL_QC_SCREEN_H_ +#define INC_SP140_LVGL_LVGL_QC_SCREEN_H_ + +#include +#include "sp140/qc_logic.h" + +// Max rows in the checklist view (POST checks). +#define QC_SCREEN_MAX_ROWS 8 + +// Create + load the QC screen (checklist view visible, all rows pending). +void setupQcScreen(bool darkMode); + +// Update one checklist row. `value` is optional right-aligned detail text +// (e.g. "1002 hPa"); pass nullptr for none. +void qcScreenSetCheck(uint8_t row, const char* name, QcCheckStatus status, + const char* value); + +// Switch to the guided prompt view. `instruction` is the big line +// ("RELEASE THROTTLE"), `subtext` the smaller helper line. +void qcScreenPrompt(const char* instruction, const char* subtext); + +// Update the large live value on the prompt view (pre-formatted text — +// raw pot counts, countdown seconds, etc.). +void qcScreenPromptValue(const char* text); + +// Update the prompt progress bar (0-100). Used for stability progress and +// confirm countdowns. +void qcScreenPromptProgress(uint8_t pct); + +// Return to the checklist view. +void qcScreenShowChecklist(); + +// Full-screen final banner. `detail` lists failed/skipped checks (may be ""). +void qcScreenBanner(bool passed, const char* detail); + +// Delete the QC screen and load `nextScreen` (normally main_screen). +void teardownQcScreen(lv_obj_t* nextScreen); + +#endif // INC_SP140_LVGL_LVGL_QC_SCREEN_H_ diff --git a/platformio.ini b/platformio.ini index 4f1654e..d710d7f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -115,6 +115,7 @@ build_src_filter = + + + + + test_filter = test_screenshots lib_deps = lvgl/lvgl@^9.5.0 diff --git a/src/sp140/first_boot_qc.cpp b/src/sp140/first_boot_qc.cpp index 943983b..32759dc 100644 --- a/src/sp140/first_boot_qc.cpp +++ b/src/sp140/first_boot_qc.cpp @@ -11,6 +11,8 @@ #include "Arduino.h" #include +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include "sp140/qc_logic.h" #include "sp140/factory_settings.h" @@ -21,11 +23,14 @@ #include "sp140/esc.h" #include "sp140/bms.h" #include "sp140/shared-config.h" +#include "sp140/lvgl/lvgl_qc_screen.h" +#include "sp140/lvgl/lvgl_main_screen.h" #include "../../inc/version.h" #include "../../inc/sp140/esp32s3-config.h" extern const char* buildDate; extern HardwareConfig board_config; +extern SemaphoreHandle_t lvglMutex; // --------------------------------------------------------------------------- // Boot context (captured before refreshDeviceData() writes defaults) @@ -92,13 +97,23 @@ bool qcShouldRun() { // bench log either way). // --------------------------------------------------------------------------- +static uint8_t s_checkRow = 0; + static void viewPump() { - // Keep the system responsive / yield to IDLE so the task WDT stays fed. + // Render + keep the system responsive / yield to IDLE so the task WDT + // stays fed. Single-threaded: no other task contends for LVGL or SPI. + lv_timer_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } static void viewCheckResult(const char* name, QcCheckStatus status) { USBSerial.printf("QC: %-10s %s\n", name, qcCheckStatusStr(status)); + qcScreenShowChecklist(); + if (s_checkRow < QC_SCREEN_MAX_ROWS) { + qcScreenSetCheck(s_checkRow, name, status, nullptr); + s_checkRow++; + } + viewPump(); } static void viewPrompt(const char* line1, const char* line2) { @@ -109,6 +124,8 @@ static void viewPrompt(const char* line1, const char* line2) { USBSerial.print(line2); } USBSerial.println(); + qcScreenPrompt(line1, line2); + viewPump(); } // --------------------------------------------------------------------------- @@ -116,11 +133,20 @@ static void viewPrompt(const char* line1, const char* line2) { // --------------------------------------------------------------------------- // Poll the top button (INPUT_PULLUP — LOW = pressed) with debounce. Returns -// true if a debounced press is seen within timeoutMs. +// true if a debounced press is seen within timeoutMs. Shows a live countdown +// on the prompt view. static bool qcWaitButtonPress(uint32_t timeoutMs) { const uint32_t start = millis(); uint32_t lowSince = 0; while (millis() - start < timeoutMs) { + const uint32_t elapsed = millis() - start; + char countdown[16]; + snprintf(countdown, sizeof(countdown), "%us", + (unsigned int)((timeoutMs - elapsed) / 1000 + 1)); + qcScreenPromptValue(countdown); + qcScreenPromptProgress( + static_cast(100 - (elapsed * 100) / timeoutMs)); + if (digitalRead(board_config.button_top) == LOW) { if (lowSince == 0) { lowSince = millis(); @@ -253,9 +279,30 @@ static QcCheckStatus postCheckThrottleAdc() { // The flow // --------------------------------------------------------------------------- +// Append a failed/skipped check name to the banner detail line. +static void appendCheckNote(char* buf, size_t bufLen, const char* name, + QcCheckStatus status) { + if (status != QcCheckStatus::FAIL && status != QcCheckStatus::SKIP) { + return; + } + const size_t used = strlen(buf); + snprintf(buf + used, bufLen - used, "%s%s%s", + used > 0 ? " " : "", name, + status == QcCheckStatus::SKIP ? "(skip)" : ""); +} + void runFirstBootQc() { USBSerial.println(F("QC: ===== FACTORY QC START =====")); + // Single-threaded here, but take the LVGL mutex to keep the same invariant + // the splash/main-screen setup path uses. + if (lvglMutex != NULL) { + xSemaphoreTake(lvglMutex, portMAX_DELAY); + } + s_checkRow = 0; + setupQcScreen(deviceData.theme == 1); + viewPump(); + QcRecord rec = {}; snprintf(rec.fw, sizeof(rec.fw), "%d.%d", VERSION_MAJOR, VERSION_MINOR); snprintf(rec.build, sizeof(rec.build), "%s", buildDate); @@ -303,6 +350,31 @@ void runFirstBootQc() { USBSerial.println(json); } + // --- Final banner (hold ~5 s), then hand the display back --- + char detail[128] = ""; + appendCheckNote(detail, sizeof(detail), "baro", rec.i2cBaro); + appendCheckNote(detail, sizeof(detail), "spi", rec.spiBms); + appendCheckNote(detail, sizeof(detail), "esc", rec.canEsc); + appendCheckNote(detail, sizeof(detail), "bms", rec.canBms); + appendCheckNote(detail, sizeof(detail), "cpu", rec.cpu); + appendCheckNote(detail, sizeof(detail), "nvs", rec.nvs); + appendCheckNote(detail, sizeof(detail), "throttle", rec.throttle); + appendCheckNote(detail, sizeof(detail), "cal", rec.cal); + appendCheckNote(detail, sizeof(detail), "buzzer", rec.buzzer); + appendCheckNote(detail, sizeof(detail), "vibe", rec.vibe); + appendCheckNote(detail, sizeof(detail), "button", rec.button); + qcScreenBanner(passed, detail); + const uint32_t bannerStart = millis(); + while (millis() - bannerStart < 5000) { + viewPump(); + } + teardownQcScreen(main_screen); + viewPump(); + if (lvglMutex != NULL && + xSemaphoreGetMutexHolder(lvglMutex) == xTaskGetCurrentTaskHandle()) { + xSemaphoreGive(lvglMutex); + } + USBSerial.printf("QC: ===== FACTORY QC %s =====\n", passed ? "PASSED" : "FAILED"); } diff --git a/src/sp140/lvgl/lvgl_qc_screen.cpp b/src/sp140/lvgl/lvgl_qc_screen.cpp new file mode 100644 index 0000000..85c6b74 --- /dev/null +++ b/src/sp140/lvgl/lvgl_qc_screen.cpp @@ -0,0 +1,250 @@ +// Copyright 2026 +// OpenPPG +// +// Factory QC screen implementation. 160x128 ST7735. See lvgl_qc_screen.h. + +#include "../../../inc/sp140/lvgl/lvgl_qc_screen.h" + +#include + +// --------------------------------------------------------------------------- +// Colors +// --------------------------------------------------------------------------- + +#define QC_COLOR_PASS lv_color_hex(0x00A650) // green +#define QC_COLOR_FAIL lv_color_hex(0xE01010) // red +#define QC_COLOR_SKIP lv_color_hex(0xE0A000) // amber +#define QC_COLOR_PEND lv_color_hex(0x808080) // gray + +// --------------------------------------------------------------------------- +// Screen objects +// --------------------------------------------------------------------------- + +static lv_obj_t* qc_screen = NULL; +static bool qc_dark = false; + +// checklist view +static lv_obj_t* list_view = NULL; +static lv_obj_t* row_name_labels[QC_SCREEN_MAX_ROWS] = {NULL}; +static lv_obj_t* row_status_labels[QC_SCREEN_MAX_ROWS] = {NULL}; + +// prompt view +static lv_obj_t* prompt_view = NULL; +static lv_obj_t* prompt_instruction = NULL; +static lv_obj_t* prompt_value = NULL; +static lv_obj_t* prompt_subtext = NULL; +static lv_obj_t* prompt_bar = NULL; + +// banner view +static lv_obj_t* banner_view = NULL; +static lv_obj_t* banner_title = NULL; +static lv_obj_t* banner_detail = NULL; + +static lv_color_t qcBgColor() { + return qc_dark ? lv_color_black() : lv_color_white(); +} + +static lv_color_t qcFgColor() { + return qc_dark ? lv_color_white() : lv_color_black(); +} + +static void qcShowOnly(lv_obj_t* view) { + lv_obj_add_flag(list_view, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(prompt_view, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(banner_view, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(view, LV_OBJ_FLAG_HIDDEN); +} + +// Bare full-size container with no LVGL default chrome. +static lv_obj_t* qcMakeView(lv_obj_t* parent) { + lv_obj_t* v = lv_obj_create(parent); + lv_obj_set_size(v, 160, 128); + lv_obj_set_pos(v, 0, 0); + lv_obj_remove_flag(v, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(v, 0, LV_PART_MAIN); + lv_obj_set_style_radius(v, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(v, 0, LV_PART_MAIN); + lv_obj_set_style_bg_opa(v, LV_OPA_0, LV_PART_MAIN); + return v; +} + +void setupQcScreen(bool darkMode) { + qc_dark = darkMode; + + if (qc_screen != NULL) { + lv_obj_delete(qc_screen); + qc_screen = NULL; + } + + qc_screen = lv_obj_create(NULL); + lv_obj_remove_flag(qc_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(qc_screen, qcBgColor(), LV_PART_MAIN); + + // ---- checklist view ---- + list_view = qcMakeView(qc_screen); + + lv_obj_t* title = lv_label_create(list_view); + lv_label_set_text(title, "FACTORY QC"); + lv_obj_set_style_text_font(title, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(title, qcFgColor(), 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 2); + + for (uint8_t i = 0; i < QC_SCREEN_MAX_ROWS; i++) { + const int y = 17 + i * 13; + + row_name_labels[i] = lv_label_create(list_view); + lv_label_set_text(row_name_labels[i], ""); + lv_obj_set_style_text_font(row_name_labels[i], &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(row_name_labels[i], qcFgColor(), 0); + lv_obj_set_pos(row_name_labels[i], 4, y); + + row_status_labels[i] = lv_label_create(list_view); + lv_label_set_text(row_status_labels[i], ""); + lv_obj_set_style_text_font(row_status_labels[i], &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(row_status_labels[i], QC_COLOR_PEND, 0); + lv_obj_set_width(row_status_labels[i], 76); + lv_obj_set_style_text_align(row_status_labels[i], LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_set_pos(row_status_labels[i], 80, y); + } + + // ---- prompt view ---- + prompt_view = qcMakeView(qc_screen); + + prompt_instruction = lv_label_create(prompt_view); + lv_label_set_text(prompt_instruction, ""); + lv_obj_set_style_text_font(prompt_instruction, &lv_font_montserrat_18, 0); + lv_obj_set_style_text_color(prompt_instruction, qcFgColor(), 0); + lv_obj_set_width(prompt_instruction, 152); + lv_obj_set_style_text_align(prompt_instruction, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_long_mode(prompt_instruction, LV_LABEL_LONG_WRAP); + lv_obj_align(prompt_instruction, LV_ALIGN_TOP_MID, 0, 6); + + prompt_value = lv_label_create(prompt_view); + lv_label_set_text(prompt_value, ""); + lv_obj_set_style_text_font(prompt_value, &lv_font_montserrat_28, 0); + lv_obj_set_style_text_color(prompt_value, qcFgColor(), 0); + lv_obj_align(prompt_value, LV_ALIGN_CENTER, 0, 8); + + prompt_subtext = lv_label_create(prompt_view); + lv_label_set_text(prompt_subtext, ""); + lv_obj_set_style_text_font(prompt_subtext, &lv_font_montserrat_10, 0); + lv_obj_set_style_text_color(prompt_subtext, qcFgColor(), 0); + lv_obj_set_width(prompt_subtext, 152); + lv_obj_set_style_text_align(prompt_subtext, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_long_mode(prompt_subtext, LV_LABEL_LONG_WRAP); + lv_obj_align(prompt_subtext, LV_ALIGN_BOTTOM_MID, 0, -14); + + prompt_bar = lv_bar_create(prompt_view); + lv_obj_set_size(prompt_bar, 152, 6); + lv_obj_align(prompt_bar, LV_ALIGN_BOTTOM_MID, 0, -4); + lv_bar_set_range(prompt_bar, 0, 100); + lv_bar_set_value(prompt_bar, 0, LV_ANIM_OFF); + lv_obj_set_style_bg_color(prompt_bar, QC_COLOR_PEND, LV_PART_MAIN); + lv_obj_set_style_bg_color(prompt_bar, QC_COLOR_PASS, LV_PART_INDICATOR); + + // ---- banner view ---- + banner_view = qcMakeView(qc_screen); + lv_obj_set_style_bg_opa(banner_view, LV_OPA_100, LV_PART_MAIN); + + banner_title = lv_label_create(banner_view); + lv_label_set_text(banner_title, ""); + lv_obj_set_style_text_font(banner_title, &lv_font_montserrat_28, 0); + lv_obj_set_style_text_color(banner_title, lv_color_white(), 0); + lv_obj_align(banner_title, LV_ALIGN_CENTER, 0, -18); + + banner_detail = lv_label_create(banner_view); + lv_label_set_text(banner_detail, ""); + lv_obj_set_style_text_font(banner_detail, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(banner_detail, lv_color_white(), 0); + lv_obj_set_width(banner_detail, 152); + lv_obj_set_style_text_align(banner_detail, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_long_mode(banner_detail, LV_LABEL_LONG_WRAP); + lv_obj_align(banner_detail, LV_ALIGN_CENTER, 0, 22); + + qcShowOnly(list_view); + lv_screen_load(qc_screen); +} + +void qcScreenSetCheck(uint8_t row, const char* name, QcCheckStatus status, + const char* value) { + if (qc_screen == NULL || row >= QC_SCREEN_MAX_ROWS) { + return; + } + lv_label_set_text(row_name_labels[row], name); + + char statusText[32]; + const char* word; + lv_color_t color; + switch (status) { + case QcCheckStatus::PASS: word = "OK"; color = QC_COLOR_PASS; break; + case QcCheckStatus::FAIL: word = "FAIL"; color = QC_COLOR_FAIL; break; + case QcCheckStatus::SKIP: word = "SKIP"; color = QC_COLOR_SKIP; break; + case QcCheckStatus::NOT_RUN: + default: word = "..."; color = QC_COLOR_PEND; break; + } + if (value != NULL && value[0] != '\0') { + snprintf(statusText, sizeof(statusText), "%s %s", value, word); + } else { + snprintf(statusText, sizeof(statusText), "%s", word); + } + lv_label_set_text(row_status_labels[row], statusText); + lv_obj_set_style_text_color(row_status_labels[row], color, 0); +} + +void qcScreenPrompt(const char* instruction, const char* subtext) { + if (qc_screen == NULL) { + return; + } + lv_label_set_text(prompt_instruction, instruction != NULL ? instruction : ""); + lv_label_set_text(prompt_subtext, subtext != NULL ? subtext : ""); + lv_label_set_text(prompt_value, ""); + lv_bar_set_value(prompt_bar, 0, LV_ANIM_OFF); + qcShowOnly(prompt_view); +} + +void qcScreenPromptValue(const char* text) { + if (qc_screen == NULL) { + return; + } + lv_label_set_text(prompt_value, text != NULL ? text : ""); +} + +void qcScreenPromptProgress(uint8_t pct) { + if (qc_screen == NULL) { + return; + } + lv_bar_set_value(prompt_bar, pct > 100 ? 100 : pct, LV_ANIM_OFF); +} + +void qcScreenShowChecklist() { + if (qc_screen == NULL) { + return; + } + qcShowOnly(list_view); +} + +void qcScreenBanner(bool passed, const char* detail) { + if (qc_screen == NULL) { + return; + } + lv_obj_set_style_bg_color(banner_view, + passed ? QC_COLOR_PASS : QC_COLOR_FAIL, + LV_PART_MAIN); + lv_label_set_text(banner_title, passed ? "QC PASSED" : "QC FAILED"); + lv_label_set_text(banner_detail, detail != NULL ? detail : ""); + qcShowOnly(banner_view); +} + +void teardownQcScreen(lv_obj_t* nextScreen) { + if (qc_screen == NULL) { + return; + } + if (nextScreen != NULL) { + lv_screen_load(nextScreen); + } + lv_obj_delete(qc_screen); + qc_screen = NULL; + list_view = NULL; + prompt_view = NULL; + banner_view = NULL; +} diff --git a/test/test_screenshots/CMakeLists.txt b/test/test_screenshots/CMakeLists.txt index 37e5cba..cc42b05 100644 --- a/test/test_screenshots/CMakeLists.txt +++ b/test/test_screenshots/CMakeLists.txt @@ -63,6 +63,7 @@ set(UI_SOURCES ${PROJECT_ROOT}/src/sp140/lvgl/lvgl_main_screen.cpp ${PROJECT_ROOT}/src/sp140/lvgl/lvgl_updates.cpp ${PROJECT_ROOT}/src/sp140/lvgl/lvgl_alerts.cpp + ${PROJECT_ROOT}/src/sp140/lvgl/lvgl_qc_screen.cpp ) # Emulator sources diff --git a/test/test_screenshots/reference/qc_banner_failed.bmp b/test/test_screenshots/reference/qc_banner_failed.bmp new file mode 100644 index 0000000000000000000000000000000000000000..fbcfce988decdfc99d68873fb74b46cb19ed0472 GIT binary patch literal 61494 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 literal 0 HcmV?d00001 diff --git a/test/test_screenshots/reference/qc_checklist_progress.bmp b/test/test_screenshots/reference/qc_checklist_progress.bmp new file mode 100644 index 0000000000000000000000000000000000000000..2c146705d3b7350dd1b98ea904b71da2310e11ba GIT binary patch literal 61494 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 literal 0 HcmV?d00001 diff --git a/test/test_screenshots/test_screenshots.cpp b/test/test_screenshots/test_screenshots.cpp index 05e95e2..c527304 100644 --- a/test/test_screenshots/test_screenshots.cpp +++ b/test/test_screenshots/test_screenshots.cpp @@ -704,3 +704,76 @@ TEST_F(ScreenshotTest, SplashScreen_Dark) { emulator_init_display(true); render_splash("splash_dark", true); } + +// ============================================================ +// FIRST_BOOT_QC screen states +// ============================================================ + +#include "sp140/lvgl/lvgl_qc_screen.h" + +// Render the current QC screen state, save + compare like render_and_save. +static void qc_render_and_save(const char* name) { + emulator_render_frame(); + + char out_path[256]; + snprintf(out_path, sizeof(out_path), "%s/%s.bmp", OUTPUT_DIR, name); + ASSERT_TRUE(emulator_save_bmp(out_path)) << "Failed to save " << out_path; + + char ref_path[256]; + snprintf(ref_path, sizeof(ref_path), "%s/%s.bmp", REFERENCE_DIR, name); + if (file_exists(ref_path)) { + int diff = emulator_compare_bmp(ref_path, out_path); + if (diff > 0) { + char diff_path[256]; + snprintf(diff_path, sizeof(diff_path), "%s/%s_diff.bmp", OUTPUT_DIR, name); + emulator_save_diff_bmp(ref_path, out_path, diff_path); + EXPECT_EQ(0, diff) + << "Screenshot regression: " << name << " has " << diff << " differing pixels"; + } + } else { + printf(" [INFO] No reference for '%s' - generating initial reference\n", name); + FILE* src = fopen(out_path, "rb"); + FILE* dst = fopen(ref_path, "wb"); + if (src && dst) { char buf[4096]; size_t n; while ((n = fread(buf, 1, sizeof(buf), src)) > 0) fwrite(buf, 1, n, dst); } + if (src) fclose(src); + if (dst) fclose(dst); + } + + teardownQcScreen(NULL); +} + +TEST_F(ScreenshotTest, QcScreen_ChecklistInProgress) { + emulator_init_display(false); + setupQcScreen(false); + qcScreenSetCheck(0, "display", QcCheckStatus::PASS, nullptr); + qcScreenSetCheck(1, "i2c baro", QcCheckStatus::PASS, "1002"); + qcScreenSetCheck(2, "spi bms", QcCheckStatus::PASS, nullptr); + qcScreenSetCheck(3, "can esc", QcCheckStatus::SKIP, nullptr); + qcScreenSetCheck(4, "can bms", QcCheckStatus::FAIL, nullptr); + qcScreenSetCheck(5, "cpu", QcCheckStatus::PASS, "41C"); + qcScreenSetCheck(6, "nvs", QcCheckStatus::NOT_RUN, nullptr); + qc_render_and_save("qc_checklist_progress"); +} + +TEST_F(ScreenshotTest, QcScreen_CalSqueezePrompt) { + emulator_init_display(false); + setupQcScreen(false); + qcScreenPrompt("SQUEEZE FULL", "hold steady until captured"); + qcScreenPromptValue("3987"); + qcScreenPromptProgress(64); + qc_render_and_save("qc_prompt_squeeze"); +} + +TEST_F(ScreenshotTest, QcScreen_BannerPassed) { + emulator_init_display(false); + setupQcScreen(false); + qcScreenBanner(true, ""); + qc_render_and_save("qc_banner_passed"); +} + +TEST_F(ScreenshotTest, QcScreen_BannerFailed) { + emulator_init_display(false); + setupQcScreen(false); + qcScreenBanner(false, "esc(skip) cal buzzer"); + qc_render_and_save("qc_banner_failed"); +} From 41def2cbe6b5a35a4043654fe9d614f5fa65e4d6 Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Mon, 6 Jul 2026 14:31:05 -0400 Subject: [PATCH 5/9] feat(qc): screen-guided throttle calibration capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release -> squeeze -> release-recheck, each auto-advancing on a 50 Hz stability window (25 samples within 30 counts; median captured) with live raw value + fill progress on the display — no button involved. Sanity gates (span >= 2000, idle <= 800, full >= 3200, release-recheck tolerance) reject bad pots; only a passing capture writes pot_min/max to the factory namespace. The live throttle mapping is untouched — capture-only in v8.1; the mapping switch is v8.2, data-gated. Per-step 60 s operator timeout fails cal rather than hanging the line. Co-Authored-By: Claude Opus 4.8 --- inc/sp140/shared-config.h | 1 + src/sp140/first_boot_qc.cpp | 102 +++++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/inc/sp140/shared-config.h b/inc/sp140/shared-config.h index bbaa92c..2010a2f 100644 --- a/inc/sp140/shared-config.h +++ b/inc/sp140/shared-config.h @@ -33,6 +33,7 @@ #define QC_SKIP_CONFIRM_TIMEOUT_MS 15000 // button window to confirm an absent device #define QC_CAN_POLL_ITERATIONS 10 // POST CAN polls (x interval = ~2 s) #define QC_CAN_POLL_INTERVAL_MS 200 +#define QC_CAL_STEP_TIMEOUT_MS 60000 // per calibration step (operator paced) #define QC_RECORD_JSON_MAX 512 // QC record JSON buffer size #endif // INC_SP140_SHARED_CONFIG_H_ diff --git a/src/sp140/first_boot_qc.cpp b/src/sp140/first_boot_qc.cpp index 32759dc..0d60258 100644 --- a/src/sp140/first_boot_qc.cpp +++ b/src/sp140/first_boot_qc.cpp @@ -178,6 +178,102 @@ static QcCheckStatus qcSkipConfirm(const char* deviceName) { : QcCheckStatus::FAIL; } +// Show a brief step result on the prompt view (cal + interactive checks live +// outside the 8-row POST checklist). +static void viewStepResult(const char* name, QcCheckStatus status) { + USBSerial.printf("QC: %-10s %s\n", name, qcCheckStatusStr(status)); + char line[48]; + snprintf(line, sizeof(line), "%s: %s", name, qcCheckStatusStr(status)); + qcScreenPrompt(line, ""); + const uint32_t start = millis(); + while (millis() - start < 900) { + viewPump(); + } +} + +// --------------------------------------------------------------------------- +// Guided throttle calibration (CAPTURE ONLY — the live mapping stays on the +// fixed 0..4095 curve in v8.1; the switch to calibrated endpoints is v8.2, +// gated on the data these captures produce.) +// --------------------------------------------------------------------------- + +// Screen-guided, auto-advancing capture: sample the pot at ~50 Hz into a +// stability window; capture the median once the full window sits within +// epsilon. No button involved. Returns false on step timeout. +static bool qcCaptureStableRaw(const char* instruction, const char* subtext, + uint16_t* outValue) { + viewPrompt(instruction, subtext); + QcStabilityWindow window(QC_STABLE_EPSILON, QC_STABLE_WINDOW_SAMPLES); + const uint32_t start = millis(); + uint32_t lastSample = 0; + uint32_t sampleCount = 0; + + while (millis() - start < QC_CAL_STEP_TIMEOUT_MS) { + const uint32_t now = millis(); + if (now - lastSample >= 20) { // ~50 Hz sampling + lastSample = now; + const uint16_t raw = readThrottleRaw(); + window.push(raw); + sampleCount++; + + char valText[16]; + snprintf(valText, sizeof(valText), "%u", raw); + qcScreenPromptValue(valText); + // Progress = window fill; holds at 100 while waiting for stability. + const uint8_t pct = window.isFull() + ? 100 + : static_cast( + (sampleCount * 100) / QC_STABLE_WINDOW_SAMPLES); + qcScreenPromptProgress(pct); + + if (window.isFull() && window.isStable()) { + *outValue = window.median(); + return true; + } + } + viewPump(); + } + return false; +} + +// Full calibration sequence: release -> squeeze -> release recheck, then the +// sanity gates. Saves to the factory namespace only when everything passes. +static QcCheckStatus qcRunThrottleCalibration(QcRecord* rec) { + uint16_t rawMin = 0; + uint16_t rawMax = 0; + uint16_t rawRecheck = 0; + + if (!qcCaptureStableRaw("RELEASE THROTTLE", "let go fully and hold still", + &rawMin) || + !qcCaptureStableRaw("SQUEEZE FULL", "hold full throttle steady", + &rawMax) || + !qcCaptureStableRaw("RELEASE AGAIN", "let go fully and hold still", + &rawRecheck)) { + USBSerial.println(F("QC: calibration step timed out")); + return QcCheckStatus::FAIL; + } + + rec->potMin = rawMin; + rec->potMax = rawMax; + + const QcCalGates gates = {QC_MIN_SPAN, QC_MAX_IDLE, QC_MIN_FULL, + QC_RELEASE_TOLERANCE}; + const QcCalResult result = + qcValidateCalibration(rawMin, rawMax, rawRecheck, gates); + + USBSerial.printf("QC: cal raw_min=%u raw_max=%u recheck=%u result=%d\n", + rawMin, rawMax, rawRecheck, static_cast(result)); + + if (result != QcCalResult::OK) { + // Do NOT save — the unit keeps the safe 0..4095 defaults. + return QcCheckStatus::FAIL; + } + + factoryWriteCal(rawMin, rawMax); + rec->calSaved = true; + return QcCheckStatus::PASS; +} + // --------------------------------------------------------------------------- // POST checks (bus-communication focus: I2C / SPI / CAN) // --------------------------------------------------------------------------- @@ -334,7 +430,11 @@ void runFirstBootQc() { rec.throttle = postCheckThrottleAdc(); viewCheckResult("throttle", rec.throttle); - // --- Guided calibration + interactive checks (later commits) --- + // --- Guided throttle calibration (capture only; mapping unchanged) --- + rec.cal = qcRunThrottleCalibration(&rec); + viewStepResult("cal", rec.cal); + + // --- Interactive checks (next commit) --- // NOT_RUN counts as failure, so a partially-implemented flow can never // stamp qc_passed. From 60da0204962d6b1d7847fe31eff6dab7e9fcc2a9 Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Mon, 6 Jul 2026 14:34:12 -0400 Subject: [PATCH 6/9] feat(qc): interactive pot-confirm checks (buzzer, vibe, button) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The freshly calibrated throttle is the confirmation input: each cue pulses intermittently (300/700 ms) while the operator squeezes past 50% of span to confirm observing it; 10 s timeout = fail; the pot must return to idle between checks so one long squeeze can't blanket-pass. The button is used only for its own functional test (single press). No LED check — the LED is inside the case; the TFT is the feedback surface (cosmetic LED cycle stays in POST, unrecorded). Adds vibeDirectSet() to the vibe module: direct LEDC write bypassing the queue, for pre-task contexts (vibeTask doesn't exist during QC). Buzzer uses the already-direct startTone()/stopTone(). Co-Authored-By: Claude Opus 4.8 --- inc/sp140/vibration_pwm.h | 10 +++++ src/sp140/first_boot_qc.cpp | 86 +++++++++++++++++++++++++++++++++++-- src/sp140/vibration_pwm.cpp | 10 +++++ 3 files changed, 103 insertions(+), 3 deletions(-) diff --git a/inc/sp140/vibration_pwm.h b/inc/sp140/vibration_pwm.h index 89be351..c7cc69f 100644 --- a/inc/sp140/vibration_pwm.h +++ b/inc/sp140/vibration_pwm.h @@ -74,4 +74,14 @@ void pulseVibration(uint16_t duration_ms, uint8_t intensity); */ void stopVibration(); +/** + * @brief Directly sets motor PWM intensity, bypassing the queue/task. + * + * For pre-task contexts only (factory QC runs before vibeTask exists, so a + * queued request would sit unserviced). Pass 0 to stop. + * + * @param intensity The vibration intensity (0-255). + */ +void vibeDirectSet(uint8_t intensity); + #endif // INC_SP140_VIBRATION_PWM_H_ diff --git a/src/sp140/first_boot_qc.cpp b/src/sp140/first_boot_qc.cpp index 0d60258..cd44a35 100644 --- a/src/sp140/first_boot_qc.cpp +++ b/src/sp140/first_boot_qc.cpp @@ -23,6 +23,8 @@ #include "sp140/esc.h" #include "sp140/bms.h" #include "sp140/shared-config.h" +#include "sp140/buzzer.h" +#include "sp140/vibration_pwm.h" #include "sp140/lvgl/lvgl_qc_screen.h" #include "sp140/lvgl/lvgl_main_screen.h" #include "../../inc/version.h" @@ -371,6 +373,74 @@ static QcCheckStatus postCheckThrottleAdc() { return (raw <= QC_MAX_IDLE) ? QcCheckStatus::PASS : QcCheckStatus::FAIL; } +// --------------------------------------------------------------------------- +// Interactive checks — pot-confirm pattern. The freshly calibrated throttle +// is the confirmation input: squeeze past 50% of span = "yes, I observed the +// cue". The pot must return to idle between checks so one long squeeze can't +// blanket-confirm consecutive cues. All instructions on the TFT. +// --------------------------------------------------------------------------- + +typedef void (*QcCueFn)(); + +static void qcCueBuzzerOn() { startTone(2093); } // C7 — loud + distinct +static void qcCueBuzzerOff() { stopTone(); } +static void qcCueVibeOn() { vibeDirectSet(220); } +static void qcCueVibeOff() { vibeDirectSet(0); } + +static QcCheckStatus qcPotConfirm(const char* instruction, QcCueFn cueOn, + QcCueFn cueOff, uint16_t potMin, + uint16_t potMax) { + viewPrompt(instruction, "squeeze throttle to confirm"); + const uint16_t span = (potMax > potMin) ? (potMax - potMin) : 4095; + const uint16_t confirmLevel = potMin + span / 2; + const uint16_t releaseLevel = potMin + span / 10; + + const uint32_t start = millis(); + bool cueState = false; + uint32_t lastToggle = 0; + QcCheckStatus result = QcCheckStatus::FAIL; + + while (millis() - start < QC_CONFIRM_TIMEOUT_MS) { + // Pulse the cue 300 ms on / 700 ms off so it's clearly intermittent. + const uint32_t now = millis(); + if (!cueState && now - lastToggle >= 700) { + cueOn(); + cueState = true; + lastToggle = now; + } else if (cueState && now - lastToggle >= 300) { + cueOff(); + cueState = false; + lastToggle = now; + } + + const uint16_t raw = readThrottleRaw(); + char valText[16]; + snprintf(valText, sizeof(valText), "%u", raw); + qcScreenPromptValue(valText); + const uint32_t elapsed = now - start; + qcScreenPromptProgress( + static_cast(100 - (elapsed * 100) / QC_CONFIRM_TIMEOUT_MS)); + + if (raw >= confirmLevel) { + result = QcCheckStatus::PASS; + break; + } + viewPump(); + } + cueOff(); + + // Release gate: require the pot back at idle before the next check arms. + viewPrompt("RELEASE THROTTLE", "let go to continue"); + const uint32_t relStart = millis(); + while (millis() - relStart < QC_CONFIRM_TIMEOUT_MS) { + if (readThrottleRaw() <= releaseLevel) { + break; + } + viewPump(); + } + return result; +} + // --------------------------------------------------------------------------- // The flow // --------------------------------------------------------------------------- @@ -434,9 +504,19 @@ void runFirstBootQc() { rec.cal = qcRunThrottleCalibration(&rec); viewStepResult("cal", rec.cal); - // --- Interactive checks (next commit) --- - // NOT_RUN counts as failure, so a partially-implemented flow can never - // stamp qc_passed. + // --- Interactive checks (pot-confirm; button used only for its own test) --- + rec.buzzer = qcPotConfirm("TONE PLAYING - hear it?", qcCueBuzzerOn, + qcCueBuzzerOff, rec.potMin, rec.potMax); + viewStepResult("buzzer", rec.buzzer); + + rec.vibe = qcPotConfirm("VIBRATING - feel it?", qcCueVibeOn, qcCueVibeOff, + rec.potMin, rec.potMax); + viewStepResult("vibe", rec.vibe); + + viewPrompt("PRESS BUTTON", "press the top button"); + rec.button = qcWaitButtonPress(QC_CONFIRM_TIMEOUT_MS) ? QcCheckStatus::PASS + : QcCheckStatus::FAIL; + viewStepResult("button", rec.button); // --- Persist + report --- const bool passed = qcRecordAllPassed(rec); diff --git a/src/sp140/vibration_pwm.cpp b/src/sp140/vibration_pwm.cpp index 23d2ca5..2e82b70 100644 --- a/src/sp140/vibration_pwm.cpp +++ b/src/sp140/vibration_pwm.cpp @@ -52,6 +52,16 @@ bool initVibeMotor() { return true; } +/** + * Directly set motor PWM, bypassing the queue/task. Pre-task contexts only + * (factory QC): vibeTask does not exist yet, so queued requests would sit + * unserviced until Phase 6. + */ +void vibeDirectSet(uint8_t intensity) { + if (!ENABLE_VIBE) return; + ledcWrite(VIBE_PWM_CHANNEL, intensity); +} + /** * Pulse the vibration motor with a single 400ms pulse (non-blocking) */ From 341cd492ec622313d9b9ff0d2bfb0008c38c4e30 Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Mon, 6 Jul 2026 14:37:47 -0400 Subject: [PATCH 7/9] feat(qc): BLE QC-record characteristic for silent fleet sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QC_RECORD_UUID on the config service, mirroring the proven ESC_PARAM_DATA paged-fetch pattern (app writes u32 LE offset cursor, reads back <=240-byte chunks of the stored QC JSON record; empty read = no record / past end). The smartphone app reads this silently on connect and uploads to Supabase — the only fleet QC/calibration data path once units leave the bench. App-side read + backend land in the app/site repos. Co-Authored-By: Claude Opus 4.8 --- inc/sp140/ble/ble_ids.h | 7 ++++++ src/sp140/ble/config_service.cpp | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/inc/sp140/ble/ble_ids.h b/inc/sp140/ble/ble_ids.h index cf7fa64..04c43cf 100644 --- a/inc/sp140/ble/ble_ids.h +++ b/inc/sp140/ble/ble_ids.h @@ -41,6 +41,13 @@ // [0x02]=DATA[offset u16][bytes]. See ESC-Config-Relay-Design.md. #define ESC_RELAY_NOTIFY_UUID "E5C0C0DE-0006-4A5C-9B21-7E5C0F1A2B30" +// Factory QC record fetch (paged, same pattern as ESC_PARAM_DATA): app writes +// [offset u32 LE], then reads back up to ~240 bytes of the stored QC JSON +// record from that offset. Empty read = no record / past end. The app syncs +// this silently on connect and uploads to the cloud (fleet QC/cal data). +// See FIRST_BOOT_QC.md. +#define QC_RECORD_UUID "E5C0C0DE-0007-4A5C-9B21-7E5C0F1A2B30" + // Device info service #define DEVICE_INFO_SERVICE_UUID "180A" #define MANUFACTURER_NAME_UUID "2A29" diff --git a/src/sp140/ble/config_service.cpp b/src/sp140/ble/config_service.cpp index f12b328..29047b0 100644 --- a/src/sp140/ble/config_service.cpp +++ b/src/sp140/ble/config_service.cpp @@ -20,6 +20,8 @@ #include "sp140/ble/ota_service.h" #include "sp140/esc_config_relay.h" #include "sp140/esc_flasher_relay.h" +#include "sp140/factory_settings.h" +#include "sp140/shared-config.h" extern void writeDeviceData(); extern QueueHandle_t throttleUpdateQueue; @@ -350,6 +352,38 @@ class EscParamDataCallbacks : public NimBLECharacteristicCallbacks { } }; +// Factory QC record fetch — same paged-fetch pattern as EscParamDataCallbacks: +// the app writes a 4-byte LE offset cursor, then reads back up to 240 bytes of +// the stored QC JSON record from that offset. Empty read = no record stored or +// cursor past end. Synced silently by the app on connect (fleet QC/cal data). +class QcRecordDataCallbacks : public NimBLECharacteristicCallbacks { + uint32_t offset_ = 0; + + void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { + (void)connInfo; + std::string value = characteristic->getValue(); + if (value.size() < 4) return; + offset_ = static_cast(static_cast(value[0])) | + (static_cast(static_cast(value[1])) << 8) | + (static_cast(static_cast(value[2])) << 16) | + (static_cast(static_cast(value[3])) << 24); + } + + void onRead(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { + (void)connInfo; + char record[QC_RECORD_JSON_MAX]; + const size_t total = factoryReadQcRecordBlob(record, sizeof(record)); + if (total == 0 || offset_ >= total) { + characteristic->setValue(reinterpret_cast(""), 0); + return; + } + size_t n = total - offset_; + if (n > 240) n = 240; + characteristic->setValue( + reinterpret_cast(record + offset_), n); + } +}; + // ESC config relay status characteristic (read + notify). Returns the latched // session status so the app can poll until a terminal result (the design's // poll-until-terminal contract, which also survives a BLE drop + reconnect). @@ -573,6 +607,12 @@ void initConfigBleService(NimBLEServer* server, const std::string& uniqueId) { NimBLEUUID(ESC_RELAY_NOTIFY_UUID), kNotifyReadSecure); #endif // ESC_RELAY_BLE_CHARS + // Factory QC record (paged fetch: write offset, read chunk). + NimBLECharacteristic* qcRecordData = configService->createCharacteristic( + NimBLEUUID(QC_RECORD_UUID), kReadWriteSecure); + static QcRecordDataCallbacks qcRecordDataCallbacks; + qcRecordData->setCallbacks(&qcRecordDataCallbacks); + NimBLEService* deviceInfoService = server->createService(NimBLEUUID(DEVICE_INFO_SERVICE_UUID)); NimBLECharacteristic* manufacturer = deviceInfoService->createCharacteristic( NimBLEUUID(MANUFACTURER_NAME_UUID), kReadSecure); From b24387a96b028ac706c0669abee176ce6455a8ef Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Sat, 18 Jul 2026 21:50:52 -0400 Subject: [PATCH 8/9] feat(qc): run_qc serial command and bench logger Add {"command":"run_qc"} over webSerial (sets factory rerun flag + reboot) and tools/qc_bench_logger.py for factory JSONL capture. Ignore local plans/. Co-authored-by: Cursor --- .gitignore | 3 ++ src/sp140/device_settings.cpp | 11 +++++ tools/qc_bench_logger.py | 77 +++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 tools/qc_bench_logger.py diff --git a/.gitignore b/.gitignore index b97023d..0c42206 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ diagnostics-logs/ build-screenshot/ test/test_screenshots/output/*.bmp test/test_screenshots/output/png/ + +# Local executor plans (not tracked) +plans/ diff --git a/src/sp140/device_settings.cpp b/src/sp140/device_settings.cpp index f3b0df8..75e42b9 100644 --- a/src/sp140/device_settings.cpp +++ b/src/sp140/device_settings.cpp @@ -12,6 +12,7 @@ #include "freertos/semphr.h" #include "../../inc/sp140/throttle.h" #include "../../inc/sp140/diagnostics.h" +#include "../../inc/sp140/factory_settings.h" /** * WebSerial Protocol Documentation @@ -274,6 +275,16 @@ void parse_serial_command_line(const char* json_line) { } else if (command == "sync") { send_device_data(); return; + } else if (command == "run_qc") { + // Deliberate bench/field-service action: set the factory rerun flag and + // reboot. The QC gate consumes the flag on the next boot — this is one + // of exactly two QC entry paths (the other is truly fresh NVS). + USBSerial.println(F("QC rerun requested - rebooting into factory QC")); + factorySetRerunFlag(); + diagnosticsMarkPlannedRestart( + PlannedRestartReason::USB_COMMAND_REBOOT); + ESP.restart(); + return; } else if (command == "diag_sync") { diagnosticsSendJson(USBSerial); USBSerial.println(); diff --git a/tools/qc_bench_logger.py b/tools/qc_bench_logger.py new file mode 100644 index 0000000..eef3c13 --- /dev/null +++ b/tools/qc_bench_logger.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Factory QC bench logger. + +Tails the controller's USB serial output and appends every FIRST_BOOT_QC +record (single-line JSON starting with {"qc":) to a JSONL file, wrapped with a +capture timestamp and the serial port. Everything else is echoed to stdout so +the operator still sees the live boot/QC log. + +Usage: + python3 tools/qc_bench_logger.py --port /dev/tty.usbmodem101 + python3 tools/qc_bench_logger.py --port COM7 --out qc-records.jsonl + +Reconnects automatically when the device reboots (QC runs at boot, so the +port comes and goes). Companion to tools/usb_diag_logger.py. +""" + +import argparse +import datetime +import json +import sys +import time + +try: + import serial # pyserial +except ImportError: + sys.exit("pyserial is required: pip3 install pyserial") + + +def parse_args(): + p = argparse.ArgumentParser(description="OpenPPG factory QC bench logger") + p.add_argument("--port", required=True, help="Serial port (e.g. /dev/tty.usbmodem101)") + p.add_argument("--baud", type=int, default=115200) + p.add_argument("--out", default="qc-records.jsonl", help="JSONL output path") + return p.parse_args() + + +def record_line(out_path, port, line): + entry = { + "captured_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "port": port, + } + try: + entry["record"] = json.loads(line) + except json.JSONDecodeError: + entry["raw"] = line # keep malformed records for debugging + with open(out_path, "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + +def main(): + args = parse_args() + print(f"qc_bench_logger: port={args.port} -> {args.out} (Ctrl-C to stop)") + while True: + try: + with serial.Serial(args.port, args.baud, timeout=1) as ser: + print(f"qc_bench_logger: connected to {args.port}") + while True: + raw = ser.readline() + if not raw: + continue + line = raw.decode("utf-8", errors="replace").strip() + if not line: + continue + print(line) + if line.startswith('{"qc":'): + record_line(args.out, args.port, line) + print(f"qc_bench_logger: >>> QC record captured to {args.out}") + except serial.SerialException: + print("qc_bench_logger: port unavailable, retrying in 2s...") + time.sleep(2) + except KeyboardInterrupt: + print("\nqc_bench_logger: stopped") + return + + +if __name__ == "__main__": + main() From 8b870d5693f79b91247c1f2b8a51cc910ae52cbd Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Sat, 18 Jul 2026 21:53:11 -0400 Subject: [PATCH 9/9] Retry first-boot QC after failed attempts Adds a sticky `qc_attempted` factory flag and updates QC gate logic so failed/aborted first-boot runs re-enter QC on reboot instead of being treated as legacy fleet units. QC results are now always written (`qc_passed` true/false), rerun handling is unified, and the gate API/action names were simplified. Refactors interactive pot-confirm behavior to use shared confirm/release thresholds with explicit release-before/after gating, and keeps safe default calibration bounds when calibration fails. Also increases QC JSON record buffer size for added IDs/check data and updates native tests to cover the new gate behavior, pot-confirm levels, and larger JSON payloads. --- inc/sp140/factory_settings.h | 5 +- inc/sp140/first_boot_qc.h | 6 +- inc/sp140/qc_logic.h | 28 ++++--- inc/sp140/shared-config.h | 2 +- src/sp140/factory_settings.cpp | 30 +++---- src/sp140/first_boot_qc.cpp | 145 +++++++++++++++++---------------- src/sp140/qc_logic.cpp | 32 ++++---- test/test_qc/test_qc.cpp | 68 ++++++++++------ 8 files changed, 169 insertions(+), 147 deletions(-) diff --git a/inc/sp140/factory_settings.h b/inc/sp140/factory_settings.h index 86b173b..edb1316 100644 --- a/inc/sp140/factory_settings.h +++ b/inc/sp140/factory_settings.h @@ -24,12 +24,15 @@ void factorySettingsInit(); // --- QC gate state --- bool factoryQcPassed(); +bool factoryQcAttempted(); // set when QC flow starts; survives fail/abort +void factoryMarkQcAttempted(); // call as soon as the gate decides to run QC bool factoryRerunRequested(); // qc_rerun flag (set by the run_qc command) void factorySetRerunFlag(); // called by the "run_qc" serial command void factoryClearRerunFlag(); // consumed at boot by the QC gate // --- Results --- -// qc_passed + qc_fw in one commit. +// qc_passed + qc_fw in one commit. Write false on FAIL so the next boot +// retries (combined with qc_attempted) instead of legacy-backfilling. void factoryWriteQcResult(bool passed, uint16_t fwEncoded); // pot_min/pot_max + pot_calibrated=1 in one commit. void factoryWriteCal(uint16_t potMin, uint16_t potMax); diff --git a/inc/sp140/first_boot_qc.h b/inc/sp140/first_boot_qc.h index a09d88e..b9dd3ec 100644 --- a/inc/sp140/first_boot_qc.h +++ b/inc/sp140/first_boot_qc.h @@ -5,9 +5,9 @@ // Runs as a blocking guided flow inside setup() at the Phase 4/5 boundary // (display + hardware up, no app tasks running). See FIRST_BOOT_QC.md. // -// Entry has exactly two paths: truly fresh NVS (new factory unit) or the -// serial "run_qc" command flag. The installed fleet is back-filled as passed -// at first boot on this firmware and never sees the flow. +// Entry paths: truly fresh NVS, prior failed/aborted attempt (qc_attempted), +// or the serial "run_qc" command flag. The installed fleet (user settings, +// never attempted) is back-filled as passed and never sees the flow. #ifndef INC_SP140_FIRST_BOOT_QC_H_ #define INC_SP140_FIRST_BOOT_QC_H_ diff --git a/inc/sp140/qc_logic.h b/inc/sp140/qc_logic.h index 52e862c..fa2880d 100644 --- a/inc/sp140/qc_logic.h +++ b/inc/sp140/qc_logic.h @@ -16,20 +16,26 @@ // Boot gate decision // --------------------------------------------------------------------------- -// QC entry has exactly two paths: fresh factory NVS, or the serial-command -// rerun flag. Existing units (any pre-QC firmware data in the "openppg" -// namespace) are back-filled as passed and NEVER auto-calibrated — the QC -// target is factory PCB/IC defects on new boards, not the installed fleet. +// SKIP = already passed. MARK_LEGACY = fleet unit, never attempted. +// RUN = fresh / retry after fail / serial run_qc (caller clears rerun flag). enum class QcGateAction : uint8_t { - SKIP_NORMAL_BOOT = 0, // QC already passed — boot normally - MARK_LEGACY_AND_SKIP, // existing unit: back-fill qc_passed, no calibration - RUN_QC, // fresh factory unit — run the full flow - RUN_QC_RERUN, // deliberate bench/field re-entry via serial command + SKIP = 0, + MARK_LEGACY, + RUN, }; -QcGateAction qcGateDecision(bool factoryQcPassed, - bool factoryRerunRequested, - bool userSettingsPresent); +// `attempted` separates fleet (user NVS, never QC'd) from a factory board that +// already got user defaults written then failed/aborted mid-QC. +QcGateAction qcGateDecision(bool passed, bool rerun, bool userSettings, + bool attempted); + +// Pot-confirm thresholds from cal endpoints (or 0..4095 if cal unsaved). +struct QcPotConfirmLevels { + uint16_t confirm; // squeeze past = observed cue (~50% span) + uint16_t release; // must be at/below between checks (~10% span) +}; + +QcPotConfirmLevels qcPotConfirmLevels(uint16_t potMin, uint16_t potMax); // --------------------------------------------------------------------------- // Throttle calibration sanity gates diff --git a/inc/sp140/shared-config.h b/inc/sp140/shared-config.h index 2010a2f..3c607a9 100644 --- a/inc/sp140/shared-config.h +++ b/inc/sp140/shared-config.h @@ -34,6 +34,6 @@ #define QC_CAN_POLL_ITERATIONS 10 // POST CAN polls (x interval = ~2 s) #define QC_CAN_POLL_INTERVAL_MS 200 #define QC_CAL_STEP_TIMEOUT_MS 60000 // per calibration step (operator paced) -#define QC_RECORD_JSON_MAX 512 // QC record JSON buffer size +#define QC_RECORD_JSON_MAX 768 // QC record JSON buffer (IDs + checks) #endif // INC_SP140_SHARED_CONFIG_H_ diff --git a/src/sp140/factory_settings.cpp b/src/sp140/factory_settings.cpp index 8af2b59..92afd5d 100644 --- a/src/sp140/factory_settings.cpp +++ b/src/sp140/factory_settings.cpp @@ -20,6 +20,7 @@ static const char* FACTORY_NAMESPACE = "openppg-factory"; // Factory keys static const char* KEY_QC_PASSED = "qc_passed"; // u8 +static const char* KEY_QC_ATTEMPTED = "qc_attempted"; // u8 (fail/abort retry) static const char* KEY_QC_FW = "qc_fw"; // u16 (major<<8 | minor) static const char* KEY_POT_CALIBRATED = "pot_cal"; // u8 static const char* KEY_POT_MIN = "pot_min"; // u16 @@ -62,29 +63,21 @@ static Preferences& factoryPrefs() { return prefs; } -bool factoryQcPassed() { +static bool factoryGetFlag(const char* key) { factoryLock(); + bool value = false; Preferences& p = factoryPrefs(); - bool passed = false; if (p.begin(FACTORY_NAMESPACE, true)) { - passed = p.getUChar(KEY_QC_PASSED, 0) == 1; + value = p.getUChar(key, 0) == 1; p.end(); } factoryUnlock(); - return passed; + return value; } -bool factoryRerunRequested() { - factoryLock(); - Preferences& p = factoryPrefs(); - bool rerun = false; - if (p.begin(FACTORY_NAMESPACE, true)) { - rerun = p.getUChar(KEY_QC_RERUN, 0) == 1; - p.end(); - } - factoryUnlock(); - return rerun; -} +bool factoryQcPassed() { return factoryGetFlag(KEY_QC_PASSED); } +bool factoryQcAttempted() { return factoryGetFlag(KEY_QC_ATTEMPTED); } +bool factoryRerunRequested() { return factoryGetFlag(KEY_QC_RERUN); } FactoryCal factoryGetCal() { FactoryCal cal = {false, 0, 4095}; @@ -123,7 +116,14 @@ static bool factoryBatchedWrite(Fn fn) { return success; } +void factoryMarkQcAttempted() { + factoryBatchedWrite([](nvs_handle_t h) { + return nvs_set_u8(h, KEY_QC_ATTEMPTED, 1) == ESP_OK; + }); +} + void factoryWriteQcResult(bool passed, uint16_t fwEncoded) { + // qc_attempted is already sticky from factoryMarkQcAttempted() at gate entry. factoryBatchedWrite([&](nvs_handle_t h) { bool ok = (nvs_set_u8(h, KEY_QC_PASSED, passed ? 1 : 0) == ESP_OK); ok &= (nvs_set_u16(h, KEY_QC_FW, fwEncoded) == ESP_OK); diff --git a/src/sp140/first_boot_qc.cpp b/src/sp140/first_boot_qc.cpp index cd44a35..ad85227 100644 --- a/src/sp140/first_boot_qc.cpp +++ b/src/sp140/first_boot_qc.cpp @@ -40,6 +40,7 @@ extern SemaphoreHandle_t lvglMutex; static bool s_userSettingsPresentAtBoot = false; static bool s_factoryQcPassedAtBoot = false; +static bool s_factoryQcAttemptedAtBoot = false; static bool s_rerunRequestedAtBoot = false; static bool s_contextCaptured = false; @@ -58,39 +59,40 @@ void qcCaptureBootContext() { } s_factoryQcPassedAtBoot = factoryQcPassed(); + s_factoryQcAttemptedAtBoot = factoryQcAttempted(); s_rerunRequestedAtBoot = factoryRerunRequested(); s_contextCaptured = true; } bool qcShouldRun() { if (!s_contextCaptured) { - // Defensive: without a captured context, never run QC (fail safe for the - // fleet; a factory unit can always be re-triggered via run_qc). - return false; + return false; // fail-safe for fleet; factory can always use run_qc } const QcGateAction action = qcGateDecision( s_factoryQcPassedAtBoot, s_rerunRequestedAtBoot, - s_userSettingsPresentAtBoot); - - switch (action) { - case QcGateAction::RUN_QC: - USBSerial.println(F("QC: fresh factory unit - entering QC flow")); - return true; - case QcGateAction::RUN_QC_RERUN: - USBSerial.println(F("QC: rerun requested via serial command")); - factoryClearRerunFlag(); // consume so the next boot is normal - return true; - case QcGateAction::MARK_LEGACY_AND_SKIP: - // Existing unit (settings from v8.0-or-prior): back-fill and never - // calibrate. The installed fleet must never see this flow. - USBSerial.println(F("QC: existing unit detected - back-filling qc_passed")); - factoryMarkLegacyUnit(factoryEncodeFw(VERSION_MAJOR, VERSION_MINOR)); - return false; - case QcGateAction::SKIP_NORMAL_BOOT: - default: - return false; + s_userSettingsPresentAtBoot, s_factoryQcAttemptedAtBoot); + + if (action == QcGateAction::MARK_LEGACY) { + USBSerial.println(F("QC: existing unit detected - back-filling qc_passed")); + factoryMarkLegacyUnit(factoryEncodeFw(VERSION_MAJOR, VERSION_MINOR)); + return false; + } + if (action != QcGateAction::RUN) { + return false; // SKIP + } + + // Sticky before UI/HW work so a mid-flow power-cut retries next boot. + factoryMarkQcAttempted(); + if (s_rerunRequestedAtBoot) { + USBSerial.println(F("QC: rerun requested via serial command")); + factoryClearRerunFlag(); + } else if (s_factoryQcAttemptedAtBoot) { + USBSerial.println(F("QC: prior attempt incomplete - re-entering QC")); + } else { + USBSerial.println(F("QC: fresh factory unit - entering QC flow")); } + return true; } // --------------------------------------------------------------------------- @@ -240,6 +242,8 @@ static bool qcCaptureStableRaw(const char* instruction, const char* subtext, // Full calibration sequence: release -> squeeze -> release recheck, then the // sanity gates. Saves to the factory namespace only when everything passes. +// On fail/timeout, leave rec->potMin/Max at the safe 0..4095 defaults so +// later pot-confirm checks do not use a rejected capture for thresholds. static QcCheckStatus qcRunThrottleCalibration(QcRecord* rec) { uint16_t rawMin = 0; uint16_t rawMax = 0; @@ -255,9 +259,6 @@ static QcCheckStatus qcRunThrottleCalibration(QcRecord* rec) { return QcCheckStatus::FAIL; } - rec->potMin = rawMin; - rec->potMax = rawMax; - const QcCalGates gates = {QC_MIN_SPAN, QC_MAX_IDLE, QC_MIN_FULL, QC_RELEASE_TOLERANCE}; const QcCalResult result = @@ -267,10 +268,12 @@ static QcCheckStatus qcRunThrottleCalibration(QcRecord* rec) { rawMin, rawMax, rawRecheck, static_cast(result)); if (result != QcCalResult::OK) { - // Do NOT save — the unit keeps the safe 0..4095 defaults. + // Do NOT save — keep safe 0..4095 defaults on the record for pot-confirm. return QcCheckStatus::FAIL; } + rec->potMin = rawMin; + rec->potMax = rawMax; factoryWriteCal(rawMin, rawMax); rec->calSaved = true; return QcCheckStatus::PASS; @@ -373,72 +376,70 @@ static QcCheckStatus postCheckThrottleAdc() { return (raw <= QC_MAX_IDLE) ? QcCheckStatus::PASS : QcCheckStatus::FAIL; } -// --------------------------------------------------------------------------- -// Interactive checks — pot-confirm pattern. The freshly calibrated throttle -// is the confirmation input: squeeze past 50% of span = "yes, I observed the -// cue". The pot must return to idle between checks so one long squeeze can't -// blanket-confirm consecutive cues. All instructions on the TFT. -// --------------------------------------------------------------------------- - +// Interactive pot-confirm: squeeze ~50% span = observed cue; release gated. typedef void (*QcCueFn)(); - -static void qcCueBuzzerOn() { startTone(2093); } // C7 — loud + distinct +static void qcCueBuzzerOn() { startTone(2093); } static void qcCueBuzzerOff() { stopTone(); } static void qcCueVibeOn() { vibeDirectSet(220); } static void qcCueVibeOff() { vibeDirectSet(0); } +static void showPotProgress(uint16_t raw, uint32_t elapsed, uint32_t timeoutMs) { + char valText[16]; + snprintf(valText, sizeof(valText), "%u", raw); + qcScreenPromptValue(valText); + qcScreenPromptProgress( + static_cast(100 - (elapsed * 100) / timeoutMs)); +} + +// Wait until pot <= releaseLevel. False on timeout. +static bool qcWaitPotRelease(uint16_t releaseLevel) { + viewPrompt("RELEASE THROTTLE", "let go to continue"); + const uint32_t start = millis(); + while (millis() - start < QC_CONFIRM_TIMEOUT_MS) { + const uint16_t raw = readThrottleRaw(); + showPotProgress(raw, millis() - start, QC_CONFIRM_TIMEOUT_MS); + if (raw <= releaseLevel) return true; + viewPump(); + } + return false; +} + static QcCheckStatus qcPotConfirm(const char* instruction, QcCueFn cueOn, QcCueFn cueOff, uint16_t potMin, uint16_t potMax) { - viewPrompt(instruction, "squeeze throttle to confirm"); - const uint16_t span = (potMax > potMin) ? (potMax - potMin) : 4095; - const uint16_t confirmLevel = potMin + span / 2; - const uint16_t releaseLevel = potMin + span / 10; + const QcPotConfirmLevels lvl = qcPotConfirmLevels(potMin, potMax); + // Release before + after: one held squeeze cannot blanket-pass checks. + if (!qcWaitPotRelease(lvl.release)) return QcCheckStatus::FAIL; + + viewPrompt(instruction, "squeeze throttle to confirm"); const uint32_t start = millis(); - bool cueState = false; - uint32_t lastToggle = 0; - QcCheckStatus result = QcCheckStatus::FAIL; + bool cueOnState = false; + uint32_t lastToggle = start; // 700 ms quiet, then 300 ms on, repeat + bool squeezed = false; while (millis() - start < QC_CONFIRM_TIMEOUT_MS) { - // Pulse the cue 300 ms on / 700 ms off so it's clearly intermittent. const uint32_t now = millis(); - if (!cueState && now - lastToggle >= 700) { - cueOn(); - cueState = true; - lastToggle = now; - } else if (cueState && now - lastToggle >= 300) { - cueOff(); - cueState = false; + if (now - lastToggle >= (cueOnState ? 300u : 700u)) { + cueOnState = !cueOnState; lastToggle = now; + if (cueOnState) cueOn(); + else cueOff(); } const uint16_t raw = readThrottleRaw(); - char valText[16]; - snprintf(valText, sizeof(valText), "%u", raw); - qcScreenPromptValue(valText); - const uint32_t elapsed = now - start; - qcScreenPromptProgress( - static_cast(100 - (elapsed * 100) / QC_CONFIRM_TIMEOUT_MS)); - - if (raw >= confirmLevel) { - result = QcCheckStatus::PASS; + showPotProgress(raw, now - start, QC_CONFIRM_TIMEOUT_MS); + if (raw >= lvl.confirm) { + squeezed = true; break; } viewPump(); } cueOff(); - // Release gate: require the pot back at idle before the next check arms. - viewPrompt("RELEASE THROTTLE", "let go to continue"); - const uint32_t relStart = millis(); - while (millis() - relStart < QC_CONFIRM_TIMEOUT_MS) { - if (readThrottleRaw() <= releaseLevel) { - break; - } - viewPump(); - } - return result; + if (!squeezed) return QcCheckStatus::FAIL; + return qcWaitPotRelease(lvl.release) ? QcCheckStatus::PASS + : QcCheckStatus::FAIL; } // --------------------------------------------------------------------------- @@ -519,10 +520,10 @@ void runFirstBootQc() { viewStepResult("button", rec.button); // --- Persist + report --- + // Always write the result: pass stamps qc_passed=1; fail stamps 0 so the + // next boot retries (qc_attempted stays set) instead of legacy-backfilling. const bool passed = qcRecordAllPassed(rec); - if (passed) { - factoryWriteQcResult(true, factoryEncodeFw(VERSION_MAJOR, VERSION_MINOR)); - } + factoryWriteQcResult(passed, factoryEncodeFw(VERSION_MAJOR, VERSION_MINOR)); char json[QC_RECORD_JSON_MAX]; if (qcRecordToJson(rec, json, sizeof(json)) > 0) { diff --git a/src/sp140/qc_logic.cpp b/src/sp140/qc_logic.cpp index e61fb13..8044f7e 100644 --- a/src/sp140/qc_logic.cpp +++ b/src/sp140/qc_logic.cpp @@ -13,24 +13,20 @@ // Boot gate decision // --------------------------------------------------------------------------- -QcGateAction qcGateDecision(bool factoryQcPassed, - bool factoryRerunRequested, - bool userSettingsPresent) { - // A deliberate serial-command rerun overrides everything (bench/service). - if (factoryRerunRequested) { - return QcGateAction::RUN_QC_RERUN; - } - // Already QC'd — normal boot. - if (factoryQcPassed) { - return QcGateAction::SKIP_NORMAL_BOOT; - } - // Existing unit (settings written by v8.0-or-prior firmware): back-fill the - // pass flag and never auto-calibrate. The installed fleet must never see QC. - if (userSettingsPresent) { - return QcGateAction::MARK_LEGACY_AND_SKIP; - } - // Truly fresh NVS: brand-new factory controller. - return QcGateAction::RUN_QC; +QcGateAction qcGateDecision(bool passed, bool rerun, bool userSettings, + bool attempted) { + if (rerun) return QcGateAction::RUN; + if (passed) return QcGateAction::SKIP; + // attempted → retry (user defaults may already exist from refreshDeviceData) + if (attempted || !userSettings) return QcGateAction::RUN; + return QcGateAction::MARK_LEGACY; // fleet: user NVS, never attempted +} + +QcPotConfirmLevels qcPotConfirmLevels(uint16_t potMin, uint16_t potMax) { + const bool ok = potMax > potMin; + const uint16_t min = ok ? potMin : 0; + const uint16_t span = ok ? (uint16_t)(potMax - potMin) : 4095; + return {(uint16_t)(min + span / 2), (uint16_t)(min + span / 10)}; } // --------------------------------------------------------------------------- diff --git a/test/test_qc/test_qc.cpp b/test/test_qc/test_qc.cpp index 792ee22..a26df98 100644 --- a/test/test_qc/test_qc.cpp +++ b/test/test_qc/test_qc.cpp @@ -16,45 +16,44 @@ // --------------------------------------------------------------------------- TEST(QcGate, FreshFactoryUnitRunsQc) { - // No factory state, no rerun flag, no user settings => brand-new unit. - EXPECT_EQ(qcGateDecision(false, false, false), QcGateAction::RUN_QC); + EXPECT_EQ(qcGateDecision(false, false, false, false), QcGateAction::RUN); } TEST(QcGate, ExistingFleetUnitNeverSeesQc) { - // Settings from v8.0-or-prior exist but factory namespace is empty: - // this is an OTA'd customer device. Must back-fill and skip — never QC. - EXPECT_EQ(qcGateDecision(false, false, true), - QcGateAction::MARK_LEGACY_AND_SKIP); + // User NVS from ≤v8.0, never attempted → back-fill, never QC. + EXPECT_EQ(qcGateDecision(false, false, true, false), QcGateAction::MARK_LEGACY); +} + +TEST(QcGate, FailedOrAbortedFactoryAttemptRetries) { + // User defaults may exist, but attempted ⇒ retry, not legacy. + EXPECT_EQ(qcGateDecision(false, false, true, true), QcGateAction::RUN); + EXPECT_EQ(qcGateDecision(false, false, false, true), QcGateAction::RUN); } TEST(QcGate, PassedUnitBootsNormally) { - EXPECT_EQ(qcGateDecision(true, false, false), QcGateAction::SKIP_NORMAL_BOOT); - EXPECT_EQ(qcGateDecision(true, false, true), QcGateAction::SKIP_NORMAL_BOOT); + EXPECT_EQ(qcGateDecision(true, false, false, false), QcGateAction::SKIP); + EXPECT_EQ(qcGateDecision(true, false, true, true), QcGateAction::SKIP); } TEST(QcGate, SerialRerunOverridesEverything) { - EXPECT_EQ(qcGateDecision(true, true, true), QcGateAction::RUN_QC_RERUN); - EXPECT_EQ(qcGateDecision(false, true, true), QcGateAction::RUN_QC_RERUN); - EXPECT_EQ(qcGateDecision(true, true, false), QcGateAction::RUN_QC_RERUN); + EXPECT_EQ(qcGateDecision(true, true, true, true), QcGateAction::RUN); + EXPECT_EQ(qcGateDecision(false, true, true, false), QcGateAction::RUN); + EXPECT_EQ(qcGateDecision(true, true, false, false), QcGateAction::RUN); } -// There is deliberately NO input that maps a button state to QC entry — the -// gate takes only {factory passed, rerun flag, user settings present}. This -// test documents that contract at compile time by exhaustively covering the -// full input space. TEST(QcGate, ExhaustiveInputSpace) { for (int passed = 0; passed <= 1; passed++) { for (int rerun = 0; rerun <= 1; rerun++) { for (int user = 0; user <= 1; user++) { - const QcGateAction a = qcGateDecision(passed, rerun, user); - if (rerun) { - EXPECT_EQ(a, QcGateAction::RUN_QC_RERUN); - } else if (passed) { - EXPECT_EQ(a, QcGateAction::SKIP_NORMAL_BOOT); - } else if (user) { - EXPECT_EQ(a, QcGateAction::MARK_LEGACY_AND_SKIP); - } else { - EXPECT_EQ(a, QcGateAction::RUN_QC); + for (int attempted = 0; attempted <= 1; attempted++) { + const QcGateAction a = qcGateDecision(passed, rerun, user, attempted); + if (rerun || (!passed && (attempted || !user))) { + EXPECT_EQ(a, QcGateAction::RUN); + } else if (passed) { + EXPECT_EQ(a, QcGateAction::SKIP); + } else { + EXPECT_EQ(a, QcGateAction::MARK_LEGACY); + } } } } @@ -112,6 +111,23 @@ TEST(QcCalGatesTest, BoundaryValues) { QcCalResult::OK); } +// --------------------------------------------------------------------------- +// Pot-confirm thresholds (interactive checks use calibrated endpoints) +// --------------------------------------------------------------------------- + +TEST(QcPotConfirmLevelsTest, UsesCalibratedSpan) { + const QcPotConfirmLevels levels = qcPotConfirmLevels(142, 3987); + EXPECT_EQ(levels.confirm, static_cast(142 + (3987 - 142) / 2)); + EXPECT_EQ(levels.release, static_cast(142 + (3987 - 142) / 10)); + EXPECT_LT(levels.release, levels.confirm); +} + +TEST(QcPotConfirmLevelsTest, DegenerateFallsBackToFullAdcRange) { + const QcPotConfirmLevels levels = qcPotConfirmLevels(3000, 1000); + EXPECT_EQ(levels.confirm, 2047); + EXPECT_EQ(levels.release, 409); +} + // --------------------------------------------------------------------------- // Stability window // --------------------------------------------------------------------------- @@ -284,7 +300,7 @@ TEST(QcRecordTest, NotRunCountsAsFailure) { TEST(QcRecordTest, JsonGolden) { QcRecord r = makePassingRecord(); r.canEsc = QcCheckStatus::SKIP; - char buf[512]; + char buf[768]; const size_t n = qcRecordToJson(r, buf, sizeof(buf)); ASSERT_GT(n, 0u); @@ -308,7 +324,7 @@ TEST(QcRecordTest, JsonNullIds) { r.escHwId[0] = '\0'; r.escSn[0] = '\0'; r.bmsId[0] = '\0'; - char buf[512]; + char buf[768]; ASSERT_GT(qcRecordToJson(r, buf, sizeof(buf)), 0u); const std::string json(buf); EXPECT_NE(json.find("\"esc_hw_id\":null"), std::string::npos);