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
110 changes: 110 additions & 0 deletions include/menu.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#pragma once

#include "calibration.h"
#include "screen.h"

// Pure on-device menu state machine. Knows nothing about real buttons or
// the display — SnipsController.ino translates physical button edges into
// the four nav calls below (and detects the open combo via
// updateOpenCombo()), and renderMenuScreen() turns the current state into
// a ScreenBuffer for the (already-existing, hardware-touching) OledDisplay
// to draw.
//
// Only screens with everything they need already built land here: Manage/
// Switch Droid (PR 7) and Display Config (PR 9) aren't part of this menu
// tree yet.
enum class MenuScreen {
kInactive,
kMainMenu,
kCalibrateStick,
kCalibrateTrigger,
kDeviceInfo,
kFactoryResetConfirm,
};

enum class MainMenuItem {
kCalibrateStick = 0,
kCalibrateTrigger,
kDeviceInfo,
kFactoryReset,
kCount,
};

const char *mainMenuItemLabel(MainMenuItem item);

class MenuController {
public:
MenuScreen currentScreen() const { return screen_; }
MainMenuItem selectedMainMenuItem() const;

// Held-combo detection to open the menu from kInactive — call every
// loop tick with the current debounced state of the two buttons this
// controller uses to enter the menu, regardless of currentScreen().
void updateOpenCombo(bool comboButtonAPressed, bool comboButtonBPressed,
unsigned long nowMs);

// Discrete nav events — call at most once per tick, only on a fresh
// button-press edge (not while held).
void onUp();
void onDown();
void onBack();

// Enter needs the current raw analog readings so the calibration
// screens can capture a sample at the moment of confirmation; ignored
// 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);

TriggerCalibrationFlow::Step triggerCalibrationStep() const {
return triggerFlow_.currentStep();
}
StickCalibrationFlow::Step stickCalibrationStep() const {
return stickFlow_.currentStep();
}

// Each returns true exactly once, the tick a new result becomes ready
// to persist, and writes it into the output params.
bool consumeNewTriggerCalibration(int *outMin, int *outMax);
bool consumeNewStickCalibration(int *outCenterX, int *outCenterY,
int *outMinX, int *outMaxX, int *outMinY,
int *outMaxY);
bool consumeFactoryResetConfirmed();

private:
static constexpr unsigned long kOpenComboHoldMs = 1000;

void open();
void enterMainMenuItem(MainMenuItem item);

MenuScreen screen_ = MenuScreen::kInactive;
int mainMenuIndex_ = 0;

bool comboHeld_ = false;
unsigned long comboStartMs_ = 0;

TriggerCalibrationFlow triggerFlow_;
StickCalibrationFlow stickFlow_;

bool hasNewTriggerCalibration_ = false;
int pendingTriggerMin_ = 0;
int pendingTriggerMax_ = 0;

bool hasNewStickCalibration_ = false;
int pendingStickCenterX_ = 0;
int pendingStickCenterY_ = 0;
int pendingStickMinX_ = 0;
int pendingStickMaxX_ = 0;
int pendingStickMinY_ = 0;
int pendingStickMaxY_ = 0;

bool factoryResetConfirmed_ = false;
};

// Decides what text should be on screen for the menu's current state.
// Pure — takes no display dependency, just fills in a ScreenBuffer.
void renderMenuScreen(const MenuController &menu, ScreenBuffer *screen);
55 changes: 55 additions & 0 deletions include/text_entry.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#pragma once

#include <cstddef>

// Reusable one-character-at-a-time entry widget ("scroll wheel" style,
// like old feature-phone name entry): scroll to a character, commit it,
// repeat. A trailing "done" position past the last real character in the
// charset finishes entry. Pure logic — no display or button-mapping
// decisions live here; the menu screen that owns an instance decides how
// scroll/commit/backspace map to physical buttons and how to render the
// current highlight.
class TextEntryWidget {
public:
enum class CharSet { kAlphanumeric, kHex };

explicit TextEntryWidget(CharSet charSet, size_t maxLength = 20);

// Cycles the current slot's highlight forward/backward through the
// charset, wrapping at both ends (including the trailing "done"
// position). No-op once done().
void scrollNext();
void scrollPrev();

// Commits the current highlight into the buffer and advances to a new
// slot (highlight resets to the first charset character), unless the
// current highlight is the "done" position, which finishes entry
// instead — see done(). No-op once done(), and no-op if the buffer is
// already at maxLength and the highlight isn't "done".
void commitChar();

// Removes the last committed character. No-op if empty or once done().
void backspace();

// True when the current highlight is the trailing "done" position —
// the caller should show "DONE" rather than calling currentChar().
bool isDoneSelected() const;

// Valid only when !isDoneSelected().
char currentChar() const;

bool done() const { return done_; }
const char *text() const { return buffer_; }
size_t length() const { return length_; }

private:
static constexpr size_t kMaxBufferLength = 20;

const char *charset_;
size_t charsetLength_;
size_t cursor_ = 0; // 0..charsetLength_ inclusive; charsetLength_ = done
char buffer_[kMaxBufferLength + 1] = {};
size_t length_ = 0;
size_t maxLength_;
bool done_ = false;
};
98 changes: 89 additions & 9 deletions src/SnipsController.ino
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "buttons.h"
#include "calibration.h"
#include "calibration_store.h"
#include "menu.h"
#include "oled.h"
#include "pin_assignment.h"
#include "power_latch.h"
Expand All @@ -24,9 +25,19 @@ RgbLed rgbLed;
StatusLedController statusLedController;
BatteryMonitor batteryMonitor;
CalibrationData calibrationData;
MenuController menuController;
bool lastReportedPressed[Buttons::kCount] = {};
unsigned long lastTelemetryLogMs = 0;
constexpr unsigned long kTelemetryLogIntervalMs = 1000;
MenuScreen previousMenuScreen = MenuScreen::kInactive;
MainMenuItem previousMainMenuItem = MainMenuItem::kCalibrateStick;

