Add Motion Sensor functionality and integrate timezone, bedtime, and wake time settings

This commit is contained in:
Joshua King
2026-02-21 20:25:30 -05:00
parent 192e657b07
commit 63061bdab2
9 changed files with 346 additions and 42 deletions

View File

@@ -9,6 +9,7 @@ lib_deps =
adafruit/Adafruit GFX Library adafruit/Adafruit GFX Library
adafruit/Adafruit SSD1306 adafruit/Adafruit SSD1306
adafruit/Adafruit VEML7700 Library adafruit/Adafruit VEML7700 Library
adafruit/Adafruit MPU6050
bblanchon/ArduinoJson bblanchon/ArduinoJson
; board_build.filesystem = spiffs ; board_build.filesystem = spiffs
@@ -17,4 +18,4 @@ lib_deps =
build_flags = build_flags =
-D PB_VERSION=\"1.0.0\" -D PB_VERSION=\"1.0.0\"
-D PB_HOSTNAME=\"faceplant\" -D PB_HOSTNAME=\"faceplant\"
-D PB_TZ=\"UTC0\" -D PB_TZ=\"America/New_York\"

View File

@@ -13,6 +13,7 @@
#include "../sensors/MoistureSensor.h" #include "../sensors/MoistureSensor.h"
#include "../sensors/BatterySensor.h" #include "../sensors/BatterySensor.h"
#include "../sensors/AmbientLightSensor.h" #include "../sensors/AmbientLightSensor.h"
#include "../sensors/MotionSensor.h"
#include "../ui/FaceRenderer.h" #include "../ui/FaceRenderer.h"
#include <time.h> #include <time.h>
@@ -26,6 +27,7 @@ static Display display;
static MoistureSensor moisture; static MoistureSensor moisture;
static BatterySensor battery; static BatterySensor battery;
static AmbientLightSensor ambient; static AmbientLightSensor ambient;
static MotionSensor motion;
static FaceRenderer face; static FaceRenderer face;
static unsigned long bootMs = 0; static unsigned long bootMs = 0;
@@ -38,16 +40,15 @@ static bool ntpConfigured = false;
static bool timeValid = false; static bool timeValid = false;
static unsigned long nextTimeCheckMs = 0; static unsigned long nextTimeCheckMs = 0;
static bool lastScheduleSleep = false; 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_MIN = 0.0f;
static constexpr float DIM_LUX_MAX = 300.0f; static constexpr float DIM_LUX_MAX = 300.0f;
static constexpr uint8_t DIM_CONTRAST_MIN = 0x10; static constexpr uint8_t DIM_CONTRAST_MIN = 0x10;
static constexpr uint8_t DIM_CONTRAST_MAX = 0xFF; 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 int ROUTINE_WINDOW_MIN = 5;
static constexpr unsigned long MOTION_WAKE_MS = 30000UL;
static PlantEventType moodToEvent(FaceRenderer::Mood m) { static PlantEventType moodToEvent(FaceRenderer::Mood m) {
if (m == FaceRenderer::DRY) return EVT_DRY; 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); return (sec >= startSec) || (sec < endSec);
} }
static void maybeInitTimeSync(bool wifiConnected) { static int wrapDaySeconds(int sec) {
if (!wifiConnected || ntpConfigured) return; const int day = 24 * 3600;
configTzTime(PB_TZ, "pool.ntp.org", "time.nist.gov"); 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; ntpConfigured = true;
Serial.print("[Clock] NTP started, TZ="); lastConfiguredTz = tzForEsp;
Serial.println(PB_TZ); Serial.print("[Clock] NTP started/reconfigured, TZ=");
Serial.println(tz);
} }
static ScheduleState currentScheduleState() { static ScheduleState currentScheduleState() {
@@ -108,14 +141,24 @@ static ScheduleState currentScheduleState() {
s.hasTime = true; s.hasTime = true;
const int sec = secondsOfDay(local); const int sec = secondsOfDay(local);
const int bedSec = BED_HOUR * 3600 + BED_MINUTE * 60; int bedHour = 22, bedMinute = 0;
const int wakeSec = WAKE_HOUR * 3600 + WAKE_MINUTE * 60; int wakeHour = 7, wakeMinute = 0;
const int windDownStartSec = bedSec - ROUTINE_WINDOW_MIN * 60; parseClockHHMM(settings.bedtime(), bedHour, bedMinute);
const int wakeAnimEndSec = wakeSec + ROUTINE_WINDOW_MIN * 60; 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.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; return s;
} }
@@ -124,9 +167,14 @@ static ScheduleState currentScheduleState() {
return s; 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.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; return s;
} }
@@ -137,7 +185,7 @@ static void updateScheduleState() {
if ((long)(millis() - nextTimeCheckMs) < 0) return; if ((long)(millis() - nextTimeCheckMs) < 0) return;
nextTimeCheckMs = millis() + 1000; nextTimeCheckMs = millis() + 1000;
maybeInitTimeSync(wifi.connected()); ensureTimeSyncConfigured(wifi.connected());
ScheduleState sched = currentScheduleState(); ScheduleState sched = currentScheduleState();
timeValid = sched.hasTime; timeValid = sched.hasTime;
@@ -152,12 +200,7 @@ static void updateScheduleState() {
lastScheduleSleep = shouldSleep; lastScheduleSleep = shouldSleep;
} }
bool wasNightMode = isNightMode;
isNightMode = shouldSleep; isNightMode = shouldSleep;
if (display.ok()) {
display.setDisplayEnabled(!isNightMode);
if (wasNightMode && !isNightMode) lastContrast = 0xFF; // force contrast refresh when waking
}
} }
static void updateAmbientDimming() { static void updateAmbientDimming() {
@@ -197,6 +240,7 @@ void App::setup() {
moisture.begin(settings); moisture.begin(settings);
battery.begin(); battery.begin();
ambient.begin(); ambient.begin();
motion.begin();
face.begin(display, settings); face.begin(display, settings);
webhook.begin(settings); webhook.begin(settings);
@@ -218,7 +262,32 @@ void App::loop() {
wifi.loop(); wifi.loop();
web.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(); 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(); updateAmbientDimming();
// Show WiFi status on display during connection attempts // Show WiFi status on display during connection attempts
@@ -226,13 +295,13 @@ void App::loop() {
if (currentConnected != lastConnected) { if (currentConnected != lastConnected) {
if (currentConnected) { if (currentConnected) {
Serial.println("[App] WiFi connected - showing on display"); Serial.println("[App] WiFi connected - showing on display");
if (!isNightMode) { if (!displaySleeping) {
display.showStatus("WiFi Connected!", wifi.ssid().c_str()); display.showStatus("WiFi Connected!", wifi.ssid().c_str());
delay(2000); delay(2000);
} }
} else if (wifi.mode() == NET_STA) { } else if (wifi.mode() == NET_STA) {
Serial.println("[App] WiFi disconnected"); Serial.println("[App] WiFi disconnected");
if (!isNightMode && millis() - lastDisplayUpdate > 5000) { if (!displaySleeping && millis() - lastDisplayUpdate > 5000) {
display.showStatus("WiFi", "Connecting..."); display.showStatus("WiFi", "Connecting...");
lastDisplayUpdate = millis(); lastDisplayUpdate = millis();
} }
@@ -242,7 +311,7 @@ void App::loop() {
moisture.loop(); moisture.loop();
battery.loop(); battery.loop();
if (!isNightMode) { if (!displaySleeping) {
face.loop(moisture, battery); face.loop(moisture, battery);
} }

View File

@@ -20,6 +20,9 @@ void WebUI::begin(Settings& settings,
String wifiSsid = settings.wifiSsid(); String wifiSsid = settings.wifiSsid();
String currentSsid = wifi.ssid(); String currentSsid = wifi.ssid();
String plantProfile = settings.plantProfile(); String plantProfile = settings.plantProfile();
String timezone = settings.timezone();
String bedtime = settings.bedtime();
String wakeTime = settings.wakeTime();
String page = String page =
"<!DOCTYPE html><html><head>" "<!DOCTYPE html><html><head>"
@@ -132,6 +135,22 @@ void WebUI::begin(Settings& settings,
"<label class='checkbox-label'>" "<label class='checkbox-label'>"
"<input type='checkbox' name='wh_en' " + String(settings.webhookEnabled() ? "checked" : "") + "> Enable Webhook" "<input type='checkbox' name='wh_en' " + String(settings.webhookEnabled() ? "checked" : "") + "> Enable Webhook"
"</label>" "</label>"
"<h2><i class='fas fa-clock' style='margin-right: 10px;'></i>Sleep Schedule</h2>"
"<label for='bed'>Bedtime</label>"
"<input type='time' id='bed' name='bed' value='" + bedtime + "' required>"
"<div class='form-hint'>FacePlant starts falling asleep 5 minutes before bedtime</div>"
"<label for='wake'>Wake Time</label>"
"<input type='time' id='wake' name='wake' value='" + wakeTime + "' required>"
"<div class='form-hint'>FacePlant shows wake-up animation for 5 minutes after this time</div>"
"<label for='tz'>Timezone</label>"
"<select id='tz' name='tz'>"
"<option value='America/New_York'" + String(timezone == "America/New_York" ? " selected" : "") + ">America/New_York (Eastern)</option>"
"<option value='America/Chicago'" + String(timezone == "America/Chicago" ? " selected" : "") + ">America/Chicago (Central)</option>"
"<option value='America/Denver'" + String(timezone == "America/Denver" ? " selected" : "") + ">America/Denver (Mountain)</option>"
"<option value='America/Los_Angeles'" + String(timezone == "America/Los_Angeles" ? " selected" : "") + ">America/Los_Angeles (Pacific)</option>"
"<option value='UTC0'" + String(timezone == "UTC0" ? " selected" : "") + ">UTC</option>"
"</select>"
"<div class='form-hint'>Default is America/New_York</div>"
"<div class='btn-group'>" "<div class='btn-group'>"
"<button type='submit' class='btn btn-primary'>Save Settings</button>" "<button type='submit' class='btn btn-primary'>Save Settings</button>"
"</div>" "</div>"
@@ -235,6 +254,9 @@ void WebUI::begin(Settings& settings,
settings.setKidsMode(_server.hasArg("kids")); settings.setKidsMode(_server.hasArg("kids"));
settings.setWebhookEnabled(_server.hasArg("wh_en")); settings.setWebhookEnabled(_server.hasArg("wh_en"));
if (_server.hasArg("wh")) settings.setWebhookUrl(_server.arg("wh")); 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.sendHeader("Location", "/");
_server.send(303); _server.send(303);
}); });
@@ -302,6 +324,9 @@ void WebUI::begin(Settings& settings,
doc["moisture_pct"] = moisture.percent(); doc["moisture_pct"] = moisture.percent();
doc["raw"] = moisture.raw(); doc["raw"] = moisture.raw();
doc["kids_mode"] = settings.kidsMode(); doc["kids_mode"] = settings.kidsMode();
doc["timezone"] = settings.timezone();
doc["bedtime"] = settings.bedtime();
doc["wake_time"] = settings.wakeTime();
doc["dead_mode"] = face.isDeadMode(); doc["dead_mode"] = face.isDeadMode();
doc["uptime_ms"] = millis() - bootMs; doc["uptime_ms"] = millis() - bootMs;

View File

@@ -0,0 +1,85 @@
#include "MotionSensor.h"
#include <math.h>
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)
}

View File

@@ -0,0 +1,40 @@
#pragma once
#include <Arduino.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>
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);
};

View File

@@ -9,6 +9,14 @@ static PlantThresholds thresholdsForProfile(const String& key) {
return {35, 42, 85}; // houseplant default 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() { void Settings::begin() {
_prefs.begin("faceplant", false); _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", ""); } String Settings::webhookUrl() const { return _prefs.getString("wh_url", ""); }
void Settings::setWebhookUrl(const String& url) { _prefs.putString("wh_url", 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() { void Settings::factoryReset() {
Serial.println("[Settings] Factory reset - clearing all settings"); Serial.println("[Settings] Factory reset - clearing all settings");
_prefs.clear(); _prefs.clear();

View File

@@ -43,6 +43,14 @@ public:
String webhookUrl() const; String webhookUrl() const;
void setWebhookUrl(const String& url); 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 // Factory reset
void factoryReset(); void factoryReset();

View File

@@ -21,6 +21,16 @@ void FaceRenderer::setRoutineAnimation(RoutineAnim anim, uint16_t progressPermil
_routineProgressPermille = progressPermille > 1000 ? 1000 : progressPermille; _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) { void FaceRenderer::begin(Display& display, Settings& settings) {
_display = &display; _display = &display;
_settings = &settings; _settings = &settings;
@@ -48,6 +58,11 @@ void FaceRenderer::loop(const MoistureSensor& moisture, const BatterySensor& bat
return; return;
} }
if ((long)(now - _surprisedUntilMs) < 0) {
renderSurprised(now, battery);
return;
}
if (_deadMode) { if (_deadMode) {
renderDead(now, battery); renderDead(now, battery);
return; return;
@@ -221,20 +236,20 @@ void FaceRenderer::renderNormal(unsigned long now, const BatterySensor& battery)
if (_mood == HAPPY) { if (_mood == HAPPY) {
if (_silly) drawSilly(_sillyVariant); if (_silly) drawSilly(_sillyVariant);
else if (_blinking) drawEyesClosed(); else if (_blinking) drawEyesClosed();
else drawEyesOpen(_gazeX, _gazeY); else drawEyesOpen(_gazeX, _gazeY, 4);
drawMouthHappy(); drawMouthHappy();
drawSparkles(now); drawSparkles(now);
} }
else if (_mood == DRY) { else if (_mood == DRY) {
if (_blinking) drawEyesClosed(); if (_blinking) drawEyesClosed();
else drawEyesSmallPupils(6, 0, 0); else drawEyesSmallPupils(6, 0, 0, 2);
drawBrowsVerySad(); drawBrowsVerySad();
drawMouthFrown(); drawMouthFrown();
drawBigWaterText(now); drawBigWaterText(now);
} }
else { else {
if (_blinking) drawEyesClosed(); if (_blinking) drawEyesClosed();
else drawEyesOpen(0, 0); else drawEyesOpen(0, 0, 4);
drawMouthFlat(); drawMouthFlat();
drawBubbles((now / 100) % 12 - 6); drawBubbles((now / 100) % 12 - 6);
} }
@@ -294,14 +309,32 @@ void FaceRenderer::renderRoutine(unsigned long now, const BatterySensor& battery
d.display(); 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 --- */ /* --- Drawing helpers --- */
void FaceRenderer::drawEyesOpen(int dx, int dy) { void FaceRenderer::drawEyesOpen(int dx, int dy, int pupilRadius) {
auto &d = _display->oled(); auto &d = _display->oled();
d.fillCircle(40, 24, 12, 1); int eyeR = clampInt(12 + _tiltPupilSizeDelta, 6, 18);
d.fillCircle(88, 24, 12, 1); int pupilR = clampInt(pupilRadius + (_tiltPupilSizeDelta / 2), 1, eyeR - 2);
d.fillCircle(40 + dx, 24 + dy, 4, 0); int leftCx = 40 + _tiltEyeDx;
d.fillCircle(88 + dx, 24 + dy, 4, 0); 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() { void FaceRenderer::drawEyesClosed() {
@@ -310,12 +343,16 @@ void FaceRenderer::drawEyesClosed() {
d.drawLine(76, 24, 100, 24, 1); 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(); auto &d = _display->oled();
d.fillCircle(40, 24, 12, 1); int eyeR = clampInt(12 + _tiltPupilSizeDelta, 6, 18);
d.fillCircle(88, 24, 12, 1); int r = clampInt(pupilRadius + (_tiltPupilSizeDelta / 2), 1, 7);
d.fillCircle(40 + sx, 24 + dy, 2, 0); int leftCx = 40 + _tiltEyeDx;
d.fillCircle(88 + sx, 24 + dy, 2, 0); 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() { void FaceRenderer::drawBrowsVerySad() {
@@ -359,6 +396,12 @@ void FaceRenderer::drawMouthNervous() {
d.drawLine(70, 47, 76, 48, 1); 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) { void FaceRenderer::drawBubbles(int off) {
auto &d = _display->oled(); auto &d = _display->oled();
d.drawCircle(10 + off, 12, 4, 1); d.drawCircle(10 + off, 12, 4, 1);

View File

@@ -13,6 +13,8 @@ public:
void begin(Display& display, Settings& settings); void begin(Display& display, Settings& settings);
void loop(const MoistureSensor& moisture, const BatterySensor& battery); void loop(const MoistureSensor& moisture, const BatterySensor& battery);
void setRoutineAnimation(RoutineAnim anim, uint16_t progressPermille); 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; } bool isDeadMode() const { return _deadMode; }
Mood mood() const { return _mood; } Mood mood() const { return _mood; }
@@ -55,6 +57,9 @@ private:
RoutineAnim _routineAnim = ROUTINE_NONE; RoutineAnim _routineAnim = ROUTINE_NONE;
uint16_t _routineProgressPermille = 0; uint16_t _routineProgressPermille = 0;
int8_t _tiltEyeDx = 0;
int8_t _tiltPupilSizeDelta = 0;
unsigned long _surprisedUntilMs = 0;
void updateMood(int moisturePct); void updateMood(int moisturePct);
void updateDeathMode(unsigned long now); void updateDeathMode(unsigned long now);
@@ -67,16 +72,18 @@ private:
void renderBatteryLow(unsigned long now, const BatterySensor& battery); void renderBatteryLow(unsigned long now, const BatterySensor& battery);
void renderNormal(unsigned long now, const BatterySensor& battery); void renderNormal(unsigned long now, const BatterySensor& battery);
void renderRoutine(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 drawEyesClosed();
void drawEyesSmallPupils(int pupilDy, int sx, int sy); void drawEyesSmallPupils(int pupilDy, int sx, int sy, int pupilRadius = 2);
void drawBrowsVerySad(); void drawBrowsVerySad();
void drawBrowsWorried(); void drawBrowsWorried();
void drawMouthHappy(); void drawMouthHappy();
void drawMouthFrown(); void drawMouthFrown();
void drawMouthFlat(); void drawMouthFlat();
void drawMouthNervous(); void drawMouthNervous();
void drawMouthSurprised();
void drawDroplet(int x, int y, bool pulse); void drawDroplet(int x, int y, bool pulse);
void drawBubbles(int off); void drawBubbles(int off);
void drawSparkles(unsigned long now); void drawSparkles(unsigned long now);