Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ diagnostics-logs/
build-screenshot/
test/test_screenshots/output/*.bmp
test/test_screenshots/output/png/

# Local executor plans (not tracked)
plans/
160 changes: 160 additions & 0 deletions FIRST_BOOT_QC.md
Original file line number Diff line number Diff line change
@@ -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=<version>`; 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)?
7 changes: 7 additions & 0 deletions inc/sp140/ble/ble_ids.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
59 changes: 59 additions & 0 deletions inc/sp140/factory_settings.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright 2026 <Zach Whitehead>
// 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 <stdint.h>
#include <stddef.h>

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_
29 changes: 29 additions & 0 deletions inc/sp140/first_boot_qc.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright 2026 <Zach Whitehead>
// 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_
49 changes: 49 additions & 0 deletions inc/sp140/lvgl/lvgl_qc_screen.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright 2026 <Zach Whitehead>
// 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 <lvgl.h>
#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_
Loading