From b60f44f99f2dbedabcf16209b750684c219abbb5 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 3 Mar 2026 08:41:49 -0500 Subject: [PATCH] feat: Add OLED size build flag support and implement PowerManager for enhanced power management --- README.md | 16 ++++++ platformio.ini | 70 ++++++++++++++++++++++++ src/app/App.cpp | 103 +++++++++++++++++++++++++++++------ src/power/PowerManager.cpp | 71 ++++++++++++++++++++++++ src/power/PowerManager.h | 26 +++++++++ src/sensors/MoistureSensor.h | 10 +++- src/ui/Display.cpp | 2 + src/ui/Display.h | 34 +++++++++++- 8 files changed, 310 insertions(+), 22 deletions(-) create mode 100644 src/power/PowerManager.cpp create mode 100644 src/power/PowerManager.h diff --git a/README.md b/README.md index 4d613d1..2b098ca 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,19 @@ ESP32 + SH1106 OLED + soil moisture buddy with expressions. ## Wiring - OLED (I2C): SDA=GPIO21, SCL=GPIO22, VCC=3.3V, GND=GND - Moisture sensor analog out: GPIO34 + +## OLED Size Build Flag +FacePlant now supports compile-time OLED profiles via `PB_OLED_SIZE`: +- `96` for 0.96" OLED +- `130` for 1.3" OLED +- `240` for 2.4" OLED + +Predefined PlatformIO environments: +- `esp32-s3-oled-096` +- `esp32-s3-oled-130` +- `esp32-s3-oled-240` + +Example: +```bash +pio run -e esp32-s3-oled-240 -t upload +``` diff --git a/platformio.ini b/platformio.ini index aada122..a19686a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -25,3 +25,73 @@ build_flags = -D PB_VERSION=\"1.0.0\" -D PB_HOSTNAME=\"faceplant\" -D PB_TZ=\"America/New_York\" + -D PB_OLED_SIZE=130 + +[env:esp32-s3-oled-096] +platform = espressif32 +board = esp32-s3-devkitc-1 +framework = arduino +monitor_speed = 115200 +lib_deps = + adafruit/Adafruit GFX Library + adafruit/Adafruit SH110X + adafruit/Adafruit VEML7700 Library + adafruit/Adafruit MPU6050 + adafruit/Adafruit MAX1704X + bblanchon/ArduinoJson +board_upload.flash_size = 16MB +board_build.partitions = default_16MB.csv +board_build.arduino.memory_type = qio_opi +build_flags = + -D BOARD_HAS_PSRAM + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D PB_VERSION=\"1.0.0\" + -D PB_HOSTNAME=\"faceplant\" + -D PB_TZ=\"America/New_York\" + -D PB_OLED_SIZE=96 + +[env:esp32-s3-oled-130] +platform = espressif32 +board = esp32-s3-devkitc-1 +framework = arduino +monitor_speed = 115200 +lib_deps = + adafruit/Adafruit GFX Library + adafruit/Adafruit SH110X + adafruit/Adafruit VEML7700 Library + adafruit/Adafruit MPU6050 + adafruit/Adafruit MAX1704X + bblanchon/ArduinoJson +board_upload.flash_size = 16MB +board_build.partitions = default_16MB.csv +board_build.arduino.memory_type = qio_opi +build_flags = + -D BOARD_HAS_PSRAM + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D PB_VERSION=\"1.0.0\" + -D PB_HOSTNAME=\"faceplant\" + -D PB_TZ=\"America/New_York\" + -D PB_OLED_SIZE=130 + +[env:esp32-s3-oled-240] +platform = espressif32 +board = esp32-s3-devkitc-1 +framework = arduino +monitor_speed = 115200 +lib_deps = + adafruit/Adafruit GFX Library + adafruit/Adafruit SH110X + adafruit/Adafruit VEML7700 Library + adafruit/Adafruit MPU6050 + adafruit/Adafruit MAX1704X + bblanchon/ArduinoJson +board_upload.flash_size = 16MB +board_build.partitions = default_16MB.csv +board_build.arduino.memory_type = qio_opi +build_flags = + -D BOARD_HAS_PSRAM + -D ARDUINO_USB_CDC_ON_BOOT=1 + -D PB_VERSION=\"1.0.0\" + -D PB_HOSTNAME=\"faceplant\" + -D PB_TZ=\"America/New_York\" + -D PB_OLED_SIZE=240 diff --git a/src/app/App.cpp b/src/app/App.cpp index 9e0250a..64150ff 100644 --- a/src/app/App.cpp +++ b/src/app/App.cpp @@ -10,6 +10,7 @@ #include "../net/WebhookService.h" #include "../ui/Display.h" +#include "../power/PowerManager.h" #include "../sensors/MoistureSensor.h" #include "../sensors/BatterySensor.h" #include "../sensors/AmbientLightSensor.h" @@ -29,6 +30,7 @@ static BatterySensor battery; static AmbientLightSensor ambient; static MotionSensor motion; static FaceRenderer face; +static PowerManager power; static unsigned long bootMs = 0; @@ -48,6 +50,9 @@ static bool restartButtonStable = true; static unsigned long restartButtonLastEdgeMs = 0; static unsigned long restartButtonPressedSinceMs = 0; static bool restartButtonArmed = true; +static bool networkServicesStarted = false; +static bool bootForceSetup = false; +static unsigned long nightDarkSinceMs = 0; static constexpr float DIM_LUX_MIN = 0.0f; static constexpr float DIM_LUX_MAX = 300.0f; @@ -55,6 +60,10 @@ 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 constexpr float NIGHT_LUX_THRESHOLD = 8.0f; +static constexpr unsigned long NIGHT_DWELL_MS = 5UL * 60UL * 1000UL; +static constexpr uint32_t NIGHT_SLEEP_SECONDS = 30UL * 60UL; +static constexpr uint32_t BATTERY_SLEEP_SECONDS = 60UL * 60UL; static constexpr int PIN_RESTART_BUTTON = 2; // Active-low button to GND static constexpr unsigned long BUTTON_DEBOUNCE_MS = 30UL; static constexpr unsigned long BUTTON_HOLD_RESTART_MS = 1200UL; @@ -98,6 +107,31 @@ static int8_t rollToFaceSlideX(float rollDeg) { return (int8_t)v; } +static bool shouldNightSleepFromAmbient(bool pluggedIn) { + if (!pluggedIn || !ambient.available()) { + nightDarkSinceMs = 0; + return false; + } + + float lux = ambient.filteredLux(); + if (lux < NIGHT_LUX_THRESHOLD) { + if (nightDarkSinceMs == 0) nightDarkSinceMs = millis(); + return (millis() - nightDarkSinceMs) >= NIGHT_DWELL_MS; + } + + nightDarkSinceMs = 0; + return false; +} + +static void startNetworkServices(bool forceSetup) { + if (networkServicesStarted) return; + + wifi.begin(settings, forceSetup); + web.begin(settings, wifi, moisture, motion, face, webhook, bootMs); + ota.begin(web.server(), &display); + networkServicesStarted = true; +} + static void initRestartButton() { pinMode(PIN_RESTART_BUTTON, INPUT_PULLUP); bool raw = digitalRead(PIN_RESTART_BUTTON); @@ -280,7 +314,6 @@ static void updateScheduleState() { } static void updateAmbientDimming() { - ambient.loop(); if (!ambient.available() || !display.ok() || isNightMode || !display.displayEnabled()) return; float lux = ambient.filteredLux(); @@ -312,23 +345,28 @@ void App::setup() { display.showStatus("FacePlant", "Starting..."); initRestartButton(); - wifi.begin(settings, forceSetup); - moisture.begin(settings); battery.begin(); + power.begin(bootMs); + power.setBatteryAwakeWindowMs(2UL * 60UL * 1000UL); + power.loop(battery); // initialize plug state before deciding service startup ambient.begin(); motion.begin(); face.begin(display, settings); webhook.begin(settings); + bootForceSetup = forceSetup; { MotionCalibration mc = settings.motionCalibration(); motion.setZeroOffsets(mc.rollZeroDeg, mc.pitchZeroDeg); } - web.begin(settings, wifi, moisture, motion, face, webhook, bootMs); - ota.begin(web.server(), &display); + if (bootForceSetup || power.isPluggedIn()) { + startNetworkServices(bootForceSetup); + } else { + Serial.println("[Power] Booting on battery - network services deferred"); + } lastMood = face.mood(); lastDead = face.isDeadMode(); @@ -343,8 +381,21 @@ void App::loop() { BootTrigger::clearAfterStableUptime(); handleRestartButton(); - wifi.loop(); - web.loop(); + moisture.loop(); + battery.loop(); + power.loop(battery); + ambient.loop(); + + bool pluggedIn = power.isPluggedIn(); + if (!networkServicesStarted && (bootForceSetup || pluggedIn)) { + startNetworkServices(bootForceSetup); + bootForceSetup = false; + } + + if (networkServicesStarted) { + wifi.loop(); + web.loop(); + } motion.loop(); if (motion.available()) { @@ -376,8 +427,8 @@ void App::loop() { updateAmbientDimming(); // Show WiFi status on display during connection attempts - bool currentConnected = wifi.connected(); - if (currentConnected != lastConnected) { + bool currentConnected = networkServicesStarted && wifi.connected(); + if (networkServicesStarted && currentConnected != lastConnected) { if (currentConnected) { Serial.println("[App] WiFi connected - showing on display"); if (!displaySleeping) { @@ -392,10 +443,10 @@ void App::loop() { } } lastConnected = currentConnected; + } else if (!networkServicesStarted) { + lastConnected = false; } - moisture.loop(); - battery.loop(); if (!displaySleeping) { face.loop(moisture, battery); } @@ -404,14 +455,32 @@ void App::loop() { 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); + if (networkServicesStarted && wifi.connected()) { + 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; + + // Plugged-in ambient night sleep: if it's consistently dark, deep-sleep and wake later to re-check. + if (shouldNightSleepFromAmbient(pluggedIn)) { + display.showStatus("FacePlant", "Sleeping (night)"); + delay(250); + power.deepSleepForSeconds(NIGHT_SLEEP_SECONDS, "night ambient", &display); + } + + // Battery mode: keep face awake while dry; otherwise sleep after a short awake window. + bool inSetupPortal = networkServicesStarted && (wifi.mode() == NET_AP_SETUP); + if (!pluggedIn && !inSetupPortal && m != FaceRenderer::DRY && + power.batteryWindowElapsed(millis())) { + display.showStatus("FacePlant", "Sleeping (battery)"); + delay(250); + power.deepSleepForSeconds(BATTERY_SLEEP_SECONDS, "battery idle", &display); + } } diff --git a/src/power/PowerManager.cpp b/src/power/PowerManager.cpp new file mode 100644 index 0000000..eb26c34 --- /dev/null +++ b/src/power/PowerManager.cpp @@ -0,0 +1,71 @@ +#include "PowerManager.h" + +#include +#include +#include + +void PowerManager::begin(unsigned long bootMs) { + _bootMs = bootMs; + _onBatterySinceMs = bootMs; + _pluggedIn = true; + _plugStateInitialized = false; +} + +void PowerManager::setBatteryAwakeWindowMs(unsigned long windowMs) { + _batteryAwakeWindowMs = windowMs; +} + +bool PowerManager::detectPluggedIn(const BatterySensor& battery) const { + // Primary signal: positive charge rate from MAX17048. + if (battery.isCharging()) return true; + + // Fallback for topped-off battery where charge rate can read near zero while still plugged. + return (battery.percent() >= 98) && (battery.voltage() >= 4.15f); +} + +void PowerManager::loop(const BatterySensor& battery) { + bool pluggedNow = detectPluggedIn(battery); + unsigned long now = millis(); + + if (!_plugStateInitialized) { + _plugStateInitialized = true; + _pluggedIn = pluggedNow; + _onBatterySinceMs = pluggedNow ? _bootMs : now; + Serial.print("[Power] Initial state: "); + Serial.println(_pluggedIn ? "plugged-in" : "battery"); + return; + } + + if (pluggedNow == _pluggedIn) return; + _pluggedIn = pluggedNow; + + if (_pluggedIn) { + Serial.println("[Power] Source changed: plugged-in"); + } else { + _onBatterySinceMs = now; + Serial.println("[Power] Source changed: battery"); + } +} + +bool PowerManager::batteryWindowElapsed(unsigned long nowMs) const { + if (_pluggedIn) return false; + return (nowMs - _onBatterySinceMs) >= _batteryAwakeWindowMs; +} + +void PowerManager::deepSleepForSeconds(uint32_t seconds, const char* reason, Display* display) const { + Serial.print("[Power] Deep sleep: "); + Serial.print(reason); + Serial.print(" for "); + Serial.print(seconds); + Serial.println("s"); + + if (display && display->ok()) display->setDisplayEnabled(false); + + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + btStop(); + + esp_sleep_enable_timer_wakeup((uint64_t)seconds * 1000000ULL); + delay(50); + esp_deep_sleep_start(); +} diff --git a/src/power/PowerManager.h b/src/power/PowerManager.h new file mode 100644 index 0000000..9d68538 --- /dev/null +++ b/src/power/PowerManager.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include "../sensors/BatterySensor.h" +#include "../ui/Display.h" + +class PowerManager { +public: + void begin(unsigned long bootMs); + void setBatteryAwakeWindowMs(unsigned long windowMs); + void loop(const BatterySensor& battery); + + bool isPluggedIn() const { return _pluggedIn; } + bool batteryWindowElapsed(unsigned long nowMs) const; + + void deepSleepForSeconds(uint32_t seconds, const char* reason, Display* display = nullptr) const; + +private: + bool detectPluggedIn(const BatterySensor& battery) const; + + bool _pluggedIn = true; + bool _plugStateInitialized = false; + unsigned long _bootMs = 0; + unsigned long _onBatterySinceMs = 0; + unsigned long _batteryAwakeWindowMs = 2UL * 60UL * 1000UL; +}; diff --git a/src/sensors/MoistureSensor.h b/src/sensors/MoistureSensor.h index 60f8203..8182f06 100644 --- a/src/sensors/MoistureSensor.h +++ b/src/sensors/MoistureSensor.h @@ -2,6 +2,14 @@ #include #include "../settings/Settings.h" +// Configurable via PlatformIO build flag: -D MOISTURE_ADC_PIN= +// Defaults to GPIO3 if not provided. +#ifndef MOISTURE_ADC_PIN +#define MOISTURE_ADC_PIN 3 +#endif + +static constexpr int PIN_MOISTURE = MOISTURE_ADC_PIN; + class MoistureSensor { public: void begin(Settings& settings); @@ -12,8 +20,6 @@ public: unsigned long lastUpdateMs() const { return _lastUpdateMs; } private: - static constexpr int PIN_MOISTURE = 3; - Settings* _settings = nullptr; static constexpr int NUM_SAMPLES = 12; diff --git a/src/ui/Display.cpp b/src/ui/Display.cpp index ce7ef68..a589f42 100644 --- a/src/ui/Display.cpp +++ b/src/ui/Display.cpp @@ -26,6 +26,8 @@ static void logExpectedI2CDevices() { void Display::begin() { Serial.println("[Display] begin()"); + Serial.print("[Display] Profile: "); + Serial.println(SCREEN_NAME); Serial.print("[Display] SDA="); Serial.print(PIN_SDA); Serial.print(" SCL="); diff --git a/src/ui/Display.h b/src/ui/Display.h index 7f1fc5b..c5c75d0 100644 --- a/src/ui/Display.h +++ b/src/ui/Display.h @@ -4,6 +4,14 @@ #include #include +#ifndef PB_OLED_SIZE +#define PB_OLED_SIZE 130 +#endif + +#if (PB_OLED_SIZE != 96) && (PB_OLED_SIZE != 130) && (PB_OLED_SIZE != 240) +#error "PB_OLED_SIZE must be one of: 96, 130, 240" +#endif + class Display { public: void begin(); @@ -20,9 +28,29 @@ public: private: static constexpr int PIN_SDA = 8; static constexpr int PIN_SCL = 9; - static constexpr uint8_t OLED_ADDR = 0x3C; // try 0x3D if blank - Adafruit_SH1106G _oled = Adafruit_SH1106G(128, 64, &Wire, -1); + +#if PB_OLED_SIZE == 96 + static constexpr const char* SCREEN_NAME = "0.96in OLED"; + static constexpr uint8_t OLED_ADDR = 0x3C; + static constexpr int OLED_WIDTH = 128; + static constexpr int OLED_HEIGHT = 64; + static constexpr uint8_t DEFAULT_CONTRAST = 0x9F; +#elif PB_OLED_SIZE == 130 + static constexpr const char* SCREEN_NAME = "1.3in OLED"; + static constexpr uint8_t OLED_ADDR = 0x3C; + static constexpr int OLED_WIDTH = 128; + static constexpr int OLED_HEIGHT = 64; + static constexpr uint8_t DEFAULT_CONTRAST = 0xCF; +#else + static constexpr const char* SCREEN_NAME = "2.4in OLED"; + static constexpr uint8_t OLED_ADDR = 0x3C; + static constexpr int OLED_WIDTH = 128; + static constexpr int OLED_HEIGHT = 64; + static constexpr uint8_t DEFAULT_CONTRAST = 0xFF; +#endif + + Adafruit_SH1106G _oled = Adafruit_SH1106G(OLED_WIDTH, OLED_HEIGHT, &Wire, -1); bool _ok = false; bool _displayEnabled = true; - uint8_t _contrast = 0xCF; + uint8_t _contrast = DEFAULT_CONTRAST; };