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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
Expand Down
35 changes: 35 additions & 0 deletions include/battery.h
Original file line number Diff line number Diff line change
@@ -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;
};
87 changes: 87 additions & 0 deletions include/calibration.h
Original file line number Diff line number Diff line change
@@ -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;
};
17 changes: 17 additions & 0 deletions include/calibration_store.h
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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> -<oled.cpp> -<rgb_led.cpp>
build_src_filter = +<*> -<*.ino> -<oled.cpp> -<rgb_led.cpp> -<calibration_store.cpp>
test_framework = unity
build_flags = --coverage
extra_scripts = pre:scripts/native_coverage_linkflags.py
66 changes: 66 additions & 0 deletions src/SnipsController.ino
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#include <Arduino.h>

#include "battery.h"
#include "buttons.h"
#include "calibration.h"
#include "calibration_store.h"
#include "oled.h"
#include "pin_assignment.h"
#include "power_latch.h"
Expand All @@ -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) {
Expand All @@ -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() {
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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);
}
}
24 changes: 24 additions & 0 deletions src/battery.cpp
Original file line number Diff line number Diff line change
@@ -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<float>(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<int>(percent + 0.5f);
}
93 changes: 93 additions & 0 deletions src/calibration.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#include "calibration.h"

#include <cstdlib>

namespace {

int roundToInt(float value) {
return value >= 0.0f ? static_cast<int>(value + 0.5f)
: static_cast<int>(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<float>(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<float>(max - min) * kDeadzonePercentOfRange / 100.0f;
if (std::abs(raw - center) <= deadzoneHalfWidth) {
return 0;
}

if (raw > center) {
const float percent =
static_cast<float>(raw - center) / (max - center) * 100.0f;
return clamp(roundToInt(percent), 0, 100);
}

const float percent =
static_cast<float>(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;
}
Loading
Loading