diff --git a/.gitignore b/.gitignore index b97023dc..0c422064 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/FIRST_BOOT_QC.md b/FIRST_BOOT_QC.md new file mode 100644 index 00000000..cd9a4fd0 --- /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)? diff --git a/inc/sp140/ble/ble_ids.h b/inc/sp140/ble/ble_ids.h index cf7fa640..04c43cff 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/inc/sp140/factory_settings.h b/inc/sp140/factory_settings.h new file mode 100644 index 00000000..edb13168 --- /dev/null +++ b/inc/sp140/factory_settings.h @@ -0,0 +1,59 @@ +// 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 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. 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); +// 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/first_boot_qc.h b/inc/sp140/first_boot_qc.h new file mode 100644 index 00000000..b9dd3ec2 --- /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 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_ + +// 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/lvgl/lvgl_qc_screen.h b/inc/sp140/lvgl/lvgl_qc_screen.h new file mode 100644 index 00000000..9e194866 --- /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/inc/sp140/qc_logic.h b/inc/sp140/qc_logic.h new file mode 100644 index 00000000..fa2880db --- /dev/null +++ b/inc/sp140/qc_logic.h @@ -0,0 +1,148 @@ +// 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 +// --------------------------------------------------------------------------- + +// 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 = 0, + MARK_LEGACY, + RUN, +}; + +// `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 +// --------------------------------------------------------------------------- + +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/inc/sp140/shared-config.h b/inc/sp140/shared-config.h index bea64ad5..3c607a9c 100644 --- a/inc/sp140/shared-config.h +++ b/inc/sp140/shared-config.h @@ -21,4 +21,19 @@ #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_CAL_STEP_TIMEOUT_MS 60000 // per calibration step (operator paced) +#define QC_RECORD_JSON_MAX 768 // QC record JSON buffer (IDs + checks) + #endif // INC_SP140_SHARED_CONFIG_H_ diff --git a/inc/sp140/vibration_pwm.h b/inc/sp140/vibration_pwm.h index 89be3513..c7cc69f3 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/platformio.ini b/platformio.ini index 4f1654ee..d710d7fd 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/ble/config_service.cpp b/src/sp140/ble/config_service.cpp index f12b3288..29047b09 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); diff --git a/src/sp140/device_settings.cpp b/src/sp140/device_settings.cpp index f3b0df8b..75e42b9b 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/src/sp140/factory_settings.cpp b/src/sp140/factory_settings.cpp new file mode 100644 index 00000000..92afd5d1 --- /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_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 +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; +} + +static bool factoryGetFlag(const char* key) { + factoryLock(); + bool value = false; + Preferences& p = factoryPrefs(); + if (p.begin(FACTORY_NAMESPACE, true)) { + value = p.getUChar(key, 0) == 1; + p.end(); + } + factoryUnlock(); + return value; +} + +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}; + 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 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); + 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/first_boot_qc.cpp b/src/sp140/first_boot_qc.cpp new file mode 100644 index 00000000..ad85227e --- /dev/null +++ b/src/sp140/first_boot_qc.cpp @@ -0,0 +1,561 @@ +// 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 "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#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 "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" +#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) +// --------------------------------------------------------------------------- + +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; + +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_factoryQcAttemptedAtBoot = factoryQcAttempted(); + s_rerunRequestedAtBoot = factoryRerunRequested(); + s_contextCaptured = true; +} + +bool qcShouldRun() { + if (!s_contextCaptured) { + return false; // fail-safe for fleet; factory can always use run_qc + } + + const QcGateAction action = qcGateDecision( + s_factoryQcPassedAtBoot, s_rerunRequestedAtBoot, + 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; +} + +// --------------------------------------------------------------------------- +// 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 uint8_t s_checkRow = 0; + +static void viewPump() { + // 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) { + USBSerial.print(F("QC: ")); + USBSerial.print(line1); + if (line2 != nullptr && line2[0] != '\0') { + USBSerial.print(F(" - ")); + USBSerial.print(line2); + } + USBSerial.println(); + qcScreenPrompt(line1, line2); + viewPump(); +} + +// --------------------------------------------------------------------------- +// 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. 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(); + } 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; +} + +// 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. +// 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; + 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; + } + + 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 — 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; +} + +// --------------------------------------------------------------------------- +// 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; +} + +// Interactive pot-confirm: squeeze ~50% span = observed cue; release gated. +typedef void (*QcCueFn)(); +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) { + 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 cueOnState = false; + uint32_t lastToggle = start; // 700 ms quiet, then 300 ms on, repeat + bool squeezed = false; + + while (millis() - start < QC_CONFIRM_TIMEOUT_MS) { + const uint32_t now = millis(); + if (now - lastToggle >= (cueOnState ? 300u : 700u)) { + cueOnState = !cueOnState; + lastToggle = now; + if (cueOnState) cueOn(); + else cueOff(); + } + + const uint16_t raw = readThrottleRaw(); + showPotProgress(raw, now - start, QC_CONFIRM_TIMEOUT_MS); + if (raw >= lvl.confirm) { + squeezed = true; + break; + } + viewPump(); + } + cueOff(); + + if (!squeezed) return QcCheckStatus::FAIL; + return qcWaitPotRelease(lvl.release) ? QcCheckStatus::PASS + : QcCheckStatus::FAIL; +} + +// --------------------------------------------------------------------------- +// 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); + 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 throttle calibration (capture only; mapping unchanged) --- + rec.cal = qcRunThrottleCalibration(&rec); + viewStepResult("cal", rec.cal); + + // --- 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 --- + // 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); + factoryWriteQcResult(passed, 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); + } + + // --- 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 00000000..85c6b747 --- /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/src/sp140/main.cpp b/src/sp140/main.cpp index ee43dc87..e9843a76 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 // ========================================================================= diff --git a/src/sp140/qc_logic.cpp b/src/sp140/qc_logic.cpp new file mode 100644 index 00000000..8044f7e9 --- /dev/null +++ b/src/sp140/qc_logic.cpp @@ -0,0 +1,233 @@ +// 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 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)}; +} + +// --------------------------------------------------------------------------- +// 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/src/sp140/vibration_pwm.cpp b/src/sp140/vibration_pwm.cpp index 23d2ca51..2e82b703 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) */ diff --git a/test/test_qc/test_qc.cpp b/test/test_qc/test_qc.cpp new file mode 100644 index 00000000..a26df98e --- /dev/null +++ b/test/test_qc/test_qc.cpp @@ -0,0 +1,343 @@ +// 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) { + EXPECT_EQ(qcGateDecision(false, false, false, false), QcGateAction::RUN); +} + +TEST(QcGate, ExistingFleetUnitNeverSeesQc) { + // 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, false), QcGateAction::SKIP); + EXPECT_EQ(qcGateDecision(true, false, true, true), QcGateAction::SKIP); +} + +TEST(QcGate, SerialRerunOverridesEverything) { + 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); +} + +TEST(QcGate, ExhaustiveInputSpace) { + for (int passed = 0; passed <= 1; passed++) { + for (int rerun = 0; rerun <= 1; rerun++) { + for (int user = 0; user <= 1; user++) { + 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); + } + } + } + } + } +} + +// --------------------------------------------------------------------------- +// 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); +} + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- + +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[768]; + 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[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); + 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(); +} diff --git a/test/test_screenshots/CMakeLists.txt b/test/test_screenshots/CMakeLists.txt index 37e5cba5..cc42b056 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 00000000..fbcfce98 Binary files /dev/null and b/test/test_screenshots/reference/qc_banner_failed.bmp differ diff --git a/test/test_screenshots/reference/qc_banner_passed.bmp b/test/test_screenshots/reference/qc_banner_passed.bmp new file mode 100644 index 00000000..98972f61 Binary files /dev/null and b/test/test_screenshots/reference/qc_banner_passed.bmp differ 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 00000000..2c146705 Binary files /dev/null and b/test/test_screenshots/reference/qc_checklist_progress.bmp differ diff --git a/test/test_screenshots/reference/qc_prompt_squeeze.bmp b/test/test_screenshots/reference/qc_prompt_squeeze.bmp new file mode 100644 index 00000000..68d83d94 Binary files /dev/null and b/test/test_screenshots/reference/qc_prompt_squeeze.bmp differ diff --git a/test/test_screenshots/test_screenshots.cpp b/test/test_screenshots/test_screenshots.cpp index 05e95e26..c527304c 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"); +} diff --git a/tools/qc_bench_logger.py b/tools/qc_bench_logger.py new file mode 100644 index 00000000..eef3c13e --- /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()