#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" #include "../sensors/BatterySensor.h" #include "../sensors/AmbientLightSensor.h" #include "../sensors/MotionSensor.h" #include "../ui/FaceRenderer.h" #include static Settings settings; static WiFiManager wifi; static WebUI web; static OtaService ota; static WebhookService webhook; static Display display; static MoistureSensor moisture; static BatterySensor battery; static AmbientLightSensor ambient; static MotionSensor motion; static FaceRenderer face; static unsigned long bootMs = 0; static FaceRenderer::Mood lastMood = FaceRenderer::HAPPY; static bool lastDead = false; 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; 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 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; if (m == FaceRenderer::TOO_WET) return EVT_TOO_WET; return EVT_OK; } 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); } 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; lastConfiguredTz = tzForEsp; Serial.print("[Clock] NTP started/reconfigured, TZ="); Serial.println(tz); } 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); int bedHour = 22, bedMinute = 0; int wakeHour = 7, wakeMinute = 0; parseClockHHMM(settings.bedtime(), bedHour, bedMinute); parseClockHHMM(settings.wakeTime(), wakeHour, wakeMinute); 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)((elapsed * 1000L) / (ROUTINE_WINDOW_MIN * 60L)); return s; } if (isInOvernightRange(sec, bedSec, wakeSec)) { s.sleeping = true; return s; } 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)((elapsed * 1000L) / (ROUTINE_WINDOW_MIN * 60L)); return s; } return s; } static void updateScheduleState() { if ((long)(millis() - nextTimeCheckMs) < 0) return; nextTimeCheckMs = millis() + 1000; ensureTimeSyncConfigured(wifi.connected()); 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; } } void App::setup() { bootMs = millis(); Serial.begin(115200); delay(100); Serial.println("\n\n=== FacePlant Starting ==="); Serial.print("Firmware: "); Serial.println(PB_VERSION); settings.begin(); bool forceSetup = BootTrigger::checkAndConsume(); Serial.print("Force setup mode: "); Serial.println(forceSetup ? "YES" : "NO"); Serial.print("Saved WiFi SSID: "); Serial.println(settings.hasWiFi() ? settings.wifiSsid() : "(none)"); display.begin(); display.showStatus("FacePlant", "Starting..."); wifi.begin(settings, forceSetup); moisture.begin(settings); battery.begin(); ambient.begin(); motion.begin(); 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(); Serial.println("=== Setup Complete ===\n"); } void App::loop() { static bool lastConnected = false; static unsigned long lastDisplayUpdate = 0; BootTrigger::clearAfterStableUptime(); 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 bool currentConnected = wifi.connected(); if (currentConnected != lastConnected) { if (currentConnected) { Serial.println("[App] WiFi connected - showing on display"); if (!displaySleeping) { display.showStatus("WiFi Connected!", wifi.ssid().c_str()); delay(2000); } } else if (wifi.mode() == NET_STA) { Serial.println("[App] WiFi disconnected"); if (!displaySleeping && millis() - lastDisplayUpdate > 5000) { display.showStatus("WiFi", "Connecting..."); lastDisplayUpdate = millis(); } } lastConnected = currentConnected; } moisture.loop(); battery.loop(); if (!displaySleeping) { face.loop(moisture, battery); } // 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; }