Compare commits

14 Commits

Author SHA1 Message Date
Joshua King
b60f44f99f feat: Add OLED size build flag support and implement PowerManager for enhanced power management 2026-03-03 08:41:49 -05:00
Joshua King
16b9471107 feat: Implement motion sway effects and enhance face sliding based on roll angle 2026-02-22 20:40:27 -05:00
Joshua King
9decccdce5 feat: Enhance motion sensor functionality with I2C error handling and display updates 2026-02-22 15:39:45 -05:00
Joshua King
df9bd461d1 feat: Add restart button functionality and motion calibration
- Implemented a restart button with debounce handling and long-press detection in App.cpp.
- Added motion calibration settings to Settings.cpp and Settings.h, allowing for roll and pitch zero offsets.
- Enhanced WebUI to include motion calibration controls and a restart option.
- Updated FaceRenderer to adjust eye and mouth positions based on screen dimensions.
- Introduced deadzone handling for motion sensor readings to improve stability.
2026-02-22 13:58:59 -05:00
Joshua King
a8e8268b65 Refactor I2C device scanning and update display pin assignments
- Changed I2C scanning function to probe only expected device addresses, improving startup reliability.
- Updated logging to provide clearer output for found and missing devices.
- Modified I2C SDA and SCL pin assignments from 6 and 7 to 8 and 9, respectively, to accommodate hardware changes.
- Added a timeout for I2C communication to enhance robustness.
2026-02-22 11:21:29 -05:00
Joshua King
a80a6b59d1 Refactor code structure for improved readability and maintainability 2026-02-21 22:25:59 -05:00
Joshua King
df00d77ce1 Refactor BatterySensor and Display classes to integrate MAX17048 and SH110X support 2026-02-21 22:09:13 -05:00
Joshua King
63061bdab2 Add Motion Sensor functionality and integrate timezone, bedtime, and wake time settings 2026-02-21 20:25:30 -05:00
Joshua King
192e657b07 Refactor platformio.ini for ESP32-S3 and add Ambient Light Sensor functionality with adaptive display contrast 2026-02-21 20:09:41 -05:00
Joshua King
08a2ee0852 Updates 2026-02-10 21:31:36 -05:00
Joshua King
b2752b8f72 Implement battery low mode rendering and update logic 2026-02-10 17:09:05 -05:00
Joshua King
0b627ffa75 Add Battery Sensor functionality and integrate with display and app
- Implement BatterySensor class to monitor battery voltage and status.
- Update App to initialize and loop BatterySensor.
- Modify FaceRenderer to display battery status on the screen.
- Enhance Display class with a method to draw battery icon.
- Update WebUI to include battery status in the interface.
- Refactor WiFiManager to improve connection handling and logging.
- Adjust Settings to include factory reset functionality.
- Improve HTML structure and styling in WebUI for better user experience.
2026-02-10 17:05:42 -05:00
Joshua King
483507e26c Refactor configuration for ESP32-C3 and update moisture sensor pin assignment 2026-02-10 14:17:55 -05:00
Joshua King
26a839f6e0 Update display library to use Adafruit SSD1306 for improved compatibility 2026-02-09 12:50:09 -05:00
24 changed files with 2734 additions and 110 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
.DS_Store

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

