Compare commits
14 Commits
main
...
0.96-scree
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60f44f99f | ||
|
|
16b9471107 | ||
|
|
9decccdce5 | ||
|
|
df9bd461d1 | ||
|
|
a8e8268b65 | ||
|
|
a80a6b59d1 | ||
|
|
df00d77ce1 | ||
|
|
63061bdab2 | ||
|
|
192e657b07 | ||
|
|
08a2ee0852 | ||
|
|
b2752b8f72 | ||
|
|
0b627ffa75 | ||
|
|
483507e26c | ||
|
|
26a839f6e0 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
.DS_Store
|
||||
|
||||
16
README.md
16
README.md
@@ -15,3 +15,19 @@ ESP32 + SH1106 OLED + soil moisture buddy with expressions.
|
||||
## Wiring
|
||||
- OLED (I2C): SDA=GPIO21, SCL=GPIO22, VCC=3.3V, GND=GND
|
||||
- Moisture sensor analog out: GPIO34
|
||||
|
||||
## OLED Size Build Flag
|
||||
FacePlant now supports compile-time OLED profiles via `PB_OLED_SIZE`:
|
||||
- `96` for 0.96" OLED
|
||||
- `130` for 1.3" OLED
|
||||
- `240` for 2.4" OLED
|
||||
|
||||
Predefined PlatformIO environments:
|
||||
- `esp32-s3-oled-096`
|
||||
- `esp32-s3-oled-130`
|
||||
- `esp32-s3-oled-240`
|
||||
|
||||
Example:
|
||||
```bash
|
||||
pio run -e esp32-s3-oled-240 -t upload
|
||||
```
|
||||
|
||||
608
compile_commands.json
Normal file
608
compile_commands.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
419
src/app/App.cpp
419
src/app/App.cpp
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) + "°</div></div>"
|
||||
"<div class='status-item'><div class='status-label'>Pitch</div>"
|
||||
"<div class='status-value'>" + String(motion.pitchDeg(), 1) + "°</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) + "°</div>"
|
||||
"<div style='font-size: 1em; color: #1d1d1f;'>Pitch: " + String(motion.pitchDeg(), 1) + "°</div>"
|
||||
"<div style='font-size: 0.85em; color: #86868b; margin-top: 8px;'>"
|
||||
"Neutral offsets: roll " + String(mc.rollZeroDeg, 1) + "°, pitch " + String(mc.pitchZeroDeg, 1) + "°</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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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++);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
71
src/power/PowerManager.cpp
Normal file
71
src/power/PowerManager.cpp
Normal file
@@ -0,0 +1,71 @@
|
||||
#include "PowerManager.h"
|
||||
|
||||
#include <WiFi.h>
|
||||
#include <esp_bt.h>
|
||||
#include <esp_sleep.h>
|
||||
|
||||
void PowerManager::begin(unsigned long bootMs) {
|
||||
_bootMs = bootMs;
|
||||
_onBatterySinceMs = bootMs;
|
||||
_pluggedIn = true;
|
||||
_plugStateInitialized = false;
|
||||
}
|
||||
|
||||
void PowerManager::setBatteryAwakeWindowMs(unsigned long windowMs) {
|
||||
_batteryAwakeWindowMs = windowMs;
|
||||
}
|
||||
|
||||
bool PowerManager::detectPluggedIn(const BatterySensor& battery) const {
|
||||
// Primary signal: positive charge rate from MAX17048.
|
||||
if (battery.isCharging()) return true;
|
||||
|
||||
// Fallback for topped-off battery where charge rate can read near zero while still plugged.
|
||||
return (battery.percent() >= 98) && (battery.voltage() >= 4.15f);
|
||||
}
|
||||
|
||||
void PowerManager::loop(const BatterySensor& battery) {
|
||||
bool pluggedNow = detectPluggedIn(battery);
|
||||
unsigned long now = millis();
|
||||
|
||||
if (!_plugStateInitialized) {
|
||||
_plugStateInitialized = true;
|
||||
_pluggedIn = pluggedNow;
|
||||
_onBatterySinceMs = pluggedNow ? _bootMs : now;
|
||||
Serial.print("[Power] Initial state: ");
|
||||
Serial.println(_pluggedIn ? "plugged-in" : "battery");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pluggedNow == _pluggedIn) return;
|
||||
_pluggedIn = pluggedNow;
|
||||
|
||||
if (_pluggedIn) {
|
||||
Serial.println("[Power] Source changed: plugged-in");
|
||||
} else {
|
||||
_onBatterySinceMs = now;
|
||||
Serial.println("[Power] Source changed: battery");
|
||||
}
|
||||
}
|
||||
|
||||
bool PowerManager::batteryWindowElapsed(unsigned long nowMs) const {
|
||||
if (_pluggedIn) return false;
|
||||
return (nowMs - _onBatterySinceMs) >= _batteryAwakeWindowMs;
|
||||
}
|
||||
|
||||
void PowerManager::deepSleepForSeconds(uint32_t seconds, const char* reason, Display* display) const {
|
||||
Serial.print("[Power] Deep sleep: ");
|
||||
Serial.print(reason);
|
||||
Serial.print(" for ");
|
||||
Serial.print(seconds);
|
||||
Serial.println("s");
|
||||
|
||||
if (display && display->ok()) display->setDisplayEnabled(false);
|
||||
|
||||
WiFi.disconnect(true);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
btStop();
|
||||
|
||||
esp_sleep_enable_timer_wakeup((uint64_t)seconds * 1000000ULL);
|
||||
delay(50);
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
26
src/power/PowerManager.h
Normal file
26
src/power/PowerManager.h
Normal file
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "../sensors/BatterySensor.h"
|
||||
#include "../ui/Display.h"
|
||||
|
||||
class PowerManager {
|
||||
public:
|
||||
void begin(unsigned long bootMs);
|
||||
void setBatteryAwakeWindowMs(unsigned long windowMs);
|
||||
void loop(const BatterySensor& battery);
|
||||
|
||||
bool isPluggedIn() const { return _pluggedIn; }
|
||||
bool batteryWindowElapsed(unsigned long nowMs) const;
|
||||
|
||||
void deepSleepForSeconds(uint32_t seconds, const char* reason, Display* display = nullptr) const;
|
||||
|
||||
private:
|
||||
bool detectPluggedIn(const BatterySensor& battery) const;
|
||||
|
||||
bool _pluggedIn = true;
|
||||
bool _plugStateInitialized = false;
|
||||
unsigned long _bootMs = 0;
|
||||
unsigned long _onBatterySinceMs = 0;
|
||||
unsigned long _batteryAwakeWindowMs = 2UL * 60UL * 1000UL;
|
||||
};
|
||||
40
src/sensors/AmbientLightSensor.cpp
Normal file
40
src/sensors/AmbientLightSensor.cpp
Normal 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;
|
||||
}
|
||||
25
src/sensors/AmbientLightSensor.h
Normal file
25
src/sensors/AmbientLightSensor.h
Normal 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;
|
||||
};
|
||||
71
src/sensors/BatterySensor.cpp
Normal file
71
src/sensors/BatterySensor.cpp
Normal 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;
|
||||
}
|
||||
35
src/sensors/BatterySensor.h
Normal file
35
src/sensors/BatterySensor.h
Normal 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();
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
150
src/sensors/MotionSensor.cpp
Normal file
150
src/sensors/MotionSensor.cpp
Normal 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)
|
||||
}
|
||||
51
src/sensors/MotionSensor.h
Normal file
51
src/sensors/MotionSensor.h
Normal 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);
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user