From c2f961801a2d1d625cd40e01069cef6ca7dd01d7 Mon Sep 17 00:00:00 2001 From: Jessica Janiuk Date: Mon, 7 Sep 2026 19:40:07 -0500 Subject: [PATCH] feat: battery/charge status and analog trigger+stick calibration (PR 5/9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles three subsystems that are all "ADC reading + calibration," the same shape of problem: - BatteryMonitor (battery.h/.cpp): raw VSYS ADC -> battery percentage, via the R_VSYS1/R_VSYS2 divide-by-3 network and a linear approximation between empty/full cell voltage (documented as a v1 simplification of a real Li-ion discharge curve). Charge-state decoding from STAT1/STAT2 is pulled directly from the bq25185 datasheet's (SLUSF65A) Table 7-2 "Status Pins State Table" rather than guessed. - AnalogCalibration (calibration.h/.cpp): raw trigger/stick ADC -> calibrated 0-100 / -100..100, using stored min/max/center. Stick axes get a small deadzone around center so a resting stick reads exactly 0. - TriggerCalibrationFlow / StickCalibrationFlow: pure guided-calibration state machines (release-then-pull for the trigger; center-then-roll for the stick). Not wired to any UI yet — that lands with the menu system in PR 6/7 — but the reusable logic exists and is fully tested now. - CalibrationStore (calibration_store.cpp): thin Preferences/NVS adapter persisting calibration data, excluded from native build/coverage alongside oled.cpp/rgb_led.cpp. SnipsController.ino reads all four analog channels plus STAT1/STAT2 each second and logs the calibrated/decoded results — packet protocol to actually transmit this lands in PR 8. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 2 +- include/battery.h | 35 ++++ include/calibration.h | 87 +++++++++ include/calibration_store.h | 17 ++ platformio.ini | 4 +- src/SnipsController.ino | 66 +++++++ src/battery.cpp | 24 +++ src/calibration.cpp | 93 +++++++++ src/calibration_store.cpp | 45 +++++ test/test_battery/test_battery.cpp | 82 ++++++++ test/test_calibration/test_calibration.cpp | 212 +++++++++++++++++++++ 11 files changed, 664 insertions(+), 3 deletions(-) create mode 100644 include/battery.h create mode 100644 include/calibration.h create mode 100644 include/calibration_store.h create mode 100644 src/battery.cpp create mode 100644 src/calibration.cpp create mode 100644 src/calibration_store.cpp create mode 100644 test/test_battery/test_battery.cpp create mode 100644 test/test_calibration/test_calibration.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb39a8e..347be61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - name: Coverage report run: | - output=$(gcovr --root . --filter 'src/' --exclude 'src/SnipsController\.ino' --exclude 'src/oled\.cpp' --exclude 'src/rgb_led\.cpp' --print-summary --fail-under-line 90) + output=$(gcovr --root . --filter 'src/' --exclude 'src/SnipsController\.ino' --exclude 'src/oled\.cpp' --exclude 'src/rgb_led\.cpp' --exclude 'src/calibration_store\.cpp' --print-summary --fail-under-line 90) status=$? { echo '### Coverage (src/, excluding SnipsController.ino and hardware adapters)' diff --git a/include/battery.h b/include/battery.h new file mode 100644 index 0000000..77c79f0 --- /dev/null +++ b/include/battery.h @@ -0,0 +1,35 @@ +#pragma once + +// Pure logic for battery percentage and bq25185 charge-status decoding. +// Knows nothing about real ADC/GPIO reads — SnipsController.ino does the +// actual analogRead()/digitalRead() and passes raw values in. +enum class ChargeState { + kDone, // charge complete, sleep mode, or charging disabled + kCharging, // normal charging in progress (incl. auto-recharge) + kRecoverableFault, // VIN_OVP, TS hot/cold, or system short + kLatchedFault, // ILIM/ISET short, BATOCP, or safety timer expired +}; + +class BatteryMonitor { + public: + // Truth table is bq25185 datasheet (SLUSF65A) Table 7-2 "Status Pins + // State Table" — stat1High/stat2High are the STAT1/STAT2 pin states as + // read directly (both are open-drain with an external pull-up to 3V3, + // so HIGH/LOW here is the real electrical state, no polarity inversion). + ChargeState chargeStateFor(bool stat1High, bool stat2High) const; + + // Raw ADC (0-4095, ESP32 12-bit) -> battery percentage (0-100), via the + // VSYS divide-by-3 network (R_VSYS1/R_VSYS2, see PCB/GPIO_table.md) and a + // linear approximation between empty/full cell voltage. Assumes the ADC + // is configured for its default ~3.3V full-scale range — confirm against + // real hardware, and note a linear curve is a simplification of a real + // Li-ion discharge curve, good enough for a v1 estimate. + int percentFor(int rawAdc) const; + + private: + static constexpr int kAdcMaxCounts = 4095; + static constexpr float kAdcFullScaleVolts = 3.3f; + static constexpr float kVsysDividerRatio = 3.0f; + static constexpr float kEmptyVoltage = 3.0f; + static constexpr float kFullVoltage = 4.2f; +}; diff --git a/include/calibration.h b/include/calibration.h new file mode 100644 index 0000000..4f5175f --- /dev/null +++ b/include/calibration.h @@ -0,0 +1,87 @@ +#pragma once + +// Pure logic for analog trigger/thumbstick calibration. Knows nothing +// about real ADC reads or persistence — SnipsController.ino (and, later, +// the menu system) supply raw ADC samples and user confirm/advance +// events; calibration_store.h persists the result to NVS. + +// Persisted calibration values. Defaults assume an uncalibrated 12-bit ADC +// (0-4095) so trigger/stick still produce a reasonable (if unrefined) +// reading before the user ever runs calibration. +struct CalibrationData { + int triggerMin = 0; + int triggerMax = 4095; + int stickXMin = 0; + int stickXMax = 4095; + int stickXCenter = 2048; + int stickYMin = 0; + int stickYMax = 4095; + int stickYCenter = 2048; +}; + +class AnalogCalibration { + public: + // Raw trigger ADC -> 0-100 (0 = released, 100 = fully pulled). + static int calibrateTrigger(int raw, const CalibrationData &data); + + // Raw stick-axis ADC -> -100..100 (0 = center), with a small deadzone + // around center so a physically-resting stick reads as exactly 0. + static int calibrateStickAxis(int raw, int min, int center, int max); + + private: + static constexpr int kDeadzonePercentOfRange = 3; +}; + +// Guided two-step trigger calibration: release, then full pull. +class TriggerCalibrationFlow { + public: + enum class Step { kAwaitingRelease, kAwaitingFullPull, kDone }; + + Step currentStep() const { return step_; } + + // Call when the user confirms ("Enter") at the current step, with the + // current raw trigger ADC reading to capture. No-op once kDone. + void confirmStep(int rawAdc); + + int min() const { return min_; } + int max() const { return max_; } + + private: + Step step_ = Step::kAwaitingRelease; + int min_ = 0; + int max_ = 4095; +}; + +// Guided stick calibration: center first, then roll to every extreme +// while samples are continuously tracked, then an explicit "done". +class StickCalibrationFlow { + public: + enum class Step { kAwaitingCenter, kRolling, kDone }; + + Step currentStep() const { return step_; } + + // Step 1: call once when the user confirms the stick is at rest. + void confirmCenter(int rawX, int rawY); + + // Step 2: call every tick while rolling; no-op outside kRolling. + void sample(int rawX, int rawY); + + // Finishes step 2; no-op outside kRolling. + void confirmDone(); + + int centerX() const { return centerX_; } + int centerY() const { return centerY_; } + int minX() const { return minX_; } + int maxX() const { return maxX_; } + int minY() const { return minY_; } + int maxY() const { return maxY_; } + + private: + Step step_ = Step::kAwaitingCenter; + int centerX_ = 0; + int centerY_ = 0; + int minX_ = 0; + int maxX_ = 4095; + int minY_ = 0; + int maxY_ = 4095; +}; diff --git a/include/calibration_store.h b/include/calibration_store.h new file mode 100644 index 0000000..b4294c7 --- /dev/null +++ b/include/calibration_store.h @@ -0,0 +1,17 @@ +#pragma once + +#include "calibration.h" + +// Thin adapter persisting CalibrationData to NVS (ESP32 Preferences). +// No calibration math lives here — see calibration.h. Excluded from +// native build/coverage (see platformio.ini's [env:native] +// build_src_filter). +namespace CalibrationStore { + +// Returns the stored calibration, or CalibrationData's defaults if none +// has been saved yet. +CalibrationData load(); + +void save(const CalibrationData &data); + +} // namespace CalibrationStore diff --git a/platformio.ini b/platformio.ini index cd45e67..6b185f5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -38,11 +38,11 @@ lib_deps = ; adapter files (thin wrappers over I2C/SPI/ADC/RMT/NVS), added here ; alongside as new subsystems land — see CLAUDE.md's testing-approach note. ; Currently: oled.cpp (Adafruit_SSD1306/Wire), rgb_led.cpp -; (Adafruit_NeoPixel/RMT). +; (Adafruit_NeoPixel/RMT), calibration_store.cpp (Preferences/NVS). [env:native] platform = native test_build_src = yes -build_src_filter = +<*> -<*.ino> - - +build_src_filter = +<*> -<*.ino> - - - test_framework = unity build_flags = --coverage extra_scripts = pre:scripts/native_coverage_linkflags.py diff --git a/src/SnipsController.ino b/src/SnipsController.ino index ed3733b..8bedf0b 100644 --- a/src/SnipsController.ino +++ b/src/SnipsController.ino @@ -1,6 +1,9 @@ #include +#include "battery.h" #include "buttons.h" +#include "calibration.h" +#include "calibration_store.h" #include "oled.h" #include "pin_assignment.h" #include "power_latch.h" @@ -19,7 +22,11 @@ ButtonPanel buttonPanel; OledDisplay oledDisplay; RgbLed rgbLed; StatusLedController statusLedController; +BatteryMonitor batteryMonitor; +CalibrationData calibrationData; bool lastReportedPressed[Buttons::kCount] = {}; +unsigned long lastTelemetryLogMs = 0; +constexpr unsigned long kTelemetryLogIntervalMs = 1000; const char *buttonName(size_t index) { switch (index) { @@ -39,6 +46,16 @@ const char *buttonName(size_t index) { } } +const char *chargeStateName(ChargeState state) { + switch (state) { + case ChargeState::kDone: return "done"; + case ChargeState::kCharging: return "charging"; + case ChargeState::kRecoverableFault: return "recoverable-fault"; + case ChargeState::kLatchedFault: return "latched-fault"; + default: return "unknown"; + } +} + } // namespace void setup() { @@ -68,6 +85,17 @@ void setup() { pinMode(Buttons::kPins[i], INPUT_PULLUP); } + // bq25185 STAT1/STAT2: open-drain, external 10kOhm pull-up to 3V3 (see + // PCB/GPIO_table.md) — plain INPUT, no internal pull needed. + pinMode(PinAssignment::kChargeStat1, INPUT); + pinMode(PinAssignment::kChargeStat2, INPUT); + + // Restores any previously-run trigger/stick calibration; defaults to an + // uncalibrated full ADC range if none has been saved yet. The guided + // calibration flows that produce new values live in calibration.h and + // get wired to the on-device menu in a later PR. + calibrationData = CalibrationStore::load(); + // Real screen content (menus, complications, gesture feedback) lands in // later PRs. For now this just proves the display works end to end. if (oledDisplay.begin()) { @@ -122,4 +150,42 @@ void loop() { Serial.println(pressed ? " pressed" : " released"); } } + + // Packet protocol lands in PR 8 — for now, just log periodically (not + // every tick) so bring-up can confirm these readings look right. + if (now - lastTelemetryLogMs >= kTelemetryLogIntervalMs) { + lastTelemetryLogMs = now; + + const int rawTrigger = analogRead(PinAssignment::kAnalogTrigger); + const int rawStickX = analogRead(PinAssignment::kThumbstickX); + const int rawStickY = analogRead(PinAssignment::kThumbstickY); + const int rawVsys = analogRead(PinAssignment::kVsysSense); + const bool stat1High = digitalRead(PinAssignment::kChargeStat1) == HIGH; + const bool stat2High = digitalRead(PinAssignment::kChargeStat2) == HIGH; + + const int triggerPercent = + AnalogCalibration::calibrateTrigger(rawTrigger, calibrationData); + const int stickXPercent = AnalogCalibration::calibrateStickAxis( + rawStickX, calibrationData.stickXMin, calibrationData.stickXCenter, + calibrationData.stickXMax); + const int stickYPercent = AnalogCalibration::calibrateStickAxis( + rawStickY, calibrationData.stickYMin, calibrationData.stickYCenter, + calibrationData.stickYMax); + const int batteryPercent = batteryMonitor.percentFor(rawVsys); + const ChargeState chargeState = + batteryMonitor.chargeStateFor(stat1High, stat2High); + + Serial.print("Battery "); + Serial.print(batteryPercent); + Serial.print("% ("); + Serial.print(chargeStateName(chargeState)); + Serial.println(")"); + Serial.print("Trigger "); + Serial.print(triggerPercent); + Serial.println("%"); + Serial.print("Stick X "); + Serial.print(stickXPercent); + Serial.print(" Y "); + Serial.println(stickYPercent); + } } diff --git a/src/battery.cpp b/src/battery.cpp new file mode 100644 index 0000000..0695803 --- /dev/null +++ b/src/battery.cpp @@ -0,0 +1,24 @@ +#include "battery.h" + +ChargeState BatteryMonitor::chargeStateFor(bool stat1High, + bool stat2High) const { + if (stat1High && stat2High) return ChargeState::kDone; + if (stat1High && !stat2High) return ChargeState::kCharging; + if (!stat1High && stat2High) return ChargeState::kRecoverableFault; + return ChargeState::kLatchedFault; +} + +int BatteryMonitor::percentFor(int rawAdc) const { + if (rawAdc < 0) rawAdc = 0; + if (rawAdc > kAdcMaxCounts) rawAdc = kAdcMaxCounts; + + const float pinVolts = + (static_cast(rawAdc) / kAdcMaxCounts) * kAdcFullScaleVolts; + const float batteryVolts = pinVolts * kVsysDividerRatio; + + float percent = (batteryVolts - kEmptyVoltage) / + (kFullVoltage - kEmptyVoltage) * 100.0f; + if (percent < 0.0f) percent = 0.0f; + if (percent > 100.0f) percent = 100.0f; + return static_cast(percent + 0.5f); +} diff --git a/src/calibration.cpp b/src/calibration.cpp new file mode 100644 index 0000000..ec5a68f --- /dev/null +++ b/src/calibration.cpp @@ -0,0 +1,93 @@ +#include "calibration.h" + +#include + +namespace { + +int roundToInt(float value) { + return value >= 0.0f ? static_cast(value + 0.5f) + : static_cast(value - 0.5f); +} + +int clamp(int value, int lo, int hi) { + if (value < lo) return lo; + if (value > hi) return hi; + return value; +} + +} // namespace + +int AnalogCalibration::calibrateTrigger(int raw, const CalibrationData &data) { + if (data.triggerMax <= data.triggerMin) { + return 0; // uncalibrated/degenerate range + } + const float percent = static_cast(raw - data.triggerMin) / + (data.triggerMax - data.triggerMin) * 100.0f; + return clamp(roundToInt(percent), 0, 100); +} + +int AnalogCalibration::calibrateStickAxis(int raw, int min, int center, + int max) { + if (max <= center || center <= min) { + return 0; // uncalibrated/degenerate range + } + + const float deadzoneHalfWidth = + static_cast(max - min) * kDeadzonePercentOfRange / 100.0f; + if (std::abs(raw - center) <= deadzoneHalfWidth) { + return 0; + } + + if (raw > center) { + const float percent = + static_cast(raw - center) / (max - center) * 100.0f; + return clamp(roundToInt(percent), 0, 100); + } + + const float percent = + static_cast(raw - center) / (center - min) * 100.0f; + return clamp(roundToInt(percent), -100, 0); +} + +void TriggerCalibrationFlow::confirmStep(int rawAdc) { + switch (step_) { + case Step::kAwaitingRelease: + min_ = rawAdc; + step_ = Step::kAwaitingFullPull; + break; + case Step::kAwaitingFullPull: + max_ = rawAdc; + step_ = Step::kDone; + break; + case Step::kDone: + break; + } +} + +void StickCalibrationFlow::confirmCenter(int rawX, int rawY) { + if (step_ != Step::kAwaitingCenter) { + return; + } + centerX_ = rawX; + centerY_ = rawY; + minX_ = maxX_ = rawX; + minY_ = maxY_ = rawY; + step_ = Step::kRolling; +} + +void StickCalibrationFlow::sample(int rawX, int rawY) { + if (step_ != Step::kRolling) { + return; + } + if (rawX < minX_) minX_ = rawX; + if (rawX > maxX_) maxX_ = rawX; + if (rawY < minY_) minY_ = rawY; + if (rawY > maxY_) maxY_ = rawY; +} + +void StickCalibrationFlow::confirmDone() { + if (step_ != Step::kRolling) { + return; + } + step_ = Step::kDone; +} diff --git a/src/calibration_store.cpp b/src/calibration_store.cpp new file mode 100644 index 0000000..163bc07 --- /dev/null +++ b/src/calibration_store.cpp @@ -0,0 +1,45 @@ +#include "calibration_store.h" + +#include + +namespace { +constexpr const char *kNamespace = "snips_cal"; +} // namespace + +CalibrationData CalibrationStore::load() { + CalibrationData data; // defaults if nothing has been saved yet + + Preferences prefs; + if (!prefs.begin(kNamespace, /*readOnly=*/true)) { + return data; + } + + data.triggerMin = prefs.getInt("trigMin", data.triggerMin); + data.triggerMax = prefs.getInt("trigMax", data.triggerMax); + data.stickXMin = prefs.getInt("xMin", data.stickXMin); + data.stickXMax = prefs.getInt("xMax", data.stickXMax); + data.stickXCenter = prefs.getInt("xCenter", data.stickXCenter); + data.stickYMin = prefs.getInt("yMin", data.stickYMin); + data.stickYMax = prefs.getInt("yMax", data.stickYMax); + data.stickYCenter = prefs.getInt("yCenter", data.stickYCenter); + prefs.end(); + + return data; +} + +void CalibrationStore::save(const CalibrationData &data) { + Preferences prefs; + if (!prefs.begin(kNamespace, /*readOnly=*/false)) { + return; + } + + prefs.putInt("trigMin", data.triggerMin); + prefs.putInt("trigMax", data.triggerMax); + prefs.putInt("xMin", data.stickXMin); + prefs.putInt("xMax", data.stickXMax); + prefs.putInt("xCenter", data.stickXCenter); + prefs.putInt("yMin", data.stickYMin); + prefs.putInt("yMax", data.stickYMax); + prefs.putInt("yCenter", data.stickYCenter); + prefs.end(); +} diff --git a/test/test_battery/test_battery.cpp b/test/test_battery/test_battery.cpp new file mode 100644 index 0000000..3163407 --- /dev/null +++ b/test/test_battery/test_battery.cpp @@ -0,0 +1,82 @@ +#include + +#include "battery.h" + +void setUp(void) {} +void tearDown(void) {} + +// ---- chargeStateFor — bq25185 datasheet Table 7-2 ----------------------- + +void test_charge_state_both_high_is_done() { + BatteryMonitor monitor; + TEST_ASSERT_TRUE(ChargeState::kDone == monitor.chargeStateFor(true, true)); +} + +void test_charge_state_stat1_high_stat2_low_is_charging() { + BatteryMonitor monitor; + TEST_ASSERT_TRUE(ChargeState::kCharging == + monitor.chargeStateFor(true, false)); +} + +void test_charge_state_stat1_low_stat2_high_is_recoverable_fault() { + BatteryMonitor monitor; + TEST_ASSERT_TRUE(ChargeState::kRecoverableFault == + monitor.chargeStateFor(false, true)); +} + +void test_charge_state_both_low_is_latched_fault() { + BatteryMonitor monitor; + TEST_ASSERT_TRUE(ChargeState::kLatchedFault == + monitor.chargeStateFor(false, false)); +} + +// ---- percentFor ----------------------------------------------------------- + +void test_percent_at_raw_zero_is_zero() { + BatteryMonitor monitor; + TEST_ASSERT_EQUAL_INT(0, monitor.percentFor(0)); +} + +void test_percent_near_empty_cutoff_is_zero() { + BatteryMonitor monitor; + // ~3.0V at the battery (this class's empty-cell cutoff). + TEST_ASSERT_EQUAL_INT(0, monitor.percentFor(1241)); +} + +void test_percent_at_midpoint_is_about_fifty() { + BatteryMonitor monitor; + // ~3.6V at the battery — halfway between the 3.0V/4.2V empty/full range. + TEST_ASSERT_EQUAL_INT(50, monitor.percentFor(1489)); +} + +void test_percent_at_raw_max_clamps_to_hundred() { + BatteryMonitor monitor; + TEST_ASSERT_EQUAL_INT(100, monitor.percentFor(4095)); +} + +void test_percent_clamps_negative_raw_to_zero() { + BatteryMonitor monitor; + TEST_ASSERT_EQUAL_INT(0, monitor.percentFor(-500)); +} + +void test_percent_clamps_raw_beyond_range_to_hundred() { + BatteryMonitor monitor; + // Out-of-range high input (beyond the 12-bit ADC's max) should still + // clamp safely rather than doing anything undefined. + TEST_ASSERT_EQUAL_INT(100, monitor.percentFor(999999)); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + RUN_TEST(test_charge_state_both_high_is_done); + RUN_TEST(test_charge_state_stat1_high_stat2_low_is_charging); + RUN_TEST(test_charge_state_stat1_low_stat2_high_is_recoverable_fault); + RUN_TEST(test_charge_state_both_low_is_latched_fault); + RUN_TEST(test_percent_at_raw_zero_is_zero); + RUN_TEST(test_percent_near_empty_cutoff_is_zero); + RUN_TEST(test_percent_at_midpoint_is_about_fifty); + RUN_TEST(test_percent_at_raw_max_clamps_to_hundred); + RUN_TEST(test_percent_clamps_negative_raw_to_zero); + RUN_TEST(test_percent_clamps_raw_beyond_range_to_hundred); + return UNITY_END(); +} diff --git a/test/test_calibration/test_calibration.cpp b/test/test_calibration/test_calibration.cpp new file mode 100644 index 0000000..42472cd --- /dev/null +++ b/test/test_calibration/test_calibration.cpp @@ -0,0 +1,212 @@ +#include + +#include "calibration.h" + +void setUp(void) {} +void tearDown(void) {} + +// ---- AnalogCalibration::calibrateTrigger --------------------------------- + +void test_trigger_at_min_is_zero_percent() { + CalibrationData data; + TEST_ASSERT_EQUAL_INT(0, AnalogCalibration::calibrateTrigger(0, data)); +} + +void test_trigger_at_max_is_hundred_percent() { + CalibrationData data; + TEST_ASSERT_EQUAL_INT(100, AnalogCalibration::calibrateTrigger(4095, data)); +} + +void test_trigger_at_midpoint_is_about_fifty_percent() { + CalibrationData data; + TEST_ASSERT_EQUAL_INT(50, AnalogCalibration::calibrateTrigger(2048, data)); +} + +void test_trigger_clamps_beyond_max() { + CalibrationData data; + TEST_ASSERT_EQUAL_INT(100, AnalogCalibration::calibrateTrigger(5000, data)); +} + +void test_trigger_clamps_below_min() { + CalibrationData data; + TEST_ASSERT_EQUAL_INT(0, AnalogCalibration::calibrateTrigger(-100, data)); +} + +void test_trigger_degenerate_range_returns_zero() { + CalibrationData data; + data.triggerMin = 100; + data.triggerMax = 100; + TEST_ASSERT_EQUAL_INT(0, AnalogCalibration::calibrateTrigger(2048, data)); +} + +// ---- AnalogCalibration::calibrateStickAxis -------------------------------- + +void test_stick_axis_at_center_is_zero() { + TEST_ASSERT_EQUAL_INT( + 0, AnalogCalibration::calibrateStickAxis(2048, 0, 2048, 4095)); +} + +void test_stick_axis_within_deadzone_is_zero() { + // Deadzone half-width for this range is (4095-0)*3/100 = 122.85. + TEST_ASSERT_EQUAL_INT( + 0, AnalogCalibration::calibrateStickAxis(2170, 0, 2048, 4095)); +} + +void test_stick_axis_just_outside_deadzone_is_nonzero() { + TEST_ASSERT_EQUAL_INT( + 6, AnalogCalibration::calibrateStickAxis(2171, 0, 2048, 4095)); +} + +void test_stick_axis_at_max_is_hundred() { + TEST_ASSERT_EQUAL_INT( + 100, AnalogCalibration::calibrateStickAxis(4095, 0, 2048, 4095)); +} + +void test_stick_axis_at_min_is_negative_hundred() { + TEST_ASSERT_EQUAL_INT( + -100, AnalogCalibration::calibrateStickAxis(0, 0, 2048, 4095)); +} + +void test_stick_axis_clamps_beyond_max() { + TEST_ASSERT_EQUAL_INT( + 100, AnalogCalibration::calibrateStickAxis(6000, 0, 2048, 4095)); +} + +void test_stick_axis_clamps_below_min() { + TEST_ASSERT_EQUAL_INT( + -100, AnalogCalibration::calibrateStickAxis(-500, 0, 2048, 4095)); +} + +void test_stick_axis_degenerate_max_at_center_returns_zero() { + TEST_ASSERT_EQUAL_INT( + 0, AnalogCalibration::calibrateStickAxis(1000, 0, 4095, 4095)); +} + +void test_stick_axis_degenerate_center_at_min_returns_zero() { + TEST_ASSERT_EQUAL_INT( + 0, AnalogCalibration::calibrateStickAxis(3000, 2048, 2048, 4095)); +} + +// ---- TriggerCalibrationFlow ------------------------------------------------ + +void test_trigger_flow_starts_awaiting_release() { + TriggerCalibrationFlow flow; + TEST_ASSERT_TRUE(TriggerCalibrationFlow::Step::kAwaitingRelease == + flow.currentStep()); +} + +void test_trigger_flow_captures_min_then_max_then_done() { + TriggerCalibrationFlow flow; + flow.confirmStep(50); + TEST_ASSERT_TRUE(TriggerCalibrationFlow::Step::kAwaitingFullPull == + flow.currentStep()); + TEST_ASSERT_EQUAL_INT(50, flow.min()); + + flow.confirmStep(4000); + TEST_ASSERT_TRUE(TriggerCalibrationFlow::Step::kDone == flow.currentStep()); + TEST_ASSERT_EQUAL_INT(4000, flow.max()); +} + +void test_trigger_flow_ignores_confirm_once_done() { + TriggerCalibrationFlow flow; + flow.confirmStep(50); + flow.confirmStep(4000); + flow.confirmStep(9999); + TEST_ASSERT_TRUE(TriggerCalibrationFlow::Step::kDone == flow.currentStep()); + TEST_ASSERT_EQUAL_INT(50, flow.min()); + TEST_ASSERT_EQUAL_INT(4000, flow.max()); +} + +// ---- StickCalibrationFlow --------------------------------------------------- + +void test_stick_flow_starts_awaiting_center() { + StickCalibrationFlow flow; + TEST_ASSERT_TRUE(StickCalibrationFlow::Step::kAwaitingCenter == + flow.currentStep()); +} + +void test_stick_flow_sample_before_center_is_noop() { + StickCalibrationFlow flow; + flow.sample(1500, 1500); + TEST_ASSERT_TRUE(StickCalibrationFlow::Step::kAwaitingCenter == + flow.currentStep()); +} + +void test_stick_flow_confirm_done_before_rolling_is_noop() { + StickCalibrationFlow flow; + flow.confirmDone(); + TEST_ASSERT_TRUE(StickCalibrationFlow::Step::kAwaitingCenter == + flow.currentStep()); +} + +void test_stick_flow_confirm_center_starts_rolling() { + StickCalibrationFlow flow; + flow.confirmCenter(2000, 2100); + TEST_ASSERT_TRUE(StickCalibrationFlow::Step::kRolling == flow.currentStep()); + TEST_ASSERT_EQUAL_INT(2000, flow.centerX()); + TEST_ASSERT_EQUAL_INT(2100, flow.centerY()); + // Range starts collapsed to the center point itself. + TEST_ASSERT_EQUAL_INT(2000, flow.minX()); + TEST_ASSERT_EQUAL_INT(2000, flow.maxX()); +} + +void test_stick_flow_sample_tracks_min_and_max_per_axis() { + StickCalibrationFlow flow; + flow.confirmCenter(2000, 2100); + flow.sample(1500, 2600); + flow.sample(2500, 1800); + + TEST_ASSERT_EQUAL_INT(1500, flow.minX()); + TEST_ASSERT_EQUAL_INT(2500, flow.maxX()); + TEST_ASSERT_EQUAL_INT(1800, flow.minY()); + TEST_ASSERT_EQUAL_INT(2600, flow.maxY()); +} + +void test_stick_flow_confirm_center_again_while_rolling_is_noop() { + StickCalibrationFlow flow; + flow.confirmCenter(2000, 2100); + flow.confirmCenter(9999, 9999); + TEST_ASSERT_EQUAL_INT(2000, flow.centerX()); +} + +void test_stick_flow_confirm_done_finishes() { + StickCalibrationFlow flow; + flow.confirmCenter(2000, 2100); + flow.sample(1500, 2600); + flow.confirmDone(); + TEST_ASSERT_TRUE(StickCalibrationFlow::Step::kDone == flow.currentStep()); + + // Further samples/confirms are ignored once done. + flow.sample(0, 0); + TEST_ASSERT_EQUAL_INT(1500, flow.minX()); +} + +int main(int argc, char **argv) { + UNITY_BEGIN(); + RUN_TEST(test_trigger_at_min_is_zero_percent); + RUN_TEST(test_trigger_at_max_is_hundred_percent); + RUN_TEST(test_trigger_at_midpoint_is_about_fifty_percent); + RUN_TEST(test_trigger_clamps_beyond_max); + RUN_TEST(test_trigger_clamps_below_min); + RUN_TEST(test_trigger_degenerate_range_returns_zero); + RUN_TEST(test_stick_axis_at_center_is_zero); + RUN_TEST(test_stick_axis_within_deadzone_is_zero); + RUN_TEST(test_stick_axis_just_outside_deadzone_is_nonzero); + RUN_TEST(test_stick_axis_at_max_is_hundred); + RUN_TEST(test_stick_axis_at_min_is_negative_hundred); + RUN_TEST(test_stick_axis_clamps_beyond_max); + RUN_TEST(test_stick_axis_clamps_below_min); + RUN_TEST(test_stick_axis_degenerate_max_at_center_returns_zero); + RUN_TEST(test_stick_axis_degenerate_center_at_min_returns_zero); + RUN_TEST(test_trigger_flow_starts_awaiting_release); + RUN_TEST(test_trigger_flow_captures_min_then_max_then_done); + RUN_TEST(test_trigger_flow_ignores_confirm_once_done); + RUN_TEST(test_stick_flow_starts_awaiting_center); + RUN_TEST(test_stick_flow_sample_before_center_is_noop); + RUN_TEST(test_stick_flow_confirm_done_before_rolling_is_noop); + RUN_TEST(test_stick_flow_confirm_center_starts_rolling); + RUN_TEST(test_stick_flow_sample_tracks_min_and_max_per_axis); + RUN_TEST(test_stick_flow_confirm_center_again_while_rolling_is_noop); + RUN_TEST(test_stick_flow_confirm_done_finishes); + return UNITY_END(); +}