diff --git a/src/app/App.cpp b/src/app/App.cpp index 41f02ca..b8187a7 100644 --- a/src/app/App.cpp +++ b/src/app/App.cpp @@ -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(); diff --git a/src/net/WebUI.cpp b/src/net/WebUI.cpp index 8005f60..9f15a3a 100644 --- a/src/net/WebUI.cpp +++ b/src/net/WebUI.cpp @@ -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 = "" @@ -157,6 +159,26 @@ void WebUI::begin(Settings& settings, "" "" "
" + "

Accelerometer Calibration

" + "
" + "
Current Tilt
" + "
Roll: " + String(motion.rollDeg(), 1) + "°
" + "
Pitch: " + String(motion.pitchDeg(), 1) + "°
" + "
" + "Neutral offsets: roll " + String(mc.rollZeroDeg, 1) + "°, pitch " + String(mc.pitchZeroDeg, 1) + "°
" + "
" + "
" + "
" + "" + "
" + "
" + "
" + "
" + "" + "
" + "
" + "
" + "
" "

Sensor Calibration

" "
" "
Current Reading
" @@ -184,6 +206,12 @@ void WebUI::begin(Settings& settings, "
" "

Danger Zone

" "

Irreversible actions that will reset your device

" + "
" + "

Restart Device

" + "

" + "Reboots FacePlant without erasing settings.

" + "" + "
" "
" "

Factory Reset

" "

" @@ -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", + "" + "" + "" + "

" + "
" + "

Restarting

" + "

FacePlant is rebooting...

" + "

Settings are preserved.

" + "
"); + 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; diff --git a/src/net/WebUI.h b/src/net/WebUI.h index 01975ed..a5b6f88 100644 --- a/src/net/WebUI.h +++ b/src/net/WebUI.h @@ -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}; -}; \ No newline at end of file +}; diff --git a/src/sensors/MotionSensor.cpp b/src/sensors/MotionSensor.cpp index c4f3669..1268882 100644 --- a/src/sensors/MotionSensor.cpp +++ b/src/sensors/MotionSensor.cpp @@ -2,6 +2,13 @@ #include 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) } diff --git a/src/sensors/MotionSensor.h b/src/sensors/MotionSensor.h index ac625ee..a75fa49 100644 --- a/src/sensors/MotionSensor.h +++ b/src/sensors/MotionSensor.h @@ -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); }; diff --git a/src/settings/Settings.cpp b/src/settings/Settings.cpp index bdb5ca8..2a4141a 100644 --- a/src/settings/Settings.cpp +++ b/src/settings/Settings.cpp @@ -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(); diff --git a/src/settings/Settings.h b/src/settings/Settings.h index d64920d..eaea391 100644 --- a/src/settings/Settings.h +++ b/src/settings/Settings.h @@ -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(); diff --git a/src/ui/Display.cpp b/src/ui/Display.cpp index fabf994..931763a 100644 --- a/src/ui/Display.cpp +++ b/src/ui/Display.cpp @@ -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 diff --git a/src/ui/FaceRenderer.cpp b/src/ui/FaceRenderer.cpp index 43ce4eb..3ac43a3 100644 --- a/src/ui/FaceRenderer.cpp +++ b/src/ui/FaceRenderer.cpp @@ -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); diff --git a/src/ui/FaceRenderer.h b/src/ui/FaceRenderer.h index 41e5941..3da67d4 100644 --- a/src/ui/FaceRenderer.h +++ b/src/ui/FaceRenderer.h @@ -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; };