608
compile_commands.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
[env:esp32]
[env:esp32-s3]
platform = espressif32
board = esp32dev
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
@@ -8,8 +8,90 @@ 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
; board_build.filesystem = spiffs
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-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,8 +10,13 @@
#include "../net/WebhookService.h"
#include "../ui/Display.h"
#include "../power/PowerManager.h"
#include "../sensors/MoistureSensor.h"
#include "../sensors/BatterySensor.h"
#include "../sensors/AmbientLightSensor.h"
#include "../sensors/MotionSensor.h"
#include "../ui/FaceRenderer.h"
#include <time.h>
static Settings settings;
static WiFiManager wifi;
@@ -21,12 +26,47 @@ static WebhookService webhook;
static Display display;
static MoistureSensor moisture;
static BatterySensor battery;
static AmbientLightSensor ambient;
static MotionSensor motion;
static FaceRenderer face;
static PowerManager power;
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 bool restartButtonEnabled = true;
static bool restartButtonLastRaw = true;
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;
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;
static PlantEventType moodToEvent(FaceRenderer::Mood m) {
if (m == FaceRenderer::DRY) return EVT_DRY;
@@ -34,42 +74,388 @@ 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;
}
static int8_t motionSwayOffsetX(unsigned long nowMs) {
// 8-step loop for a clear side-to-side motion while the accelerometer reports movement.
static const int8_t kSway[] = {-8, -4, 0, 4, 8, 4, 0, -4};
const size_t steps = sizeof(kSway) / sizeof(kSway[0]);
size_t idx = (nowMs / 90UL) % steps;
return kSway[idx];
}
static int8_t rollToFaceSlideX(float rollDeg) {
// Map tilt to a larger screen slide so the face visibly shifts toward the tilted side.
float clamped = clampFloat(rollDeg, -35.0f, 35.0f);
int v = (int)lroundf((clamped / 35.0f) * 24.0f); // -24..+24 px
if (v < -24) v = -24;
if (v > 24) v = 24;
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);
restartButtonLastRaw = raw;
restartButtonStable = raw;
restartButtonLastEdgeMs = millis();
restartButtonPressedSinceMs = 0;
restartButtonArmed = true;
Serial.print("[Button] Restart button on GPIO ");
Serial.print(PIN_RESTART_BUTTON);
Serial.print(" (active-low), initial=");
Serial.println(raw ? "released" : "pressed");
}
static void handleRestartButton() {
if (!restartButtonEnabled) return;
unsigned long now = millis();
bool raw = digitalRead(PIN_RESTART_BUTTON);
if (raw != restartButtonLastRaw) {
restartButtonLastRaw = raw;
restartButtonLastEdgeMs = now;
}
if ((now - restartButtonLastEdgeMs) >= BUTTON_DEBOUNCE_MS && restartButtonStable != raw) {
restartButtonStable = raw;
bool pressed = !restartButtonStable; // active-low
if (pressed) {
restartButtonPressedSinceMs = now;
Serial.println("[Button] Restart button pressed");
} else {
Serial.println("[Button] Restart button released");
restartButtonPressedSinceMs = 0;
restartButtonArmed = true;
}
}
bool pressed = !restartButtonStable;
if (pressed && restartButtonArmed && restartButtonPressedSinceMs != 0 &&
(now - restartButtonPressedSinceMs) >= BUTTON_HOLD_RESTART_MS) {
restartButtonArmed = false;
Serial.println("[Button] Restart hold detected - rebooting");
delay(50);
ESP.restart();
}
}
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() {
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);
initRestartButton();
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;
web.begin(settings, wifi, moisture, face, webhook, bootMs);
ota.begin(web.server(), &display);
{
MotionCalibration mc = settings.motionCalibration();
motion.setZeroOffsets(mc.rollZeroDeg, mc.pitchZeroDeg);
}
if (bootForceSetup || power.isPluggedIn()) {
startNetworkServices(bootForceSetup);
} else {
Serial.println("[Power] Booting on battery - network services deferred");
}
lastMood = face.mood();
lastDead = face.isDeadMode();
Serial.println("=== Setup Complete ===\n");
}
void App::loop() {
BootTrigger::clearAfterStableUptime();
static bool lastConnected = false;
static unsigned long lastDisplayUpdate = 0;
wifi.loop();
web.loop();
BootTrigger::clearAfterStableUptime();
handleRestartButton();
moisture.loop();
face.loop(moisture);
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()) {
face.setTiltEffects(0, 0); // disable old motion-based eye deformation
int8_t slideX = rollToFaceSlideX(motion.rollDeg()); // stronger roll -> face slide
(void)motion.consumePickupEvent(); // consume pickup events so they don't accumulate
if (motion.isMoving()) {
motionWakeUntilMs = millis() + MOTION_WAKE_MS;
slideX = motionSwayOffsetX(millis());
}
face.setFaceSlideX(slideX);
} else {
face.setTiltEffects(0, 0);
face.setFaceSlideX(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 = networkServicesStarted && wifi.connected();
if (networkServicesStarted && currentConnected != lastConnected) {
if (currentConnected) {
Serial.println("[App] WiFi connected - showing on display");
if (!displaySleeping) {
display.showStatus("WiFi Connected!", wifi.ip().toString());
delay(3000);
}
} else if (wifi.mode() == NET_STA) {
Serial.println("[App] WiFi disconnected");
if (!displaySleeping && millis() - lastDisplayUpdate > 5000) {
display.showStatus("WiFi", "Connecting...");
lastDisplayUpdate = millis();
}
}
lastConnected = currentConnected;
} else if (!networkServicesStarted) {
lastConnected = false;
}
if (!displaySleeping) {
face.loop(moisture, battery);
}
// Webhook events on state transitions
FaceRenderer::Mood m = face.mood();
bool dead = face.isDeadMode();
if (networkServicesStarted && wifi.connected()) {
if (dead && !lastDead) {
webhook.send(EVT_DEAD, moisture, dead, bootMs);
} else if (!dead && lastDead) {
@@ -77,7 +463,24 @@ void App::loop() {
} 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

@@ -5,6 +5,7 @@
void WebUI::begin(Settings& settings,
WiFiManager& wifi,
MoistureSensor& moisture,
MotionSensor& motion,
FaceRenderer& face,
WebhookService& webhook,
unsigned long bootMs) {
@@ -19,40 +20,253 @@ void WebUI::begin(Settings& settings,
String mode = (wifi.mode() == NET_AP_SETUP) ? "Setup AP" : "Station";
String wifiSsid = settings.wifiSsid();
String currentSsid = wifi.ssid();
String plantProfile = settings.plantProfile();
String timezone = settings.timezone();
String bedtime = settings.bedtime();
String wakeTime = settings.wakeTime();
MotionCalibration mc = settings.motionCalibration();
String page =
"<h2>FacePlant</h2>"
"<p>Firmware v" + String(PB_VERSION) + "</p>"
"<h3>Wi-Fi</h3>"
"<p>Mode: " + mode + "</p>"
"<p>Connected SSID: " + (currentSsid.length() ? currentSsid : "(not connected)") + "</p>"
"<p>Saved SSID: " + (wifiSsid.length() ? wifiSsid : "(none)") + "</p>"
"<p>Setup AP: " + String(wifi.setupSsid()) + " / " + wifi.apIp().toString() + "</p>"
"<form method='POST' action='/wifi'>"
"<label>SSID</label><br>"
"<input name='ssid' size='30' value='" + wifiSsid + "'><br>"
"<label>Password</label><br>"
"<input type='password' name='pass' size='30' value=''><br>"
"<small>Leave password blank to keep saved password for the same SSID.</small><br><br>"
"<button type='submit' name='action' value='connect'>Connect Wi-Fi</button> "
"<button type='submit' name='action' value='forget'>Forget Wi-Fi</button>"
"</form><br>"
"<form method='POST' action='/config'>"
"<label>Plant profile</label><br>"
"<select name='plant'>"
"<option value='house'>Houseplant</option>"
"<option value='succulent'>Succulent</option>"
"<option value='herbs'>Herbs</option>"
"<option value='fern'>Fern</option>"
"<option value='tropical'>Tropical</option>"
"</select><br><br>"
"<label><input type='checkbox' name='kids' " + String(settings.kidsMode() ? "checked" : "") + "> Kids Mode</label><br><br>"
"<label>Webhook URL</label><br>"
"<input name='wh' size='40' value='" + settings.webhookUrl() + "'><br>"
"<label><input type='checkbox' name='wh_en' " + String(settings.webhookEnabled() ? "checked" : "") + "> Enable webhook</label><br><br>"
"<button type='submit'>Save</button>"
"<!DOCTYPE html><html><head>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>FacePlant</title>"
"<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css' "
"integrity='sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==' "
"crossorigin='anonymous' referrerpolicy='no-referrer' />"
"<style>"
"* { margin: 0; padding: 0; box-sizing: border-box; }"
"body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; "
"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; }"
".container { max-width: 600px; margin: 0 auto; }"
".card { background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); "
"border-radius: 20px; padding: 30px; margin-bottom: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }"
".header { text-align: center; margin-bottom: 10px; }"
".header h1 { font-size: 2.5em; font-weight: 700; color: #1d1d1f; margin-bottom: 5px; "
"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; "
"-webkit-text-fill-color: transparent; }"
".version { color: #86868b; font-size: 0.9em; margin-bottom: 20px; }"
".status-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin: 20px 0; }"
".status-item { background: #f5f5f7; padding: 15px; border-radius: 12px; }"
".status-label { font-size: 0.85em; color: #86868b; margin-bottom: 5px; }"
".status-value { font-size: 1em; color: #1d1d1f; font-weight: 500; word-break: break-word; }"
".status-value.connected { color: #34c759; }"
".status-value.disconnected { color: #ff3b30; }"
"h2 { font-size: 1.5em; font-weight: 600; color: #1d1d1f; margin: 25px 0 15px 0; }"
"label { display: block; font-size: 0.95em; font-weight: 500; color: #1d1d1f; margin-bottom: 8px; }"
"input[type='text'], input[type='password'], select { width: 100%; padding: 12px 16px; "
"border: 1px solid #d2d2d7; border-radius: 10px; font-size: 1em; "
"transition: all 0.2s; background: white; }"
"input[type='text']:focus, input[type='password']:focus, select:focus { "
"outline: none; border-color: #667eea; box-shadow: 0 0 0 4px rgba(102,126,234,0.1); }"
".form-hint { font-size: 0.85em; color: #86868b; margin-top: 5px; margin-bottom: 15px; }"
".checkbox-label { display: flex; align-items: center; margin: 15px 0; cursor: pointer; }"
"input[type='checkbox'] { width: 20px; height: 20px; margin-right: 10px; cursor: pointer; "
"accent-color: #667eea; }"
".btn { display: inline-block; padding: 12px 24px; border: none; border-radius: 10px; "
"font-size: 1em; font-weight: 500; cursor: pointer; transition: all 0.2s; "
"text-decoration: none; text-align: center; }"
".btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); "
"color: white; box-shadow: 0 4px 12px rgba(102,126,234,0.4); }"
".btn-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(102,126,234,0.5); }"
".btn-secondary { background: #f5f5f7; color: #1d1d1f; }"
".btn-secondary:hover { background: #e8e8ed; }"
".btn-danger { background: #ff3b30; color: white; }"
".btn-danger:hover { background: #ff2d20; transform: translateY(-2px); }"
".btn-group { display: flex; gap: 10px; margin-top: 20px; }"
".btn-group .btn { flex: 1; }"
".links { margin-top: 20px; text-align: center; }"
".links a { color: #667eea; text-decoration: none; margin: 0 10px; font-weight: 500; }"
".links a:hover { text-decoration: underline; }"
"@media (max-width: 600px) { .status-grid { grid-template-columns: 1fr; } }"
"</style>"
"</head><body>"
"<div class='container'>"
"<div class='card'>"
"<div class='header'>"
"<h1><i class='fas fa-seedling' style='margin-right: 10px;'></i>FacePlant</h1>"
"<div class='version'>Firmware v" + String(PB_VERSION) + "</div>"
"</div>"
"<div class='status-grid'>"
"<div class='status-item'><div class='status-label'>Status</div>"
"<div class='status-value " + String(wifi.connected() ? "connected" : "disconnected") + "'>"
+ (wifi.connected() ? "Connected" : "Not Connected") + "</div></div>"
"<div class='status-item'><div class='status-label'>Mode</div>"
"<div class='status-value'>" + mode + "</div></div>"
"<div class='status-item'><div class='status-label'>Current Network</div>"
"<div class='status-value'>" + (currentSsid.length() ? currentSsid : "None") + "</div></div>"
"<div class='status-item'><div class='status-label'>Setup AP</div>"
"<div class='status-value'>" + String(wifi.setupSsid()) + "</div></div>"
"</div>"
"</div>"
"<div class='card'>"
"<h2><i class='fas fa-wave-square' style='margin-right: 10px;'></i>Sensor Readings</h2>"
"<div class='status-grid'>"
"<div class='status-item'><div class='status-label'>Soil Moisture</div>"
"<div class='status-value'>" + String(moisture.percent()) + "% (" + String(moisture.raw()) + ")</div></div>"
"<div class='status-item'><div class='status-label'>Accelerometer</div>"
"<div class='status-value'>" + String(motion.available() ? "Detected" : "Not detected") + "</div></div>"
"<div class='status-item'><div class='status-label'>Roll</div>"
"<div class='status-value'>" + String(motion.rollDeg(), 1) + "&deg;</div></div>"
"<div class='status-item'><div class='status-label'>Pitch</div>"
"<div class='status-value'>" + String(motion.pitchDeg(), 1) + "&deg;</div></div>"
"</div>"
"</div>"
"<div class='card'>"
"<h2><i class='fas fa-wifi' style='margin-right: 10px;'></i>Wi-Fi Settings</h2>"
"<form method='POST' action='/wifi' id='wifiForm'>"
"<label for='ssid'>Network Name (SSID)</label>"
"<input type='text' id='ssid' name='ssid' value='" + wifiSsid + "' required>"
"<div class='form-hint'>Enter your Wi-Fi network name</div>"
"<label for='pass'>Password</label>"
"<input type='password' id='pass' name='pass' placeholder='Enter password'>"
"<div class='form-hint'>Leave blank to keep existing password for same SSID</div>"
"<div class='btn-group'>"
"<button type='submit' name='action' value='connect' class='btn btn-primary'>Connect</button>"
"<button type='submit' name='action' value='forget' class='btn btn-danger' "
"onclick='return confirm(\"Forget Wi-Fi settings?\")'>Forget</button>"
"</div>"
"</form>"
"<p><a href='/status'>Status (JSON)</a></p>"
"<p><a href='/update'>OTA Update</a></p>";
"</div>"
"<div class='card'>"
"<h2><i class='fas fa-leaf' style='margin-right: 10px;'></i>Plant Settings</h2>"
"<form method='POST' action='/config'>"
"<label for='plant'>Plant Profile</label>"
"<select id='plant' name='plant'>"
"<option value='house'" + String(plantProfile == "house" ? " selected" : "") + ">Houseplant</option>"
"<option value='succulent'" + String(plantProfile == "succulent" ? " selected" : "") + ">Succulent</option>"
"<option value='herbs'" + String(plantProfile == "herbs" ? " selected" : "") + ">Herbs</option>"
"<option value='fern'" + String(plantProfile == "fern" ? " selected" : "") + ">Fern</option>"
"<option value='tropical'" + String(plantProfile == "tropical" ? " selected" : "") + ">Tropical</option>"
"<option value='veg'" + String(plantProfile == "veg" ? " selected" : "") + ">Vegetables</option>"
"</select>"
"<div class='form-hint'>Select your plant type for optimal watering thresholds</div>"
"<label class='checkbox-label'>"
"<input type='checkbox' name='kids' " + String(settings.kidsMode() ? "checked" : "") + "> Kids Mode"
"</label>"
"<div class='form-hint'>Simplified interface for children</div>"
"<label for='wh'>Webhook URL</label>"
"<input type='text' id='wh' name='wh' value='" + settings.webhookUrl() + "' placeholder='https://...'>"
"<div class='form-hint'>Optional webhook for notifications</div>"
"<label class='checkbox-label'>"
"<input type='checkbox' name='wh_en' " + String(settings.webhookEnabled() ? "checked" : "") + "> Enable Webhook"
"</label>"
"<h2><i class='fas fa-clock' style='margin-right: 10px;'></i>Sleep Schedule</h2>"
"<label for='bed'>Bedtime</label>"
"<input type='time' id='bed' name='bed' value='" + bedtime + "' required>"
"<div class='form-hint'>FacePlant starts falling asleep 5 minutes before bedtime</div>"
"<label for='wake'>Wake Time</label>"
"<input type='time' id='wake' name='wake' value='" + wakeTime + "' required>"
"<div class='form-hint'>FacePlant shows wake-up animation for 5 minutes after this time</div>"
"<label for='tz'>Timezone</label>"
"<select id='tz' name='tz'>"
"<option value='America/New_York'" + String(timezone == "America/New_York" ? " selected" : "") + ">America/New_York (Eastern)</option>"
"<option value='America/Chicago'" + String(timezone == "America/Chicago" ? " selected" : "") + ">America/Chicago (Central)</option>"
"<option value='America/Denver'" + String(timezone == "America/Denver" ? " selected" : "") + ">America/Denver (Mountain)</option>"
"<option value='America/Los_Angeles'" + String(timezone == "America/Los_Angeles" ? " selected" : "") + ">America/Los_Angeles (Pacific)</option>"
"<option value='UTC0'" + String(timezone == "UTC0" ? " selected" : "") + ">UTC</option>"
"</select>"
"<div class='form-hint'>Default is America/New_York</div>"
"<div class='btn-group'>"
"<button type='submit' class='btn btn-primary'>Save Settings</button>"
"</div>"
"</form>"
"</div>"
"<div class='card'>"
"<h2><i class='fas fa-compass' style='margin-right: 10px;'></i>Accelerometer Calibration</h2>"
"<div style='background: #f5f5f7; padding: 15px; border-radius: 12px; margin-bottom: 20px;'>"
"<div style='font-size: 0.85em; color: #86868b; margin-bottom: 5px;'>Current Tilt</div>"
"<div style='font-size: 1em; color: #1d1d1f;'>Roll: " + String(motion.rollDeg(), 1) + "&deg;</div>"
"<div style='font-size: 1em; color: #1d1d1f;'>Pitch: " + String(motion.pitchDeg(), 1) + "&deg;</div>"
"<div style='font-size: 0.85em; color: #86868b; margin-top: 8px;'>"
"Neutral offsets: roll " + String(mc.rollZeroDeg, 1) + "&deg;, pitch " + String(mc.pitchZeroDeg, 1) + "&deg;</div>"
"</div>"
"<form method='POST' action='/motion-calibrate' style='margin-bottom: 12px;'>"
"<div class='btn-group'>"
"<button type='submit' name='action' value='zero_now' class='btn btn-primary'>Set Current Position as Neutral</button>"
"</div>"
"</form>"
"<form method='POST' action='/motion-calibrate'>"
"<div class='btn-group'>"
"<button type='submit' name='action' value='reset' class='btn btn-secondary'>Reset Accelerometer Calibration</button>"
"</div>"
"</form>"
"</div>"
"<div class='card'>"
"<h2><i class='fas fa-sliders-h' style='margin-right: 10px;'></i>Sensor Calibration</h2>"
"<div style='background: #f5f5f7; padding: 15px; border-radius: 12px; margin-bottom: 20px;'>"
"<div style='font-size: 0.85em; color: #86868b; margin-bottom: 5px;'>Current Reading</div>"
"<div style='font-size: 1.5em; font-weight: 600; color: #667eea;'>" + String(moisture.raw()) + "</div>"
"<div style='font-size: 0.85em; color: #86868b; margin-top: 5px;'>Moisture: " + String(moisture.percent()) + "%</div>"
"</div>"
"<form method='POST' action='/calibrate'>"
"<p style='color: #86868b; font-size: 0.9em; margin-bottom: 20px;'>"
"<i class='fas fa-lightbulb' style='color: #667eea; margin-right: 5px;'></i><strong>How to calibrate:</strong><br>"
"1. For <strong>dry</strong> reading: Remove sensor from soil, wait 30 seconds, note the value above<br>"
"2. For <strong>wet</strong> reading: Place sensor in water, wait 30 seconds, note the value above<br>"
"3. Enter both values below and save"
"</p>"
"<label for='dry'>Dry Value (sensor in air)</label>"
"<input type='number' id='dry' name='dry' value='" + String(settings.dryRaw()) + "' required min='0' max='4095'>"
"<div class='form-hint'>Raw ADC value when sensor is completely dry (typically 3000-4000)</div>"
"<label for='wet'>Wet Value (sensor in water)</label>"
"<input type='number' id='wet' name='wet' value='" + String(settings.wetRaw()) + "' required min='0' max='4095'>"
"<div class='form-hint'>Raw ADC value when sensor is fully wet (typically 1000-2000)</div>"
"<div class='btn-group'>"
"<button type='submit' class='btn btn-primary'>Save Calibration</button>"
"</div>"
"</form>"
"</div>"
"<div class='card' style='border: 2px solid #ff3b30;'>"
"<h2 style='color: #ff3b30;'><i class='fas fa-exclamation-triangle' style='margin-right: 10px;'></i>Danger Zone</h2>"
"<p style='color: #86868b; margin-bottom: 15px;'>Irreversible actions that will reset your device</p>"
"<form method='POST' action='/restart' id='restartForm' style='margin-bottom: 20px;'>"
"<p style='margin-bottom: 15px;'><strong>Restart Device</strong></p>"
"<p style='color: #86868b; font-size: 0.9em; margin-bottom: 15px;'>"
"Reboots FacePlant without erasing settings.</p>"
"<button type='submit' class='btn btn-secondary' style='width: 100%;'>Restart</button>"
"</form>"
"<form method='POST' action='/factory-reset' id='resetForm'>"
"<p style='margin-bottom: 15px;'><strong>Factory Reset</strong></p>"
"<p style='color: #86868b; font-size: 0.9em; margin-bottom: 15px;'>"
"This will erase all settings including Wi-Fi credentials, plant profile, calibration data, and webhooks. "
"The device will restart in setup mode.</p>"
"<button type='submit' class='btn btn-danger' style='width: 100%;'>Reset to Factory Defaults</button>"
"</form>"
"</div>"
"<div class='card links'>"
"<a href='/status'><i class='fas fa-chart-bar'></i> Status (JSON)</a>"
"<a href='/update'><i class='fas fa-cloud-upload-alt'></i> OTA Update</a>"
"</div>"
"</div>"
"<script>"
"document.getElementById('wifiForm').addEventListener('submit', function(e) {"
" if (e.submitter.value === 'connect') {"
" const ssid = document.getElementById('ssid').value.trim();"
" const pass = document.getElementById('pass').value;"
" const savedSsid = '" + wifiSsid + "';"
" if (ssid !== savedSsid && pass.length === 0) {"
" e.preventDefault();"
" alert('Please enter a password for the new network.');"
" return false;"
" }"
" }"
"});"
"document.getElementById('restartForm').addEventListener('submit', function(e) {"
" e.preventDefault();"
" if (confirm('Restart FacePlant now?')) {"
" this.submit();"
" }"
"});"
"document.getElementById('resetForm').addEventListener('submit', function(e) {"
" e.preventDefault();"
" if (confirm('Are you sure you want to reset to factory defaults? This cannot be undone.')) {"
" if (confirm('Last warning: This will erase ALL settings and restart the device. Continue?')) {"
" this.submit();"
" }"
" }"
"});"
"</script>"
"</body></html>";
_server.send(200, "text/html", page);
});
@@ -69,6 +283,10 @@ void WebUI::begin(Settings& settings,
pass = _server.arg("pass");
} else if (ssid == settings.wifiSsid()) {
pass = settings.wifiPass();
} else {
// New SSID but no password provided - reject
_server.send(400, "text/plain", "Password required for new network");
return;
}
if (ssid.length() > 0) wifi.saveAndConnect(ssid, pass);
@@ -83,10 +301,109 @@ void WebUI::begin(Settings& settings,
settings.setKidsMode(_server.hasArg("kids"));
settings.setWebhookEnabled(_server.hasArg("wh_en"));
if (_server.hasArg("wh")) settings.setWebhookUrl(_server.arg("wh"));
if (_server.hasArg("tz")) settings.setTimezone(_server.arg("tz"));
if (_server.hasArg("bed")) settings.setBedtime(_server.arg("bed"));
if (_server.hasArg("wake")) settings.setWakeTime(_server.arg("wake"));
_server.sendHeader("Location", "/");
_server.send(303);
});
_server.on("/calibrate", HTTP_POST, [&]() {
if (_server.hasArg("dry") && _server.hasArg("wet")) {
int dryVal = _server.arg("dry").toInt();
int wetVal = _server.arg("wet").toInt();
Serial.println("[WebUI] Calibration update requested");
Serial.print("[WebUI] Dry value: ");
Serial.println(dryVal);
Serial.print("[WebUI] Wet value: ");
Serial.println(wetVal);
if (dryVal > wetVal && dryVal <= 4095 && wetVal >= 0) {
settings.setCalibration(dryVal, wetVal);
Serial.println("[WebUI] Calibration saved");
} else {
Serial.println("[WebUI] Invalid calibration values (dry must be > wet)");
}
}
_server.sendHeader("Location", "/");
_server.send(303);
});
_server.on("/motion-calibrate", HTTP_POST, [&]() {
String action = _server.hasArg("action") ? _server.arg("action") : "zero_now";
if (action == "reset") {
settings.clearMotionCalibration();
motion.setZeroOffsets(0.0f, 0.0f);
Serial.println("[WebUI] Accelerometer calibration reset");
} else {
if (motion.available()) {
settings.setMotionCalibration(motion.rawRollDeg(), motion.rawPitchDeg());
motion.setZeroOffsets(motion.rawRollDeg(), motion.rawPitchDeg());
Serial.print("[WebUI] Accelerometer neutral set from current position: roll=");
Serial.print(motion.rawRollDeg(), 2);
Serial.print(" pitch=");
Serial.println(motion.rawPitchDeg(), 2);
} else {
Serial.println("[WebUI] Accelerometer calibration requested but MPU6050 unavailable");
}
}
_server.sendHeader("Location", "/");
_server.send(303);
});
_server.on("/factory-reset", HTTP_POST, [&]() {
Serial.println("[WebUI] Factory reset requested");
_server.send(200, "text/html",
"<!DOCTYPE html><html><head><meta http-equiv='refresh' content='10;url=http://192.168.4.1'>"
"<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'>"
"<style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"
"background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);min-height:100vh;display:flex;"
"align-items:center;justify-content:center;margin:0;padding:20px;}"
".card{background:rgba(255,255,255,0.95);border-radius:20px;padding:40px;text-align:center;"
"box-shadow:0 20px 60px rgba(0,0,0,0.3);max-width:400px;}"
"h1{font-size:2em;margin-bottom:10px;color:#1d1d1f;}"
"p{color:#86868b;margin:10px 0;}"
".spinner{font-size:3em;color:#667eea;margin:20px 0;animation:spin 2s linear infinite;}"
"@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}</style></head><body>"
"<div class='card'>"
"<div class='spinner'><i class='fas fa-sync-alt'></i></div>"
"<h1>Factory Reset</h1>"
"<p>Erasing all settings...</p>"
"<p>Device will restart in setup mode.</p>"
"<p>Reconnect to <strong>FacePlant-Setup</strong> network in 10 seconds.</p>"
"</div></body></html>");
delay(1000);
settings.factoryReset();
delay(1000);
Serial.println("[WebUI] Restarting device...");
ESP.restart();
});
_server.on("/restart", HTTP_POST, [&]() {
Serial.println("[WebUI] Restart requested");
_server.send(200, "text/html",
"<!DOCTYPE html><html><head><meta http-equiv='refresh' content='8;url=/'>"
"<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'>"
"<style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"
"background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);min-height:100vh;display:flex;"
"align-items:center;justify-content:center;margin:0;padding:20px;}"
".card{background:rgba(255,255,255,0.95);border-radius:20px;padding:40px;text-align:center;"
"box-shadow:0 20px 60px rgba(0,0,0,0.3);max-width:400px;}"
"h1{font-size:2em;margin-bottom:10px;color:#1d1d1f;}"
"p{color:#86868b;margin:10px 0;}"
".spinner{font-size:3em;color:#667eea;margin:20px 0;animation:spin 2s linear infinite;}"
"@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}</style></head><body>"
"<div class='card'>"
"<div class='spinner'><i class='fas fa-sync-alt'></i></div>"
"<h1>Restarting</h1>"
"<p>FacePlant is rebooting...</p>"
"<p>Settings are preserved.</p>"
"</div></body></html>");
delay(500);
ESP.restart();
});
_server.on("/status", HTTP_GET, [&]() {
JsonDocument doc;
doc["device"] = "FacePlant";
@@ -100,6 +417,14 @@ void WebUI::begin(Settings& settings,
doc["moisture_pct"] = moisture.percent();
doc["raw"] = moisture.raw();
doc["kids_mode"] = settings.kidsMode();
doc["timezone"] = settings.timezone();
doc["bedtime"] = settings.bedtime();
doc["wake_time"] = settings.wakeTime();
doc["motion_ok"] = motion.available();
doc["motion_roll_deg"] = motion.rollDeg();
doc["motion_pitch_deg"] = motion.pitchDeg();
doc["motion_roll_zero_deg"] = motion.rollZeroDeg();
doc["motion_pitch_zero_deg"] = motion.pitchZeroDeg();
doc["dead_mode"] = face.isDeadMode();
doc["uptime_ms"] = millis() - bootMs;

View File

@@ -3,6 +3,7 @@
#include "../settings/Settings.h"
#include "../net/WiFiManager.h"
#include "../sensors/MoistureSensor.h"
#include "../sensors/MotionSensor.h"
#include "../ui/FaceRenderer.h"
#include "../net/WebhookService.h"
@@ -11,6 +12,7 @@ public:
void begin(Settings& settings,
WiFiManager& wifi,
MoistureSensor& moisture,
MotionSensor& motion,
FaceRenderer& face,
WebhookService& webhook,
unsigned long bootMs);

View File

@@ -20,18 +20,26 @@ String WiFiManager::ssid() const {
void WiFiManager::begin(Settings& settings, bool forceSetupAP) {
_settings = &settings;
Serial.println("[WiFi] Initializing...");
WiFi.persistent(false);
WiFi.setAutoReconnect(false);
if (forceSetupAP) { startSetupAP(); return; }
if (forceSetupAP) {
Serial.println("[WiFi] Forced setup AP mode");
startSetupAP();
return;
}
if (_settings->hasWiFi()) {
Serial.print("[WiFi] Connecting to: ");
Serial.println(_settings->wifiSsid());
WiFi.mode(WIFI_STA);
WiFi.setHostname(PB_HOSTNAME);
WiFi.begin(_settings->wifiSsid().c_str(), _settings->wifiPass().c_str());
_bootConnectStartMs = millis();
_mode = NET_STA;
} else {
Serial.println("[WiFi] No saved credentials, starting setup AP");
startSetupAP();
}
}
@@ -46,12 +54,23 @@ void WiFiManager::saveAndConnect(const String& ssid, const String& pass) {
if (cleanSsid.length() == 0) return;
bool changed = (cleanSsid != _settings->wifiSsid()) || (pass != _settings->wifiPass());
Serial.println("[WiFi] Saving credentials...");
Serial.print("[WiFi] SSID: ");
Serial.println(cleanSsid);
Serial.print("[WiFi] Password length: ");
Serial.println(pass.length());
Serial.print("[WiFi] Changed: ");
Serial.println(changed ? "YES" : "NO");
_settings->saveWiFi(cleanSsid, pass);
if (changed) _settings->setEverConnected(false);
_bootConnectStartMs = millis();
_reconnectBackoffStep = 0;
_nextReconnectMs = _bootConnectStartMs + 5000UL;
Serial.println("[WiFi] Starting connection attempt...");
startStationAttempt();
}
@@ -63,6 +82,7 @@ void WiFiManager::clearAndStartSetupAP() {
}
void WiFiManager::startSetupAP() {
Serial.println("[WiFi] Starting Setup AP mode");
_mode = NET_AP_SETUP;
_setupRequested = false;
@@ -71,6 +91,11 @@ void WiFiManager::startSetupAP() {
WiFi.softAPConfig(_apIP, _apIP, _apMask);
WiFi.softAP(_setupSsid, _setupPass);
Serial.print("[WiFi] AP SSID: ");
Serial.println(_setupSsid);
Serial.print("[WiFi] AP IP: ");
Serial.println(_apIP);
_portal.start(_apIP);
MDNS.end();
@@ -104,20 +129,56 @@ void WiFiManager::startStation() {
}
void WiFiManager::startStationAttempt() {
Serial.print("[WiFi] Starting connection attempt to: ");
Serial.println(_settings->wifiSsid());
Serial.print("[WiFi] Mode: ");
Serial.println(_mode == NET_AP_SETUP ? "AP+STA (dual)" : "STA only");
WiFi.disconnect();
delay(100);
if (_mode == NET_AP_SETUP) WiFi.mode(WIFI_AP_STA);
else WiFi.mode(WIFI_STA);
WiFi.setHostname(PB_HOSTNAME);
WiFi.begin(_settings->wifiSsid().c_str(), _settings->wifiPass().c_str());
Serial.println("[WiFi] WiFi.begin() called");
}
void WiFiManager::loop() {
static unsigned long lastStatusLog = 0;
static wl_status_t lastStatus = WL_IDLE_STATUS;
if (_mode == NET_AP_SETUP) _portal.loop();
if (_setupRequested) { startSetupAP(); return; }
wl_status_t currentStatus = WiFi.status();
if (currentStatus != lastStatus || (millis() - lastStatusLog > 5000)) {
Serial.print("[WiFi] Status: ");
switch (currentStatus) {
case WL_CONNECTED: Serial.println("CONNECTED"); break;
case WL_NO_SSID_AVAIL: Serial.println("SSID not found"); break;
case WL_CONNECT_FAILED: Serial.println("Connection FAILED (wrong password?)"); break;
case WL_IDLE_STATUS: Serial.println("Idle"); break;
case WL_DISCONNECTED: Serial.println("Disconnected"); break;
default: Serial.print("Other ("); Serial.print(currentStatus); Serial.println(")"); break;
}
lastStatus = currentStatus;
lastStatusLog = millis();
}
if (WiFi.status() == WL_CONNECTED) {
if (_mode != NET_STA || WiFi.getMode() != WIFI_STA) startStation();
if (!_settings->everConnected()) _settings->setEverConnected(true);
if (_mode != NET_STA || WiFi.getMode() != WIFI_STA) {
Serial.print("[WiFi] CONNECTED! IP: ");
Serial.println(WiFi.localIP());
startStation();
}
if (!_settings->everConnected()) {
Serial.println("[WiFi] First successful connection!");
_settings->setEverConnected(true);
}
return;
}
@@ -128,6 +189,10 @@ void WiFiManager::loop() {
if (!_settings->everConnected() && _bootConnectStartMs != 0 &&
(millis() - _bootConnectStartMs) > BOOT_CONNECT_TIMEOUT_MS) {
Serial.println("[WiFi] Boot connection timeout - returning to setup AP");
Serial.print("[WiFi] Elapsed time: ");
Serial.print((millis() - _bootConnectStartMs) / 1000);
Serial.println(" seconds");
_bootConnectStartMs = 0;
startSetupAP();
return;
@@ -136,6 +201,9 @@ void WiFiManager::loop() {
if (_settings->everConnected()) {
unsigned long now = millis();
if ((long)(now - _nextReconnectMs) >= 0) {
Serial.print("[WiFi] Retry attempt (backoff step ");
Serial.print(_reconnectBackoffStep);
Serial.println(")");
startStationAttempt();
_nextReconnectMs = now + reconnectDelayMs(_reconnectBackoffStep++);
}

View File

@@ -40,7 +40,7 @@ private:
const char* _setupPass = "faceplant";
unsigned long _bootConnectStartMs = 0;
static constexpr unsigned long BOOT_CONNECT_TIMEOUT_MS = 20000;
static constexpr unsigned long BOOT_CONNECT_TIMEOUT_MS = 60000;
unsigned long _nextReconnectMs = 0;
int _reconnectBackoffStep = 0;

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

@@ -0,0 +1,40 @@
#include "AmbientLightSensor.h"
#include <math.h>
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;
}

View File

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

View File

@@ -0,0 +1,71 @@
#include "BatterySensor.h"
#include <math.h>
void BatterySensor::begin() {
_ok = _maxlipo.begin();
if (!_ok) {
Serial.println("[Battery] MAX17048 not detected");
return;
}
Serial.println("[Battery] MAX17048 initialized on I2C");
refresh();
Serial.print("[Battery] Initial voltage: ");
Serial.print(_voltage, 2);
Serial.print("V, ");
Serial.print(_percent);
Serial.println("%");
}
void BatterySensor::loop() {
if (!_ok) return;
unsigned long now = millis();
if (now - _lastCheckMs < CHECK_INTERVAL_MS) return;
_lastCheckMs = now;
int prevPercent = _percent;
bool wasLow = _isLow;
bool wasCharging = _isCharging;
refresh();
if (abs(_percent - prevPercent) >= 2 || _isLow != wasLow || _isCharging != wasCharging) {
Serial.print("[Battery] Voltage: ");
Serial.print(_voltage, 2);
Serial.print("V (");
Serial.print(_percent);
Serial.print("%)");
if (_isCharging) Serial.print(" CHARGING");
Serial.println();
}
}
void BatterySensor::refresh() {
float v = _maxlipo.cellVoltage();
float p = _maxlipo.cellPercent();
float rate = _maxlipo.chargeRate();
if (!isnan(v) && isfinite(v) && v > 0.0f) _voltage = v;
if (!isnan(p) && isfinite(p)) {
int pct = (int)lroundf(p);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
_percent = pct;
}
_isLow = (_voltage < VOLTAGE_LOW) || (_percent < 15);
_isCharging = (!isnan(rate) && isfinite(rate) && rate > CHARGE_RATE_MIN_PCT_PER_HR);
}
bool BatterySensor::shouldBlink() const {
// Blink every 500ms when battery is low (< 20%)
return (_percent < 20) && ((millis() / 500) % 2 == 0);
}
int BatterySensor::iconFillWidth(int maxWidth) const {
return (_percent * maxWidth) / 100;
}

View File

@@ -0,0 +1,35 @@
#pragma once
#include <Arduino.h>
#include <Adafruit_MAX1704X.h>
class BatterySensor {
public:
void begin();
void loop();
// Battery status
int percent() const { return _percent; }
float voltage() const { return _voltage; }
bool isLow() const { return _isLow; }
bool isCharging() const { return _isCharging; }
bool shouldBlink() const;
// For display
int iconFillWidth(int maxWidth) const;
private:
static constexpr unsigned long CHECK_INTERVAL_MS = 2000;
static constexpr float VOLTAGE_LOW = 3.2; // Low battery threshold
static constexpr float CHARGE_RATE_MIN_PCT_PER_HR = 0.2f; // heuristic
int _percent = 100;
float _voltage = 3.7;
bool _isLow = false;
bool _isCharging = false;
unsigned long _lastCheckMs = 0;
bool _ok = false;
Adafruit_MAX17048 _maxlipo;
void refresh();
};

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 = 34;
Settings* _settings = nullptr;
static constexpr int NUM_SAMPLES = 12;

View File

@@ -0,0 +1,150 @@
#include "MotionSensor.h"
#include <math.h>
#include <Wire.h>
static constexpr float GRAVITY_MS2 = 9.80665f;
static constexpr float ROLL_DEADZONE_DEG = 6.0f;
static constexpr float PITCH_DEADZONE_DEG = 6.0f;
static constexpr uint8_t MAX_I2C_ERRORS_BEFORE_DISABLE = 8;
static float applyDeadzone(float value, float deadzone) {
if (fabsf(value) <= deadzone) return 0.0f;
return value > 0.0f ? (value - deadzone) : (value + deadzone);
}
float MotionSensor::clampf(float v, float lo, float hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
bool MotionSensor::pingMpu(uint8_t attempts) {
for (uint8_t i = 0; i < attempts; i++) {
Wire.beginTransmission(_i2cAddr);
uint8_t err = Wire.endTransmission();
if (err == 0) return true;
delay(2);
}
return false;
}
void MotionSensor::begin() {
_ok = _mpu.begin();
if (!_ok) {
Serial.println("[Motion] MPU6050 not detected");
return;
}
_mpu.setAccelerometerRange(MPU6050_RANGE_4_G);
_mpu.setGyroRange(MPU6050_RANGE_250_DEG);
_mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);
// Determine active address for later health pings (most modules use 0x68).
_i2cAddr = 0x68;
if (!pingMpu(1)) {
_i2cAddr = 0x69;
if (!pingMpu(1)) _i2cAddr = 0x68;
}
_nextMs = millis();
Serial.print("[Motion] MPU6050 ready @ 0x");
if (_i2cAddr < 16) Serial.print('0');
Serial.println(_i2cAddr, HEX);
}
void MotionSensor::loop() {
if (!_ok) return;
unsigned long now = millis();
if ((long)(now - _nextMs) < 0) return;
if (_i2cBackoffUntilMs != 0 && (long)(now - _i2cBackoffUntilMs) < 0) return;
_nextMs = now + INTERVAL_MS;
if (!pingMpu(2)) {
_consecutiveI2cErrors++;
unsigned long backoffMs = (unsigned long)(_consecutiveI2cErrors * 50U);
if (backoffMs > 1000UL) backoffMs = 1000UL;
_i2cBackoffUntilMs = now + backoffMs;
Serial.print("[Motion] I2C ping failed (");
Serial.print(_consecutiveI2cErrors);
Serial.print("/");
Serial.print(MAX_I2C_ERRORS_BEFORE_DISABLE);
Serial.println(") - backing off");
if (_consecutiveI2cErrors >= MAX_I2C_ERRORS_BEFORE_DISABLE) {
_ok = false;
Serial.println("[Motion] Too many I2C failures - disabling MPU6050 motion features");
}
return;
}
if (_consecutiveI2cErrors > 0) {
Serial.println("[Motion] I2C recovered");
_consecutiveI2cErrors = 0;
}
_i2cBackoffUntilMs = 0;
sensors_event_t a, g, t;
_mpu.getEvent(&a, &g, &t);
float ax = a.acceleration.x / GRAVITY_MS2;
float ay = a.acceleration.y / GRAVITY_MS2;
float az = a.acceleration.z / GRAVITY_MS2;
_accelMagG = sqrtf(ax * ax + ay * ay + az * az);
// Orientation estimated from gravity vector. Axis sign may need tuning based on physical mounting.
float newRoll = atan2f(ax, az) * 57.29578f;
float newPitch = atan2f(ay, az) * 57.29578f;
float dRoll = fabsf(newRoll - _rollDeg);
float dPitch = fabsf(newPitch - _pitchDeg);
float dMag = fabsf(_accelMagG - 1.0f);
_lastRollDeg = _rollDeg;
_lastPitchDeg = _pitchDeg;
_rollDeg = (_rollDeg * 0.75f) + (newRoll * 0.25f);
_pitchDeg = (_pitchDeg * 0.75f) + (newPitch * 0.25f);
bool movingNow = (dRoll > 2.0f) || (dPitch > 2.0f) || (dMag > 0.10f);
if (movingNow) _movingUntilMs = now + MOVE_HOLD_MS;
// Heuristic pickup event: stronger motion / lift / rotation burst.
bool pickup = (dRoll > 10.0f) || (dPitch > 10.0f) || (dMag > 0.22f) || (fabsf(g.gyro.x) > 0.8f) || (fabsf(g.gyro.y) > 0.8f);
if (pickup) _pickupLatched = true;
}
bool MotionSensor::isMoving() const {
return _ok && ((long)(millis() - _movingUntilMs) < 0);
}
bool MotionSensor::consumePickupEvent() {
bool out = _pickupLatched;
_pickupLatched = false;
return out;
}
void MotionSensor::setZeroOffsets(float rollZeroDeg, float pitchZeroDeg) {
_rollZeroDeg = rollZeroDeg;
_pitchZeroDeg = pitchZeroDeg;
Serial.print("[Motion] Calibration set: roll0=");
Serial.print(_rollZeroDeg, 2);
Serial.print(" pitch0=");
Serial.println(_pitchZeroDeg, 2);
}
int8_t MotionSensor::eyeOffsetX() const {
if (!_ok) return 0;
float roll = applyDeadzone(rollDeg(), ROLL_DEADZONE_DEG);
roll = clampf(roll, -40.0f, 40.0f);
return (int8_t)lroundf((roll / 40.0f) * 7.0f);
}
int8_t MotionSensor::pupilSizeDelta() const {
if (!_ok) return 0;
// Positive pitch is treated as forward tilt (bigger pupils).
float pitch = applyDeadzone(pitchDeg(), PITCH_DEADZONE_DEG);
pitch = clampf(pitch, -35.0f, 35.0f);
return (int8_t)lroundf((pitch / 35.0f) * 4.0f); // -4..+4 (used for whole-eye scaling now)
}

View File

@@ -0,0 +1,51 @@
#pragma once
#include <Arduino.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>
class MotionSensor {
public:
void begin();
void loop();
bool available() const { return _ok; }
bool isMoving() const;
bool consumePickupEvent();
float rollDeg() const { return _rollDeg - _rollZeroDeg; } // corrected left/right tilt
float pitchDeg() const { return _pitchDeg - _pitchZeroDeg; } // corrected forward/back tilt
float rawRollDeg() const { return _rollDeg; }
float rawPitchDeg() const { return _pitchDeg; }
void setZeroOffsets(float rollZeroDeg, float pitchZeroDeg);
float rollZeroDeg() const { return _rollZeroDeg; }
float pitchZeroDeg() const { return _pitchZeroDeg; }
int8_t eyeOffsetX() const;
int8_t pupilSizeDelta() const;
private:
static constexpr unsigned long INTERVAL_MS = 50;
static constexpr unsigned long MOVE_HOLD_MS = 1200;
Adafruit_MPU6050 _mpu;
bool _ok = false;
unsigned long _nextMs = 0;
float _rollDeg = 0.0f;
float _pitchDeg = 0.0f;
float _lastRollDeg = 0.0f;
float _lastPitchDeg = 0.0f;
float _accelMagG = 1.0f;
unsigned long _movingUntilMs = 0;
bool _pickupLatched = false;
float _rollZeroDeg = 0.0f;
float _pitchZeroDeg = 0.0f;
uint8_t _i2cAddr = 0x68;
uint8_t _consecutiveI2cErrors = 0;
unsigned long _i2cBackoffUntilMs = 0;
static float clampf(float v, float lo, float hi);
bool pingMpu(uint8_t attempts = 2);
};

View File

@@ -9,6 +9,14 @@ static PlantThresholds thresholdsForProfile(const String& key) {
return {35, 42, 85}; // houseplant default
}
static bool isValidHHMM(const String& s) {
if (s.length() != 5 || s.charAt(2) != ':') return false;
if (!isDigit(s.charAt(0)) || !isDigit(s.charAt(1)) || !isDigit(s.charAt(3)) || !isDigit(s.charAt(4))) return false;
int h = (s.charAt(0) - '0') * 10 + (s.charAt(1) - '0');
int m = (s.charAt(3) - '0') * 10 + (s.charAt(4) - '0');
return h >= 0 && h <= 23 && m >= 0 && m <= 59;
}
void Settings::begin() {
_prefs.begin("faceplant", false);
}
@@ -25,12 +33,15 @@ void Settings::saveWiFi(const String& ssid, const String& pass) {
}
void Settings::clearWiFi() { _prefs.remove("wifi_ssid"); _prefs.remove("wifi_pass"); }
String Settings::plantProfile() const { return _prefs.getString("plant_profile", "house"); }
String Settings::plantProfile() const {
if (!_prefs.isKey("plant_profile")) return "house";
return _prefs.getString("plant_profile", "house");
}
void Settings::setPlantProfile(const String& key) { _prefs.putString("plant_profile", key); }
PlantThresholds Settings::thresholds() const { return thresholdsForProfile(plantProfile()); }
int Settings::dryRaw() const { return _prefs.getInt("cal_dry_raw", 3000); }
int Settings::wetRaw() const { return _prefs.getInt("cal_wet_raw", 1600); }
int Settings::dryRaw() const { return _prefs.getInt("cal_dry_raw", 3772); }
int Settings::wetRaw() const { return _prefs.getInt("cal_wet_raw", 1416); }
void Settings::setCalibration(int dryRaw, int wetRaw) {
_prefs.putInt("cal_dry_raw", dryRaw);
_prefs.putInt("cal_wet_raw", wetRaw);
@@ -42,5 +53,58 @@ void Settings::setKidsMode(bool v) { _prefs.putBool("kids_mode", v); }
bool Settings::webhookEnabled() const { return _prefs.getBool("wh_en", false); }
void Settings::setWebhookEnabled(bool v) { _prefs.putBool("wh_en", v); }
String Settings::webhookUrl() const { return _prefs.getString("wh_url", ""); }
String Settings::webhookUrl() const {
if (!_prefs.isKey("wh_url")) return "";
return _prefs.getString("wh_url", "");
}
void Settings::setWebhookUrl(const String& url) { _prefs.putString("wh_url", url); }
String Settings::timezone() const {
if (!_prefs.isKey("tz")) return String(PB_TZ);
return _prefs.getString("tz", String(PB_TZ));
}
void Settings::setTimezone(const String& tz) {
String v = tz;
v.trim();
if (v.length() == 0) v = String(PB_TZ);
_prefs.putString("tz", v);
}
String Settings::bedtime() const {
if (!_prefs.isKey("bed")) return "23:00";
return _prefs.getString("bed", "23:00");
}
void Settings::setBedtime(const String& hhmm) {
_prefs.putString("bed", isValidHHMM(hhmm) ? hhmm : "23:00");
}
String Settings::wakeTime() const {
if (!_prefs.isKey("wake")) return "07:00";
return _prefs.getString("wake", "07:00");
}
void Settings::setWakeTime(const String& hhmm) {
_prefs.putString("wake", isValidHHMM(hhmm) ? hhmm : "07:00");
}
MotionCalibration Settings::motionCalibration() const {
MotionCalibration c{};
c.rollZeroDeg = _prefs.isKey("accel_r0") ? _prefs.getFloat("accel_r0", 0.0f) : 0.0f;
c.pitchZeroDeg = _prefs.isKey("accel_p0") ? _prefs.getFloat("accel_p0", 0.0f) : 0.0f;
return c;
}
void Settings::setMotionCalibration(float rollZeroDeg, float pitchZeroDeg) {
_prefs.putFloat("accel_r0", rollZeroDeg);
_prefs.putFloat("accel_p0", pitchZeroDeg);
}
void Settings::clearMotionCalibration() {
_prefs.remove("accel_r0");
_prefs.remove("accel_p0");
}
void Settings::factoryReset() {
Serial.println("[Settings] Factory reset - clearing all settings");
_prefs.clear();
Serial.println("[Settings] All settings cleared");
}

View File

@@ -8,6 +8,11 @@ struct PlantThresholds {
int tooWetPct;
};
struct MotionCalibration {
float rollZeroDeg;
float pitchZeroDeg;
};
class Settings {
public:
void begin();
@@ -43,6 +48,22 @@ public:
String webhookUrl() const;
void setWebhookUrl(const String& url);
// Daily routine schedule
String timezone() const;
void setTimezone(const String& tz);
String bedtime() const; // "HH:MM"
void setBedtime(const String& hhmm);
String wakeTime() const; // "HH:MM"
void setWakeTime(const String& hhmm);
// Accelerometer calibration (neutral orientation offsets)
MotionCalibration motionCalibration() const;
void setMotionCalibration(float rollZeroDeg, float pitchZeroDeg);
void clearMotionCalibration();
// Factory reset
void factoryReset();
private:
mutable Preferences _prefs;
};

View File

@@ -1,14 +1,70 @@
#include "Display.h"
static void logExpectedI2CDevices() {
// Probe only expected addresses to reduce startup risk if the bus is unhealthy.
const uint8_t addrs[] = {0x3C, 0x3D, 0x10, 0x36, 0x68}; // SH1106, VEML7700, MAX17048, MPU6050
Serial.println("[Display] Probing expected I2C addresses...");
uint8_t found = 0;
for (uint8_t i = 0; i < sizeof(addrs); i++) {
uint8_t addr = addrs[i];
Wire.beginTransmission(addr);
uint8_t err = Wire.endTransmission();
Serial.print("[Display] 0x");
if (addr < 16) Serial.print('0');
Serial.print(addr, HEX);
Serial.print(" -> ");
if (err == 0) {
Serial.println("OK");
found++;
} else {
Serial.print("ERR ");
Serial.println(err);
}
}
if (found == 0) Serial.println("[Display] No expected I2C devices responded");
}
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=");
Serial.print(PIN_SCL);
Serial.print(" addr=0x");
if (OLED_ADDR < 16) Serial.print('0');
Serial.println(OLED_ADDR, HEX);
Wire.begin(PIN_SDA, PIN_SCL);
Wire.setClock(100000); // More tolerant for multi-device sensor bus wiring
Wire.setTimeOut(20);
Serial.println("[Display] I2C clock set to 100kHz");
delay(20);
logExpectedI2CDevices();
Serial.println("[Display] Initializing SH1106...");
_ok = _oled.begin(OLED_ADDR, true);
if (!_ok) return;
Serial.print("[Display] SH1106 begin() -> ");
Serial.println(_ok ? "OK" : "FAIL");
if (!_ok) {
Serial.println("[Display] Hint: verify address (0x3C/0x3D), power, and SH1106 wiring");
return;
}
_displayEnabled = true;
setContrast(_contrast);
Serial.println("[Display] Running boot animation");
bootAnimation();
Serial.println("[Display] Ready");
}
void Display::showStatus(const String& line1, const String& line2) {
if (!_ok) return;
Serial.print("[Display] showStatus: ");
Serial.print(line1);
Serial.print(" | ");
Serial.println(line2);
if (!_displayEnabled) setDisplayEnabled(true);
_oled.clearDisplay();
_oled.setTextSize(1);
_oled.setTextColor(1);
@@ -19,6 +75,36 @@ void Display::showStatus(const String& line1, const String& line2) {
_oled.display();
}
void Display::setContrast(uint8_t contrast) {
if (!_ok) return;
_contrast = contrast;
static uint8_t lastLogged = 0xFF;
if (lastLogged == 0xFF || abs((int)_contrast - (int)lastLogged) >= 16) {
Serial.print("[Display] Contrast -> ");
Serial.println(_contrast);
lastLogged = _contrast;
}
_oled.oled_command(SH110X_SETCONTRAST);
_oled.oled_command(_contrast);
}
void Display::setDisplayEnabled(bool enabled) {
if (!_ok) return;
if (_displayEnabled == enabled) return;
_displayEnabled = enabled;
Serial.print("[Display] Power -> ");
Serial.println(enabled ? "ON" : "OFF");
if (enabled) {
_oled.oled_command(SH110X_DISPLAYON);
setContrast(_contrast);
} else {
_oled.clearDisplay();
_oled.display();
_oled.oled_command(SH110X_DISPLAYOFF);
}
}
void Display::bootAnimation() {
if (!_ok) return;
@@ -59,3 +145,49 @@ _oled.display();
// The animation above already takes ~0.9s; hold this screen ~2.1s more.
delay(2100);
}
void Display::drawBatteryIcon(int x, int y, int percent, bool blink, bool charging) {
if (!_ok) return;
// Skip if blinking and blink state is off (unless charging)
if (!charging && blink && ((millis() / 500) % 2 == 1)) return;
// Battery body: 14x7 rectangle
_oled.drawRect(x, y, 14, 7, 1);
// Battery terminal: small nub on right
_oled.fillRect(x + 14, y + 2, 2, 3, 1);
// Fill level: 12 pixels max width inside battery
int fillWidth = (percent * 12) / 100;
if (charging) {
// Draw animated lightning bolt when charging
bool phase = (millis() / 300) % 2 == 0;
bool invertBolt = (percent >= 50);
int color = invertBolt ? 0 : (phase ? 1 : 0);
// Lightning bolt shape (simple zigzag). Above 50%, keep it black in the center.
_oled.drawLine(x + 7, y + 1, x + 5, y + 3, color); // Top diagonal
_oled.drawLine(x + 5, y + 3, x + 9, y + 3, color); // Middle horizontal
_oled.drawLine(x + 9, y + 3, x + 7, y + 5, color); // Bottom diagonal
// Always draw at least some fill when charging
if (fillWidth == 0 && phase) {
_oled.fillRect(x + 1, y + 1, 2, 5, 1); // Small pulse even at 0%
} else if (fillWidth > 0) {
_oled.fillRect(x + 1, y + 1, fillWidth, 5, 1);
}
} else {
// Normal fill level
if (fillWidth > 0) {
_oled.fillRect(x + 1, y + 1, fillWidth, 5, 1);
}
// Low battery warning: blink outline
if (percent < 20 && blink) {
// Draw thicker outline for emphasis
_oled.drawRect(x - 1, y - 1, 16, 9, 1);
}
}
}

View File

@@ -4,18 +4,53 @@
#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();
Adafruit_SH1106G& 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 = 21;
static constexpr int PIN_SCL = 22;
static constexpr uint8_t OLED_ADDR = 0x3C; // try 0x3D if blank
Adafruit_SH1106G _oled = Adafruit_SH1106G(128, 64, &Wire, -1);
static constexpr int PIN_SDA = 8;
static constexpr int PIN_SCL = 9;
#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 = DEFAULT_CONTRAST;
};

View File

@@ -16,6 +16,59 @@ int8_t FaceRenderer::randRangeI8(int8_t lo, int8_t hi) {
return (int8_t)(lo + random((int)(hi - lo + 1)));
}
int FaceRenderer::batteryIconX() const {
if (!_display) return 110;
int x = _display->oled().width() - 18; // 16px icon + 2px margin
return x < 0 ? 0 : x;
}
int FaceRenderer::screenW() const {
return _display ? _display->oled().width() : 128;
}
int FaceRenderer::screenH() const {
return _display ? _display->oled().height() : 64;
}
int FaceRenderer::eyeCy() const {
return screenH() * 3 / 8; // ~24 on 64px tall displays
}
int FaceRenderer::leftEyeCxBase() const {
return screenW() * 5 / 16 + _faceSlideX; // 40 on 128px
}
int FaceRenderer::rightEyeCxBase() const {
return screenW() * 11 / 16 + _faceSlideX; // 88 on 128px
}
int FaceRenderer::mouthCx() const {
return screenW() / 2 + _faceSlideX;
}
int FaceRenderer::mouthY() const {
return screenH() * 3 / 4; // ~48 on 64px
}
void FaceRenderer::setRoutineAnimation(RoutineAnim anim, uint16_t progressPermille) {
_routineAnim = anim;
_routineProgressPermille = progressPermille > 1000 ? 1000 : progressPermille;
}
void FaceRenderer::setTiltEffects(int8_t eyeDx, int8_t pupilSizeDelta) {
_tiltEyeDx = clampInt(eyeDx, -8, 8);
_tiltPupilSizeDelta = clampInt(pupilSizeDelta, -4, 4);
}
void FaceRenderer::triggerSurprised(unsigned long durationMs) {
unsigned long now = millis();
_surprisedUntilMs = now + durationMs;
}
void FaceRenderer::setFaceSlideX(int8_t x) {
_faceSlideTargetX = clampInt(x, -24, 24);
}
void FaceRenderer::begin(Display& display, Settings& settings) {
_display = &display;
_settings = &settings;
@@ -27,18 +80,36 @@ void FaceRenderer::begin(Display& display, Settings& settings) {
_nextBlinkMs = now + randRange(3000, 9000);
_nextSillyMs = now + randRange(20000, 60000);
_nextGazeMs = now + randRange(800, 2000);
_nextTooWetMouthEventMs = now + randRange(45000, 90000);
}
void FaceRenderer::loop(const MoistureSensor& moisture) {
void FaceRenderer::loop(const MoistureSensor& moisture, const BatterySensor& battery) {
unsigned long now = millis();
if ((long)(now - _nextFrameMs) < 0) return;
_nextFrameMs = now + FRAME_MS;
updateMood(moisture.percent());
updateDeathMode(now);
updateBatteryLowMode(battery.percent());
_faceSlideX += clampInt(_faceSlideTargetX - _faceSlideX, -1, 1);
if (_routineAnim != ROUTINE_NONE) {
renderRoutine(now, battery);
return;
}
if ((long)(now - _surprisedUntilMs) < 0) {
renderSurprised(now, battery);
return;
}
if (_deadMode) {
renderDead(now);
renderDead(now, battery);
return;
}
if (_batteryLowMode) {
renderBatteryLow(now, battery);
return;
}
@@ -46,7 +117,7 @@ void FaceRenderer::loop(const MoistureSensor& moisture) {
else if (_mood == DRY) updateDry(now);
else updateTooWet(now);
renderNormal(now);
renderNormal(now, battery);
}
void FaceRenderer::updateMood(int moisturePct) {
@@ -76,6 +147,11 @@ void FaceRenderer::updateDeathMode(unsigned long now) {
}
}
void FaceRenderer::updateBatteryLowMode(int batteryPercent) {
// Enter nervous mode when battery is critically low (< 10%)
_batteryLowMode = (batteryPercent < 10);
}
void FaceRenderer::updateHappy(unsigned long now) {
if ((long)(now - _nextGazeMs) >= 0) {
_targetX = randRangeI8(-3, 3);
@@ -123,9 +199,49 @@ void FaceRenderer::updateTooWet(unsigned long now) {
_lastBlinkMs = now;
}
if (_blinking && (long)(now - _blinkUntilMs) >= 0) _blinking = false;
if ((long)(now - _nextTooWetMouthEventMs) >= 0) {
_tooWetMouthOpenUntilMs = now + randRange(1200, 2200);
_nextTooWetMouthEventMs = now + randRange(45000, 90000);
}
void FaceRenderer::renderDead(unsigned long now) {
stepTooWetBubbles(now);
}
void FaceRenderer::stepTooWetBubbles(unsigned long now) {
if (!_display) return;
const int w = screenW();
const int h = screenH();
auto respawnBubble = [&](uint8_t i, bool randomY) {
_tooWetBubbleR[i] = (uint8_t)random(2, 6); // 2..5 px
_tooWetBubbleX[i] = (float)random(_tooWetBubbleR[i], w - _tooWetBubbleR[i]);
_tooWetBubbleSpeed[i] = ((float)random(10, 28)) / 10.0f; // 1.0..2.7 px per step
_tooWetBubbleY[i] = randomY ? (float)random(-h, h + 8) : (float)(h + _tooWetBubbleR[i] + random(0, 12));
};
if (!_tooWetBubblesInit) {
for (uint8_t i = 0; i < TOO_WET_BUBBLE_COUNT; i++) respawnBubble(i, true);
_tooWetBubblesInit = true;
_lastTooWetBubbleStepMs = now;
return;
}
if (_lastTooWetBubbleStepMs == 0) _lastTooWetBubbleStepMs = now;
unsigned long dtMs = now - _lastTooWetBubbleStepMs;
if (dtMs < 50) return;
_lastTooWetBubbleStepMs = now;
float dtScale = dtMs / 50.0f;
for (uint8_t i = 0; i < TOO_WET_BUBBLE_COUNT; i++) {
_tooWetBubbleY[i] -= _tooWetBubbleSpeed[i] * dtScale;
if (_tooWetBubbleY[i] < -(float)_tooWetBubbleR[i] - 2.0f) {
respawnBubble(i, false); // restart below bottom and rise all the way back up
}
}
}
void FaceRenderer::renderDead(unsigned long now, const BatterySensor& battery) {
auto &d = _display->oled();
d.clearDisplay();
@@ -135,106 +251,307 @@ void FaceRenderer::renderDead(unsigned long now) {
}
if (_deadShowTombstone) {
d.drawRoundRect(38, 12, 52, 40, 8, 1);
d.setCursor(52, 28);
int w = 52, h = 40;
int x = (screenW() - w) / 2;
int y = (screenH() - h) / 2;
d.drawRoundRect(x, y, w, h, 8, 1);
d.setCursor(x + 14, y + 16);
d.setTextSize(2);
d.print("RIP");
} else {
drawXEye(40, 24);
drawXEye(88, 24);
drawXEye(leftEyeCxBase(), eyeCy());
drawXEye(rightEyeCxBase(), eyeCy());
drawMouthFlat();
}
// Draw battery icon in top-right corner
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
d.display();
}
void FaceRenderer::renderNormal(unsigned long now) {
void FaceRenderer::renderBatteryLow(unsigned long now, const BatterySensor& battery) {
auto &d = _display->oled();
d.clearDisplay();
// Nervous animation: eyes glance up at battery icon repeatedly
// Battery icon is at (110, 2) in top-right corner
// Eyes are centered at (40, 24) for left, (88, 24) for right
// Create a glancing pattern: look up at battery, then center, repeat
int glancePhase = (now / 800) % 3; // 3 phases: up, center, up
int pupilX = 0, pupilY = 0;
if (glancePhase == 0 || glancePhase == 2) {
// Look up toward battery icon (upper right)
pupilX = 3; // Look right
pupilY = -4; // Look up
} else {
// Brief center position between glances
pupilX = 0;
pupilY = -2; // Still slightly upward (worried/anxious)
}
// Draw eyes with pupils positioned
if (_blinking) {
drawEyesClosed();
} else {
drawEyesOpen(pupilX, pupilY);
}
// Worried eyebrows
drawBrowsWorried();
// Nervous mouth (wavy/uncertain)
drawMouthNervous();
// Draw battery icon in top-right corner (blinking)
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), true, battery.isCharging());
d.display();
}
void FaceRenderer::renderNormal(unsigned long now, const BatterySensor& battery) {
auto &d = _display->oled();
d.clearDisplay();
if (_mood == HAPPY) {
if (_silly) drawSilly(_sillyVariant);
else if (_blinking) drawEyesClosed();
else drawEyesOpen(_gazeX, _gazeY);
else drawEyesOpen(_gazeX, _gazeY, 4);
drawMouthHappy();
drawSparkles(now);
}
else if (_mood == DRY) {
if (_blinking) drawEyesClosed();
else drawEyesSmallPupils(6, 0, 0);
else drawEyesSmallPupils(6, 0, 0, 2);
drawBrowsVerySad();
drawMouthFrown();
drawBigWaterText(now);
}
else {
drawBubbles(0);
if (_blinking) drawEyesClosed();
else drawEyesOpen(0, 0);
drawMouthFlat();
drawBubbles((now / 100) % 12 - 6);
else drawEyesOpen(0, 0, 4);
bool mouthOpen = ((long)(now - _tooWetMouthOpenUntilMs) < 0);
if (mouthOpen) drawMouthSurprised();
else drawMouthFlat();
if (mouthOpen) drawMouthBubbles(now);
}
// Draw battery icon in top-right corner
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
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;
int lcx = leftEyeCxBase();
int rcx = rightEyeCxBase();
int cy = eyeCy();
d.fillCircle(lcx, cy, 12, 1);
d.fillCircle(rcx, cy, 12, 1);
d.fillCircle(lcx, cy + pupilY, 3, 0);
d.fillCircle(rcx, cy + pupilY, 3, 0);
if (close > 0) {
d.fillRect(lcx - 12, cy - 12, 24, close, 0);
d.fillRect(rcx - 12, cy - 12, 24, close, 0);
}
if (close > 6) {
d.drawLine(lcx - 12, cy, lcx + 12, cy, 1);
d.drawLine(rcx - 12, cy, rcx + 12, cy, 1);
}
d.drawLine(lcx - 14, cy - 10, lcx + 8, cy - 8, 1);
d.drawLine(rcx - 8, cy - 8, rcx + 14, cy - 10, 1);
d.drawLine(mouthCx() - 12, mouthY(), mouthCx() + 12, mouthY(), 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
int lcx = leftEyeCxBase();
int rcx = rightEyeCxBase();
int cy = eyeCy();
d.fillCircle(lcx, cy, 12, 1);
d.fillCircle(rcx, cy, 12, 1);
d.fillCircle(lcx, cy + pupilY, open / 3, 0);
d.fillCircle(rcx, cy + pupilY, open / 3, 0);
// Bright eyebrows + growing smile
d.drawLine(lcx - 14, cy - 10, lcx + 8, cy - 13, 1);
d.drawLine(rcx - 8, cy - 13, rcx + 14, cy - 10, 1);
int smileLift = (_routineProgressPermille * 3) / 1000;
int cx = mouthCx();
int y = mouthY() - 1;
for (int i = 0; i < 4; i++) {
d.drawLine(cx - 9 + i, y + i / 2 - smileLift, cx + 9 - i, y + i / 2 - smileLift, 1);
}
drawSunRise(now, (uint8_t)(1 + ((_routineProgressPermille * 4) / 1000)));
}
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
d.display();
}
void FaceRenderer::renderSurprised(unsigned long now, const BatterySensor& battery) {
auto &d = _display->oled();
d.clearDisplay();
// Keep "too wet" bubbles active even while the pickup surprise face is showing.
if (_mood == TOO_WET) {
drawBubbles(0);
}
int pulse = ((now / 180UL) % 2UL == 0UL) ? 1 : 0;
drawEyesOpen(0, 0, 5 + pulse);
d.drawLine(leftEyeCxBase() - 14, eyeCy() - 8, leftEyeCxBase() + 8, eyeCy() - 14, 1);
d.drawLine(rightEyeCxBase() - 8, eyeCy() - 14, rightEyeCxBase() + 14, eyeCy() - 8, 1);
drawMouthSurprised();
if (_mood == TOO_WET && ((long)(now - _tooWetMouthOpenUntilMs) < 0)) {
drawMouthBubbles(now);
}
_display->drawBatteryIcon(batteryIconX(), 2, battery.percent(), battery.shouldBlink(), battery.isCharging());
d.display();
}
/* --- Drawing helpers --- */
void FaceRenderer::drawEyesOpen(int dx, int dy) {
void FaceRenderer::drawEyesOpen(int dx, int dy, int pupilRadius) {
auto &d = _display->oled();
d.fillCircle(40, 24, 12, 1);
d.fillCircle(88, 24, 12, 1);
d.fillCircle(40 + dx, 24 + dy, 4, 0);
d.fillCircle(88 + dx, 24 + dy, 4, 0);
int eyeR = 12;
int pupilR = clampInt(pupilRadius, 1, eyeR - 2);
int cy = eyeCy();
int leftCx = clampInt(leftEyeCxBase(), eyeR, d.width() - eyeR - 1);
int rightCx = clampInt(rightEyeCxBase(), eyeR, d.width() - eyeR - 1);
d.fillCircle(leftCx, cy, eyeR, 1);
d.fillCircle(rightCx, cy, eyeR, 1);
d.fillCircle(leftCx + dx, cy + dy, pupilR, 0);
d.fillCircle(rightCx + dx, cy + dy, pupilR, 0);
}
void FaceRenderer::drawEyesClosed() {
auto &d = _display->oled();
d.drawLine(28, 24, 52, 24, 1);
d.drawLine(76, 24, 100, 24, 1);
int cy = eyeCy();
d.drawLine(leftEyeCxBase() - 12, cy, leftEyeCxBase() + 12, cy, 1);
d.drawLine(rightEyeCxBase() - 12, cy, rightEyeCxBase() + 12, cy, 1);
}
void FaceRenderer::drawEyesSmallPupils(int dy, int sx, int sy) {
void FaceRenderer::drawEyesSmallPupils(int dy, int sx, int sy, int pupilRadius) {
auto &d = _display->oled();
d.fillCircle(40, 24, 12, 1);
d.fillCircle(88, 24, 12, 1);
d.fillCircle(40 + sx, 24 + dy, 2, 0);
d.fillCircle(88 + sx, 24 + dy, 2, 0);
int eyeR = 12;
int r = clampInt(pupilRadius, 1, 7);
int cy = eyeCy();
int leftCx = clampInt(leftEyeCxBase(), eyeR, d.width() - eyeR - 1);
int rightCx = clampInt(rightEyeCxBase(), eyeR, d.width() - eyeR - 1);
d.fillCircle(leftCx, cy, eyeR, 1);
d.fillCircle(rightCx, cy, eyeR, 1);
d.fillCircle(leftCx + sx, cy + dy + sy, r, 0);
d.fillCircle(rightCx + sx, cy + dy + sy, r, 0);
}
void FaceRenderer::drawBrowsVerySad() {
auto &d = _display->oled();
d.drawLine(26, 12, 48, 18, 1);
d.drawLine(80, 18, 102, 12, 1);
int yTop = eyeCy() - 12;
d.drawLine(leftEyeCxBase() - 14, yTop, leftEyeCxBase() + 8, yTop + 6, 1);
d.drawLine(rightEyeCxBase() - 8, yTop + 6, rightEyeCxBase() + 14, yTop, 1);
}
void FaceRenderer::drawBrowsWorried() {
auto &d = _display->oled();
int y = eyeCy() - 10;
d.drawLine(leftEyeCxBase() - 14, y, leftEyeCxBase() - 4, y - 4, 1);
d.drawLine(leftEyeCxBase() - 4, y - 4, leftEyeCxBase() + 8, y - 2, 1);
d.drawLine(rightEyeCxBase() - 8, y - 2, rightEyeCxBase() + 4, y - 4, 1);
d.drawLine(rightEyeCxBase() + 4, y - 4, rightEyeCxBase() + 14, y, 1);
}
void FaceRenderer::drawMouthHappy() {
auto &d = _display->oled();
int cx = mouthCx();
int y = mouthY() - 2;
for (int i = 0; i < 5; i++)
d.drawLine(54 + i, 46 + i / 2, 74 - i, 46 + i / 2, 1);
d.drawLine(cx - 10 + i, y + i / 2, cx + 10 - i, y + i / 2, 1);
}
void FaceRenderer::drawMouthFrown() {
auto &d = _display->oled();
int cx = mouthCx();
int y = mouthY();
for (int i = 0; i < 5; i++)
d.drawLine(54 - i, 48 + i / 2, 74 + i, 48 + i / 2, 1);
d.drawLine(cx - 10 - i, y + i / 2, cx + 10 + i, y + i / 2, 1);
}
void FaceRenderer::drawMouthFlat() {
auto &d = _display->oled();
d.drawLine(52, 48, 76, 48, 1);
int cx = mouthCx();
int y = mouthY();
d.drawLine(cx - 12, y, cx + 12, y, 1);
}
void FaceRenderer::drawMouthNervous() {
auto &d = _display->oled();
int cx = mouthCx();
int y = mouthY();
d.drawLine(cx - 12, y, cx - 6, y - 1, 1);
d.drawLine(cx - 6, y - 1, cx, y, 1);
d.drawLine(cx, y, cx + 6, y - 1, 1);
d.drawLine(cx + 6, y - 1, cx + 12, y, 1);
}
void FaceRenderer::drawMouthSurprised() {
auto &d = _display->oled();
int cx = mouthCx();
int y = mouthY();
d.drawCircle(cx, y, 5, 1);
d.drawCircle(cx, y, 4, 1);
}
void FaceRenderer::drawBubbles(int off) {
auto &d = _display->oled();
d.drawCircle(10 + off, 12, 4, 1);
d.drawCircle(20 + off, 20, 3, 1);
d.drawCircle(30 + off, 14, 5, 1);
(void)off;
for (uint8_t i = 0; i < TOO_WET_BUBBLE_COUNT; i++) {
int r = _tooWetBubbleR[i];
int x = (int)_tooWetBubbleX[i];
int y = (int)_tooWetBubbleY[i];
if (r <= 0) continue;
if (y < -r || y > d.height() + r) continue;
d.drawCircle(x, y, r, 1);
}
}
void FaceRenderer::drawMouthBubbles(unsigned long now) {
auto &d = _display->oled();
int cx = mouthCx();
int y = mouthY();
int rise = (now / 90UL) % 26UL;
int x1 = cx + 9 + ((now / 160UL) % 3);
int y1 = y - 2 - rise;
int y2 = y - 8 - ((rise + 8) % 26);
int y3 = y - 14 - ((rise + 16) % 26);
if (y1 > 2) d.drawCircle(x1, y1, 2, 1);
if (y2 > 2) d.drawCircle(cx + 15, y2, 2, 1);
if (y3 > 2) d.drawCircle(cx + 20, y3, 3, 1);
}
void FaceRenderer::drawSparkles(unsigned long now) {
auto &d = _display->oled();
if ((now / 250) % 2 == 0) {
d.drawPixel(10, 10, 1);
d.drawPixel(118, 12, 1);
d.drawPixel(d.width() - 11, 12, 1);
}
}
@@ -242,14 +559,14 @@ void FaceRenderer::drawSilly(uint8_t v) {
auto &d = _display->oled();
if (v == 0) {
drawEyesOpen(2, 2);
d.drawCircle(64, 48, 6, 1);
d.drawCircle(mouthCx(), mouthY(), 6, 1);
} else if (v == 1) {
drawEyesClosed();
d.drawRect(58, 44, 12, 8, 1);
d.drawRect(mouthCx() - 6, mouthY() - 4, 12, 8, 1);
} else {
drawEyesOpen(-2, 1);
d.drawLine(52, 48, 76, 48, 1);
d.drawPixel(64, 54, 1);
d.drawLine(mouthCx() - 12, mouthY(), mouthCx() + 12, mouthY(), 1);
d.drawPixel(mouthCx(), mouthY() + 6, 1);
}
}
@@ -269,7 +586,38 @@ void FaceRenderer::drawBigWaterText(unsigned long now) {
uint16_t w = 0, h = 0;
d.getTextBounds(msg, 0, 0, &x1, &y1, &w, &h);
int16_t x = (int16_t)((128 - (int)w) / 2);
d.setCursor(x < 0 ? 0 : x, 56);
int16_t x = (int16_t)(((int)d.width() - (int)w) / 2);
d.setCursor(x < 0 ? 0 : x, d.height() - 8);
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 = rightEyeCxBase() + 10 + i * 8 - (int)phase;
int y = eyeCy() - 2 - i * 7;
if (x < rightEyeCxBase() - 4 || 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 = d.height() - 12;
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);
}
}

View File

@@ -3,13 +3,19 @@
#include "../ui/Display.h"
#include "../settings/Settings.h"
#include "../sensors/MoistureSensor.h"
#include "../sensors/BatterySensor.h"
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);
void loop(const MoistureSensor& moisture, const BatterySensor& battery);
void setRoutineAnimation(RoutineAnim anim, uint16_t progressPermille);
void setTiltEffects(int8_t eyeDx, int8_t pupilSizeDelta);
void triggerSurprised(unsigned long durationMs = 1500);
void setFaceSlideX(int8_t x);
bool isDeadMode() const { return _deadMode; }
Mood mood() const { return _mood; }
@@ -21,6 +27,7 @@ private:
Mood _mood = HAPPY;
bool _deadMode = false;
bool _batteryLowMode = false;
unsigned long _dryStartMs = 0;
static constexpr unsigned long DRY_DEADLINE_MS = 72UL * 60UL * 60UL * 1000UL;
unsigned long _nextDeadToggleMs = 0;
@@ -39,6 +46,16 @@ private:
uint8_t _blinkStep = 0;
unsigned long _blinkUntilMs = 0;
unsigned long _tooWetMouthOpenUntilMs = 0;
unsigned long _nextTooWetMouthEventMs = 0;
bool _tooWetBubblesInit = false;
static constexpr uint8_t TOO_WET_BUBBLE_COUNT = 8;
float _tooWetBubbleX[TOO_WET_BUBBLE_COUNT]{};
float _tooWetBubbleY[TOO_WET_BUBBLE_COUNT]{};
float _tooWetBubbleSpeed[TOO_WET_BUBBLE_COUNT]{};
uint8_t _tooWetBubbleR[TOO_WET_BUBBLE_COUNT]{};
unsigned long _lastTooWetBubbleStepMs = 0;
bool _silly = false;
uint8_t _sillyVariant = 0;
unsigned long _sillyUntilMs = 0;
@@ -49,29 +66,56 @@ private:
int8_t _shakeX = 0, _shakeY = 0;
unsigned long _nextShakeMs = 0;
RoutineAnim _routineAnim = ROUTINE_NONE;
uint16_t _routineProgressPermille = 0;
int8_t _tiltEyeDx = 0;
int8_t _tiltPupilSizeDelta = 0;
unsigned long _surprisedUntilMs = 0;
int8_t _faceSlideX = 0;
int8_t _faceSlideTargetX = 0;
void updateMood(int moisturePct);
void updateDeathMode(unsigned long now);
void updateBatteryLowMode(int batteryPercent);
void updateHappy(unsigned long now);
void updateDry(unsigned long now);
void updateTooWet(unsigned long now);
void stepTooWetBubbles(unsigned long now);
void renderDead(unsigned long now);
void renderNormal(unsigned long now);
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 renderSurprised(unsigned long now, const BatterySensor& battery);
void drawEyesOpen(int pupilDx, int pupilDy);
void drawEyesOpen(int pupilDx, int pupilDy, int pupilRadius = 4);
void drawEyesClosed();
void drawEyesSmallPupils(int pupilDy, int sx, int sy);
void drawEyesSmallPupils(int pupilDy, int sx, int sy, int pupilRadius = 2);
void drawBrowsVerySad();
void drawBrowsWorried();
void drawMouthHappy();
void drawMouthFrown();
void drawMouthFlat();
void drawMouthNervous();
void drawMouthSurprised();
void drawDroplet(int x, int y, bool pulse);
void drawBubbles(int off);
void drawMouthBubbles(unsigned long now);
void drawSparkles(unsigned long now);
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);
int batteryIconX() const;
int screenW() const;
int screenH() const;
int eyeCy() const;
int leftEyeCxBase() const;
int rightEyeCxBase() const;
int mouthCx() const;
int mouthY() const;
};