feat: Add OLED size build flag support and implement PowerManager for enhanced power management
This commit is contained in:
16
README.md
16
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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
103
src/app/App.cpp
103
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);
|
||||
}
|
||||
}
|
||||
|
||||
71
src/power/PowerManager.cpp
Normal file
71
src/power/PowerManager.cpp
Normal 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
26
src/power/PowerManager.h
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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=");
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user