void showBootScreen() {
ScreenBuffer bootScreen;
bootScreen.setLine(0, "Snips Controller");
bootScreen.setLine(1, "OLED OK");
oledDisplay.render(bootScreen);
}

const char *buttonName(size_t index) {
switch (index) {
Expand Down Expand Up @@ -96,13 +107,11 @@ void setup() {
// 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.
// Real "normal operating" screen content (complications) lands in a
// later PR. For now this just proves the display works end to end, and
// is what's restored whenever the on-device menu closes.
if (oledDisplay.begin()) {
ScreenBuffer bootScreen;
bootScreen.setLine(0, "Snips Controller");
bootScreen.setLine(1, "OLED OK");
oledDisplay.render(bootScreen);
showBootScreen();
} else {
Serial.println("OLED not found at boot.");
}
Expand Down Expand Up @@ -139,26 +148,97 @@ void loop() {
"Power button held 3s - shutdown sequence would run here (PR 9).");
}

// Read every tick (not just on the telemetry throttle below) — the menu
// needs a fresh sample at the exact moment of each button press for
// calibration, and the stick's "roll to extremes" step needs continuous
// per-tick sampling.
const int rawTrigger = analogRead(PinAssignment::kAnalogTrigger);
const int rawStickX = analogRead(PinAssignment::kThumbstickX);
const int rawStickY = analogRead(PinAssignment::kThumbstickY);

bool justPressed[Buttons::kCount] = {};
for (size_t i = 0; i < Buttons::kCount; ++i) {
const bool rawPressed = digitalRead(Buttons::kPins[i]) == LOW;
buttonPanel.update(i, rawPressed, now);

const bool pressed = buttonPanel.isPressed(i);
justPressed[i] = pressed && !lastReportedPressed[i];
if (pressed != lastReportedPressed[i]) {
lastReportedPressed[i] = pressed;
Serial.print(buttonName(i));
Serial.println(pressed ? " pressed" : " released");
}
}

// On-device menu: Left Up+Down held together opens it; once open, Left
// Up/Down scroll, Stick Click confirms, Bumper backs out. These four
// buttons are "stolen" for navigation only while the menu is active —
// Amidala never sees them any differently either way, since the packet
// protocol (PR 8) doesn't exist yet.
menuController.updateOpenCombo(buttonPanel.isPressed(Buttons::kLeftUp),
buttonPanel.isPressed(Buttons::kLeftDown),
now);
menuController.tick(rawStickX, rawStickY);
if (justPressed[Buttons::kLeftUp]) menuController.onUp();
if (justPressed[Buttons::kLeftDown]) menuController.onDown();
if (justPressed[Buttons::kStickClick]) {
menuController.onEnter(rawTrigger, rawStickX, rawStickY);
}
if (justPressed[Buttons::kBumper]) menuController.onBack();

int newTriggerMin, newTriggerMax;
if (menuController.consumeNewTriggerCalibration(&newTriggerMin,
&newTriggerMax)) {
calibrationData.triggerMin = newTriggerMin;
calibrationData.triggerMax = newTriggerMax;
CalibrationStore::save(calibrationData);
Serial.println("Trigger calibration saved.");
}

int newCenterX, newCenterY, newMinX, newMaxX, newMinY, newMaxY;
if (menuController.consumeNewStickCalibration(&newCenterX, &newCenterY,
&newMinX, &newMaxX, &newMinY,
&newMaxY)) {
calibrationData.stickXCenter = newCenterX;
calibrationData.stickYCenter = newCenterY;
calibrationData.stickXMin = newMinX;
calibrationData.stickXMax = newMaxX;
calibrationData.stickYMin = newMinY;
calibrationData.stickYMax = newMaxY;
CalibrationStore::save(calibrationData);
Serial.println("Stick calibration saved.");
}

if (menuController.consumeFactoryResetConfirmed()) {
// Droid list wipe joins this once DroidStore exists (PR 7).
calibrationData = CalibrationData();
CalibrationStore::save(calibrationData);
Serial.println("Factory reset: calibration cleared.");
}

// Only touch the display when something actually changed — a full
// redraw every tick would be needless I2C traffic for static text.
const bool menuStateChanged =
menuController.currentScreen() != previousMenuScreen ||
menuController.selectedMainMenuItem() != previousMainMenuItem;
previousMenuScreen = menuController.currentScreen();
previousMainMenuItem = menuController.selectedMainMenuItem();

if (menuStateChanged) {
if (menuController.currentScreen() != MenuScreen::kInactive) {
ScreenBuffer menuScreen;
renderMenuScreen(menuController, &menuScreen);
oledDisplay.render(menuScreen);
} else {
showBootScreen();
}
}

// 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;
Expand Down
Loading
Loading