2026-02-09 11:41:12 -05:00
|
|
|
#include "App.h"
|
|
|
|
|
|
|
|
|
|
#include "../util/Version.h"
|
|
|
|
|
#include "../util/BootTrigger.h"
|
|
|
|
|
|
|
|
|
|
#include "../settings/Settings.h"
|
|
|
|
|
#include "../net/WiFiManager.h"
|
|
|
|
|
#include "../net/WebUI.h"
|
|
|
|
|
#include "../net/OtaService.h"
|
|
|
|
|
#include "../net/WebhookService.h"
|
|
|
|
|
|
|
|
|
|
#include "../ui/Display.h"
|
|
|
|
|
#include "../sensors/MoistureSensor.h"
|
2026-02-10 17:05:42 -05:00
|
|
|
#include "../sensors/BatterySensor.h"
|
2026-02-21 20:09:41 -05:00
|
|
|
#include "../sensors/AmbientLightSensor.h"
|
2026-02-21 20:25:30 -05:00
|
|
|
#include "../sensors/MotionSensor.h"
|
2026-02-09 11:41:12 -05:00
|
|
|
#include "../ui/FaceRenderer.h"
|
2026-02-21 20:09:41 -05:00
|
|
|
#include <time.h>
|
2026-02-09 11:41:12 -05:00
|
|
|
|
|
|
|
|
static Settings settings;
|
|
|
|
|
static WiFiManager wifi;
|
|
|
|
|
static WebUI web;
|
|
|
|
|
static OtaService ota;
|
|
|
|
|
static WebhookService webhook;
|
|
|
|
|
|
|
|
|
|
static Display display;
|
|
|
|
|
static MoistureSensor moisture;
|
2026-02-10 17:05:42 -05:00
|
|
|
static BatterySensor battery;
|
2026-02-21 20:09:41 -05:00
|
|
|
static AmbientLightSensor ambient;
|
2026-02-21 20:25:30 -05:00
|
|
|
static MotionSensor motion;
|
2026-02-09 11:41:12 -05:00
|
|
|
static FaceRenderer face;
|
|
|
|
|
|
|
|
|
|
static unsigned long bootMs = 0;
|
|
|
|
|
|
|
|
|
|
static FaceRenderer::Mood lastMood = FaceRenderer::HAPPY;
|
|
|
|
|
static bool lastDead = false;
|
2026-02-21 20:09:41 -05:00
|
|
|
static bool isNightMode = false;
|
|
|
|
|
static uint8_t lastContrast = 0xFF;
|
|
|
|
|
static bool ntpConfigured = false;
|
|
|
|
|
static bool timeValid = false;
|
|
|
|
|
static unsigned long nextTimeCheckMs = 0;
|
|
|
|
|
static bool lastScheduleSleep = false;
|
2026-02-21 20:25:30 -05:00
|
|
|
static String lastConfiguredTz;
|
|
|
|
|
static unsigned long motionWakeUntilMs = 0;
|
2026-02-21 20:09:41 -05:00
|
|
|
|
|
|
|
|
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 ROUTINE_WINDOW_MIN = 5;
|
2026-02-21 20:25:30 -05:00
|
|
|
static constexpr unsigned long MOTION_WAKE_MS = 30000UL;
|
2026-02-09 11:41:12 -05:00
|
|
|
|
|
|
|
|
static PlantEventType moodToEvent(FaceRenderer::Mood m) {
|
|
|
|
|
if (m == FaceRenderer::DRY) return EVT_DRY;
|
|
|
|
|
if (m == FaceRenderer::TOO_WET) return EVT_TOO_WET;
|
|
|
|
|
return EVT_OK;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 20:09:41 -05:00
|
|
|
static float clampFloat(float v, float lo, float hi) {
|
|
|
|
|
if (v < lo) return lo;
|
|
|
|
|
if (v > hi) return hi;
|
|
|
|
|
return v;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static uint8_t luxToContrast(float lux) {
|
|
|
|
|
float clamped = clampFloat(lux, DIM_LUX_MIN, DIM_LUX_MAX);
|
|
|
|
|
float ratio = (DIM_LUX_MAX <= DIM_LUX_MIN) ? 1.0f
|
|
|
|
|
: (clamped - DIM_LUX_MIN) / (DIM_LUX_MAX - DIM_LUX_MIN);
|
|
|
|
|
int value = (int)(DIM_CONTRAST_MIN + ratio * (DIM_CONTRAST_MAX - DIM_CONTRAST_MIN));
|
|
|
|
|
if (value < 0) value = 0;
|
|
|
|
|
if (value > 255) value = 255;
|
|
|
|
|
return (uint8_t)value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct ScheduleState {
|
|
|
|
|
bool hasTime = false;
|
|
|
|
|
bool sleeping = false;
|
|
|
|
|
FaceRenderer::RoutineAnim routineAnim = FaceRenderer::ROUTINE_NONE;
|
|
|
|
|
uint16_t routineProgressPermille = 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static int secondsOfDay(const tm& local) {
|
|
|
|
|
return local.tm_hour * 3600 + local.tm_min * 60 + local.tm_sec;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static bool isInRangeSameDay(int sec, int startSec, int endSec) {
|
|
|
|
|
return sec >= startSec && sec < endSec;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static bool isInOvernightRange(int sec, int startSec, int endSec) {
|
|
|
|
|
return (sec >= startSec) || (sec < endSec);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 20:25:30 -05:00
|
|
|
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");
|
2026-02-21 20:09:41 -05:00
|
|
|
ntpConfigured = true;
|
2026-02-21 20:25:30 -05:00
|
|
|
lastConfiguredTz = tzForEsp;
|
|
|
|
|
Serial.print("[Clock] NTP started/reconfigured, TZ=");
|
|
|
|
|
Serial.println(tz);
|
2026-02-21 20:09:41 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static ScheduleState currentScheduleState() {
|
|
|
|
|
ScheduleState s;
|
|
|
|
|
time_t nowEpoch = time(nullptr);
|
|
|
|
|
if (nowEpoch < 1700000000) return s; // not synced yet
|
|
|
|
|
|
|
|
|
|
tm local {};
|
|
|
|
|
if (!localtime_r(&nowEpoch, &local)) return s;
|
|
|
|
|
s.hasTime = true;
|
|
|
|
|
|
|
|
|
|
const int sec = secondsOfDay(local);
|
2026-02-21 20:25:30 -05:00
|
|
|
int bedHour = 22, bedMinute = 0;
|
|
|
|
|
int wakeHour = 7, wakeMinute = 0;
|
|
|
|
|
parseClockHHMM(settings.bedtime(), bedHour, bedMinute);
|
|
|
|
|
parseClockHHMM(settings.wakeTime(), wakeHour, wakeMinute);
|
2026-02-21 20:09:41 -05:00
|
|
|
|
2026-02-21 20:25:30 -05:00
|
|
|
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;
|
2026-02-21 20:09:41 -05:00
|
|
|
s.routineAnim = FaceRenderer::ROUTINE_SLEEPING_SOON;
|
2026-02-21 20:25:30 -05:00
|
|
|
s.routineProgressPermille = (uint16_t)((elapsed * 1000L) / (ROUTINE_WINDOW_MIN * 60L));
|
2026-02-21 20:09:41 -05:00
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isInOvernightRange(sec, bedSec, wakeSec)) {
|
|
|
|
|
s.sleeping = true;
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 20:25:30 -05:00
|
|
|
bool inWakeAnim = (wakeSec <= wakeAnimEndSec)
|
|
|
|
|
? isInRangeSameDay(sec, wakeSec, wakeAnimEndSec)
|
|
|
|
|
: isInOvernightRange(sec, wakeSec, wakeAnimEndSec);
|
|
|
|
|
if (inWakeAnim) {
|
|
|
|
|
int elapsed = (sec - wakeSec);
|
|
|
|
|
if (elapsed < 0) elapsed += 24 * 3600;
|
2026-02-21 20:09:41 -05:00
|
|
|
s.routineAnim = FaceRenderer::ROUTINE_WAKING_UP;
|
2026-02-21 20:25:30 -05:00
|
|
|
s.routineProgressPermille = (uint16_t)((elapsed * 1000L) / (ROUTINE_WINDOW_MIN * 60L));
|
2026-02-21 20:09:41 -05:00
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void updateScheduleState() {
|
|
|
|
|
if ((long)(millis() - nextTimeCheckMs) < 0) return;
|
|
|
|
|
nextTimeCheckMs = millis() + 1000;
|
|
|
|
|
|
2026-02-21 20:25:30 -05:00
|
|
|
ensureTimeSyncConfigured(wifi.connected());
|
2026-02-21 20:09:41 -05:00
|
|
|
ScheduleState sched = currentScheduleState();
|
|
|
|
|
timeValid = sched.hasTime;
|
|
|
|
|
|
|
|
|
|
face.setRoutineAnimation(sched.routineAnim, sched.routineProgressPermille);
|
|
|
|
|
|
|
|
|
|
bool shouldSleep = sched.sleeping;
|
|
|
|
|
if (!sched.hasTime) shouldSleep = false; // fail-safe: stay awake until time sync exists
|
|
|
|
|
|
|
|
|
|
if (shouldSleep != lastScheduleSleep) {
|
|
|
|
|
Serial.print("[Clock] Schedule ");
|
|
|
|
|
Serial.println(shouldSleep ? "sleep window entered" : "wake window entered");
|
|
|
|
|
lastScheduleSleep = shouldSleep;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isNightMode = shouldSleep;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void updateAmbientDimming() {
|
|
|
|
|
ambient.loop();
|
|
|
|
|
if (!ambient.available() || !display.ok() || isNightMode || !display.displayEnabled()) return;
|
|
|
|
|
|
|
|
|
|
float lux = ambient.filteredLux();
|
|
|
|
|
uint8_t contrast = luxToContrast(lux);
|
|
|
|
|
if (lastContrast == 0xFF || abs((int)contrast - (int)lastContrast) >= 4) {
|
|
|
|
|
display.setContrast(contrast);
|
|
|
|
|
lastContrast = contrast;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 11:41:12 -05:00
|
|
|
void App::setup() {
|
|
|
|
|
bootMs = millis();
|
|
|
|
|
|
2026-02-10 17:05:42 -05:00
|
|
|
Serial.begin(115200);
|
|
|
|
|
delay(100);
|
|
|
|
|
Serial.println("\n\n=== FacePlant Starting ===");
|
|
|
|
|
Serial.print("Firmware: ");
|
|
|
|
|
Serial.println(PB_VERSION);
|
|
|
|
|
|
2026-02-09 11:41:12 -05:00
|
|
|
settings.begin();
|
|
|
|
|
bool forceSetup = BootTrigger::checkAndConsume();
|
|
|
|
|
|
2026-02-10 17:05:42 -05:00
|
|
|
Serial.print("Force setup mode: ");
|
|
|
|
|
Serial.println(forceSetup ? "YES" : "NO");
|
|
|
|
|
Serial.print("Saved WiFi SSID: ");
|
|
|
|
|
Serial.println(settings.hasWiFi() ? settings.wifiSsid() : "(none)");
|
|
|
|
|
|
2026-02-09 11:41:12 -05:00
|
|
|
display.begin();
|
|
|
|
|
display.showStatus("FacePlant", "Starting...");
|
|
|
|
|
|
|
|
|
|
wifi.begin(settings, forceSetup);
|
|
|
|
|
|
|
|
|
|
moisture.begin(settings);
|
2026-02-10 17:05:42 -05:00
|
|
|
battery.begin();
|
2026-02-21 20:09:41 -05:00
|
|
|
ambient.begin();
|
2026-02-21 20:25:30 -05:00
|
|
|
motion.begin();
|
2026-02-09 11:41:12 -05:00
|
|
|
face.begin(display, settings);
|
|
|
|
|
|
|
|
|
|
webhook.begin(settings);
|
|
|
|
|
|
|
|
|
|
web.begin(settings, wifi, moisture, face, webhook, bootMs);
|
|
|
|
|
ota.begin(web.server(), &display);
|
|
|
|
|
|
|
|
|
|
lastMood = face.mood();
|
|
|
|
|
lastDead = face.isDeadMode();
|
2026-02-10 17:05:42 -05:00
|
|
|
|
|
|
|
|
Serial.println("=== Setup Complete ===\n");
|
2026-02-09 11:41:12 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void App::loop() {
|
2026-02-10 17:05:42 -05:00
|
|
|
static bool lastConnected = false;
|
|
|
|
|
static unsigned long lastDisplayUpdate = 0;
|
|
|
|
|
|
2026-02-09 11:41:12 -05:00
|
|
|
BootTrigger::clearAfterStableUptime();
|
|
|
|
|
|
|
|
|
|
wifi.loop();
|
|
|
|
|
web.loop();
|
2026-02-21 20:25:30 -05:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 20:09:41 -05:00
|
|
|
updateScheduleState();
|
2026-02-21 20:25:30 -05:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 20:09:41 -05:00
|
|
|
updateAmbientDimming();
|
2026-02-09 11:41:12 -05:00
|
|
|
|
2026-02-10 17:05:42 -05:00
|
|
|
// Show WiFi status on display during connection attempts
|
|
|
|
|
bool currentConnected = wifi.connected();
|
|
|
|
|
if (currentConnected != lastConnected) {
|
|
|
|
|
if (currentConnected) {
|
|
|
|
|
Serial.println("[App] WiFi connected - showing on display");
|
2026-02-21 20:25:30 -05:00
|
|
|
if (!displaySleeping) {
|
2026-02-21 20:09:41 -05:00
|
|
|
display.showStatus("WiFi Connected!", wifi.ssid().c_str());
|
|
|
|
|
delay(2000);
|
|
|
|
|
}
|
2026-02-10 17:05:42 -05:00
|
|
|
} else if (wifi.mode() == NET_STA) {
|
|
|
|
|
Serial.println("[App] WiFi disconnected");
|
2026-02-21 20:25:30 -05:00
|
|
|
if (!displaySleeping && millis() - lastDisplayUpdate > 5000) {
|
2026-02-10 17:05:42 -05:00
|
|
|
display.showStatus("WiFi", "Connecting...");
|
|
|
|
|
lastDisplayUpdate = millis();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
lastConnected = currentConnected;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 11:41:12 -05:00
|
|
|
moisture.loop();
|
2026-02-10 17:05:42 -05:00
|
|
|
battery.loop();
|
2026-02-21 20:25:30 -05:00
|
|
|
if (!displaySleeping) {
|
2026-02-21 20:09:41 -05:00
|
|
|
face.loop(moisture, battery);
|
|
|
|
|
}
|
2026-02-09 11:41:12 -05:00
|
|
|
|
|
|
|
|
// Webhook events on state transitions
|
|
|
|
|
FaceRenderer::Mood m = face.mood();
|
|
|
|
|
bool dead = face.isDeadMode();
|
|
|
|
|
|
|
|
|
|
if (dead && !lastDead) {
|
|
|
|
|
webhook.send(EVT_DEAD, moisture, dead, bootMs);
|
|
|
|
|
} else if (!dead && lastDead) {
|
|
|
|
|
webhook.send(EVT_OK, moisture, dead, bootMs);
|
|
|
|
|
} else if (!dead && m != lastMood) {
|
|
|
|
|
webhook.send(moodToEvent(m), moisture, dead, bootMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastMood = m;
|
|
|
|
|
lastDead = dead;
|
|
|
|
|
}
|