// ESP COACH LITE
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <MPU6050_tockn.h>
#include "esp_sleep.h"
// ========== PIN DEFINES ==========
const int PIN_BTN_UP = 32;
const int PIN_BTN_DOWN = 33;
const int PIN_BTN_ENTER = 25;
const int PIN_BTN_BACK = 26;
const int PIN_SD_CS = 5;
const int PIN_I2C_SDA = 21;
const int PIN_I2C_SCL = 22;
const int PIN_BATTERY = 34;
// ========== OLED / MPU ==========
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
Adafruit_SSD1306 oled(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
MPU6050 mpu(Wire);
// ========== BUTTON STATE ==========
struct ButtonState {
int pin;
bool last;
bool pressed;
};
ButtonState btnUp = {PIN_BTN_UP, HIGH, false};
ButtonState btnDown = {PIN_BTN_DOWN, HIGH, false};
ButtonState btnEnter = {PIN_BTN_ENTER, HIGH, false};
ButtonState btnBack = {PIN_BTN_BACK, HIGH, false};
// ========== ACTIVITY / AUTO-OFF ==========
unsigned long lastActivityMs = 0;
const unsigned long AUTO_OFF_MS = 15UL * 60UL * 1000UL; // 15 minutes
void recordActivity() {
lastActivityMs = millis();
}
// ========== ROW DATA ==========
struct RowData {
unsigned long elapsedMs;
float t_s;
float spmAvg;
float tempC;
float vbat;
int battPct;
};
RowData rowData;
// ========== MODES & SESSION STATES ==========
enum AppMode {
MODE_MENU,
MODE_JUST_ROW,
MODE_RACE_2000,
MODE_STROKE_CT,
MODE_RATE_ONLY,
MODE_SETTINGS_MENU,
MODE_SETTINGS_SENS,
MODE_SETTINGS_CALIB
};
enum SessionState {
STATE_IDLE,
STATE_ARMED,
STATE_RUNNING
};
AppMode currentMode = MODE_MENU;
SessionState sessionState = STATE_IDLE;
// ========== MAIN MENU ==========
const uint8_t MENU_ITEMS = 5;
const char* menuItems[MENU_ITEMS] = {
"JUST ROW",
"RACE 2000",
"STROKE COUNT",
"RATE ONLY",
"SETTINGS"
};
uint8_t menuIndex = 0;
// ========== SETTINGS MENU ==========
const uint8_t SETTINGS_ITEMS = 2;
const char* settingsItems[SETTINGS_ITEMS] = {
"Sensitivity",
"Calibrate IMU"
};
uint8_t settingsIndex = 0;
// ========== STROKE / MODEL ==========
int sensitivityLevel = 1;
float thresholdHigh = 0.3f;
float thresholdLow = 0.1f;
bool waitingForPeak = true;
unsigned long lastStrokeMillis = 0;
unsigned long strokeCount = 0;
float strokeRateSPM = 0.0f;
// smoothing for rate
const int SPM_BUF_SIZE = 5;
float spmBuf[SPM_BUF_SIZE];
int spmIdx = 0;
bool spmFilled = false;
// 1-minute stroke stats
unsigned long minuteWindowStartMs = 0;
unsigned int minuteStrokeCount = 0;
unsigned int lastMinuteStrokes = 0;
// session time base
unsigned long sessionStartMillis = 0;
// logging
File logFile;
unsigned long lastLogMillis = 0;
// ==== SD paths for LITE ====
const char* DATA_DIR_LITE = "/ESP_COACH/LITE";
const char* LOG_FILE_LITE = "/ESP_COACH/LITE/log.csv";
const char* SETTINGS_FILE_LITE = "/ESP_COACH/LITE/settings.txt";
// Save only sensitivity for Lite
void saveSettingsLite() {
SD.mkdir("/ESP_COACH");
SD.mkdir(DATA_DIR_LITE);
File f = SD.open(SETTINGS_FILE_LITE, FILE_WRITE);
if (!f) return;
f.print("SENS=");
f.println(sensitivityLevel);
f.close();
}
void loadSettingsLite() {
if (!SD.exists(SETTINGS_FILE_LITE)) return;
File f = SD.open(SETTINGS_FILE_LITE, FILE_READ);
if (!f) return;
while (f.available()) {
String line = f.readStringUntil('\n');
line.trim();
if (line.startsWith("SENS=")) {
int sens = line.substring(5).toInt();
if (sens < 0) sens = 0;
if (sens > 2) sens = 2;
sensitivityLevel = sens;
}
}
f.close();
}
// ========== SENSITIVITY ==========
void applySensitivity() {
switch (sensitivityLevel) {
case 0:
thresholdHigh = 0.2f;
thresholdLow = 0.06f;
break;
case 2:
thresholdHigh = 0.4f;
thresholdLow = 0.2f;
break;
case 1:
default:
thresholdHigh = 0.3f;
thresholdLow = 0.1f;
break;
}
}
// ========== BUTTONS ==========
void updateButton(ButtonState &b) {
bool state = digitalRead(b.pin);
bool nowPressed = (b.last == HIGH && state == LOW);
b.pressed = nowPressed;
b.last = state;
if (nowPressed) {
recordActivity();
}
}
void readButtons() {
updateButton(btnUp);
updateButton(btnDown);
updateButton(btnEnter);
updateButton(btnBack);
}
// ========== TIME & BATTERY HELPERS ==========
void formatTime(char *buf, size_t len, unsigned long ms) {
unsigned long sec = ms / 1000;
unsigned long m = sec / 60;
unsigned long s = sec % 60;
snprintf(buf, len, "%02lu:%02lu", m, s);
}
float avgSPM() {
int count = spmFilled ? SPM_BUF_SIZE : spmIdx;
if (count == 0) return strokeRateSPM;
float sum = 0;
for (int i = 0; i < count; i++) sum += spmBuf[i];
return sum / count;
}
void addSPM(float v) {
spmBuf[spmIdx] = v;
spmIdx++;
if (spmIdx >= SPM_BUF_SIZE) {
spmIdx = 0;
spmFilled = true;
}
}
float readBatteryVoltage() {
int raw = analogRead(PIN_BATTERY);
float v = (raw / 4095.0f) * 3.3f;
v *= 2.0f;
return v;
}
int batteryPercent(float vbat) {
float pct = (vbat - 3.3f) / (4.2f - 3.3f) * 100.0f;
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
return (int)(pct + 0.5f);
}
// ========== AUTO-OFF & POWER ==========
void enterDeepSleep() {
if (logFile) {
logFile.flush();
logFile.close();
}
saveSettingsLite();
oled.clearDisplay();
oled.setTextSize(1);
oled.setCursor(18, 24);
oled.print("Powering off...");
oled.display();
delay(500);
// Wake on ENTER (low)
esp_sleep_enable_ext0_wakeup((gpio_num_t)PIN_BTN_ENTER, 0);
oled.ssd1306_command(SSD1306_DISPLAYOFF);
esp_deep_sleep_start();
}
void checkAutoOff() {
unsigned long now = millis();
if (now - lastActivityMs > AUTO_OFF_MS) {
enterDeepSleep();
}
}
// BACK long press (1.5 seconds) to power off
unsigned long backHoldStartMs = 0;
bool backHoldActive = false;
void handleBackLongPress() {
int state = digitalRead(PIN_BTN_BACK);
unsigned long now = millis();
if (state == LOW) {
if (!backHoldActive) {
backHoldActive = true;
backHoldStartMs = now;
} else {
if (now - backHoldStartMs >= 1500) {
enterDeepSleep();
}
}
} else {
backHoldActive = false;
}
}
// ========== BOOT & CALIBRATION ==========
void showBootScreen() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(2);
oled.setCursor(10, 10);
oled.print("ESP");
oled.setCursor(10, 32);
oled.print("COACH L");
oled.display();
int x = 10, y = 52, w = 108, h = 8;
oled.drawRoundRect(x, y, w, h, 3, SSD1306_WHITE);
oled.display();
for (int i = 0; i <= w - 4; i += 4) {
oled.fillRect(x + 2, y + 2, i, h - 4, SSD1306_WHITE);
oled.display();
delay(60);
}
}
void calibrateIMUWithMessage() {
oled.clearDisplay();
oled.setTextSize(1);
oled.setCursor(0, 8);
oled.print("Hold still for");
oled.setCursor(0, 18);
oled.print("calibration");
int x = 10, y = 36, w = 108, h = 10;
oled.drawRoundRect(x, y, w, h, 3, SSD1306_WHITE);
oled.display();
for (int i = 0; i <= w - 4; i += 12) {
oled.fillRect(x + 2, y + 2, i, h - 4, SSD1306_WHITE);
oled.display();
delay(30);
}
mpu.calcGyroOffsets(true);
}
// ========== SENSORS & STROKES ==========
bool updateMPUAndStroke() {
mpu.update();
float ay = mpu.getAccY();
bool strokeDetected = false;
if (waitingForPeak) {
if (ay > thresholdHigh) {
unsigned long now = millis();
if (lastStrokeMillis != 0) {
unsigned long dt = now - lastStrokeMillis;
if (dt > 250 && dt < 4000) {
float spm = 60000.0f / dt;
strokeRateSPM = spm;
addSPM(spm);
}
}
lastStrokeMillis = now;
strokeCount++;
strokeDetected = true;
if (minuteWindowStartMs == 0) minuteWindowStartMs = now;
if (now - minuteWindowStartMs >= 60000) {
lastMinuteStrokes = minuteStrokeCount;
minuteStrokeCount = 0;
minuteWindowStartMs = now;
}
minuteStrokeCount++;
waitingForPeak = false;
recordActivity();
}
} else {
if (ay < thresholdLow) {
waitingForPeak = true;
}
}
return strokeDetected;
}
void updateRowData() {
unsigned long now = millis();
if (sessionState == STATE_RUNNING) {
rowData.elapsedMs = now - sessionStartMillis;
} else {
rowData.elapsedMs = 0;
}
rowData.t_s = rowData.elapsedMs / 1000.0f;
rowData.spmAvg = avgSPM();
rowData.tempC = mpu.getTemp();
rowData.vbat = readBatteryVoltage();
rowData.battPct = batteryPercent(rowData.vbat);
}
// ========== LOGGING ==========
void logDataIfNeeded(const char* modeName) {
if (!logFile) return;
unsigned long now = millis();
if (now - lastLogMillis < 1000) return;
lastLogMillis = now;
logFile.print(rowData.t_s, 1); logFile.print(",");
logFile.print(modeName); logFile.print(",");
logFile.print(rowData.spmAvg, 1); logFile.print(",");
logFile.print(strokeCount); logFile.print(",");
logFile.print(lastMinuteStrokes); logFile.print(",");
logFile.println(rowData.tempC, 1);
}
// ========== SESSION RESET ==========
void resetSessionCounters() {
strokeCount = 0;
strokeRateSPM = 0.0f;
waitingForPeak = true;
lastStrokeMillis = 0;
spmIdx = 0;
spmFilled = false;
minuteWindowStartMs = 0;
minuteStrokeCount = 0;
lastMinuteStrokes = 0;
}
void startNewSession() {
resetSessionCounters();
sessionState = STATE_IDLE;
sessionStartMillis = 0;
}
// ========== UI HELPERS ==========
void drawBatteryBarSmall() {
int pct = rowData.battPct;
int w = 20;
int h = 6;
int x = 128 - w - 2;
int y = 2;
oled.drawRect(x, y, w, h, SSD1306_WHITE);
int fill = (pct * (w - 2)) / 100;
oled.fillRect(x + 1, y + 1, fill, h - 2, SSD1306_WHITE);
}
// ========== UI: MAIN MENU (LITE) ==========
void drawMenuLite() {
oled.clearDisplay();
oled.setTextSize(1);
oled.setTextColor(SSD1306_WHITE);
oled.drawRoundRect(0, 0, 128, 64, 6, SSD1306_WHITE);
oled.setCursor(4, 2);
oled.print("ESP COACH LITE");
for (uint8_t i = 0; i < MENU_ITEMS; i++) {
int y = 14 + 10 * i;
if (i == menuIndex) {
oled.fillRoundRect(2, y - 1, 124, 9, 3, SSD1306_WHITE);
oled.setTextColor(SSD1306_BLACK);
} else {
oled.setTextColor(SSD1306_WHITE);
}
oled.setCursor(6, y);
if (i == menuIndex) {
oled.print(">");
} else {
oled.print(" ");
}
oled.print(menuItems[i]);
}
oled.setTextColor(SSD1306_WHITE);
oled.display();
}
// ========== UI: JUST ROW ==========
void drawJustRowScreen() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.drawRoundRect(0, 0, 128, 64, 6, SSD1306_WHITE);
drawBatteryBarSmall();
oled.setTextSize(1);
oled.setCursor(4, 2);
oled.print("JUST ROW");
if (sessionState == STATE_IDLE) {
oled.setCursor(4, 20);
oled.print("UP: arm");
oled.setCursor(4, 32);
oled.print("First stroke starts");
oled.setCursor(4, 44);
oled.print("clock & counting");
oled.display();
return;
}
if (sessionState == STATE_ARMED) {
oled.setCursor(4, 20);
oled.print("ARMED...");
oled.setCursor(4, 32);
oled.print("Row to begin");
oled.display();
return;
}
char timeBuf[8];
formatTime(timeBuf, sizeof(timeBuf), rowData.elapsedMs);
oled.setTextSize(2);
oled.setCursor(8, 16);
oled.print("R");
oled.print((int)(rowData.spmAvg + 0.5f));
oled.setTextSize(1);
oled.setCursor(80, 16);
oled.print("MIN:");
oled.setCursor(80, 26);
oled.print(lastMinuteStrokes);
oled.setCursor(80, 38);
oled.print("CNT:");
oled.setCursor(80, 48);
oled.print((int)strokeCount);
oled.setCursor(4, 52);
oled.print("TIME ");
oled.print(timeBuf);
oled.display();
}
// ========== UI: RACE 2000 ==========
void drawRace2000Screen() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.drawRoundRect(0, 0, 128, 64, 6, SSD1306_WHITE);
drawBatteryBarSmall();
oled.setTextSize(1);
oled.setCursor(4, 2);
oled.print("RACE 2000");
if (sessionState == STATE_IDLE) {
oled.setCursor(4, 20);
oled.print("UP: arm");
oled.setCursor(4, 32);
oled.print("Row to start race");
oled.display();
return;
}
if (sessionState == STATE_ARMED) {
oled.setCursor(4, 20);
oled.print("ARMED...");
oled.setCursor(4, 32);
oled.print("First stroke = GO");
oled.display();
return;
}
char timeBuf[8];
formatTime(timeBuf, sizeof(timeBuf), rowData.elapsedMs);
oled.setTextSize(2);
oled.setCursor(8, 20);
oled.print("R");
oled.print((int)(rowData.spmAvg + 0.5f));
oled.setTextSize(1);
oled.setCursor(80, 20);
oled.print("T:");
oled.print(timeBuf);
oled.setCursor(80, 32);
oled.print("STK:");
oled.print((int)strokeCount);
oled.display();
}
// ========== UI: STROKE COUNT ==========
void drawStrokeCountScreen() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.drawRoundRect(0, 0, 128, 64, 6, SSD1306_WHITE);
oled.setTextSize(1);
oled.setCursor(4, 2);
oled.print("STROKE COUNT");
if (sessionState == STATE_IDLE) {
oled.setCursor(4, 20);
oled.print("UP: arm");
oled.setCursor(4, 32);
oled.print("Row to begin");
oled.display();
return;
}
if (sessionState == STATE_ARMED) {
oled.setCursor(4, 20);
oled.print("ARMED...");
oled.setCursor(4, 32);
oled.print("Row to count");
oled.display();
return;
}
oled.setTextSize(3);
oled.setCursor(24, 24);
oled.print((int)strokeCount);
oled.display();
}
// ========== UI: RATE ONLY ==========
void drawRateOnlyScreen() {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.drawRoundRect(0, 0, 128, 64, 6, SSD1306_WHITE);
drawBatteryBarSmall();
oled.setTextSize(1);
oled.setCursor(4, 2);
oled.print("RATE ONLY");
if (sessionState == STATE_IDLE) {
oled.setCursor(4, 20);
oled.print("UP: arm");
oled.setCursor(4, 32);
oled.print("Row to see rate");
oled.display();
return;
}
if (sessionState == STATE_ARMED) {
oled.setCursor(4, 20);
oled.print("ARMED...");
oled.setCursor(4, 32);
oled.print("Row to start");
oled.display();
return;
}
oled.setTextSize(4);
oled.setCursor(16, 18);
oled.print((int)(rowData.spmAvg + 0.5f));
oled.display();
}
// ========== UI: SETTINGS ==========
void drawSettingsMenuLite() {
oled.clearDisplay();
oled.setTextSize(1);
oled.setTextColor(SSD1306_WHITE);
oled.drawRoundRect(0, 0, 128, 64, 6, SSD1306_WHITE);
oled.setCursor(4, 2);
oled.print("SETTINGS (LITE)");
for (uint8_t i = 0; i < SETTINGS_ITEMS; i++) {
int y = 16 + 10 * i;
if (i == settingsIndex) {
oled.fillRoundRect(2, y - 1, 124, 9, 3, SSD1306_WHITE);
oled.setTextColor(SSD1306_BLACK);
} else {
oled.setTextColor(SSD1306_WHITE);
}
oled.setCursor(6, y);
if (i == settingsIndex) oled.print(">");
else oled.print(" ");
oled.print(settingsItems[i]);
}
oled.setTextColor(SSD1306_WHITE);
oled.display();
}
void drawSettingsSens() {
oled.clearDisplay();
oled.setTextSize(1);
oled.setTextColor(SSD1306_WHITE);
oled.drawRoundRect(0, 0, 128, 64, 6, SSD1306_WHITE);
oled.setCursor(4, 2);
oled.print("Sensitivity");
oled.setTextSize(2);
oled.setCursor(4, 24);
oled.print("Lvl ");
oled.print(sensitivityLevel);
oled.setTextSize(1);
oled.setCursor(4, 52);
oled.print("UP/DN change BACK=exit");
oled.display();
}
void drawSettingsCalib() {
oled.clearDisplay();
oled.setTextSize(1);
oled.setTextColor(SSD1306_WHITE);
oled.drawRoundRect(0, 0, 128, 64, 6, SSD1306_WHITE);
oled.setCursor(4, 16);
oled.print("ENTER: calibrate IMU");
oled.setCursor(4, 28);
oled.print("BACK: exit");
oled.display();
}
// ========== GENERIC SIMPLE ROW HANDLER ==========
void handleSimpleRowMode(const char* modeName, void (*drawFn)()) {
if (btnBack.pressed) {
currentMode = MODE_MENU;
startNewSession();
return;
}
if (sessionState == STATE_IDLE) {
if (btnUp.pressed) {
sessionState = STATE_ARMED;
}
updateRowData();
drawFn();
return;
}
if (sessionState == STATE_ARMED) {
bool stroke = updateMPUAndStroke();
if (stroke) {
sessionStartMillis = lastStrokeMillis;
sessionState = STATE_RUNNING;
}
updateRowData();
drawFn();
return;
}
updateMPUAndStroke();
updateRowData();
drawFn();
logDataIfNeeded(modeName);
}
// ========== MODE HANDLERS ==========
void handleMenu() {
if (btnUp.pressed && menuIndex > 0) menuIndex--;
if (btnDown.pressed && menuIndex < MENU_ITEMS - 1) menuIndex++;
if (btnEnter.pressed) {
startNewSession();
switch (menuIndex) {
case 0: currentMode = MODE_JUST_ROW; break;
case 1: currentMode = MODE_RACE_2000; break;
case 2: currentMode = MODE_STROKE_CT; break;
case 3: currentMode = MODE_RATE_ONLY; break;
case 4: currentMode = MODE_SETTINGS_MENU; break;
}
}
drawMenuLite();
}
void handleJustRow() {
handleSimpleRowMode("JUST", drawJustRowScreen);
}
void handleRace2000() {
handleSimpleRowMode("RACE2000", drawRace2000Screen);
}
void handleStrokeCount() {
handleSimpleRowMode("STROKECT", drawStrokeCountScreen);
}
void handleRateOnly() {
handleSimpleRowMode("RATEONLY", drawRateOnlyScreen);
}
void handleSettingsMenu() {
if (btnUp.pressed && settingsIndex > 0) settingsIndex--;
if (btnDown.pressed && settingsIndex < SETTINGS_ITEMS-1) settingsIndex++;
if (btnEnter.pressed) {
if (settingsIndex == 0) currentMode = MODE_SETTINGS_SENS;
else currentMode = MODE_SETTINGS_CALIB;
return;
}
if (btnBack.pressed) {
currentMode = MODE_MENU;
return;
}
drawSettingsMenuLite();
}
void handleSettingsSens() {
bool changed = false;
if (btnUp.pressed && sensitivityLevel < 2) {
sensitivityLevel++;
applySensitivity();
changed = true;
}
if (btnDown.pressed && sensitivityLevel > 0) {
sensitivityLevel--;
applySensitivity();
changed = true;
}
if (changed) {
saveSettingsLite();
}
if (btnBack.pressed) {
saveSettingsLite();
currentMode = MODE_SETTINGS_MENU;
return;
}
drawSettingsSens();
}
void handleSettingsCalib() {
if (btnEnter.pressed) {
calibrateIMUWithMessage();
}
if (btnBack.pressed) {
currentMode = MODE_SETTINGS_MENU;
return;
}
drawSettingsCalib();
}
// ========== SETUP & LOOP ==========
void setup() {
Serial.begin(115200);
pinMode(PIN_BTN_UP, INPUT_PULLUP);
pinMode(PIN_BTN_DOWN, INPUT_PULLUP);
pinMode(PIN_BTN_ENTER, INPUT_PULLUP);
pinMode(PIN_BTN_BACK, INPUT_PULLUP);
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("SSD1306 failed");
while (1) { delay(10); }
}
oled.clearDisplay();
oled.display();
mpu.begin();
applySensitivity();
if (!SD.begin(PIN_SD_CS)) {
Serial.println("SD init failed!");
} else {
SD.mkdir("/ESP_COACH");
SD.mkdir(DATA_DIR_LITE);
loadSettingsLite(); // load saved sensitivity
applySensitivity(); // apply in case it changed
logFile = SD.open(LOG_FILE_LITE, FILE_WRITE);
if (logFile) {
logFile.println("time_s,mode,spm,strokes,lastMin,tempC");
}
}
analogReadResolution(12);
showBootScreen();
calibrateIMUWithMessage();
startNewSession();
recordActivity();
drawMenuLite();
}
void loop() {
readButtons();
handleBackLongPress();
switch (currentMode) {
case MODE_MENU:
handleMenu();
break;
case MODE_JUST_ROW:
handleJustRow();
break;
case MODE_RACE_2000:
handleRace2000();
break;
case MODE_STROKE_CT:
handleStrokeCount();
break;
case MODE_RATE_ONLY:
handleRateOnly();
break;
case MODE_SETTINGS_MENU:
handleSettingsMenu();
break;
case MODE_SETTINGS_SENS:
handleSettingsSens();
break;
case MODE_SETTINGS_CALIB:
handleSettingsCalib();
break;
}
checkAutoOff();
}