Add Motion Sensor functionality and integrate timezone, bedtime, and wake time settings
This commit is contained in:
@@ -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\"
|
||||||
|
|||||||
121
src/app/App.cpp
121
src/app/App.cpp
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
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
|
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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user