From 63061bdab2c9ce870a9d5b5b6b53ebebd243fc8c Mon Sep 17 00:00:00 2001 From: Joshua King Date: Sat, 21 Feb 2026 20:25:30 -0500 Subject: [PATCH] Add Motion Sensor functionality and integrate timezone, bedtime, and wake time settings --- platformio.ini | 3 +- src/app/App.cpp | 121 +++++++++++++++++++++++++++-------- src/net/WebUI.cpp | 25 ++++++++ src/sensors/MotionSensor.cpp | 85 ++++++++++++++++++++++++ src/sensors/MotionSensor.h | 40 ++++++++++++ src/settings/Settings.cpp | 26 ++++++++ src/settings/Settings.h | 8 +++ src/ui/FaceRenderer.cpp | 69 ++++++++++++++++---- src/ui/FaceRenderer.h | 11 +++- 9 files changed, 346 insertions(+), 42 deletions(-) create mode 100644 src/sensors/MotionSensor.cpp create mode 100644 src/sensors/MotionSensor.h diff --git a/platformio.ini b/platformio.ini index 1da3e38..f614f8d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -9,6 +9,7 @@ lib_deps = adafruit/Adafruit GFX Library adafruit/Adafruit SSD1306 adafruit/Adafruit VEML7700 Library + adafruit/Adafruit MPU6050 bblanchon/ArduinoJson ; board_build.filesystem = spiffs @@ -17,4 +18,4 @@ lib_deps = build_flags = -D PB_VERSION=\"1.0.0\" -D PB_HOSTNAME=\"faceplant\" - -D PB_TZ=\"UTC0\" + -D PB_TZ=\"America/New_York\" diff --git a/src/app/App.cpp b/src/app/App.cpp index 9782f21..41f02ca 100644 --- a/src/app/App.cpp +++ b/src/app/App.cpp @@ -13,6 +13,7 @@ #include "../sensors/MoistureSensor.h" #include "../sensors/BatterySensor.h" #include "../sensors/AmbientLightSensor.h" +#include "../sensors/MotionSensor.h" #include "../ui/FaceRenderer.h" #include @@ -26,6 +27,7 @@ static Display display; static MoistureSensor moisture; static BatterySensor battery; static AmbientLightSensor ambient; +static MotionSensor motion; static FaceRenderer face; static unsigned long bootMs = 0; @@ -38,16 +40,15 @@ static bool ntpConfigured = false; static bool timeValid = false; static unsigned long nextTimeCheckMs = 0; static bool lastScheduleSleep = false; +static String lastConfiguredTz; +static unsigned long motionWakeUntilMs = 0; static constexpr float DIM_LUX_MIN = 0.0f; static constexpr float DIM_LUX_MAX = 300.0f; static constexpr uint8_t DIM_CONTRAST_MIN = 0x10; static constexpr uint8_t DIM_CONTRAST_MAX = 0xFF; -static constexpr int BED_HOUR = 22; -static constexpr int BED_MINUTE = 0; -static constexpr int WAKE_HOUR = 7; -static constexpr int WAKE_MINUTE = 0; static constexpr int ROUTINE_WINDOW_MIN = 5; +static constexpr unsigned long MOTION_WAKE_MS = 30000UL; static PlantEventType moodToEvent(FaceRenderer::Mood m) { if (m == FaceRenderer::DRY) return EVT_DRY; @@ -90,12 +91,44 @@ static bool isInOvernightRange(int sec, int startSec, int endSec) { return (sec >= startSec) || (sec < endSec); } -static void maybeInitTimeSync(bool wifiConnected) { - if (!wifiConnected || ntpConfigured) return; - configTzTime(PB_TZ, "pool.ntp.org", "time.nist.gov"); +static int wrapDaySeconds(int sec) { + const int day = 24 * 3600; + while (sec < 0) sec += day; + while (sec >= day) sec -= day; + return sec; +} + +static bool parseClockHHMM(const String& hhmm, int& hour, int& minute) { + if (hhmm.length() != 5 || hhmm.charAt(2) != ':') return false; + int h = hhmm.substring(0, 2).toInt(); + int m = hhmm.substring(3, 5).toInt(); + if (h < 0 || h > 23 || m < 0 || m > 59) return false; + hour = h; + minute = m; + return true; +} + +static String normalizeTzForEsp(const String& tz) { + if (tz == "America/New_York") return "EST5EDT,M3.2.0,M11.1.0"; + if (tz == "America/Chicago") return "CST6CDT,M3.2.0,M11.1.0"; + if (tz == "America/Denver") return "MST7MDT,M3.2.0,M11.1.0"; + if (tz == "America/Los_Angeles") return "PST8PDT,M3.2.0,M11.1.0"; + return tz; +} + +static void ensureTimeSyncConfigured(bool wifiConnected) { + if (!wifiConnected) return; + + String tz = settings.timezone(); + if (tz.length() == 0) tz = String(PB_TZ); + String tzForEsp = normalizeTzForEsp(tz); + if (ntpConfigured && tzForEsp == lastConfiguredTz) return; + + configTzTime(tzForEsp.c_str(), "pool.ntp.org", "time.nist.gov"); ntpConfigured = true; - Serial.print("[Clock] NTP started, TZ="); - Serial.println(PB_TZ); + lastConfiguredTz = tzForEsp; + Serial.print("[Clock] NTP started/reconfigured, TZ="); + Serial.println(tz); } static ScheduleState currentScheduleState() { @@ -108,14 +141,24 @@ static ScheduleState currentScheduleState() { s.hasTime = true; const int sec = secondsOfDay(local); - const int bedSec = BED_HOUR * 3600 + BED_MINUTE * 60; - const int wakeSec = WAKE_HOUR * 3600 + WAKE_MINUTE * 60; - const int windDownStartSec = bedSec - ROUTINE_WINDOW_MIN * 60; - const int wakeAnimEndSec = wakeSec + ROUTINE_WINDOW_MIN * 60; + int bedHour = 22, bedMinute = 0; + int wakeHour = 7, wakeMinute = 0; + parseClockHHMM(settings.bedtime(), bedHour, bedMinute); + parseClockHHMM(settings.wakeTime(), wakeHour, wakeMinute); - if (isInRangeSameDay(sec, windDownStartSec, bedSec)) { + const int bedSec = bedHour * 3600 + bedMinute * 60; + const int wakeSec = wakeHour * 3600 + wakeMinute * 60; + const int windDownStartSec = wrapDaySeconds(bedSec - ROUTINE_WINDOW_MIN * 60); + const int wakeAnimEndSec = wrapDaySeconds(wakeSec + ROUTINE_WINDOW_MIN * 60); + + bool inWindDown = (windDownStartSec <= bedSec) + ? isInRangeSameDay(sec, windDownStartSec, bedSec) + : isInOvernightRange(sec, windDownStartSec, bedSec); + if (inWindDown) { + int elapsed = (sec - windDownStartSec); + if (elapsed < 0) elapsed += 24 * 3600; s.routineAnim = FaceRenderer::ROUTINE_SLEEPING_SOON; - s.routineProgressPermille = (uint16_t)(((sec - windDownStartSec) * 1000L) / (ROUTINE_WINDOW_MIN * 60L)); + s.routineProgressPermille = (uint16_t)((elapsed * 1000L) / (ROUTINE_WINDOW_MIN * 60L)); return s; } @@ -124,9 +167,14 @@ static ScheduleState currentScheduleState() { return s; } - if (isInRangeSameDay(sec, wakeSec, wakeAnimEndSec)) { + bool inWakeAnim = (wakeSec <= wakeAnimEndSec) + ? isInRangeSameDay(sec, wakeSec, wakeAnimEndSec) + : isInOvernightRange(sec, wakeSec, wakeAnimEndSec); + if (inWakeAnim) { + int elapsed = (sec - wakeSec); + if (elapsed < 0) elapsed += 24 * 3600; s.routineAnim = FaceRenderer::ROUTINE_WAKING_UP; - s.routineProgressPermille = (uint16_t)(((sec - wakeSec) * 1000L) / (ROUTINE_WINDOW_MIN * 60L)); + s.routineProgressPermille = (uint16_t)((elapsed * 1000L) / (ROUTINE_WINDOW_MIN * 60L)); return s; } @@ -137,7 +185,7 @@ static void updateScheduleState() { if ((long)(millis() - nextTimeCheckMs) < 0) return; nextTimeCheckMs = millis() + 1000; - maybeInitTimeSync(wifi.connected()); + ensureTimeSyncConfigured(wifi.connected()); ScheduleState sched = currentScheduleState(); timeValid = sched.hasTime; @@ -152,12 +200,7 @@ static void updateScheduleState() { lastScheduleSleep = shouldSleep; } - bool wasNightMode = isNightMode; isNightMode = shouldSleep; - if (display.ok()) { - display.setDisplayEnabled(!isNightMode); - if (wasNightMode && !isNightMode) lastContrast = 0xFF; // force contrast refresh when waking - } } static void updateAmbientDimming() { @@ -197,6 +240,7 @@ void App::setup() { moisture.begin(settings); battery.begin(); ambient.begin(); + motion.begin(); face.begin(display, settings); webhook.begin(settings); @@ -218,7 +262,32 @@ void App::loop() { wifi.loop(); web.loop(); + + motion.loop(); + if (motion.available()) { + face.setTiltEffects(motion.eyeOffsetX(), motion.pupilSizeDelta()); + if (motion.consumePickupEvent()) { + face.triggerSurprised(); + } + if (motion.isMoving()) { + motionWakeUntilMs = millis() + MOTION_WAKE_MS; + } + } else { + face.setTiltEffects(0, 0); + } + updateScheduleState(); + + bool motionWakeActive = ((long)(millis() - motionWakeUntilMs) < 0); + bool displaySleeping = isNightMode && !motionWakeActive; + + static bool lastDisplaySleeping = false; + if (displaySleeping != lastDisplaySleeping && display.ok()) { + display.setDisplayEnabled(!displaySleeping); + if (lastDisplaySleeping && !displaySleeping) lastContrast = 0xFF; + lastDisplaySleeping = displaySleeping; + } + updateAmbientDimming(); // Show WiFi status on display during connection attempts @@ -226,13 +295,13 @@ void App::loop() { if (currentConnected != lastConnected) { if (currentConnected) { Serial.println("[App] WiFi connected - showing on display"); - if (!isNightMode) { + if (!displaySleeping) { display.showStatus("WiFi Connected!", wifi.ssid().c_str()); delay(2000); } } else if (wifi.mode() == NET_STA) { Serial.println("[App] WiFi disconnected"); - if (!isNightMode && millis() - lastDisplayUpdate > 5000) { + if (!displaySleeping && millis() - lastDisplayUpdate > 5000) { display.showStatus("WiFi", "Connecting..."); lastDisplayUpdate = millis(); } @@ -242,7 +311,7 @@ void App::loop() { moisture.loop(); battery.loop(); - if (!isNightMode) { + if (!displaySleeping) { face.loop(moisture, battery); } diff --git a/src/net/WebUI.cpp b/src/net/WebUI.cpp index 87352c9..8005f60 100644 --- a/src/net/WebUI.cpp +++ b/src/net/WebUI.cpp @@ -20,6 +20,9 @@ void WebUI::begin(Settings& settings, String wifiSsid = settings.wifiSsid(); String currentSsid = wifi.ssid(); String plantProfile = settings.plantProfile(); + String timezone = settings.timezone(); + String bedtime = settings.bedtime(); + String wakeTime = settings.wakeTime(); String page = "" @@ -132,6 +135,22 @@ void WebUI::begin(Settings& settings, "" + "

