Skip to content
Merged
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
48 changes: 41 additions & 7 deletions include/calibration.h
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
#pragma once

#include <cstddef>

// 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.
Expand All @@ -17,19 +31,20 @@ struct CalibrationData {
int stickYMin = 0;
int stickYMax = 4095;
int stickYCenter = 2048;
int stickDeadzonePercent = StickDeadzoneOptions::kPercents[1]; // 3
};

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;
// 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.
Expand Down Expand Up @@ -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.
Expand All @@ -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_; }
Expand Down
45 changes: 40 additions & 5 deletions include/menu.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ enum class MenuScreen {
kManageDroidsEnterPanId,
kManageDroidsDeleteConfirm,
kCalibrateStick,
kStickDeadzone,
kCalibrateTrigger,
kDisplayConfig,
kPowerConfig,
Expand All @@ -35,6 +36,7 @@ enum class MainMenuItem {
kSwitchDroid = 0,
kManageDroids,
kCalibrateStick,
kStickDeadzone,
kCalibrateTrigger,
kDisplayConfig,
kPowerConfig,
Expand Down Expand Up @@ -82,18 +84,31 @@ 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();
}
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.
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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.
Expand Down
124 changes: 94 additions & 30 deletions src/SnipsController.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -562,6 +617,15 @@ void loop() {
}
}
uplink.triggerPercent = static_cast<uint8_t>(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<int8_t>(stickXPercent);
uplink.stickYPercent = static_cast<int8_t>(stickYPercent);
uplink.batteryPercent = static_cast<uint8_t>(batteryPercent);
Expand Down
Loading
Loading