diff --git a/platformio.ini b/platformio.ini index d1c5dc3..1da3e38 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,6 +1,6 @@ -[env:esp32-c3] +[env:esp32-s3] platform = espressif32 -board = seeed_xiao_esp32c3 +board = esp32-s3-devkitc-1 framework = arduino monitor_speed = 115200 @@ -8,6 +8,7 @@ monitor_speed = 115200 lib_deps = adafruit/Adafruit GFX Library adafruit/Adafruit SSD1306 + adafruit/Adafruit VEML7700 Library bblanchon/ArduinoJson ; board_build.filesystem = spiffs @@ -16,3 +17,4 @@ lib_deps = build_flags = -D PB_VERSION=\"1.0.0\" -D PB_HOSTNAME=\"faceplant\" + -D PB_TZ=\"UTC0\" diff --git a/src/app/App.cpp b/src/app/App.cpp index a3162eb..9782f21 100644 --- a/src/app/App.cpp +++ b/src/app/App.cpp @@ -12,7 +12,9 @@ #include "../ui/Display.h" #include "../sensors/MoistureSensor.h" #include "../sensors/BatterySensor.h" +#include "../sensors/AmbientLightSensor.h" #include "../ui/FaceRenderer.h" +#include static Settings settings; static WiFiManager wifi; @@ -23,12 +25,29 @@ static WebhookService webhook; static Display display; static MoistureSensor moisture; static BatterySensor battery; +static AmbientLightSensor ambient; 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 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 PlantEventType moodToEvent(FaceRenderer::Mood m) { if (m == FaceRenderer::DRY) return EVT_DRY; @@ -36,6 +55,123 @@ static PlantEventType moodToEvent(FaceRenderer::Mood m) { 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 void maybeInitTimeSync(bool wifiConnected) { + if (!wifiConnected || ntpConfigured) return; + configTzTime(PB_TZ, "pool.ntp.org", "time.nist.gov"); + ntpConfigured = true; + Serial.print("[Clock] NTP started, TZ="); + Serial.println(PB_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); + 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; + + if (isInRangeSameDay(sec, windDownStartSec, bedSec)) { + s.routineAnim = FaceRenderer::ROUTINE_SLEEPING_SOON; + s.routineProgressPermille = (uint16_t)(((sec - windDownStartSec) * 1000L) / (ROUTINE_WINDOW_MIN * 60L)); + return s; + } + + if (isInOvernightRange(sec, bedSec, wakeSec)) { + s.sleeping = true; + return s; + } + + if (isInRangeSameDay(sec, wakeSec, wakeAnimEndSec)) { + s.routineAnim = FaceRenderer::ROUTINE_WAKING_UP; + s.routineProgressPermille = (uint16_t)(((sec - wakeSec) * 1000L) / (ROUTINE_WINDOW_MIN * 60L)); + return s; + } + + return s; +} + +static void updateScheduleState() { + if ((long)(millis() - nextTimeCheckMs) < 0) return; + nextTimeCheckMs = millis() + 1000; + + maybeInitTimeSync(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; + } + + bool wasNightMode = isNightMode; + isNightMode = shouldSleep; + if (display.ok()) { + display.setDisplayEnabled(!isNightMode); + if (wasNightMode && !isNightMode) lastContrast = 0xFF; // force contrast refresh when waking + } +} + +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(); @@ -60,6 +196,7 @@ void App::setup() { moisture.begin(settings); battery.begin(); + ambient.begin(); face.begin(display, settings); webhook.begin(settings); @@ -81,17 +218,21 @@ void App::loop() { wifi.loop(); web.loop(); + updateScheduleState(); + 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"); - display.showStatus("WiFi Connected!", wifi.ssid().c_str()); - delay(2000); + if (!isNightMode) { + display.showStatus("WiFi Connected!", wifi.ssid().c_str()); + delay(2000); + } } else if (wifi.mode() == NET_STA) { Serial.println("[App] WiFi disconnected"); - if (millis() - lastDisplayUpdate > 5000) { + if (!isNightMode && millis() - lastDisplayUpdate > 5000) { display.showStatus("WiFi", "Connecting..."); lastDisplayUpdate = millis(); } @@ -101,7 +242,9 @@ void App::loop() { moisture.loop(); battery.loop(); - face.loop(moisture, battery); + if (!isNightMode) { + face.loop(moisture, battery); + } // Webhook events on state transitions FaceRenderer::Mood m = face.mood(); diff --git a/src/sensors/AmbientLightSensor.cpp b/src/sensors/AmbientLightSensor.cpp new file mode 100644 index 0000000..928997d --- /dev/null +++ b/src/sensors/AmbientLightSensor.cpp @@ -0,0 +1,40 @@ +#include "AmbientLightSensor.h" +#include + +void AmbientLightSensor::begin() { + _ok = _veml.begin(); + if (!_ok) { + Serial.println("[Ambient] VEML7700 not detected"); + return; + } + + _veml.setGain(VEML7700_GAIN_1); + _veml.setIntegrationTime(VEML7700_IT_100MS); + + _lux = _veml.readLux(); + if (!isfinite(_lux) || _lux < 0.0f) _lux = 0.0f; + _filteredLux = _lux; + _nextMs = millis(); + _lastUpdateMs = millis(); + + Serial.print("[Ambient] VEML7700 ready, initial lux: "); + Serial.println(_lux, 2); +} + +void AmbientLightSensor::loop() { + if (!_ok) return; + + unsigned long now = millis(); + if ((long)(now - _nextMs) < 0) return; + _nextMs = now + INTERVAL_MS; + + float rawLux = _veml.readLux(); + if (!isfinite(rawLux) || rawLux < 0.0f) return; + + _lux = rawLux; + + // Simple low-pass filter to avoid display brightness flicker. + const float alpha = 0.2f; + _filteredLux = (_filteredLux * (1.0f - alpha)) + (_lux * alpha); + _lastUpdateMs = now; +} diff --git a/src/sensors/AmbientLightSensor.h b/src/sensors/AmbientLightSensor.h new file mode 100644 index 0000000..c2dcffa --- /dev/null +++ b/src/sensors/AmbientLightSensor.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +class AmbientLightSensor { +public: + void begin(); + void loop(); + + bool available() const { return _ok; } + float lux() const { return _lux; } + float filteredLux() const { return _filteredLux; } + unsigned long lastUpdateMs() const { return _lastUpdateMs; } + +private: + static constexpr unsigned long INTERVAL_MS = 1000; + + Adafruit_VEML7700 _veml; + bool _ok = false; + float _lux = 0.0f; + float _filteredLux = 0.0f; + unsigned long _nextMs = 0; + unsigned long _lastUpdateMs = 0; +}; diff --git a/src/ui/Display.cpp b/src/ui/Display.cpp index da2c0a7..def8333 100644 --- a/src/ui/Display.cpp +++ b/src/ui/Display.cpp @@ -4,11 +4,14 @@ void Display::begin() { Wire.begin(PIN_SDA, PIN_SCL); _ok = _oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR, true, false); if (!_ok) return; + _displayEnabled = true; + setContrast(_contrast); bootAnimation(); } void Display::showStatus(const String& line1, const String& line2) { if (!_ok) return; + if (!_displayEnabled) setDisplayEnabled(true); _oled.clearDisplay(); _oled.setTextSize(1); _oled.setTextColor(1); @@ -19,6 +22,28 @@ void Display::showStatus(const String& line1, const String& line2) { _oled.display(); } +void Display::setContrast(uint8_t contrast) { + if (!_ok) return; + _contrast = contrast; + _oled.ssd1306_command(SSD1306_SETCONTRAST); + _oled.ssd1306_command(_contrast); +} + +void Display::setDisplayEnabled(bool enabled) { + if (!_ok) return; + if (_displayEnabled == enabled) return; + + _displayEnabled = enabled; + if (enabled) { + _oled.ssd1306_command(SSD1306_DISPLAYON); + setContrast(_contrast); + } else { + _oled.clearDisplay(); + _oled.display(); + _oled.ssd1306_command(SSD1306_DISPLAYOFF); + } +} + void Display::bootAnimation() { if (!_ok) return; diff --git a/src/ui/Display.h b/src/ui/Display.h index 07f8423..ec31eb7 100644 --- a/src/ui/Display.h +++ b/src/ui/Display.h @@ -8,10 +8,14 @@ class Display { public: void begin(); Adafruit_SSD1306& oled() { return _oled; } + bool ok() const { return _ok; } void showStatus(const String& line1, const String& line2); void bootAnimation(); void drawBatteryIcon(int x, int y, int percent, bool blink, bool charging); + void setContrast(uint8_t contrast); + void setDisplayEnabled(bool enabled); + bool displayEnabled() const { return _displayEnabled; } private: static constexpr int PIN_SDA = 6; @@ -19,4 +23,6 @@ private: static constexpr uint8_t OLED_ADDR = 0x3C; // try 0x3D if blank Adafruit_SSD1306 _oled = Adafruit_SSD1306(128, 64, &Wire, -1); bool _ok = false; + bool _displayEnabled = true; + uint8_t _contrast = 0xCF; }; diff --git a/src/ui/FaceRenderer.cpp b/src/ui/FaceRenderer.cpp index 4b9b676..9e73a1c 100644 --- a/src/ui/FaceRenderer.cpp +++ b/src/ui/FaceRenderer.cpp @@ -16,6 +16,11 @@ int8_t FaceRenderer::randRangeI8(int8_t lo, int8_t hi) { return (int8_t)(lo + random((int)(hi - lo + 1))); } +void FaceRenderer::setRoutineAnimation(RoutineAnim anim, uint16_t progressPermille) { + _routineAnim = anim; + _routineProgressPermille = progressPermille > 1000 ? 1000 : progressPermille; +} + void FaceRenderer::begin(Display& display, Settings& settings) { _display = &display; _settings = &settings; @@ -38,6 +43,11 @@ void FaceRenderer::loop(const MoistureSensor& moisture, const BatterySensor& bat updateDeathMode(now); updateBatteryLowMode(battery.percent()); + if (_routineAnim != ROUTINE_NONE) { + renderRoutine(now, battery); + return; + } + if (_deadMode) { renderDead(now, battery); return; @@ -235,6 +245,55 @@ void FaceRenderer::renderNormal(unsigned long now, const BatterySensor& battery) d.display(); } +void FaceRenderer::renderRoutine(unsigned long now, const BatterySensor& battery) { + auto &d = _display->oled(); + d.clearDisplay(); + + if (_routineAnim == ROUTINE_SLEEPING_SOON) { + // Progressively lower eyelids over 5 minutes. + int close = (_routineProgressPermille * 12) / 1000; // 0..12 + int pupilY = (_routineProgressPermille > 700) ? 2 : 0; + + d.fillCircle(40, 24, 12, 1); + d.fillCircle(88, 24, 12, 1); + d.fillCircle(40, 24 + pupilY, 3, 0); + d.fillCircle(88, 24 + pupilY, 3, 0); + if (close > 0) { + d.fillRect(28, 12, 24, close, 0); + d.fillRect(76, 12, 24, close, 0); + } + if (close > 6) { + d.drawLine(28, 24, 52, 24, 1); + d.drawLine(76, 24, 100, 24, 1); + } + d.drawLine(26, 14, 48, 16, 1); + d.drawLine(80, 16, 102, 14, 1); + d.drawLine(52, 48, 76, 48, 1); + drawSleepyZs(now, (uint8_t)(1 + ((_routineProgressPermille * 3) / 1000))); + } else { + // Wake-up: eyes open wider and smile grows over 5 minutes. + int open = 4 + (_routineProgressPermille * 8) / 1000; // pupil travel + int pupilY = 4 - (_routineProgressPermille * 4) / 1000; // starts sleepy, rises to center + + d.fillCircle(40, 24, 12, 1); + d.fillCircle(88, 24, 12, 1); + d.fillCircle(40, 24 + pupilY, open / 3, 0); + d.fillCircle(88, 24 + pupilY, open / 3, 0); + + // Bright eyebrows + growing smile + d.drawLine(26, 14, 48, 11, 1); + d.drawLine(80, 11, 102, 14, 1); + int smileLift = (_routineProgressPermille * 3) / 1000; + for (int i = 0; i < 4; i++) { + d.drawLine(55 + i, 47 + i / 2 - smileLift, 73 - i, 47 + i / 2 - smileLift, 1); + } + drawSunRise(now, (uint8_t)(1 + ((_routineProgressPermille * 4) / 1000))); + } + + _display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink(), battery.isCharging()); + d.display(); +} + /* --- Drawing helpers --- */ void FaceRenderer::drawEyesOpen(int dx, int dy) { @@ -350,3 +409,34 @@ void FaceRenderer::drawBigWaterText(unsigned long now) { d.setCursor(x < 0 ? 0 : x, 56); d.print(msg); } + +void FaceRenderer::drawSleepyZs(unsigned long now, uint8_t count) { + auto &d = _display->oled(); + uint8_t phase = (now / 400UL) % 8UL; + for (uint8_t i = 0; i < count; i++) { + int x = 98 + i * 8 - (int)phase; + int y = 22 - i * 7; + if (x < 84 || y < 2) continue; + d.drawLine(x, y, x + 4, y, 1); + d.drawLine(x + 4, y, x, y + 5, 1); + d.drawLine(x, y + 5, x + 4, y + 5, 1); + } +} + +void FaceRenderer::drawSunRise(unsigned long now, uint8_t level) { + auto &d = _display->oled(); + int cx = 12; + int cy = 52; + int r = 5; + d.drawCircle(cx, cy, r, 1); + d.drawLine(cx - 10, cy + 6, cx + 10, cy + 6, 1); + if (level >= 2) { + d.drawLine(cx, cy - 10, cx, cy - 7, 1); + d.drawLine(cx - 8, cy - 4, cx - 5, cy - 3, 1); + d.drawLine(cx + 8, cy - 4, cx + 5, cy - 3, 1); + } + if (level >= 4 && ((now / 300UL) % 2UL == 0UL)) { + d.drawLine(cx - 11, cy - 10, cx - 8, cy - 7, 1); + d.drawLine(cx + 11, cy - 10, cx + 8, cy - 7, 1); + } +} diff --git a/src/ui/FaceRenderer.h b/src/ui/FaceRenderer.h index 4a33304..203c953 100644 --- a/src/ui/FaceRenderer.h +++ b/src/ui/FaceRenderer.h @@ -8,9 +8,11 @@ class FaceRenderer { public: enum Mood { HAPPY, DRY, TOO_WET }; + enum RoutineAnim { ROUTINE_NONE, ROUTINE_SLEEPING_SOON, ROUTINE_WAKING_UP }; void begin(Display& display, Settings& settings); void loop(const MoistureSensor& moisture, const BatterySensor& battery); + void setRoutineAnimation(RoutineAnim anim, uint16_t progressPermille); bool isDeadMode() const { return _deadMode; } Mood mood() const { return _mood; } @@ -51,6 +53,9 @@ private: int8_t _shakeX = 0, _shakeY = 0; unsigned long _nextShakeMs = 0; + RoutineAnim _routineAnim = ROUTINE_NONE; + uint16_t _routineProgressPermille = 0; + void updateMood(int moisturePct); void updateDeathMode(unsigned long now); void updateBatteryLowMode(int batteryPercent); @@ -61,6 +66,7 @@ private: void renderDead(unsigned long now, const BatterySensor& battery); 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 drawEyesOpen(int pupilDx, int pupilDy); void drawEyesClosed(); @@ -77,6 +83,8 @@ private: void drawSilly(uint8_t v); void drawXEye(int cx, int cy); void drawBigWaterText(unsigned long now); + void drawSleepyZs(unsigned long now, uint8_t count); + void drawSunRise(unsigned long now, uint8_t level); unsigned long randRange(unsigned long lo, unsigned long hi); int8_t randRangeI8(int8_t lo, int8_t hi);