Sleep Schedule

" + "" + "" + "
FacePlant starts falling asleep 5 minutes before bedtime
" + "" + "" + "
FacePlant shows wake-up animation for 5 minutes after this time
" + "" + "" + "
Default is America/New_York
" "
" "" "
" @@ -235,6 +254,9 @@ void WebUI::begin(Settings& settings, settings.setKidsMode(_server.hasArg("kids")); settings.setWebhookEnabled(_server.hasArg("wh_en")); if (_server.hasArg("wh")) settings.setWebhookUrl(_server.arg("wh")); + if (_server.hasArg("tz")) settings.setTimezone(_server.arg("tz")); + if (_server.hasArg("bed")) settings.setBedtime(_server.arg("bed")); + if (_server.hasArg("wake")) settings.setWakeTime(_server.arg("wake")); _server.sendHeader("Location", "/"); _server.send(303); }); @@ -302,6 +324,9 @@ void WebUI::begin(Settings& settings, doc["moisture_pct"] = moisture.percent(); doc["raw"] = moisture.raw(); doc["kids_mode"] = settings.kidsMode(); + doc["timezone"] = settings.timezone(); + doc["bedtime"] = settings.bedtime(); + doc["wake_time"] = settings.wakeTime(); doc["dead_mode"] = face.isDeadMode(); doc["uptime_ms"] = millis() - bootMs; diff --git a/src/sensors/MotionSensor.cpp b/src/sensors/MotionSensor.cpp new file mode 100644 index 0000000..c4f3669 --- /dev/null +++ b/src/sensors/MotionSensor.cpp @@ -0,0 +1,85 @@ +#include "MotionSensor.h" +#include + +static constexpr float GRAVITY_MS2 = 9.80665f; + +float MotionSensor::clampf(float v, float lo, float hi) { + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +void MotionSensor::begin() { + _ok = _mpu.begin(); + if (!_ok) { + Serial.println("[Motion] MPU6050 not detected"); + return; + } + + _mpu.setAccelerometerRange(MPU6050_RANGE_4_G); + _mpu.setGyroRange(MPU6050_RANGE_250_DEG); + _mpu.setFilterBandwidth(MPU6050_BAND_21_HZ); + + _nextMs = millis(); + Serial.println("[Motion] MPU6050 ready"); +} + +void MotionSensor::loop() { + if (!_ok) return; + + unsigned long now = millis(); + if ((long)(now - _nextMs) < 0) return; + _nextMs = now + INTERVAL_MS; + + sensors_event_t a, g, t; + _mpu.getEvent(&a, &g, &t); + + float ax = a.acceleration.x / GRAVITY_MS2; + float ay = a.acceleration.y / GRAVITY_MS2; + float az = a.acceleration.z / GRAVITY_MS2; + + _accelMagG = sqrtf(ax * ax + ay * ay + az * az); + + // Orientation estimated from gravity vector. Axis sign may need tuning based on physical mounting. + float newRoll = atan2f(ax, az) * 57.29578f; + float newPitch = atan2f(ay, az) * 57.29578f; + + float dRoll = fabsf(newRoll - _rollDeg); + float dPitch = fabsf(newPitch - _pitchDeg); + float dMag = fabsf(_accelMagG - 1.0f); + + _lastRollDeg = _rollDeg; + _lastPitchDeg = _pitchDeg; + _rollDeg = (_rollDeg * 0.75f) + (newRoll * 0.25f); + _pitchDeg = (_pitchDeg * 0.75f) + (newPitch * 0.25f); + + bool movingNow = (dRoll > 2.0f) || (dPitch > 2.0f) || (dMag > 0.10f); + if (movingNow) _movingUntilMs = now + MOVE_HOLD_MS; + + // Heuristic pickup event: stronger motion / lift / rotation burst. + bool pickup = (dRoll > 10.0f) || (dPitch > 10.0f) || (dMag > 0.22f) || (fabsf(g.gyro.x) > 0.8f) || (fabsf(g.gyro.y) > 0.8f); + if (pickup) _pickupLatched = true; +} + +bool MotionSensor::isMoving() const { + return _ok && ((long)(millis() - _movingUntilMs) < 0); +} + +bool MotionSensor::consumePickupEvent() { + bool out = _pickupLatched; + _pickupLatched = false; + return out; +} + +int8_t MotionSensor::eyeOffsetX() const { + if (!_ok) return 0; + float roll = clampf(_rollDeg, -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); + 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 new file mode 100644 index 0000000..ac625ee --- /dev/null +++ b/src/sensors/MotionSensor.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +class MotionSensor { +public: + void begin(); + void loop(); + + bool available() const { return _ok; } + bool isMoving() const; + bool consumePickupEvent(); + + float rollDeg() const { return _rollDeg; } // left/right tilt + float pitchDeg() const { return _pitchDeg; } // forward/back tilt + + int8_t eyeOffsetX() const; + int8_t pupilSizeDelta() const; + +private: + static constexpr unsigned long INTERVAL_MS = 50; + static constexpr unsigned long MOVE_HOLD_MS = 1200; + + Adafruit_MPU6050 _mpu; + bool _ok = false; + unsigned long _nextMs = 0; + + float _rollDeg = 0.0f; + float _pitchDeg = 0.0f; + float _lastRollDeg = 0.0f; + float _lastPitchDeg = 0.0f; + float _accelMagG = 1.0f; + + unsigned long _movingUntilMs = 0; + bool _pickupLatched = false; + + static float clampf(float v, float lo, float hi); +}; diff --git a/src/settings/Settings.cpp b/src/settings/Settings.cpp index 101d914..b0f7923 100644 --- a/src/settings/Settings.cpp +++ b/src/settings/Settings.cpp @@ -9,6 +9,14 @@ static PlantThresholds thresholdsForProfile(const String& key) { return {35, 42, 85}; // houseplant default } +static bool isValidHHMM(const String& s) { + if (s.length() != 5 || s.charAt(2) != ':') return false; + if (!isDigit(s.charAt(0)) || !isDigit(s.charAt(1)) || !isDigit(s.charAt(3)) || !isDigit(s.charAt(4))) return false; + int h = (s.charAt(0) - '0') * 10 + (s.charAt(1) - '0'); + int m = (s.charAt(3) - '0') * 10 + (s.charAt(4) - '0'); + return h >= 0 && h <= 23 && m >= 0 && m <= 59; +} + void Settings::begin() { _prefs.begin("faceplant", false); } @@ -45,6 +53,24 @@ void Settings::setWebhookEnabled(bool v) { _prefs.putBool("wh_en", v); } String Settings::webhookUrl() const { return _prefs.getString("wh_url", ""); } void Settings::setWebhookUrl(const String& url) { _prefs.putString("wh_url", url); } +String Settings::timezone() const { return _prefs.getString("tz", String(PB_TZ)); } +void Settings::setTimezone(const String& tz) { + String v = tz; + v.trim(); + if (v.length() == 0) v = String(PB_TZ); + _prefs.putString("tz", v); +} + +String Settings::bedtime() const { return _prefs.getString("bed", "22:00"); } +void Settings::setBedtime(const String& hhmm) { + _prefs.putString("bed", isValidHHMM(hhmm) ? hhmm : "22:00"); +} + +String Settings::wakeTime() const { return _prefs.getString("wake", "07:00"); } +void Settings::setWakeTime(const String& hhmm) { + _prefs.putString("wake", isValidHHMM(hhmm) ? hhmm : "07:00"); +} + 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 1657615..d64920d 100644 --- a/src/settings/Settings.h +++ b/src/settings/Settings.h @@ -43,6 +43,14 @@ public: String webhookUrl() const; void setWebhookUrl(const String& url); + // Daily routine schedule + String timezone() const; + void setTimezone(const String& tz); + String bedtime() const; // "HH:MM" + void setBedtime(const String& hhmm); + String wakeTime() const; // "HH:MM" + void setWakeTime(const String& hhmm); + // Factory reset void factoryReset(); diff --git a/src/ui/FaceRenderer.cpp b/src/ui/FaceRenderer.cpp index 9e73a1c..43ce4eb 100644 --- a/src/ui/FaceRenderer.cpp +++ b/src/ui/FaceRenderer.cpp @@ -21,6 +21,16 @@ void FaceRenderer::setRoutineAnimation(RoutineAnim anim, uint16_t progressPermil _routineProgressPermille = progressPermille > 1000 ? 1000 : progressPermille; } +void FaceRenderer::setTiltEffects(int8_t eyeDx, int8_t pupilSizeDelta) { + _tiltEyeDx = clampInt(eyeDx, -8, 8); + _tiltPupilSizeDelta = clampInt(pupilSizeDelta, -4, 4); +} + +void FaceRenderer::triggerSurprised(unsigned long durationMs) { + unsigned long now = millis(); + _surprisedUntilMs = now + durationMs; +} + void FaceRenderer::begin(Display& display, Settings& settings) { _display = &display; _settings = &settings; @@ -48,6 +58,11 @@ void FaceRenderer::loop(const MoistureSensor& moisture, const BatterySensor& bat return; } + if ((long)(now - _surprisedUntilMs) < 0) { + renderSurprised(now, battery); + return; + } + if (_deadMode) { renderDead(now, battery); return; @@ -221,20 +236,20 @@ void FaceRenderer::renderNormal(unsigned long now, const BatterySensor& battery) if (_mood == HAPPY) { if (_silly) drawSilly(_sillyVariant); else if (_blinking) drawEyesClosed(); - else drawEyesOpen(_gazeX, _gazeY); + else drawEyesOpen(_gazeX, _gazeY, 4); drawMouthHappy(); drawSparkles(now); } else if (_mood == DRY) { if (_blinking) drawEyesClosed(); - else drawEyesSmallPupils(6, 0, 0); + else drawEyesSmallPupils(6, 0, 0, 2); drawBrowsVerySad(); drawMouthFrown(); drawBigWaterText(now); } else { if (_blinking) drawEyesClosed(); - else drawEyesOpen(0, 0); + else drawEyesOpen(0, 0, 4); drawMouthFlat(); drawBubbles((now / 100) % 12 - 6); } @@ -294,14 +309,32 @@ void FaceRenderer::renderRoutine(unsigned long now, const BatterySensor& battery d.display(); } +void FaceRenderer::renderSurprised(unsigned long now, const BatterySensor& battery) { + auto &d = _display->oled(); + d.clearDisplay(); + + 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); + drawMouthSurprised(); + + _display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink(), battery.isCharging()); + d.display(); +} + /* --- Drawing helpers --- */ -void FaceRenderer::drawEyesOpen(int dx, int dy) { +void FaceRenderer::drawEyesOpen(int dx, int dy, int pupilRadius) { auto &d = _display->oled(); - d.fillCircle(40, 24, 12, 1); - d.fillCircle(88, 24, 12, 1); - d.fillCircle(40 + dx, 24 + dy, 4, 0); - d.fillCircle(88 + dx, 24 + dy, 4, 0); + 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); } void FaceRenderer::drawEyesClosed() { @@ -310,12 +343,16 @@ void FaceRenderer::drawEyesClosed() { d.drawLine(76, 24, 100, 24, 1); } -void FaceRenderer::drawEyesSmallPupils(int dy, int sx, int sy) { +void FaceRenderer::drawEyesSmallPupils(int dy, int sx, int sy, int pupilRadius) { auto &d = _display->oled(); - d.fillCircle(40, 24, 12, 1); - d.fillCircle(88, 24, 12, 1); - d.fillCircle(40 + sx, 24 + dy, 2, 0); - d.fillCircle(88 + sx, 24 + dy, 2, 0); + 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); } void FaceRenderer::drawBrowsVerySad() { @@ -359,6 +396,12 @@ void FaceRenderer::drawMouthNervous() { d.drawLine(70, 47, 76, 48, 1); } +void FaceRenderer::drawMouthSurprised() { + auto &d = _display->oled(); + d.drawCircle(64, 48, 5, 1); + d.drawCircle(64, 48, 4, 1); +} + void FaceRenderer::drawBubbles(int off) { auto &d = _display->oled(); d.drawCircle(10 + off, 12, 4, 1); diff --git a/src/ui/FaceRenderer.h b/src/ui/FaceRenderer.h index 203c953..41e5941 100644 --- a/src/ui/FaceRenderer.h +++ b/src/ui/FaceRenderer.h @@ -13,6 +13,8 @@ public: void begin(Display& display, Settings& settings); void loop(const MoistureSensor& moisture, const BatterySensor& battery); void setRoutineAnimation(RoutineAnim anim, uint16_t progressPermille); + void setTiltEffects(int8_t eyeDx, int8_t pupilSizeDelta); + void triggerSurprised(unsigned long durationMs = 1500); bool isDeadMode() const { return _deadMode; } Mood mood() const { return _mood; } @@ -55,6 +57,9 @@ private: RoutineAnim _routineAnim = ROUTINE_NONE; uint16_t _routineProgressPermille = 0; + int8_t _tiltEyeDx = 0; + int8_t _tiltPupilSizeDelta = 0; + unsigned long _surprisedUntilMs = 0; void updateMood(int moisturePct); void updateDeathMode(unsigned long now); @@ -67,16 +72,18 @@ private: void renderBatteryLow(unsigned long now, const BatterySensor& battery); void renderNormal(unsigned long now, const BatterySensor& battery); void renderRoutine(unsigned long now, const BatterySensor& battery); + void renderSurprised(unsigned long now, const BatterySensor& battery); - void drawEyesOpen(int pupilDx, int pupilDy); + void drawEyesOpen(int pupilDx, int pupilDy, int pupilRadius = 4); void drawEyesClosed(); - void drawEyesSmallPupils(int pupilDy, int sx, int sy); + void drawEyesSmallPupils(int pupilDy, int sx, int sy, int pupilRadius = 2); void drawBrowsVerySad(); void drawBrowsWorried(); void drawMouthHappy(); void drawMouthFrown(); void drawMouthFlat(); void drawMouthNervous(); + void drawMouthSurprised(); void drawDroplet(int x, int y, bool pulse); void drawBubbles(int off); void drawSparkles(unsigned long now);