feat: Add OLED size build flag support and implement PowerManager for enhanced power management

This commit is contained in:
Joshua King
2026-03-03 08:41:49 -05:00
parent 16b9471107
commit b60f44f99f
8 changed files with 310 additions and 22 deletions

View File

@@ -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
```

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -0,0 +1,71 @@
#include "PowerManager.h"
#include <WiFi.h>
#include <esp_bt.h>
#include <esp_sleep.h>
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();
}

26
src/power/PowerManager.h Normal file
View File

@@ -0,0 +1,26 @@
#pragma once
#include <Arduino.h>
#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;
};

View File

@@ -2,6 +2,14 @@
#include <Arduino.h>
#include "../settings/Settings.h"
// Configurable via PlatformIO build flag: -D MOISTURE_ADC_PIN=<gpio>
// 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;

View File

@@ -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=");

View File

@@ -4,6 +4,14 @@
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#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;
};