diff --git a/include/calibration.h b/include/calibration.h index 4f5175f..1971c66 100644 --- a/include/calibration.h +++ b/include/calibration.h @@ -1,10 +1,24 @@ #pragma once +#include + // 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. +// User-adjustable stick deadzone presets, cycled via the on-device Stick +// Deadzone menu screen (see menu.h) — a fixed set rather than freeform +// numeric entry, same spirit as PowerConfigOptions. 3% was this +// codebase's original hardcoded default before it became adjustable. +namespace StickDeadzoneOptions { +constexpr int kPercents[] = {0, 3, 5, 8, 12, 15, 20}; +constexpr size_t kCount = sizeof(kPercents) / sizeof(kPercents[0]); +} // namespace StickDeadzoneOptions + +int nextDeadzonePercent(int currentPercent); +int prevDeadzonePercent(int currentPercent); + // 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. @@ -17,6 +31,7 @@ struct CalibrationData { int stickYMin = 0; int stickYMax = 4095; int stickYCenter = 2048; + int stickDeadzonePercent = StickDeadzoneOptions::kPercents[1]; // 3 }; class AnalogCalibration { @@ -24,12 +39,12 @@ class AnalogCalibration { // 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; + // Raw stick-axis ADC -> -100..100 (0 = center), with a deadzone around + // center (deadzonePercent, of the calibrated min-max range) so a + // physically-resting stick reads as exactly 0. User-adjustable — see + // StickDeadzoneOptions. + static int calibrateStickAxis(int raw, int min, int center, int max, + int deadzonePercent); }; // Guided two-step trigger calibration: release, then full pull. @@ -58,6 +73,19 @@ class StickCalibrationFlow { public: enum class Step { kAwaitingCenter, kRolling, kDone }; + // Minimum distance (raw ADC counts) required between center and each + // of the four extremes before confirmDone() will actually finish — + // min/max start collapsed to the center point itself (see + // confirmCenter()) and only widen from real rolling, so without this + // floor, confirming done too early (before the stick was actually + // pushed to its extremes) silently saves a near-zero range. Later, + // calibrateStickAxis() divides by that range, so ordinary ADC noise + // gets amplified into wild reported movement with the stick at rest — + // confirmed against real hardware, not a hypothetical. Comfortably + // above typical ADC noise (tens of counts at most) and well below a + // real thumbstick's mechanical travel (typically 1500+ counts). + static constexpr int kMinRangeCounts = 300; + Step currentStep() const { return step_; } // Step 1: call once when the user confirms the stick is at rest. @@ -66,7 +94,13 @@ class StickCalibrationFlow { // Step 2: call every tick while rolling; no-op outside kRolling. void sample(int rawX, int rawY); - // Finishes step 2; no-op outside kRolling. + // True once every one of the four extremes is at least + // kMinRangeCounts away from center — see confirmDone(). + bool hasEnoughRange() const; + + // Finishes step 2; no-op outside kRolling, and no-op (stays kRolling) + // if hasEnoughRange() isn't true yet, so an under-rolled calibration + // can't be confirmed away — keep rolling. void confirmDone(); int centerX() const { return centerX_; } diff --git a/include/menu.h b/include/menu.h index 93c5985..02d7834 100644 --- a/include/menu.h +++ b/include/menu.h @@ -23,6 +23,7 @@ enum class MenuScreen { kManageDroidsEnterPanId, kManageDroidsDeleteConfirm, kCalibrateStick, + kStickDeadzone, kCalibrateTrigger, kDisplayConfig, kPowerConfig, @@ -35,6 +36,7 @@ enum class MainMenuItem { kSwitchDroid = 0, kManageDroids, kCalibrateStick, + kStickDeadzone, kCalibrateTrigger, kDisplayConfig, kPowerConfig, @@ -82,11 +84,21 @@ class MenuController { // by every other screen. void onEnter(int rawTrigger, int rawStickX, int rawStickY); - // Call every loop tick regardless of button edges, so the stick - // calibration's "roll to extremes" step can continuously track - // min/max. No-op unless currentScreen() == kCalibrateStick and its - // internal flow is in the rolling step. - void tick(int rawStickX, int rawStickY); + // Call every loop tick regardless of button edges. Two independent + // things happen here: + // - Stick calibration's "roll to extremes" step continuously tracks + // min/max from the raw readings — no-op unless currentScreen() == + // kCalibrateStick and its internal flow is in the rolling step. + // - The stick doubles as menu Up/Down navigation (same effect as the + // Left Up/Down buttons) via stickYPercent, the already-calibrated + // reading — a threshold push counts as one discrete nav event, and + // the stick must return near center before it can fire again, so + // holding it doesn't spam repeated scrolls. Skipped entirely on + // kCalibrateStick, where stick position is the data being + // captured, not a nav input. + // Returns true if it fired a nav event this call — SnipsController.ino + // needs this to know when to redraw (see its menuStateChanged comment). + bool tick(int rawStickX, int rawStickY, int stickYPercent); TriggerCalibrationFlow::Step triggerCalibrationStep() const { return triggerFlow_.currentStep(); @@ -94,6 +106,9 @@ class MenuController { StickCalibrationFlow::Step stickCalibrationStep() const { return stickFlow_.currentStep(); } + bool stickCalibrationHasEnoughRange() const { + return stickFlow_.hasEnoughRange(); + } // Each returns true exactly once, the tick a new result becomes ready // to persist, and writes it into the output params. @@ -171,6 +186,20 @@ class MenuController { } bool consumePowerConfigChanged(); + // Stick deadzone: unlike Display/Power Config, this isn't an + // externally-owned pointer — it's a plain value MenuController tracks + // itself (SnipsController.ino seeds it once at boot from the loaded + // CalibrationData, same pattern as setDeviceSerialLow()), since + // there's only one value and no natural external owner the way + // ComplicationRegistry/PowerConfig are owned elsewhere. Up/Down apply + // a change immediately (see onUp()/onDown()) rather than needing a + // separate Enter-to-confirm step. + void setStickDeadzonePercent(int percent) { + stickDeadzonePercent_ = percent; + } + int stickDeadzonePercent() const { return stickDeadzonePercent_; } + bool consumeStickDeadzoneChanged(); + private: static constexpr unsigned long kOpenComboHoldMs = 1000; @@ -203,6 +232,9 @@ class MenuController { int lastTestedButtonIndex_ = -1; + // Re-armed once stickYPercent returns near center — see tick(). + bool stickNavArmed_ = true; + DroidStore droidStore_; int droidListIndex_ = 0; bool droidStoreChanged_ = false; @@ -220,6 +252,9 @@ class MenuController { PowerConfig *powerConfig_ = nullptr; int powerConfigRowIndex_ = 0; bool powerConfigChanged_ = false; + + int stickDeadzonePercent_ = StickDeadzoneOptions::kPercents[1]; + bool stickDeadzoneChanged_ = false; }; // Decides what text should be on screen for the menu's current state. diff --git a/src/SnipsController.ino b/src/SnipsController.ino index 9014462..f78faf7 100644 --- a/src/SnipsController.ino +++ b/src/SnipsController.ino @@ -56,7 +56,6 @@ unsigned long lastDownlinkMs = 0; // 0 = never received one constexpr unsigned long kConnectionTimeoutMs = 5000; MenuScreen previousMenuScreen = MenuScreen::kInactive; MainMenuItem previousMainMenuItem = MainMenuItem::kSwitchDroid; -int previousLastTestedButtonIndex = -1; void showBootScreen() { ScreenBuffer bootScreen; @@ -177,13 +176,18 @@ void setup() { // re-enumerate with the host after every reset. That consistently // takes longer than this firmware needs to reach its early boot // prints, so they were getting lost even with a monitor already open - // and waiting. Give the host a couple seconds to finish opening the - // port before continuing — harmless in the field with no host - // attached (just a bounded worst-case boot delay), and makes early - // diagnostics reliably visible during bring-up. HWCDC's bool operator - // reflects the real host-connection state, not just whether begin() - // was called. - constexpr unsigned long kSerialWaitMs = 2000; + // and waiting -- 2000ms wasn't long enough to reliably cover a real + // dev machine's reconnect time (observed several seconds on one + // laptop). HWCDC's bool operator reflects the real host-connection + // state, not just whether begin() was called. + // + // TODO(bring-up): this wait fires on every boot with no host attached + // too -- i.e. every real-world power-on once this ships, not just + // during debugging. 5s of dead time before the splash even shows up + // is fine for now but not acceptable in the field; shorten this back + // down (or make it conditional on something cheap to detect, like a + // held button) before this goes to an actual user. + constexpr unsigned long kSerialWaitMs = 5000; const unsigned long serialWaitStartMs = millis(); while (!Serial && millis() - serialWaitStartMs < kSerialWaitMs) { delay(10); @@ -239,6 +243,7 @@ void setup() { // Restores any previously-run trigger/stick calibration; defaults to an // uncalibrated full ADC range if none has been saved yet. calibrationData = CalibrationStore::load(); + menuController.setStickDeadzonePercent(calibrationData.stickDeadzonePercent); // Restores any previously-saved droid list; defaults to empty if none // has been saved yet. @@ -373,6 +378,20 @@ void loop() { const int rawStickX = analogRead(PinAssignment::kThumbstickX); const int rawStickY = analogRead(PinAssignment::kThumbstickY); + // Calibrated early (rather than down with the rest of the telemetry + // below) because the menu's stick-based navigation needs the + // calibrated percentage, not the raw ADC reading, to pick a threshold + // that means the same thing on every controller regardless of that + // unit's own calibration. + const int triggerPercent = + AnalogCalibration::calibrateTrigger(rawTrigger, calibrationData); + const int stickXPercent = AnalogCalibration::calibrateStickAxis( + rawStickX, calibrationData.stickXMin, calibrationData.stickXCenter, + calibrationData.stickXMax, calibrationData.stickDeadzonePercent); + const int stickYPercent = AnalogCalibration::calibrateStickAxis( + rawStickY, calibrationData.stickYMin, calibrationData.stickYCenter, + calibrationData.stickYMax, calibrationData.stickDeadzonePercent); + bool justPressed[Buttons::kCount] = {}; for (size_t i = 0; i < Buttons::kCount; ++i) { const bool rawPressed = digitalRead(Buttons::kPins[i]) == LOW; @@ -396,7 +415,19 @@ void loop() { menuController.updateOpenCombo(buttonPanel.isPressed(Buttons::kLeftUp), buttonPanel.isPressed(Buttons::kLeftDown), now); - menuController.tick(rawStickX, rawStickY); + // True if anything below actually calls a MenuController mutator this + // tick — the only reliable way to know the active screen's content may + // have changed. currentScreen()/selectedMainMenuItem() alone miss any + // in-place change that doesn't also switch screens (list scrolling, + // text-entry letter changes, calibration step progress, Display/Power + // Config value cycling, Stick Deadzone's value, ...); this and + // menuStateChanged below both feed the redraw decision that follows. + bool menuInputHandled = false; + + const bool stickNavFired = + menuController.tick(rawStickX, rawStickY, stickYPercent); + if (stickNavFired) menuInputHandled = true; + // Button Test (see menu.h's onButtonTestPress()) wants to see every raw // button press, including the four normally "stolen" for menu nav // below — so while it's active, route all of them there instead of @@ -405,16 +436,40 @@ void loop() { // test for Bumper itself. if (menuController.currentScreen() == MenuScreen::kButtonTest) { for (size_t i = 0; i < Buttons::kCount; ++i) { - if (justPressed[i]) menuController.onButtonTestPress(i); + if (justPressed[i]) { + menuController.onButtonTestPress(i); + menuInputHandled = true; + } } } else { - if (justPressed[Buttons::kLeftUp]) menuController.onUp(); - if (justPressed[Buttons::kLeftDown]) menuController.onDown(); - if (justPressed[Buttons::kStickClick]) { + if (justPressed[Buttons::kLeftUp]) { + menuController.onUp(); + menuInputHandled = true; + } + if (justPressed[Buttons::kLeftDown]) { + menuController.onDown(); + menuInputHandled = true; + } + // Calibration confirms with Macro1 instead of Stick Click: clicking + // the stick itself risks nudging it right as its center/extreme + // position is being captured, and there's no button actually + // labeled "Enter" on this controller for Stick Click to stand in + // for. Every other screen still confirms with Stick Click. + const bool onCalibrationScreen = + menuController.currentScreen() == MenuScreen::kCalibrateStick || + menuController.currentScreen() == MenuScreen::kCalibrateTrigger; + const bool confirmPressed = onCalibrationScreen + ? justPressed[Buttons::kMacro1] + : justPressed[Buttons::kStickClick]; + if (confirmPressed) { menuController.onEnter(rawTrigger, rawStickX, rawStickY); + menuInputHandled = true; } } - if (justPressed[Buttons::kBumper]) menuController.onBack(); + if (justPressed[Buttons::kBumper]) { + menuController.onBack(); + menuInputHandled = true; + } int newTriggerMin, newTriggerMax; if (menuController.consumeNewTriggerCalibration(&newTriggerMin, @@ -454,18 +509,21 @@ void loop() { // Only touch the display when something actually changed — a full // redraw every tick would be needless I2C traffic for static text. - // lastTestedButtonIndex() is included because it's the Button Test - // screen's entire displayed content, but changes independently of both - // currentScreen() and selectedMainMenuItem() (pressing a button while - // already on that screen changes neither) — without this, the screen - // would render once on entry and then never update again. + // currentScreen()/selectedMainMenuItem() catch entering a new screen + // (including the menu-open transition, which isn't tied to a button + // edge — see updateOpenCombo()); menuInputHandled catches everything + // else that can change a screen's content without changing screens + // (list scrolling, text-entry letters, calibration progress, Display/ + // Power Config/Stick Deadzone values, Button Test's last-pressed + // button, ...). Relying on currentScreen()/selectedMainMenuItem() + // alone missed all of those — a screen rendered once on entry and + // then silently never updated again. const bool menuStateChanged = menuController.currentScreen() != previousMenuScreen || menuController.selectedMainMenuItem() != previousMainMenuItem || - menuController.lastTestedButtonIndex() != previousLastTestedButtonIndex; + menuInputHandled; previousMenuScreen = menuController.currentScreen(); previousMainMenuItem = menuController.selectedMainMenuItem(); - previousLastTestedButtonIndex = menuController.lastTestedButtonIndex(); if (menuStateChanged) { if (menuController.currentScreen() != MenuScreen::kInactive) { @@ -488,19 +546,16 @@ void loop() { Serial.println("Power config saved."); } + if (menuController.consumeStickDeadzoneChanged()) { + calibrationData.stickDeadzonePercent = menuController.stickDeadzonePercent(); + CalibrationStore::save(calibrationData); + Serial.println("Stick deadzone saved."); + } + 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); - // Low-power mode's idea of "activity" — any button held, the stick or // trigger moved off center/released, or (if present) accelerometer // motion. Buttons use level (isPressed), not just the press edge, so @@ -562,6 +617,15 @@ void loop() { } } uplink.triggerPercent = static_cast(triggerPercent); + // X sign convention confirmed against this controller's own hardware + // (positive = physically left, negative = right) but NOT against + // what Amidala expects -- as of this writing, SnipsRemote just + // stores stickXPercent as telemetry (see its header comment) and + // nothing there actually reads it for steering yet, so there's no + // established convention to match. Whoever wires this up to real + // driving logic on that side needs to confirm which sign it wants, + // and either that code or this line adapts -- not both silently + // assuming different things. uplink.stickXPercent = static_cast(stickXPercent); uplink.stickYPercent = static_cast(stickYPercent); uplink.batteryPercent = static_cast(batteryPercent); diff --git a/src/calibration.cpp b/src/calibration.cpp index ec5a68f..d0f971e 100644 --- a/src/calibration.cpp +++ b/src/calibration.cpp @@ -15,8 +15,41 @@ int clamp(int value, int lo, int hi) { return value; } +// Returns the option after/before `current` in `options`, wrapping +// around at either end. Falls back to the first entry if `current` isn't +// itself one of the options (e.g. a value loaded from NVS that predates +// an option-list change) — same convention as power_management.cpp's +// nextInList(). +int nextInList(int current, const int *options, size_t count) { + for (size_t i = 0; i < count; ++i) { + if (options[i] == current) { + return options[(i + 1) % count]; + } + } + return options[0]; +} + +int prevInList(int current, const int *options, size_t count) { + for (size_t i = 0; i < count; ++i) { + if (options[i] == current) { + return options[(i + count - 1) % count]; + } + } + return options[0]; +} + } // namespace +int nextDeadzonePercent(int currentPercent) { + return nextInList(currentPercent, StickDeadzoneOptions::kPercents, + StickDeadzoneOptions::kCount); +} + +int prevDeadzonePercent(int currentPercent) { + return prevInList(currentPercent, StickDeadzoneOptions::kPercents, + StickDeadzoneOptions::kCount); +} + int AnalogCalibration::calibrateTrigger(int raw, const CalibrationData &data) { if (data.triggerMax <= data.triggerMin) { return 0; // uncalibrated/degenerate range @@ -27,13 +60,13 @@ int AnalogCalibration::calibrateTrigger(int raw, const CalibrationData &data) { } int AnalogCalibration::calibrateStickAxis(int raw, int min, int center, - int max) { + int max, int deadzonePercent) { if (max <= center || center <= min) { return 0; // uncalibrated/degenerate range } const float deadzoneHalfWidth = - static_cast(max - min) * kDeadzonePercentOfRange / 100.0f; + static_cast(max - min) * deadzonePercent / 100.0f; if (std::abs(raw - center) <= deadzoneHalfWidth) { return 0; } @@ -85,8 +118,15 @@ void StickCalibrationFlow::sample(int rawX, int rawY) { if (rawY > maxY_) maxY_ = rawY; } +bool StickCalibrationFlow::hasEnoughRange() const { + return (maxX_ - centerX_) >= kMinRangeCounts && + (centerX_ - minX_) >= kMinRangeCounts && + (maxY_ - centerY_) >= kMinRangeCounts && + (centerY_ - minY_) >= kMinRangeCounts; +} + void StickCalibrationFlow::confirmDone() { - if (step_ != Step::kRolling) { + if (step_ != Step::kRolling || !hasEnoughRange()) { return; } step_ = Step::kDone; diff --git a/src/calibration_store.cpp b/src/calibration_store.cpp index 163bc07..56bad73 100644 --- a/src/calibration_store.cpp +++ b/src/calibration_store.cpp @@ -22,6 +22,8 @@ CalibrationData CalibrationStore::load() { data.stickYMin = prefs.getInt("yMin", data.stickYMin); data.stickYMax = prefs.getInt("yMax", data.stickYMax); data.stickYCenter = prefs.getInt("yCenter", data.stickYCenter); + data.stickDeadzonePercent = + prefs.getInt("deadzone", data.stickDeadzonePercent); prefs.end(); return data; @@ -41,5 +43,6 @@ void CalibrationStore::save(const CalibrationData &data) { prefs.putInt("yMin", data.stickYMin); prefs.putInt("yMax", data.stickYMax); prefs.putInt("yCenter", data.stickYCenter); + prefs.putInt("deadzone", data.stickDeadzonePercent); prefs.end(); } diff --git a/src/menu.cpp b/src/menu.cpp index 92709dd..27a4dbe 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -12,6 +12,19 @@ namespace { // associated with whatever droid it was last on. See the // kFactoryResetConfirm case in MenuController::onEnter(). constexpr const char *kClearedPanId = "0000000000000000"; + +// Stick-as-nav thresholds (see MenuController::tick()) — percentages of +// calibrated travel, not raw ADC. Sign convention (positive = up, +// negative = down) confirmed against real hardware. The fire threshold +// was originally 60 but real bring-up testing found that too demanding +// — even a full physical push doesn't reliably reach that percentage in +// every direction on real hardware (calibration accuracy varies by +// direction), so it effectively made "up" not work at all. Lowered to +// 25, with the rearm threshold dropped to match and keep a similar +// proportional hysteresis gap (prevents rapid double-firing right at +// the boundary). +constexpr int kStickNavFireThreshold = 25; +constexpr int kStickNavRearmThreshold = 10; } // namespace const char *mainMenuItemLabel(MainMenuItem item) { @@ -19,6 +32,7 @@ const char *mainMenuItemLabel(MainMenuItem item) { case MainMenuItem::kSwitchDroid: return "Switch Droid"; case MainMenuItem::kManageDroids: return "Manage Droids"; case MainMenuItem::kCalibrateStick: return "Calibrate Stick"; + case MainMenuItem::kStickDeadzone: return "Stick Deadzone"; case MainMenuItem::kCalibrateTrigger: return "Calibrate Trigger"; case MainMenuItem::kDisplayConfig: return "Display Config"; case MainMenuItem::kPowerConfig: return "Power Management"; @@ -95,6 +109,10 @@ void MenuController::onUp() { powerConfigRowIndex_ = wrapIndex( powerConfigRowIndex_ - 1, static_cast(PowerConfigRow::kCount)); break; + case MenuScreen::kStickDeadzone: + stickDeadzonePercent_ = nextDeadzonePercent(stickDeadzonePercent_); + stickDeadzoneChanged_ = true; + break; default: break; } @@ -130,6 +148,10 @@ void MenuController::onDown() { powerConfigRowIndex_ = wrapIndex( powerConfigRowIndex_ + 1, static_cast(PowerConfigRow::kCount)); break; + case MenuScreen::kStickDeadzone: + stickDeadzonePercent_ = prevDeadzonePercent(stickDeadzonePercent_); + stickDeadzoneChanged_ = true; + break; default: break; } @@ -148,19 +170,16 @@ void MenuController::onBack() { case MenuScreen::kManageDroidsDeleteConfirm: screen_ = MenuScreen::kManageDroidsList; break; + // Bumper always means "go back," same as every other screen — it + // used to backspace one character at a time first and only actually + // leave once the field was empty, which looked like it "didn't work" + // to anyone who hadn't already cleared what they'd typed. Character + // correction is still possible before committing (scroll with + // Up/Down), just not after — a deliberate simplification in favor of + // a predictable universal Back button. case MenuScreen::kManageDroidsEnterName: - if (nameEntry_.length() > 0) { - nameEntry_.backspace(); - } else { - screen_ = MenuScreen::kManageDroidsList; - } - break; case MenuScreen::kManageDroidsEnterPanId: - if (panIdEntry_.length() > 0) { - panIdEntry_.backspace(); - } else { - screen_ = MenuScreen::kManageDroidsList; - } + screen_ = MenuScreen::kManageDroidsList; break; case MenuScreen::kCalibrateStick: stickFlow_ = StickCalibrationFlow(); @@ -170,6 +189,7 @@ void MenuController::onBack() { triggerFlow_ = TriggerCalibrationFlow(); screen_ = MenuScreen::kMainMenu; break; + case MenuScreen::kStickDeadzone: case MenuScreen::kDisplayConfig: case MenuScreen::kPowerConfig: case MenuScreen::kDeviceInfo: @@ -196,6 +216,9 @@ void MenuController::enterMainMenuItem(MainMenuItem item) { stickFlow_ = StickCalibrationFlow(); screen_ = MenuScreen::kCalibrateStick; break; + case MainMenuItem::kStickDeadzone: + screen_ = MenuScreen::kStickDeadzone; + break; case MainMenuItem::kCalibrateTrigger: triggerFlow_ = TriggerCalibrationFlow(); screen_ = MenuScreen::kCalibrateTrigger; @@ -365,6 +388,11 @@ void MenuController::onEnter(int rawTrigger, int rawStickX, int rawStickY) { case MenuScreen::kButtonTest: break; + // No confirm step -- Up/Down apply and save the change immediately + // (see onUp()/onDown()). + case MenuScreen::kStickDeadzone: + break; + case MenuScreen::kFactoryResetConfirm: factoryResetConfirmed_ = true; droidStore_ = DroidStore(); @@ -396,11 +424,29 @@ void MenuController::onButtonTestPress(size_t buttonIndex) { lastTestedButtonIndex_ = static_cast(buttonIndex); } -void MenuController::tick(int rawStickX, int rawStickY) { +bool MenuController::tick(int rawStickX, int rawStickY, int stickYPercent) { if (screen_ == MenuScreen::kCalibrateStick && stickFlow_.currentStep() == StickCalibrationFlow::Step::kRolling) { stickFlow_.sample(rawStickX, rawStickY); } + + if (screen_ == MenuScreen::kCalibrateStick) return false; + + if (stickNavArmed_) { + if (stickYPercent >= kStickNavFireThreshold) { + onUp(); + stickNavArmed_ = false; + return true; + } else if (stickYPercent <= -kStickNavFireThreshold) { + onDown(); + stickNavArmed_ = false; + return true; + } + } else if (stickYPercent > -kStickNavRearmThreshold && + stickYPercent < kStickNavRearmThreshold) { + stickNavArmed_ = true; + } + return false; } bool MenuController::consumeNewTriggerCalibration(int *outMin, int *outMax) { @@ -450,6 +496,12 @@ bool MenuController::consumePowerConfigChanged() { return true; } +bool MenuController::consumeStickDeadzoneChanged() { + if (!stickDeadzoneChanged_) return false; + stickDeadzoneChanged_ = false; + return true; +} + namespace { void renderTextEntryLine(const TextEntryWidget &widget, ScreenBuffer *screen, @@ -586,11 +638,17 @@ void renderMenuScreen(const MenuController &menu, ScreenBuffer *screen) { case MenuScreen::kManageDroidsEnterName: screen->setLine(0, "Add Droid: Name"); renderTextEntryLine(menu.nameEntry(), screen, 1); + screen->setLine(2, "Up/Down: letter"); + screen->setLine(3, "Click: next"); + screen->setLine(4, "Back: delete/exit"); break; case MenuScreen::kManageDroidsEnterPanId: screen->setLine(0, "Add Droid: PAN ID"); renderTextEntryLine(menu.panIdEntry(), screen, 1); + screen->setLine(2, "Up/Down: letter"); + screen->setLine(3, "Click: next"); + screen->setLine(4, "Back: delete/exit"); break; case MenuScreen::kManageDroidsDeleteConfirm: @@ -605,11 +663,11 @@ void renderMenuScreen(const MenuController &menu, ScreenBuffer *screen) { switch (menu.triggerCalibrationStep()) { case TriggerCalibrationFlow::Step::kAwaitingRelease: screen->setLine(1, "Release trigger,"); - screen->setLine(2, "press Enter"); + screen->setLine(2, "press Macro1"); break; case TriggerCalibrationFlow::Step::kAwaitingFullPull: screen->setLine(1, "Pull fully,"); - screen->setLine(2, "press Enter"); + screen->setLine(2, "press Macro1"); break; case TriggerCalibrationFlow::Step::kDone: screen->setLine(1, "Done!"); @@ -622,11 +680,15 @@ void renderMenuScreen(const MenuController &menu, ScreenBuffer *screen) { switch (menu.stickCalibrationStep()) { case StickCalibrationFlow::Step::kAwaitingCenter: screen->setLine(1, "Center stick,"); - screen->setLine(2, "press Enter"); + screen->setLine(2, "press Macro1"); break; case StickCalibrationFlow::Step::kRolling: - screen->setLine(1, "Roll to extremes,"); - screen->setLine(2, "Enter when done"); + screen->setLine(1, "Rotate stick all the"); + screen->setLine(2, "way around 3 times,"); + screen->setLine(3, "return to center,"); + screen->setLine(4, menu.stickCalibrationHasEnoughRange() + ? "then press Macro1" + : "not enough yet"); break; case StickCalibrationFlow::Step::kDone: screen->setLine(1, "Done!"); @@ -634,6 +696,15 @@ void renderMenuScreen(const MenuController &menu, ScreenBuffer *screen) { } break; + case MenuScreen::kStickDeadzone: { + screen->setLine(0, "Stick Deadzone"); + char line[ScreenBuffer::kMaxLineLength + 1]; + std::snprintf(line, sizeof(line), "%d%%", menu.stickDeadzonePercent()); + screen->setLine(1, line); + screen->setLine(2, "Up/Down: change"); + break; + } + case MenuScreen::kDisplayConfig: { screen->setLine(0, "Display Config"); char line[ScreenBuffer::kMaxLineLength + 1]; diff --git a/test/test_calibration/test_calibration.cpp b/test/test_calibration/test_calibration.cpp index 42472cd..db829fa 100644 --- a/test/test_calibration/test_calibration.cpp +++ b/test/test_calibration/test_calibration.cpp @@ -43,48 +43,86 @@ void test_trigger_degenerate_range_returns_zero() { void test_stick_axis_at_center_is_zero() { TEST_ASSERT_EQUAL_INT( - 0, AnalogCalibration::calibrateStickAxis(2048, 0, 2048, 4095)); + 0, AnalogCalibration::calibrateStickAxis(2048, 0, 2048, 4095, 3)); } 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)); + 0, AnalogCalibration::calibrateStickAxis(2170, 0, 2048, 4095, 3)); } void test_stick_axis_just_outside_deadzone_is_nonzero() { TEST_ASSERT_EQUAL_INT( - 6, AnalogCalibration::calibrateStickAxis(2171, 0, 2048, 4095)); + 6, AnalogCalibration::calibrateStickAxis(2171, 0, 2048, 4095, 3)); } void test_stick_axis_at_max_is_hundred() { TEST_ASSERT_EQUAL_INT( - 100, AnalogCalibration::calibrateStickAxis(4095, 0, 2048, 4095)); + 100, AnalogCalibration::calibrateStickAxis(4095, 0, 2048, 4095, 3)); } void test_stick_axis_at_min_is_negative_hundred() { TEST_ASSERT_EQUAL_INT( - -100, AnalogCalibration::calibrateStickAxis(0, 0, 2048, 4095)); + -100, AnalogCalibration::calibrateStickAxis(0, 0, 2048, 4095, 3)); } void test_stick_axis_clamps_beyond_max() { TEST_ASSERT_EQUAL_INT( - 100, AnalogCalibration::calibrateStickAxis(6000, 0, 2048, 4095)); + 100, AnalogCalibration::calibrateStickAxis(6000, 0, 2048, 4095, 3)); } void test_stick_axis_clamps_below_min() { TEST_ASSERT_EQUAL_INT( - -100, AnalogCalibration::calibrateStickAxis(-500, 0, 2048, 4095)); + -100, AnalogCalibration::calibrateStickAxis(-500, 0, 2048, 4095, 3)); } void test_stick_axis_degenerate_max_at_center_returns_zero() { TEST_ASSERT_EQUAL_INT( - 0, AnalogCalibration::calibrateStickAxis(1000, 0, 4095, 4095)); + 0, AnalogCalibration::calibrateStickAxis(1000, 0, 4095, 4095, 3)); } void test_stick_axis_degenerate_center_at_min_returns_zero() { TEST_ASSERT_EQUAL_INT( - 0, AnalogCalibration::calibrateStickAxis(3000, 2048, 2048, 4095)); + 0, AnalogCalibration::calibrateStickAxis(3000, 2048, 2048, 4095, 3)); +} + +void test_stick_axis_zero_deadzone_reads_nonzero_where_default_would_not() { + // 52 counts off center: inside the default 3% deadzone (half-width + // ~123 for this range), so it reads 0 there, but with no deadzone at + // all it's a real (if small) reading. + TEST_ASSERT_EQUAL_INT( + 0, AnalogCalibration::calibrateStickAxis(2100, 0, 2048, 4095, 3)); + TEST_ASSERT_EQUAL_INT( + 3, AnalogCalibration::calibrateStickAxis(2100, 0, 2048, 4095, 0)); +} + +void test_stick_axis_larger_deadzone_reads_zero_further_from_center() { + // Deadzone half-width for 20% of a 4095-wide range is 819 -- a reading + // that registered nonzero at 3% should read as zero at 20%. + TEST_ASSERT_EQUAL_INT( + 6, AnalogCalibration::calibrateStickAxis(2171, 0, 2048, 4095, 3)); + TEST_ASSERT_EQUAL_INT( + 0, AnalogCalibration::calibrateStickAxis(2171, 0, 2048, 4095, 20)); +} + +// ---- deadzone presets --------------------------------------------------- + +void test_next_deadzone_percent_cycles_forward_and_wraps() { + TEST_ASSERT_EQUAL_INT(3, nextDeadzonePercent(0)); + TEST_ASSERT_EQUAL_INT(5, nextDeadzonePercent(3)); + TEST_ASSERT_EQUAL_INT(0, nextDeadzonePercent(20)); // wraps from the last +} + +void test_prev_deadzone_percent_cycles_backward_and_wraps() { + TEST_ASSERT_EQUAL_INT(0, prevDeadzonePercent(3)); + TEST_ASSERT_EQUAL_INT(20, prevDeadzonePercent(0)); // wraps to the last +} + +void test_next_deadzone_percent_falls_back_to_first_for_unknown_value() { + // e.g. a value loaded from NVS that predates an option-list change. + TEST_ASSERT_EQUAL_INT(StickDeadzoneOptions::kPercents[0], + nextDeadzonePercent(7)); } // ---- TriggerCalibrationFlow ------------------------------------------------ @@ -172,7 +210,9 @@ void test_stick_flow_confirm_center_again_while_rolling_is_noop() { void test_stick_flow_confirm_done_finishes() { StickCalibrationFlow flow; flow.confirmCenter(2000, 2100); + // Enough range on all four sides to clear kMinRangeCounts (300). flow.sample(1500, 2600); + flow.sample(2500, 1700); flow.confirmDone(); TEST_ASSERT_TRUE(StickCalibrationFlow::Step::kDone == flow.currentStep()); @@ -181,6 +221,43 @@ void test_stick_flow_confirm_done_finishes() { TEST_ASSERT_EQUAL_INT(1500, flow.minX()); } +void test_stick_flow_confirm_done_rejects_insufficient_range() { + StickCalibrationFlow flow; + flow.confirmCenter(2000, 2100); + // No rolling at all -- min/max are still collapsed to center exactly + // as confirmCenter() left them. This is the real bug this guards + // against: confirming done immediately produced a near-zero range + // that made ordinary ADC noise read as huge stick movement. + flow.confirmDone(); + TEST_ASSERT_TRUE(StickCalibrationFlow::Step::kRolling == flow.currentStep()); +} + +void test_stick_flow_confirm_done_rejects_partial_range() { + StickCalibrationFlow flow; + flow.confirmCenter(2000, 2100); + // X got rolled enough both ways, but Y was never pushed below center + // at all -- still not enough to finish. + flow.sample(1500, 2600); + flow.sample(2500, 2100); + flow.confirmDone(); + TEST_ASSERT_TRUE(StickCalibrationFlow::Step::kRolling == flow.currentStep()); +} + +void test_stick_flow_has_enough_range_reflects_all_four_sides() { + StickCalibrationFlow flow; + flow.confirmCenter(2000, 2100); + TEST_ASSERT_FALSE(flow.hasEnoughRange()); + + flow.sample(1500, 2100); // only X-min so far + TEST_ASSERT_FALSE(flow.hasEnoughRange()); + + flow.sample(2500, 2600); // X-max and Y-max + TEST_ASSERT_FALSE(flow.hasEnoughRange()); // Y-min still collapsed + + flow.sample(2000, 1700); // Y-min + TEST_ASSERT_TRUE(flow.hasEnoughRange()); +} + int main(int argc, char **argv) { UNITY_BEGIN(); RUN_TEST(test_trigger_at_min_is_zero_percent); @@ -198,6 +275,11 @@ int main(int argc, char **argv) { 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_stick_axis_zero_deadzone_reads_nonzero_where_default_would_not); + RUN_TEST(test_stick_axis_larger_deadzone_reads_zero_further_from_center); + RUN_TEST(test_next_deadzone_percent_cycles_forward_and_wraps); + RUN_TEST(test_prev_deadzone_percent_cycles_backward_and_wraps); + RUN_TEST(test_next_deadzone_percent_falls_back_to_first_for_unknown_value); 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); @@ -208,5 +290,8 @@ int main(int argc, char **argv) { 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); + RUN_TEST(test_stick_flow_confirm_done_rejects_insufficient_range); + RUN_TEST(test_stick_flow_confirm_done_rejects_partial_range); + RUN_TEST(test_stick_flow_has_enough_range_reflects_all_four_sides); return UNITY_END(); } diff --git a/test/test_menu/test_menu.cpp b/test/test_menu/test_menu.cpp index 93a1fa9..eab6398 100644 --- a/test/test_menu/test_menu.cpp +++ b/test/test_menu/test_menu.cpp @@ -106,6 +106,9 @@ void test_down_cycles_through_every_main_menu_item_in_order() { TEST_ASSERT_TRUE(MainMenuItem::kCalibrateStick == menu.selectedMainMenuItem()); menu.onDown(); + TEST_ASSERT_TRUE(MainMenuItem::kStickDeadzone == + menu.selectedMainMenuItem()); + menu.onDown(); TEST_ASSERT_TRUE(MainMenuItem::kCalibrateTrigger == menu.selectedMainMenuItem()); menu.onDown(); @@ -347,8 +350,8 @@ void test_stick_calibration_full_flow() { TEST_ASSERT_TRUE(StickCalibrationFlow::Step::kRolling == menu.stickCalibrationStep()); - menu.tick(1500, 2600); - menu.tick(2500, 1800); + menu.tick(1500, 2600, 0); + menu.tick(2500, 1800, 0); menu.onEnter(0, 9999, 9999); // confirm done (values here are ignored) TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); @@ -370,18 +373,87 @@ void test_stick_calibration_full_flow() { void test_tick_is_noop_outside_rolling_stick_calibration() { MenuController menu; selectMainMenuItem(&menu, MainMenuItem::kCalibrateStick); - // Menu is open but not even in the stick screen yet. - menu.tick(1234, 5678); + // Menu is open but not even in the stick screen yet -- must not crash + // or affect anything. + menu.tick(1234, 5678, 0); menu.onEnter(0, 0, 0); // -> kCalibrateStick, awaiting center - menu.tick(1234, 5678); // still awaiting center, not rolling + // Still awaiting center, not rolling yet -- an extreme value here + // must not get sampled; if it wrongly did, it would show up in + // min/max below instead of the real rolling samples that follow. + menu.tick(9999, 9999, 0); menu.onEnter(0, 2000, 2100); // confirm center -> rolling + + // Roll for real, enough to clear the minimum-range gate. + menu.tick(1500, 2600, 0); + menu.tick(2500, 1700, 0); + int centerX, centerY, minX, maxX, minY, maxY; - menu.onEnter(0, 0, 0); // confirm done immediately, no samples taken + menu.onEnter(0, 0, 0); // confirm done TEST_ASSERT_TRUE(menu.consumeNewStickCalibration(¢erX, ¢erY, &minX, &maxX, &minY, &maxY)); - // With no rolling samples, min/max should stay collapsed to the center. - TEST_ASSERT_EQUAL_INT(2000, minX); - TEST_ASSERT_EQUAL_INT(2000, maxX); + TEST_ASSERT_EQUAL_INT(1500, minX); + TEST_ASSERT_EQUAL_INT(2500, maxX); + TEST_ASSERT_EQUAL_INT(1700, minY); + TEST_ASSERT_EQUAL_INT(2600, maxY); +} + +// ---- stick-as-nav ----------------------------------------------------------- + +void test_stick_push_up_navigates_like_left_up_button() { + MenuController menu; + openMenu(&menu); + TEST_ASSERT_TRUE(MainMenuItem::kSwitchDroid == menu.selectedMainMenuItem()); + menu.tick(0, 0, 75); // past the fire threshold + TEST_ASSERT_TRUE(MainMenuItem::kFactoryReset == + menu.selectedMainMenuItem()); // onUp() wraps to the last item +} + +void test_stick_push_down_navigates_like_left_down_button() { + MenuController menu; + openMenu(&menu); + menu.tick(0, 0, -75); // past the fire threshold, opposite direction + TEST_ASSERT_TRUE(MainMenuItem::kManageDroids == menu.selectedMainMenuItem()); +} + +void test_tick_returns_true_only_when_it_fires_a_nav_event() { + // SnipsController.ino relies on this return value to know when to + // redraw the active screen -- see its menuInputHandled comment. + MenuController menu; + openMenu(&menu); + TEST_ASSERT_FALSE(menu.tick(0, 0, 15)); // short of threshold + TEST_ASSERT_TRUE(menu.tick(0, 0, 75)); // fires + TEST_ASSERT_FALSE(menu.tick(0, 0, 75)); // still held, already fired + TEST_ASSERT_FALSE(menu.tick(0, 0, 0)); // returning to center, no fire + TEST_ASSERT_TRUE(menu.tick(0, 0, -75)); // rearmed, fires again +} + +void test_stick_below_threshold_does_not_navigate() { + MenuController menu; + openMenu(&menu); + menu.tick(0, 0, 15); // short of the fire threshold (25) + TEST_ASSERT_TRUE(MainMenuItem::kSwitchDroid == menu.selectedMainMenuItem()); +} + +void test_stick_must_return_to_center_before_firing_again() { + MenuController menu; + openMenu(&menu); + menu.tick(0, 0, 75); // fires once -> kManageDroids... (wraps via onUp -> last) + menu.tick(0, 0, 75); // still held past threshold -- must not fire again + TEST_ASSERT_TRUE(MainMenuItem::kFactoryReset == menu.selectedMainMenuItem()); + + menu.tick(0, 0, 0); // back near center -- rearms + menu.tick(0, 0, 75); // fires once more, one step further up from last + TEST_ASSERT_TRUE(MainMenuItem::kButtonTest == menu.selectedMainMenuItem()); +} + +void test_stick_nav_is_disabled_during_stick_calibration() { + MenuController menu; + selectMainMenuItem(&menu, MainMenuItem::kCalibrateStick); + menu.onEnter(0, 0, 0); // -> kCalibrateStick + menu.tick(0, 0, 75); // would fire onUp() anywhere else + TEST_ASSERT_TRUE(MenuScreen::kCalibrateStick == menu.currentScreen()); + TEST_ASSERT_TRUE(StickCalibrationFlow::Step::kAwaitingCenter == + menu.stickCalibrationStep()); } void test_back_during_stick_calibration_cancels_and_resets() { @@ -493,17 +565,16 @@ void test_manage_droids_back_with_empty_name_cancels_add() { TEST_ASSERT_EQUAL_INT(0, menu.droidStore().count()); } -void test_manage_droids_back_with_text_backspaces_instead_of_cancelling() { +void test_manage_droids_back_cancels_even_with_text_entered() { MenuController menu; selectMainMenuItem(&menu, MainMenuItem::kManageDroids); menu.onEnter(0, 0, 0); // -> kManageDroidsList menu.onEnter(0, 0, 0); // -> enter name menu.onDown(); // space -> 'A' menu.onEnter(0, 0, 0); // commit 'A' - menu.onBack(); // backspace, not cancel — buffer wasn't empty - TEST_ASSERT_TRUE(MenuScreen::kManageDroidsEnterName == - menu.currentScreen()); - TEST_ASSERT_EQUAL_STRING("", menu.nameEntry().text()); + menu.onBack(); // Bumper always means "go back," regardless of what's + // been typed — no separate backspace step required. + TEST_ASSERT_TRUE(MenuScreen::kManageDroidsList == menu.currentScreen()); } void test_manage_droids_list_navigation_wraps_over_entries_and_add_new() { @@ -520,7 +591,7 @@ void test_manage_droids_list_navigation_wraps_over_entries_and_add_new() { TEST_ASSERT_EQUAL_INT(0, menu.selectedDroidListIndex()); } -void test_manage_droids_pan_id_backspace_and_cancel() { +void test_manage_droids_pan_id_back_cancels_even_with_text_entered() { MenuController menu; selectMainMenuItem(&menu, MainMenuItem::kManageDroids); menu.onEnter(0, 0, 0); // -> kManageDroidsList @@ -530,12 +601,8 @@ void test_manage_droids_pan_id_backspace_and_cancel() { menu.onDown(); // '0' -> '1' menu.onEnter(0, 0, 0); // commit '1' - menu.onBack(); // backspace, not cancel — buffer wasn't empty - TEST_ASSERT_TRUE(MenuScreen::kManageDroidsEnterPanId == - menu.currentScreen()); - TEST_ASSERT_EQUAL_STRING("", menu.panIdEntry().text()); - - menu.onBack(); // now empty — cancels the whole add flow + menu.onBack(); // Bumper always means "go back," regardless of what's + // been typed — cancels the whole add flow immediately. TEST_ASSERT_TRUE(MenuScreen::kManageDroidsList == menu.currentScreen()); TEST_ASSERT_EQUAL_INT(0, menu.droidStore().count()); } @@ -755,6 +822,78 @@ void test_power_config_back_returns_to_main_menu() { TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); } +// ---- stick deadzone --------------------------------------------------------- + +void test_enter_stick_deadzone_from_main_menu() { + MenuController menu; + selectMainMenuItem(&menu, MainMenuItem::kStickDeadzone); + menu.onEnter(0, 0, 0); + TEST_ASSERT_TRUE(MenuScreen::kStickDeadzone == menu.currentScreen()); +} + +void test_stick_deadzone_defaults_to_three_percent() { + MenuController menu; + TEST_ASSERT_EQUAL_INT(3, menu.stickDeadzonePercent()); +} + +void test_stick_deadzone_up_increases_and_wraps() { + MenuController menu; + selectMainMenuItem(&menu, MainMenuItem::kStickDeadzone); + menu.onEnter(0, 0, 0); + TEST_ASSERT_EQUAL_INT(3, menu.stickDeadzonePercent()); + menu.onUp(); + TEST_ASSERT_EQUAL_INT(5, menu.stickDeadzonePercent()); + // Presets are {0, 3, 5, 8, 12, 15, 20} -- 5 more ups from 5 reaches 20, + // the last one, and one more wraps back to 0. + menu.onUp(); + menu.onUp(); + menu.onUp(); + menu.onUp(); + menu.onUp(); + TEST_ASSERT_EQUAL_INT(0, menu.stickDeadzonePercent()); // wraps +} + +void test_stick_deadzone_down_decreases_and_wraps() { + MenuController menu; + selectMainMenuItem(&menu, MainMenuItem::kStickDeadzone); + menu.onEnter(0, 0, 0); + menu.onDown(); + TEST_ASSERT_EQUAL_INT(0, menu.stickDeadzonePercent()); + menu.onDown(); + TEST_ASSERT_EQUAL_INT(20, menu.stickDeadzonePercent()); // wraps +} + +void test_stick_deadzone_changed_is_edge_triggered() { + MenuController menu; + selectMainMenuItem(&menu, MainMenuItem::kStickDeadzone); + menu.onEnter(0, 0, 0); + TEST_ASSERT_FALSE(menu.consumeStickDeadzoneChanged()); // nothing yet + + menu.onUp(); + TEST_ASSERT_TRUE(menu.consumeStickDeadzoneChanged()); + TEST_ASSERT_FALSE(menu.consumeStickDeadzoneChanged()); // consumed once +} + +void test_stick_deadzone_back_returns_to_main_menu() { + MenuController menu; + selectMainMenuItem(&menu, MainMenuItem::kStickDeadzone); + menu.onEnter(0, 0, 0); + menu.onUp(); + menu.onBack(); + TEST_ASSERT_TRUE(MenuScreen::kMainMenu == menu.currentScreen()); +} + +void test_render_stick_deadzone_shows_current_value() { + MenuController menu; + ScreenBuffer screen; + selectMainMenuItem(&menu, MainMenuItem::kStickDeadzone); + menu.onEnter(0, 0, 0); + menu.onUp(); // 3 -> 5 + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("Stick Deadzone", screen.line(0)); + TEST_ASSERT_EQUAL_STRING("5%", screen.line(1)); +} + // ---- current droid name -------------------------------------------------------- void test_current_droid_name_defaults_to_none() { @@ -801,6 +940,8 @@ void test_main_menu_item_labels() { mainMenuItemLabel(MainMenuItem::kManageDroids)); TEST_ASSERT_EQUAL_STRING("Calibrate Stick", mainMenuItemLabel(MainMenuItem::kCalibrateStick)); + TEST_ASSERT_EQUAL_STRING("Stick Deadzone", + mainMenuItemLabel(MainMenuItem::kStickDeadzone)); TEST_ASSERT_EQUAL_STRING("Calibrate Trigger", mainMenuItemLabel(MainMenuItem::kCalibrateTrigger)); TEST_ASSERT_EQUAL_STRING("Display Config", @@ -894,7 +1035,23 @@ void test_render_stick_calibration_rolling_step_text() { menu.onEnter(0, 0, 0); menu.onEnter(0, 2000, 2100); renderMenuScreen(menu, &screen); - TEST_ASSERT_EQUAL_STRING("Roll to extremes,", screen.line(1)); + TEST_ASSERT_EQUAL_STRING("Rotate stick all the", screen.line(1)); + TEST_ASSERT_EQUAL_STRING("way around 3 times,", screen.line(2)); + TEST_ASSERT_EQUAL_STRING("return to center,", screen.line(3)); + // Nothing rolled yet -- range requirement not met. + TEST_ASSERT_EQUAL_STRING("not enough yet", screen.line(4)); +} + +void test_render_stick_calibration_rolling_step_shows_done_once_enough_range() { + MenuController menu; + ScreenBuffer screen; + selectMainMenuItem(&menu, MainMenuItem::kCalibrateStick); + menu.onEnter(0, 0, 0); + menu.onEnter(0, 2000, 2100); + menu.tick(1500, 2600, 0); + menu.tick(2500, 1700, 0); + renderMenuScreen(menu, &screen); + TEST_ASSERT_EQUAL_STRING("then press Macro1", screen.line(4)); } void test_render_device_info() { @@ -1122,6 +1279,12 @@ int main(int argc, char **argv) { RUN_TEST(test_back_during_trigger_calibration_cancels_and_resets); RUN_TEST(test_stick_calibration_full_flow); RUN_TEST(test_tick_is_noop_outside_rolling_stick_calibration); + RUN_TEST(test_stick_push_up_navigates_like_left_up_button); + RUN_TEST(test_stick_push_down_navigates_like_left_down_button); + RUN_TEST(test_tick_returns_true_only_when_it_fires_a_nav_event); + RUN_TEST(test_stick_below_threshold_does_not_navigate); + RUN_TEST(test_stick_must_return_to_center_before_firing_again); + RUN_TEST(test_stick_nav_is_disabled_during_stick_calibration); RUN_TEST(test_back_during_stick_calibration_cancels_and_resets); RUN_TEST(test_switch_droid_list_empty_stays_put_on_enter); RUN_TEST(test_switch_droid_navigates_and_reports_no_transport_by_default); @@ -1129,9 +1292,9 @@ int main(int argc, char **argv) { RUN_TEST(test_switch_droid_succeeds_with_transport_set); RUN_TEST(test_manage_droids_add_flow_creates_entry); RUN_TEST(test_manage_droids_back_with_empty_name_cancels_add); - RUN_TEST(test_manage_droids_back_with_text_backspaces_instead_of_cancelling); + RUN_TEST(test_manage_droids_back_cancels_even_with_text_entered); RUN_TEST(test_manage_droids_list_navigation_wraps_over_entries_and_add_new); - RUN_TEST(test_manage_droids_pan_id_backspace_and_cancel); + RUN_TEST(test_manage_droids_pan_id_back_cancels_even_with_text_entered); RUN_TEST(test_manage_droids_empty_pan_id_is_not_saved); RUN_TEST(test_switch_droid_pads_short_saved_pan_id); RUN_TEST(test_switch_droid_with_invalid_saved_pan_id_shows_invalid_pan_id); @@ -1147,6 +1310,13 @@ int main(int argc, char **argv) { RUN_TEST(test_power_config_enter_cycles_auto_poweroff_row_through_minute_options); RUN_TEST(test_power_config_enter_without_config_is_noop); RUN_TEST(test_power_config_back_returns_to_main_menu); + RUN_TEST(test_enter_stick_deadzone_from_main_menu); + RUN_TEST(test_stick_deadzone_defaults_to_three_percent); + RUN_TEST(test_stick_deadzone_up_increases_and_wraps); + RUN_TEST(test_stick_deadzone_down_decreases_and_wraps); + RUN_TEST(test_stick_deadzone_changed_is_edge_triggered); + RUN_TEST(test_stick_deadzone_back_returns_to_main_menu); + RUN_TEST(test_render_stick_deadzone_shows_current_value); RUN_TEST(test_current_droid_name_defaults_to_none); RUN_TEST(test_current_droid_name_set_on_successful_switch); RUN_TEST(test_current_droid_name_unchanged_on_failed_switch); @@ -1159,6 +1329,7 @@ int main(int argc, char **argv) { RUN_TEST(test_render_trigger_calibration_step_text); RUN_TEST(test_render_stick_calibration_awaiting_center_step_text); RUN_TEST(test_render_stick_calibration_rolling_step_text); + RUN_TEST(test_render_stick_calibration_rolling_step_shows_done_once_enough_range); RUN_TEST(test_render_device_info); RUN_TEST(test_device_serial_low_defaults_then_reflects_what_was_set); RUN_TEST(test_render_factory_reset_confirm);