Add Motion Sensor functionality and integrate timezone, bedtime, and wake time settings
This commit is contained in:
121
src/app/App.cpp
121
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 <time.h>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =
|
||||
"<!DOCTYPE html><html><head>"
|
||||
@@ -132,6 +135,22 @@ void WebUI::begin(Settings& settings,
|
||||
"<label class='checkbox-label'>"
|
||||
"<input type='checkbox' name='wh_en' " + String(settings.webhookEnabled() ? "checked" : "") + "> Enable Webhook"
|
||||
"</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'>"
|
||||
"<button type='submit' class='btn btn-primary'>Save Settings</button>"
|
||||
"</div>"
|
||||
@@ -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;
|
||||
|
||||
|
||||
85
src/sensors/MotionSensor.cpp
Normal file
85
src/sensors/MotionSensor.cpp
Normal 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)
|
||||
}
|
||||
40
src/sensors/MotionSensor.h
Normal file
40
src/sensors/MotionSensor.h
Normal 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);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user