feat: Add restart button functionality and motion calibration

- Implemented a restart button with debounce handling and long-press detection in App.cpp.
- Added motion calibration settings to Settings.cpp and Settings.h, allowing for roll and pitch zero offsets.
- Enhanced WebUI to include motion calibration controls and a restart option.
- Updated FaceRenderer to adjust eye and mouth positions based on screen dimensions.
- Introduced deadzone handling for motion sensor readings to improve stability.
This commit is contained in:
Joshua King
2026-02-22 13:58:59 -05:00
parent a8e8268b65
commit df9bd461d1
10 changed files with 392 additions and 84 deletions

View File

@@ -42,6 +42,12 @@ static unsigned long nextTimeCheckMs = 0;
static bool lastScheduleSleep = false;
static String lastConfiguredTz;
static unsigned long motionWakeUntilMs = 0;
static bool restartButtonEnabled = true;
static bool restartButtonLastRaw = true;
static bool restartButtonStable = true;
static unsigned long restartButtonLastEdgeMs = 0;
static unsigned long restartButtonPressedSinceMs = 0;
static bool restartButtonArmed = true;
static constexpr float DIM_LUX_MIN = 0.0f;
static constexpr float DIM_LUX_MAX = 300.0f;
@@ -49,6 +55,9 @@ static constexpr uint8_t DIM_CONTRAST_MIN = 0x10;
static constexpr uint8_t DIM_CONTRAST_MAX = 0xFF;
static constexpr int ROUTINE_WINDOW_MIN = 5;
static constexpr unsigned long MOTION_WAKE_MS = 30000UL;
static constexpr int PIN_RESTART_BUTTON = 2; // Active-low button to GND
static constexpr unsigned long BUTTON_DEBOUNCE_MS = 30UL;
static constexpr unsigned long BUTTON_HOLD_RESTART_MS = 1200UL;
static PlantEventType moodToEvent(FaceRenderer::Mood m) {
if (m == FaceRenderer::DRY) return EVT_DRY;
@@ -72,6 +81,56 @@ static uint8_t luxToContrast(float lux) {
return (uint8_t)value;
}
static void initRestartButton() {
pinMode(PIN_RESTART_BUTTON, INPUT_PULLUP);
bool raw = digitalRead(PIN_RESTART_BUTTON);
restartButtonLastRaw = raw;
restartButtonStable = raw;
restartButtonLastEdgeMs = millis();
restartButtonPressedSinceMs = 0;
restartButtonArmed = true;
Serial.print("[Button] Restart button on GPIO ");
Serial.print(PIN_RESTART_BUTTON);
Serial.print(" (active-low), initial=");
Serial.println(raw ? "released" : "pressed");
}
static void handleRestartButton() {
if (!restartButtonEnabled) return;
unsigned long now = millis();
bool raw = digitalRead(PIN_RESTART_BUTTON);
if (raw != restartButtonLastRaw) {
restartButtonLastRaw = raw;
restartButtonLastEdgeMs = now;
}
if ((now - restartButtonLastEdgeMs) >= BUTTON_DEBOUNCE_MS && restartButtonStable != raw) {
restartButtonStable = raw;
bool pressed = !restartButtonStable; // active-low
if (pressed) {
restartButtonPressedSinceMs = now;
Serial.println("[Button] Restart button pressed");
} else {
Serial.println("[Button] Restart button released");
restartButtonPressedSinceMs = 0;
restartButtonArmed = true;
}
}
bool pressed = !restartButtonStable;
if (pressed && restartButtonArmed && restartButtonPressedSinceMs != 0 &&
(now - restartButtonPressedSinceMs) >= BUTTON_HOLD_RESTART_MS) {
restartButtonArmed = false;
Serial.println("[Button] Restart hold detected - rebooting");
delay(50);
ESP.restart();
}
}
struct ScheduleState {
bool hasTime = false;
bool sleeping = false;
@@ -234,6 +293,7 @@ void App::setup() {
display.begin();
display.showStatus("FacePlant", "Starting...");
initRestartButton();
wifi.begin(settings, forceSetup);
@@ -245,7 +305,12 @@ void App::setup() {
webhook.begin(settings);
web.begin(settings, wifi, moisture, face, webhook, bootMs);
{
MotionCalibration mc = settings.motionCalibration();
motion.setZeroOffsets(mc.rollZeroDeg, mc.pitchZeroDeg);
}
web.begin(settings, wifi, moisture, motion, face, webhook, bootMs);
ota.begin(web.server(), &display);
lastMood = face.mood();
@@ -259,6 +324,7 @@ void App::loop() {
static unsigned long lastDisplayUpdate = 0;
BootTrigger::clearAfterStableUptime();
handleRestartButton();
wifi.loop();
web.loop();

View File

@@ -5,6 +5,7 @@
void WebUI::begin(Settings& settings,
WiFiManager& wifi,
MoistureSensor& moisture,
MotionSensor& motion,
FaceRenderer& face,
WebhookService& webhook,
unsigned long bootMs) {
@@ -23,6 +24,7 @@ void WebUI::begin(Settings& settings,
String timezone = settings.timezone();
String bedtime = settings.bedtime();
String wakeTime = settings.wakeTime();
MotionCalibration mc = settings.motionCalibration();
String page =
"<!DOCTYPE html><html><head>"
@@ -157,6 +159,26 @@ void WebUI::begin(Settings& settings,
"</form>"
"</div>"
"<div class='card'>"
"<h2><i class='fas fa-compass' style='margin-right: 10px;'></i>Accelerometer Calibration</h2>"
"<div style='background: #f5f5f7; padding: 15px; border-radius: 12px; margin-bottom: 20px;'>"
"<div style='font-size: 0.85em; color: #86868b; margin-bottom: 5px;'>Current Tilt</div>"
"<div style='font-size: 1em; color: #1d1d1f;'>Roll: " + String(motion.rollDeg(), 1) + "&deg;</div>"
"<div style='font-size: 1em; color: #1d1d1f;'>Pitch: " + String(motion.pitchDeg(), 1) + "&deg;</div>"
"<div style='font-size: 0.85em; color: #86868b; margin-top: 8px;'>"
"Neutral offsets: roll " + String(mc.rollZeroDeg, 1) + "&deg;, pitch " + String(mc.pitchZeroDeg, 1) + "&deg;</div>"
"</div>"
"<form method='POST' action='/motion-calibrate' style='margin-bottom: 12px;'>"
"<div class='btn-group'>"
"<button type='submit' name='action' value='zero_now' class='btn btn-primary'>Set Current Position as Neutral</button>"
"</div>"
"</form>"
"<form method='POST' action='/motion-calibrate'>"
"<div class='btn-group'>"
"<button type='submit' name='action' value='reset' class='btn btn-secondary'>Reset Accelerometer Calibration</button>"
"</div>"
"</form>"
"</div>"
"<div class='card'>"
"<h2><i class='fas fa-sliders-h' style='margin-right: 10px;'></i>Sensor Calibration</h2>"
"<div style='background: #f5f5f7; padding: 15px; border-radius: 12px; margin-bottom: 20px;'>"
"<div style='font-size: 0.85em; color: #86868b; margin-bottom: 5px;'>Current Reading</div>"
@@ -184,6 +206,12 @@ void WebUI::begin(Settings& settings,
"<div class='card' style='border: 2px solid #ff3b30;'>"
"<h2 style='color: #ff3b30;'><i class='fas fa-exclamation-triangle' style='margin-right: 10px;'></i>Danger Zone</h2>"
"<p style='color: #86868b; margin-bottom: 15px;'>Irreversible actions that will reset your device</p>"
"<form method='POST' action='/restart' id='restartForm' style='margin-bottom: 20px;'>"
"<p style='margin-bottom: 15px;'><strong>Restart Device</strong></p>"
"<p style='color: #86868b; font-size: 0.9em; margin-bottom: 15px;'>"
"Reboots FacePlant without erasing settings.</p>"
"<button type='submit' class='btn btn-secondary' style='width: 100%;'>Restart</button>"
"</form>"
"<form method='POST' action='/factory-reset' id='resetForm'>"
"<p style='margin-bottom: 15px;'><strong>Factory Reset</strong></p>"
"<p style='color: #86868b; font-size: 0.9em; margin-bottom: 15px;'>"
@@ -210,6 +238,12 @@ void WebUI::begin(Settings& settings,
" }"
" }"
"});"
"document.getElementById('restartForm').addEventListener('submit', function(e) {"
" e.preventDefault();"
" if (confirm('Restart FacePlant now?')) {"
" this.submit();"
" }"
"});"
"document.getElementById('resetForm').addEventListener('submit', function(e) {"
" e.preventDefault();"
" if (confirm('Are you sure you want to reset to factory defaults? This cannot be undone.')) {"
@@ -283,6 +317,28 @@ void WebUI::begin(Settings& settings,
_server.send(303);
});
_server.on("/motion-calibrate", HTTP_POST, [&]() {
String action = _server.hasArg("action") ? _server.arg("action") : "zero_now";
if (action == "reset") {
settings.clearMotionCalibration();
motion.setZeroOffsets(0.0f, 0.0f);
Serial.println("[WebUI] Accelerometer calibration reset");
} else {
if (motion.available()) {
settings.setMotionCalibration(motion.rawRollDeg(), motion.rawPitchDeg());
motion.setZeroOffsets(motion.rawRollDeg(), motion.rawPitchDeg());
Serial.print("[WebUI] Accelerometer neutral set from current position: roll=");
Serial.print(motion.rawRollDeg(), 2);
Serial.print(" pitch=");
Serial.println(motion.rawPitchDeg(), 2);
} else {
Serial.println("[WebUI] Accelerometer calibration requested but MPU6050 unavailable");
}
}
_server.sendHeader("Location", "/");
_server.send(303);
});
_server.on("/factory-reset", HTTP_POST, [&]() {
Serial.println("[WebUI] Factory reset requested");
_server.send(200, "text/html",
@@ -311,6 +367,30 @@ void WebUI::begin(Settings& settings,
ESP.restart();
});
_server.on("/restart", HTTP_POST, [&]() {
Serial.println("[WebUI] Restart requested");
_server.send(200, "text/html",
"<!DOCTYPE html><html><head><meta http-equiv='refresh' content='8;url=/'>"
"<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'>"
"<style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"
"background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);min-height:100vh;display:flex;"
"align-items:center;justify-content:center;margin:0;padding:20px;}"
".card{background:rgba(255,255,255,0.95);border-radius:20px;padding:40px;text-align:center;"
"box-shadow:0 20px 60px rgba(0,0,0,0.3);max-width:400px;}"
"h1{font-size:2em;margin-bottom:10px;color:#1d1d1f;}"
"p{color:#86868b;margin:10px 0;}"
".spinner{font-size:3em;color:#667eea;margin:20px 0;animation:spin 2s linear infinite;}"
"@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}</style></head><body>"
"<div class='card'>"
"<div class='spinner'><i class='fas fa-sync-alt'></i></div>"
"<h1>Restarting</h1>"
"<p>FacePlant is rebooting...</p>"
"<p>Settings are preserved.</p>"
"</div></body></html>");
delay(500);
ESP.restart();
});
_server.on("/status", HTTP_GET, [&]() {
JsonDocument doc;
doc["device"] = "FacePlant";
@@ -327,6 +407,11 @@ void WebUI::begin(Settings& settings,
doc["timezone"] = settings.timezone();
doc["bedtime"] = settings.bedtime();
doc["wake_time"] = settings.wakeTime();
doc["motion_ok"] = motion.available();
doc["motion_roll_deg"] = motion.rollDeg();
doc["motion_pitch_deg"] = motion.pitchDeg();
doc["motion_roll_zero_deg"] = motion.rollZeroDeg();
doc["motion_pitch_zero_deg"] = motion.pitchZeroDeg();
doc["dead_mode"] = face.isDeadMode();
doc["uptime_ms"] = millis() - bootMs;

View File

@@ -3,6 +3,7 @@
#include "../settings/Settings.h"
#include "../net/WiFiManager.h"
#include "../sensors/MoistureSensor.h"
#include "../sensors/MotionSensor.h"
#include "../ui/FaceRenderer.h"
#include "../net/WebhookService.h"
@@ -11,6 +12,7 @@ public:
void begin(Settings& settings,
WiFiManager& wifi,
MoistureSensor& moisture,
MotionSensor& motion,
FaceRenderer& face,
WebhookService& webhook,
unsigned long bootMs);
@@ -20,4 +22,4 @@ public:
private:
WebServer _server{80};
};
};

View File

@@ -2,6 +2,13 @@
#include <math.h>
static constexpr float GRAVITY_MS2 = 9.80665f;
static constexpr float ROLL_DEADZONE_DEG = 6.0f;
static constexpr float PITCH_DEADZONE_DEG = 6.0f;
static float applyDeadzone(float value, float deadzone) {
if (fabsf(value) <= deadzone) return 0.0f;
return value > 0.0f ? (value - deadzone) : (value + deadzone);
}
float MotionSensor::clampf(float v, float lo, float hi) {
if (v < lo) return lo;
@@ -71,15 +78,26 @@ bool MotionSensor::consumePickupEvent() {
return out;
}
void MotionSensor::setZeroOffsets(float rollZeroDeg, float pitchZeroDeg) {
_rollZeroDeg = rollZeroDeg;
_pitchZeroDeg = pitchZeroDeg;
Serial.print("[Motion] Calibration set: roll0=");
Serial.print(_rollZeroDeg, 2);
Serial.print(" pitch0=");
Serial.println(_pitchZeroDeg, 2);
}
int8_t MotionSensor::eyeOffsetX() const {
if (!_ok) return 0;
float roll = clampf(_rollDeg, -40.0f, 40.0f);
float roll = applyDeadzone(rollDeg(), ROLL_DEADZONE_DEG);
roll = clampf(roll, -40.0f, 40.0f);
return (int8_t)lroundf((roll / 40.0f) * 7.0f);
}
int8_t MotionSensor::pupilSizeDelta() const {
if (!_ok) return 0;
// Positive pitch is treated as forward tilt (bigger pupils).
float pitch = clampf(_pitchDeg, -35.0f, 35.0f);
float pitch = applyDeadzone(pitchDeg(), PITCH_DEADZONE_DEG);
pitch = clampf(pitch, -35.0f, 35.0f);
return (int8_t)lroundf((pitch / 35.0f) * 4.0f); // -4..+4 (used for whole-eye scaling now)
}

View File

@@ -13,8 +13,13 @@ public:
bool isMoving() const;
bool consumePickupEvent();
float rollDeg() const { return _rollDeg; } // left/right tilt
float pitchDeg() const { return _pitchDeg; } // forward/back tilt
float rollDeg() const { return _rollDeg - _rollZeroDeg; } // corrected left/right tilt
float pitchDeg() const { return _pitchDeg - _pitchZeroDeg; } // corrected forward/back tilt
float rawRollDeg() const { return _rollDeg; }
float rawPitchDeg() const { return _pitchDeg; }
void setZeroOffsets(float rollZeroDeg, float pitchZeroDeg);
float rollZeroDeg() const { return _rollZeroDeg; }
float pitchZeroDeg() const { return _pitchZeroDeg; }
int8_t eyeOffsetX() const;
int8_t pupilSizeDelta() const;
@@ -35,6 +40,8 @@ private:
unsigned long _movingUntilMs = 0;
bool _pickupLatched = false;
float _rollZeroDeg = 0.0f;
float _pitchZeroDeg = 0.0f;
static float clampf(float v, float lo, float hi);
};

View File

@@ -83,6 +83,23 @@ void Settings::setWakeTime(const String& hhmm) {
_prefs.putString("wake", isValidHHMM(hhmm) ? hhmm : "07:00");
}
MotionCalibration Settings::motionCalibration() const {
MotionCalibration c{};
c.rollZeroDeg = _prefs.getFloat("accel_r0", 0.0f);
c.pitchZeroDeg = _prefs.getFloat("accel_p0", 0.0f);
return c;
}
void Settings::setMotionCalibration(float rollZeroDeg, float pitchZeroDeg) {
_prefs.putFloat("accel_r0", rollZeroDeg);
_prefs.putFloat("accel_p0", pitchZeroDeg);
}
void Settings::clearMotionCalibration() {
_prefs.remove("accel_r0");
_prefs.remove("accel_p0");
}
void Settings::factoryReset() {
Serial.println("[Settings] Factory reset - clearing all settings");
_prefs.clear();

View File

@@ -8,6 +8,11 @@ struct PlantThresholds {
int tooWetPct;
};
struct MotionCalibration {
float rollZeroDeg;
float pitchZeroDeg;
};
class Settings {
public:
void begin();
@@ -51,6 +56,11 @@ public:
String wakeTime() const; // "HH:MM"
void setWakeTime(const String& hhmm);
// Accelerometer calibration (neutral orientation offsets)
MotionCalibration motionCalibration() const;
void setMotionCalibration(float rollZeroDeg, float pitchZeroDeg);
void clearMotionCalibration();
// Factory reset
void factoryReset();

View File

@@ -160,9 +160,10 @@ void Display::drawBatteryIcon(int x, int y, int percent, bool blink, bool chargi
if (charging) {
// Draw animated lightning bolt when charging
bool phase = (millis() / 300) % 2 == 0;
int color = phase ? 1 : 0; // Blink between white and inverted
bool invertBolt = (percent >= 50);
int color = invertBolt ? 0 : (phase ? 1 : 0);
// Lightning bolt shape (simple zigzag)
// Lightning bolt shape (simple zigzag). Above 50%, keep it black in the center.
_oled.drawLine(x + 7, y + 1, x + 5, y + 3, color); // Top diagonal
_oled.drawLine(x + 5, y + 3, x + 9, y + 3, color); // Middle horizontal
_oled.drawLine(x + 9, y + 3, x + 7, y + 5, color); // Bottom diagonal

View File

@@ -16,6 +16,40 @@ int8_t FaceRenderer::randRangeI8(int8_t lo, int8_t hi) {
return (int8_t)(lo + random((int)(hi - lo + 1)));
}
int FaceRenderer::batteryIconX() const {
if (!_display) return 110;
int x = _display->oled().width() - 18; // 16px icon + 2px margin
return x < 0 ? 0 : x;
}
int FaceRenderer::screenW() const {
return _display ? _display->oled().width() : 128;
}
int FaceRenderer::screenH() const {
return _display ? _display->oled().height() : 64;
}
int FaceRenderer::eyeCy() const {
return screenH() * 3 / 8; // ~24 on 64px tall displays
}
int FaceRenderer::leftEyeCxBase() const {
return screenW() * 5 / 16; // 40 on 128px
}
int FaceRenderer::rightEyeCxBase() const {
return screenW() * 11 / 16; // 88 on 128px
}
int FaceRenderer::mouthCx() const {
return screenW() / 2;
}
int FaceRenderer::mouthY() const {
return screenH() * 3 / 4; // ~48 on 64px
}
void FaceRenderer::setRoutineAnimation(RoutineAnim anim, uint16_t progressPermille) {
_routineAnim = anim;
_routineProgressPermille = progressPermille > 1000 ? 1000 : progressPermille;
@@ -42,6 +76,7 @@ void FaceRenderer::begin(Display& display, Settings& settings) {
_nextBlinkMs = now + randRange(3000, 9000);
_nextSillyMs = now + randRange(20000, 60000);
_nextGazeMs = now + randRange(800, 2000);
_nextTooWetMouthEventMs = now + randRange(45000, 90000);
}
void FaceRenderer::loop(const MoistureSensor& moisture, const BatterySensor& battery) {
@@ -159,6 +194,11 @@ void FaceRenderer::updateTooWet(unsigned long now) {
_lastBlinkMs = now;
}
if (_blinking && (long)(now - _blinkUntilMs) >= 0) _blinking = false;
if ((long)(now - _nextTooWetMouthEventMs) >= 0) {
_tooWetMouthOpenUntilMs = now + randRange(1200, 2200);
_nextTooWetMouthEventMs = now + randRange(45000, 90000);
}
}
void FaceRenderer::renderDead(unsigned long now, const BatterySensor& battery) {
@@ -171,18 +211,21 @@ void FaceRenderer::renderDead(unsigned long now, const BatterySensor& battery) {
}
if (_deadShowTombstone) {
d.drawRoundRect(38, 12, 52, 40, 8, 1);
d.setCursor(52, 28);
int w = 52, h = 40;
int x = (screenW() - w) / 2;
int y = (screenH() - h) / 2;
d.drawRoundRect(x, y, w, h, 8, 1);
d.setCursor(x + 14, y + 16);
d.setTextSize(2);
d.print("RIP");
} else {
drawXEye(40, 24);
drawXEye(88, 24);
drawXEye(leftEyeCxBase(), eyeCy());
drawXEye(rightEyeCxBase(), eyeCy());
drawMouthFlat();
}
// Draw battery icon in top-right corner
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
d.display();
}
@@ -224,7 +267,7 @@ void FaceRenderer::renderBatteryLow(unsigned long now, const BatterySensor& batt
drawMouthNervous();
// Draw battery icon in top-right corner (blinking)
_display->drawBatteryIcon(110, 2, battery.percent(), true, battery.isCharging());
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), true, battery.isCharging());
d.display();
}
@@ -250,12 +293,15 @@ void FaceRenderer::renderNormal(unsigned long now, const BatterySensor& battery)
else {
if (_blinking) drawEyesClosed();
else drawEyesOpen(0, 0, 4);
drawMouthFlat();
drawBubbles((now / 100) % 12 - 6);
bool mouthOpen = ((long)(now - _tooWetMouthOpenUntilMs) < 0);
if (mouthOpen) drawMouthSurprised();
else drawMouthFlat();
drawBubbles((now / 120) % 36);
if (mouthOpen) drawMouthBubbles(now);
}
// Draw battery icon in top-right corner
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
d.display();
}
@@ -268,44 +314,52 @@ void FaceRenderer::renderRoutine(unsigned long now, const BatterySensor& battery
// Progressively lower eyelids over 5 minutes.
int close = (_routineProgressPermille * 12) / 1000; // 0..12
int pupilY = (_routineProgressPermille > 700) ? 2 : 0;
int lcx = leftEyeCxBase();
int rcx = rightEyeCxBase();
int cy = eyeCy();
d.fillCircle(40, 24, 12, 1);
d.fillCircle(88, 24, 12, 1);
d.fillCircle(40, 24 + pupilY, 3, 0);
d.fillCircle(88, 24 + pupilY, 3, 0);
d.fillCircle(lcx, cy, 12, 1);
d.fillCircle(rcx, cy, 12, 1);
d.fillCircle(lcx, cy + pupilY, 3, 0);
d.fillCircle(rcx, cy + pupilY, 3, 0);
if (close > 0) {
d.fillRect(28, 12, 24, close, 0);
d.fillRect(76, 12, 24, close, 0);
d.fillRect(lcx - 12, cy - 12, 24, close, 0);
d.fillRect(rcx - 12, cy - 12, 24, close, 0);
}
if (close > 6) {
d.drawLine(28, 24, 52, 24, 1);
d.drawLine(76, 24, 100, 24, 1);
d.drawLine(lcx - 12, cy, lcx + 12, cy, 1);
d.drawLine(rcx - 12, cy, rcx + 12, cy, 1);
}
d.drawLine(26, 14, 48, 16, 1);
d.drawLine(80, 16, 102, 14, 1);
d.drawLine(52, 48, 76, 48, 1);
d.drawLine(lcx - 14, cy - 10, lcx + 8, cy - 8, 1);
d.drawLine(rcx - 8, cy - 8, rcx + 14, cy - 10, 1);
d.drawLine(mouthCx() - 12, mouthY(), mouthCx() + 12, mouthY(), 1);
drawSleepyZs(now, (uint8_t)(1 + ((_routineProgressPermille * 3) / 1000)));
} else {
// Wake-up: eyes open wider and smile grows over 5 minutes.
int open = 4 + (_routineProgressPermille * 8) / 1000; // pupil travel
int pupilY = 4 - (_routineProgressPermille * 4) / 1000; // starts sleepy, rises to center
int lcx = leftEyeCxBase();
int rcx = rightEyeCxBase();
int cy = eyeCy();
d.fillCircle(40, 24, 12, 1);
d.fillCircle(88, 24, 12, 1);
d.fillCircle(40, 24 + pupilY, open / 3, 0);
d.fillCircle(88, 24 + pupilY, open / 3, 0);
d.fillCircle(lcx, cy, 12, 1);
d.fillCircle(rcx, cy, 12, 1);
d.fillCircle(lcx, cy + pupilY, open / 3, 0);
d.fillCircle(rcx, cy + pupilY, open / 3, 0);
// Bright eyebrows + growing smile
d.drawLine(26, 14, 48, 11, 1);
d.drawLine(80, 11, 102, 14, 1);
d.drawLine(lcx - 14, cy - 10, lcx + 8, cy - 13, 1);
d.drawLine(rcx - 8, cy - 13, rcx + 14, cy - 10, 1);
int smileLift = (_routineProgressPermille * 3) / 1000;
int cx = mouthCx();
int y = mouthY() - 1;
for (int i = 0; i < 4; i++) {
d.drawLine(55 + i, 47 + i / 2 - smileLift, 73 - i, 47 + i / 2 - smileLift, 1);
d.drawLine(cx - 9 + i, y + i / 2 - smileLift, cx + 9 - i, y + i / 2 - smileLift, 1);
}
drawSunRise(now, (uint8_t)(1 + ((_routineProgressPermille * 4) / 1000)));
}
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
d.display();
}
@@ -315,11 +369,11 @@ void FaceRenderer::renderSurprised(unsigned long now, const BatterySensor& batte
int pulse = ((now / 180UL) % 2UL == 0UL) ? 1 : 0;
drawEyesOpen(0, 0, 5 + pulse);
d.drawLine(26, 16, 48, 10, 1);
d.drawLine(80, 10, 102, 16, 1);
d.drawLine(leftEyeCxBase() - 14, eyeCy() - 8, leftEyeCxBase() + 8, eyeCy() - 14, 1);
d.drawLine(rightEyeCxBase() - 8, eyeCy() - 14, rightEyeCxBase() + 14, eyeCy() - 8, 1);
drawMouthSurprised();
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
d.display();
}
@@ -329,91 +383,127 @@ void FaceRenderer::drawEyesOpen(int dx, int dy, int pupilRadius) {
auto &d = _display->oled();
int eyeR = clampInt(12 + _tiltPupilSizeDelta, 6, 18);
int pupilR = clampInt(pupilRadius + (_tiltPupilSizeDelta / 2), 1, eyeR - 2);
int leftCx = 40 + _tiltEyeDx;
int rightCx = 88 + _tiltEyeDx;
d.fillCircle(leftCx, 24, eyeR, 1);
d.fillCircle(rightCx, 24, eyeR, 1);
d.fillCircle(leftCx + dx, 24 + dy, pupilR, 0);
d.fillCircle(rightCx + dx, 24 + dy, pupilR, 0);
int cy = eyeCy();
int leftCx = clampInt(leftEyeCxBase() + _tiltEyeDx, eyeR, d.width() - eyeR - 1);
int rightCx = clampInt(rightEyeCxBase() + _tiltEyeDx, eyeR, d.width() - eyeR - 1);
d.fillCircle(leftCx, cy, eyeR, 1);
d.fillCircle(rightCx, cy, eyeR, 1);
d.fillCircle(leftCx + dx, cy + dy, pupilR, 0);
d.fillCircle(rightCx + dx, cy + dy, pupilR, 0);
}
void FaceRenderer::drawEyesClosed() {
auto &d = _display->oled();
d.drawLine(28, 24, 52, 24, 1);
d.drawLine(76, 24, 100, 24, 1);
int cy = eyeCy();
d.drawLine(leftEyeCxBase() - 12, cy, leftEyeCxBase() + 12, cy, 1);
d.drawLine(rightEyeCxBase() - 12, cy, rightEyeCxBase() + 12, cy, 1);
}
void FaceRenderer::drawEyesSmallPupils(int dy, int sx, int sy, int pupilRadius) {
auto &d = _display->oled();
int eyeR = clampInt(12 + _tiltPupilSizeDelta, 6, 18);
int r = clampInt(pupilRadius + (_tiltPupilSizeDelta / 2), 1, 7);
int leftCx = 40 + _tiltEyeDx;
int rightCx = 88 + _tiltEyeDx;
d.fillCircle(leftCx, 24, eyeR, 1);
d.fillCircle(rightCx, 24, eyeR, 1);
d.fillCircle(leftCx + sx, 24 + dy + sy, r, 0);
d.fillCircle(rightCx + sx, 24 + dy + sy, r, 0);
int cy = eyeCy();
int leftCx = clampInt(leftEyeCxBase() + _tiltEyeDx, eyeR, d.width() - eyeR - 1);
int rightCx = clampInt(rightEyeCxBase() + _tiltEyeDx, eyeR, d.width() - eyeR - 1);
d.fillCircle(leftCx, cy, eyeR, 1);
d.fillCircle(rightCx, cy, eyeR, 1);
d.fillCircle(leftCx + sx, cy + dy + sy, r, 0);
d.fillCircle(rightCx + sx, cy + dy + sy, r, 0);
}
void FaceRenderer::drawBrowsVerySad() {
auto &d = _display->oled();
d.drawLine(26, 12, 48, 18, 1);
d.drawLine(80, 18, 102, 12, 1);
int yTop = eyeCy() - 12;
d.drawLine(leftEyeCxBase() - 14, yTop, leftEyeCxBase() + 8, yTop + 6, 1);
d.drawLine(rightEyeCxBase() - 8, yTop + 6, rightEyeCxBase() + 14, yTop, 1);
}
void FaceRenderer::drawBrowsWorried() {
auto &d = _display->oled();
// Raised/arched worried eyebrows
d.drawLine(26, 14, 36, 10, 1);
d.drawLine(36, 10, 48, 12, 1);
d.drawLine(80, 12, 92, 10, 1);
d.drawLine(92, 10, 102, 14, 1);
int y = eyeCy() - 10;
d.drawLine(leftEyeCxBase() - 14, y, leftEyeCxBase() - 4, y - 4, 1);
d.drawLine(leftEyeCxBase() - 4, y - 4, leftEyeCxBase() + 8, y - 2, 1);
d.drawLine(rightEyeCxBase() - 8, y - 2, rightEyeCxBase() + 4, y - 4, 1);
d.drawLine(rightEyeCxBase() + 4, y - 4, rightEyeCxBase() + 14, y, 1);
}
void FaceRenderer::drawMouthHappy() {
auto &d = _display->oled();
int cx = mouthCx();
int y = mouthY() - 2;
for (int i = 0; i < 5; i++)
d.drawLine(54 + i, 46 + i / 2, 74 - i, 46 + i / 2, 1);
d.drawLine(cx - 10 + i, y + i / 2, cx + 10 - i, y + i / 2, 1);
}
void FaceRenderer::drawMouthFrown() {
auto &d = _display->oled();
int cx = mouthCx();
int y = mouthY();
for (int i = 0; i < 5; i++)
d.drawLine(54 - i, 48 + i / 2, 74 + i, 48 + i / 2, 1);
d.drawLine(cx - 10 - i, y + i / 2, cx + 10 + i, y + i / 2, 1);
}
void FaceRenderer::drawMouthFlat() {
auto &d = _display->oled();
d.drawLine(52, 48, 76, 48, 1);
int cx = mouthCx();
int y = mouthY();
d.drawLine(cx - 12, y, cx + 12, y, 1);
}
void FaceRenderer::drawMouthNervous() {
auto &d = _display->oled();
// Wavy/uncertain nervous mouth
d.drawLine(52, 48, 58, 47, 1);
d.drawLine(58, 47, 64, 48, 1);
d.drawLine(64, 48, 70, 47, 1);
d.drawLine(70, 47, 76, 48, 1);
int cx = mouthCx();
int y = mouthY();
d.drawLine(cx - 12, y, cx - 6, y - 1, 1);
d.drawLine(cx - 6, y - 1, cx, y, 1);
d.drawLine(cx, y, cx + 6, y - 1, 1);
d.drawLine(cx + 6, y - 1, cx + 12, y, 1);
}
void FaceRenderer::drawMouthSurprised() {
auto &d = _display->oled();
d.drawCircle(64, 48, 5, 1);
d.drawCircle(64, 48, 4, 1);
int cx = mouthCx();
int y = mouthY();
d.drawCircle(cx, y, 5, 1);
d.drawCircle(cx, y, 4, 1);
}
void FaceRenderer::drawBubbles(int off) {
auto &d = _display->oled();
d.drawCircle(10 + off, 12, 4, 1);
d.drawCircle(20 + off, 20, 3, 1);
d.drawCircle(30 + off, 14, 5, 1);
int rise = off % 36; // 0..35
// Upward float with staggered phases so bubbles continuously rise.
int y1 = 58 - rise;
int y2 = 58 - ((rise + 12) % 36);
int y3 = 58 - ((rise + 24) % 36);
d.drawCircle(12, y1, 4, 1);
d.drawCircle(24, y2, 3, 1);
d.drawCircle(34, y3, 5, 1);
}
void FaceRenderer::drawMouthBubbles(unsigned long now) {
auto &d = _display->oled();
int cx = mouthCx();
int y = mouthY();
int rise = (now / 90UL) % 26UL;
int x1 = cx + 9 + ((now / 160UL) % 3);
int y1 = y - 2 - rise;
int y2 = y - 8 - ((rise + 8) % 26);
int y3 = y - 14 - ((rise + 16) % 26);
if (y1 > 2) d.drawCircle(x1, y1, 2, 1);
if (y2 > 2) d.drawCircle(cx + 15, y2, 2, 1);
if (y3 > 2) d.drawCircle(cx + 20, y3, 3, 1);
}
void FaceRenderer::drawSparkles(unsigned long now) {
auto &d = _display->oled();
if ((now / 250) % 2 == 0) {
d.drawPixel(10, 10, 1);
d.drawPixel(118, 12, 1);
d.drawPixel(d.width() - 11, 12, 1);
}
}
@@ -421,14 +511,14 @@ void FaceRenderer::drawSilly(uint8_t v) {
auto &d = _display->oled();
if (v == 0) {
drawEyesOpen(2, 2);
d.drawCircle(64, 48, 6, 1);
d.drawCircle(mouthCx(), mouthY(), 6, 1);
} else if (v == 1) {
drawEyesClosed();
d.drawRect(58, 44, 12, 8, 1);
d.drawRect(mouthCx() - 6, mouthY() - 4, 12, 8, 1);
} else {
drawEyesOpen(-2, 1);
d.drawLine(52, 48, 76, 48, 1);
d.drawPixel(64, 54, 1);
d.drawLine(mouthCx() - 12, mouthY(), mouthCx() + 12, mouthY(), 1);
d.drawPixel(mouthCx(), mouthY() + 6, 1);
}
}
@@ -448,8 +538,8 @@ void FaceRenderer::drawBigWaterText(unsigned long now) {
uint16_t w = 0, h = 0;
d.getTextBounds(msg, 0, 0, &x1, &y1, &w, &h);
int16_t x = (int16_t)((128 - (int)w) / 2);
d.setCursor(x < 0 ? 0 : x, 56);
int16_t x = (int16_t)(((int)d.width() - (int)w) / 2);
d.setCursor(x < 0 ? 0 : x, d.height() - 8);
d.print(msg);
}
@@ -457,9 +547,9 @@ void FaceRenderer::drawSleepyZs(unsigned long now, uint8_t count) {
auto &d = _display->oled();
uint8_t phase = (now / 400UL) % 8UL;
for (uint8_t i = 0; i < count; i++) {
int x = 98 + i * 8 - (int)phase;
int y = 22 - i * 7;
if (x < 84 || y < 2) continue;
int x = rightEyeCxBase() + 10 + i * 8 - (int)phase;
int y = eyeCy() - 2 - i * 7;
if (x < rightEyeCxBase() - 4 || y < 2) continue;
d.drawLine(x, y, x + 4, y, 1);
d.drawLine(x + 4, y, x, y + 5, 1);
d.drawLine(x, y + 5, x + 4, y + 5, 1);
@@ -469,7 +559,7 @@ void FaceRenderer::drawSleepyZs(unsigned long now, uint8_t count) {
void FaceRenderer::drawSunRise(unsigned long now, uint8_t level) {
auto &d = _display->oled();
int cx = 12;
int cy = 52;
int cy = d.height() - 12;
int r = 5;
d.drawCircle(cx, cy, r, 1);
d.drawLine(cx - 10, cy + 6, cx + 10, cy + 6, 1);

View File

@@ -45,6 +45,9 @@ private:
uint8_t _blinkStep = 0;
unsigned long _blinkUntilMs = 0;
unsigned long _tooWetMouthOpenUntilMs = 0;
unsigned long _nextTooWetMouthEventMs = 0;
bool _silly = false;
uint8_t _sillyVariant = 0;
unsigned long _sillyUntilMs = 0;
@@ -86,6 +89,7 @@ private:
void drawMouthSurprised();
void drawDroplet(int x, int y, bool pulse);
void drawBubbles(int off);
void drawMouthBubbles(unsigned long now);
void drawSparkles(unsigned long now);
void drawSilly(uint8_t v);
void drawXEye(int cx, int cy);
@@ -95,4 +99,12 @@ private:
unsigned long randRange(unsigned long lo, unsigned long hi);
int8_t randRangeI8(int8_t lo, int8_t hi);
int batteryIconX() const;
int screenW() const;
int screenH() const;
int eyeCy() const;
int leftEyeCxBase() const;
int rightEyeCxBase() const;
int mouthCx() const;
int mouthY() const;
};