Initial Commit

This commit is contained in:
Joshua King
2026-02-09 11:41:12 -05:00
commit 558c209b6c
31 changed files with 1399 additions and 0 deletions

83
src/app/App.cpp Normal file
View File

@@ -0,0 +1,83 @@
#include "App.h"
#include "../util/Version.h"
#include "../util/BootTrigger.h"
#include "../settings/Settings.h"
#include "../net/WiFiManager.h"
#include "../net/WebUI.h"
#include "../net/OtaService.h"
#include "../net/WebhookService.h"
#include "../ui/Display.h"
#include "../sensors/MoistureSensor.h"
#include "../ui/FaceRenderer.h"
static Settings settings;
static WiFiManager wifi;
static WebUI web;
static OtaService ota;
static WebhookService webhook;
static Display display;
static MoistureSensor moisture;
static FaceRenderer face;
static unsigned long bootMs = 0;
static FaceRenderer::Mood lastMood = FaceRenderer::HAPPY;
static bool lastDead = false;
static PlantEventType moodToEvent(FaceRenderer::Mood m) {
if (m == FaceRenderer::DRY) return EVT_DRY;
if (m == FaceRenderer::TOO_WET) return EVT_TOO_WET;
return EVT_OK;
}
void App::setup() {
bootMs = millis();
settings.begin();
bool forceSetup = BootTrigger::checkAndConsume();
display.begin();
display.showStatus("FacePlant", "Starting...");
wifi.begin(settings, forceSetup);
moisture.begin(settings);
face.begin(display, settings);
webhook.begin(settings);
web.begin(settings, wifi, moisture, face, webhook, bootMs);
ota.begin(web.server(), &display);
lastMood = face.mood();
lastDead = face.isDeadMode();
}
void App::loop() {
BootTrigger::clearAfterStableUptime();
wifi.loop();
web.loop();
moisture.loop();
face.loop(moisture);
// Webhook events on state transitions
FaceRenderer::Mood m = face.mood();
bool dead = face.isDeadMode();
if (dead && !lastDead) {
webhook.send(EVT_DEAD, moisture, dead, bootMs);
} else if (!dead && lastDead) {
webhook.send(EVT_OK, moisture, dead, bootMs);
} else if (!dead && m != lastMood) {
webhook.send(moodToEvent(m), moisture, dead, bootMs);
}
lastMood = m;
lastDead = dead;
}

6
src/app/App.h Normal file
View File

@@ -0,0 +1,6 @@
#pragma once
class App {
public:
void setup();
void loop();
};

11
src/main.cpp Normal file
View File

@@ -0,0 +1,11 @@
#include "app/App.h"
App app;
void setup() {
app.setup();
}
void loop() {
app.loop();
}

18
src/net/CaptivePortal.cpp Normal file
View File

@@ -0,0 +1,18 @@
#include "CaptivePortal.h"
void CaptivePortal::start(const IPAddress& redirectIp) {
_ip = redirectIp;
_dns.start(DNS_PORT, "*", _ip);
_running = true;
}
void CaptivePortal::stop() {
if (_running) {
_dns.stop();
_running = false;
}
}
void CaptivePortal::loop() {
if (_running) _dns.processNextRequest();
}

16
src/net/CaptivePortal.h Normal file
View File

@@ -0,0 +1,16 @@
#pragma once
#include <DNSServer.h>
#include <IPAddress.h>
class CaptivePortal {
public:
void start(const IPAddress& redirectIp);
void stop();
void loop();
private:
DNSServer _dns;
bool _running = false;
IPAddress _ip;
static constexpr byte DNS_PORT = 53;
};

46
src/net/OtaService.cpp Normal file
View File

@@ -0,0 +1,46 @@
#include "OtaService.h"
#include <Update.h>
static const char* OTA_FORM =
"<!doctype html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>PlanterBuddy OTA</title></head>"
"<body style='font-family:system-ui;margin:18px;max-width:720px'>"
"<h3>Firmware Update</h3>"
"<p><small>Upload a .bin built for ESP32. Do not unplug during update.</small></p>"
"<form method='POST' action='/update' enctype='multipart/form-data'>"
"<input type='file' name='firmware' accept='.bin' required>"
"<p><button type='submit' style='padding:10px 14px;border-radius:10px;border:0;background:#111;color:#fff;font-weight:700'>Upload</button></p>"
"</form></body></html>";
void OtaService::begin(WebServer& server, Display* display) {
_display = display;
server.on("/update", HTTP_GET, [&server]() {
server.send(200, "text/html", OTA_FORM);
});
server.on("/update", HTTP_POST,
[&server]() { /* handled by upload */ },
[&server, this]() {
HTTPUpload& upload = server.upload();
if (upload.status == UPLOAD_FILE_START) {
if (_display) _display->showStatus("Updating...", "Do not unplug");
if (!Update.begin(UPDATE_SIZE_UNKNOWN)) {
server.send(500, "text/plain", "Update begin failed");
return;
}
} else if (upload.status == UPLOAD_FILE_WRITE) {
Update.write(upload.buf, upload.currentSize);
} else if (upload.status == UPLOAD_FILE_END) {
if (Update.end(true)) {
server.send(200, "text/plain", "Update complete. Rebooting...");
delay(300);
ESP.restart();
} else {
server.send(500, "text/plain", "Update failed.");
}
}
}
);
}

11
src/net/OtaService.h Normal file
View File

@@ -0,0 +1,11 @@
#pragma once
#include <WebServer.h>
#include "../ui/Display.h"
class OtaService {
public:
void begin(WebServer& server, Display* display = nullptr);
private:
Display* _display = nullptr;
};

133
src/net/WebUI.cpp Normal file
View File

@@ -0,0 +1,133 @@
#include "WebUI.h"
#include "../util/Version.h"
#include <ArduinoJson.h>
void WebUI::begin(Settings& settings,
WiFiManager& wifi,
MoistureSensor& moisture,
FaceRenderer& face,
WebhookService& webhook,
unsigned long bootMs) {
auto sendPortalRedirect = [&]() {
String target = String("http://") + wifi.apIp().toString() + "/";
_server.sendHeader("Location", target, true);
_server.send(302, "text/plain", "");
};
_server.on("/", HTTP_GET, [&]() {
String mode = (wifi.mode() == NET_AP_SETUP) ? "Setup AP" : "Station";
String wifiSsid = settings.wifiSsid();
String currentSsid = wifi.ssid();
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>"
"</form>"
"<p><a href='/status'>Status (JSON)</a></p>"
"<p><a href='/update'>OTA Update</a></p>";
_server.send(200, "text/html", page);
});
_server.on("/wifi", HTTP_POST, [&]() {
String action = _server.hasArg("action") ? _server.arg("action") : "connect";
if (action == "forget") {
wifi.clearAndStartSetupAP();
} else {
String ssid = _server.hasArg("ssid") ? _server.arg("ssid") : settings.wifiSsid();
String pass = "";
if (_server.hasArg("pass") && _server.arg("pass").length() > 0) {
pass = _server.arg("pass");
} else if (ssid == settings.wifiSsid()) {
pass = settings.wifiPass();
}
if (ssid.length() > 0) wifi.saveAndConnect(ssid, pass);
}
_server.sendHeader("Location", "/");
_server.send(303);
});
_server.on("/config", HTTP_POST, [&]() {
if (_server.hasArg("plant")) settings.setPlantProfile(_server.arg("plant"));
settings.setKidsMode(_server.hasArg("kids"));
settings.setWebhookEnabled(_server.hasArg("wh_en"));
if (_server.hasArg("wh")) settings.setWebhookUrl(_server.arg("wh"));
_server.sendHeader("Location", "/");
_server.send(303);
});
_server.on("/status", HTTP_GET, [&]() {
JsonDocument doc;
doc["device"] = "FacePlant";
doc["version"] = PB_VERSION;
doc["net_mode"] = (wifi.mode() == NET_AP_SETUP) ? "setup_ap" : "sta";
doc["setup_ssid"] = wifi.setupSsid();
doc["ip"] = wifi.ip().toString();
doc["ssid"] = wifi.ssid();
doc["saved_ssid"] = settings.wifiSsid();
doc["wifi_connected"] = wifi.connected();
doc["moisture_pct"] = moisture.percent();
doc["raw"] = moisture.raw();
doc["kids_mode"] = settings.kidsMode();
doc["dead_mode"] = face.isDeadMode();
doc["uptime_ms"] = millis() - bootMs;
String out;
serializeJson(doc, out);
_server.send(200, "application/json", out);
});
// Captive portal compatibility endpoints used by common OS network checkers.
_server.on("/generate_204", HTTP_GET, [&]() { sendPortalRedirect(); }); // Android
_server.on("/gen_204", HTTP_GET, [&]() { sendPortalRedirect(); }); // Android alt
_server.on("/hotspot-detect.html", HTTP_GET, [&]() { sendPortalRedirect(); }); // iOS/macOS
_server.on("/library/test/success.html", HTTP_GET, [&]() { sendPortalRedirect(); });
_server.on("/ncsi.txt", HTTP_GET, [&]() { sendPortalRedirect(); }); // Windows
_server.on("/connecttest.txt", HTTP_GET, [&]() { sendPortalRedirect(); }); // Windows alt
_server.on("/fwlink", HTTP_GET, [&]() { sendPortalRedirect(); }); // Windows alt
_server.onNotFound([&]() {
if (wifi.mode() == NET_AP_SETUP) {
sendPortalRedirect();
return;
}
_server.send(404, "text/plain", "Not found");
});
_server.begin();
}
void WebUI::loop() {
_server.handleClient();
}

23
src/net/WebUI.h Normal file
View File

@@ -0,0 +1,23 @@
#pragma once
#include <WebServer.h>
#include "../settings/Settings.h"
#include "../net/WiFiManager.h"
#include "../sensors/MoistureSensor.h"
#include "../ui/FaceRenderer.h"
#include "../net/WebhookService.h"
class WebUI {
public:
void begin(Settings& settings,
WiFiManager& wifi,
MoistureSensor& moisture,
FaceRenderer& face,
WebhookService& webhook,
unsigned long bootMs);
void loop();
WebServer& server() { return _server; }
private:
WebServer _server{80};
};

26
src/net/WebhookService.h Normal file
View File

@@ -0,0 +1,26 @@
#pragma once
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include "../settings/Settings.h"
#include "../sensors/MoistureSensor.h"
enum PlantEventType {
EVT_OK = 0,
EVT_DRY = 1,
EVT_TOO_WET = 2,
EVT_DEAD = 3,
EVT_TEST = 4
};
class WebhookService {
public:
void begin(Settings& settings);
bool send(PlantEventType type, const MoistureSensor& moisture, bool deadMode, unsigned long bootMs);
private:
Settings* _settings = nullptr;
const char* eventName(PlantEventType t) const;
};

View File

@@ -0,0 +1,47 @@
#include "WebhookService.h"
#include "../util/Version.h"
void WebhookService::begin(Settings& settings) {
_settings = &settings;
}
const char* WebhookService::eventName(PlantEventType t) const {
switch (t) {
case EVT_OK: return "ok";
case EVT_DRY: return "dry";
case EVT_TOO_WET: return "too_wet";
case EVT_DEAD: return "dead";
case EVT_TEST: return "test";
default: return "unknown";
}
}
bool WebhookService::send(PlantEventType type, const MoistureSensor& moisture, bool deadMode, unsigned long bootMs) {
if (!_settings) return false;
if (!_settings->webhookEnabled()) return false;
String url = _settings->webhookUrl();
if (url.length() < 8) return false;
if (WiFi.status() != WL_CONNECTED) return false;
HTTPClient http;
http.setTimeout(2500);
if (!http.begin(url)) return false;
http.addHeader("Content-Type", "application/json");
JsonDocument doc;
doc["device"] = "FacePlant";
doc["hostname"] = String(PB_HOSTNAME) + ".local";
doc["version"] = PB_VERSION;
doc["event"] = eventName(type);
doc["dead_mode"] = deadMode;
doc["moisture_pct"] = moisture.percent();
doc["moisture_raw"] = moisture.raw();
doc["uptime_ms"] = (uint32_t)(millis() - bootMs);
String body;
serializeJson(doc, body);
int code = http.POST((uint8_t*)body.c_str(), body.length());
http.end();
return (code >= 200 && code < 300);
}

143
src/net/WiFiManager.cpp Normal file
View File

@@ -0,0 +1,143 @@
#include "WiFiManager.h"
#include <ESPmDNS.h>
#include "../util/Version.h"
static unsigned long reconnectDelayMs(int step) {
if (step > 6) step = 6;
return (30000UL << step); // 30s * 2^step
}
IPAddress WiFiManager::ip() const {
if (_mode == NET_AP_SETUP) return _apIP;
return WiFi.localIP();
}
String WiFiManager::ssid() const {
if (WiFi.status() == WL_CONNECTED) return WiFi.SSID();
return "";
}
void WiFiManager::begin(Settings& settings, bool forceSetupAP) {
_settings = &settings;
WiFi.persistent(false);
WiFi.setAutoReconnect(false);
if (forceSetupAP) { startSetupAP(); return; }
if (_settings->hasWiFi()) {
WiFi.mode(WIFI_STA);
WiFi.setHostname(PB_HOSTNAME);
WiFi.begin(_settings->wifiSsid().c_str(), _settings->wifiPass().c_str());
_bootConnectStartMs = millis();
_mode = NET_STA;
} else {
startSetupAP();
}
}
void WiFiManager::requestSetupAP() { _setupRequested = true; }
void WiFiManager::saveAndConnect(const String& ssid, const String& pass) {
if (!_settings) return;
String cleanSsid = ssid;
cleanSsid.trim();
if (cleanSsid.length() == 0) return;
bool changed = (cleanSsid != _settings->wifiSsid()) || (pass != _settings->wifiPass());
_settings->saveWiFi(cleanSsid, pass);
if (changed) _settings->setEverConnected(false);
_bootConnectStartMs = millis();
_reconnectBackoffStep = 0;
_nextReconnectMs = _bootConnectStartMs + 5000UL;
startStationAttempt();
}
void WiFiManager::clearAndStartSetupAP() {
if (!_settings) return;
_settings->clearWiFi();
_settings->setEverConnected(false);
startSetupAP();
}
void WiFiManager::startSetupAP() {
_mode = NET_AP_SETUP;
_setupRequested = false;
WiFi.disconnect(true);
WiFi.mode(WIFI_AP_STA);
WiFi.softAPConfig(_apIP, _apIP, _apMask);
WiFi.softAP(_setupSsid, _setupPass);
_portal.start(_apIP);
MDNS.end();
MDNS.begin(PB_HOSTNAME);
MDNS.addService("http", "tcp", 80);
WiFi.scanDelete();
WiFi.scanNetworks(true, true);
_reconnectBackoffStep = 0;
_nextReconnectMs = millis() + 30000UL;
}
void WiFiManager::startStation() {
_mode = NET_STA;
_setupRequested = false;
_portal.stop();
WiFi.softAPdisconnect(true);
WiFi.enableAP(false);
WiFi.mode(WIFI_STA);
WiFi.setHostname(PB_HOSTNAME);
MDNS.end();
if (MDNS.begin(PB_HOSTNAME)) MDNS.addService("http", "tcp", 80);
_reconnectBackoffStep = 0;
_nextReconnectMs = millis() + 30000UL;
}
void WiFiManager::startStationAttempt() {
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());
}
void WiFiManager::loop() {
if (_mode == NET_AP_SETUP) _portal.loop();
if (_setupRequested) { startSetupAP(); return; }
if (WiFi.status() == WL_CONNECTED) {
if (_mode != NET_STA || WiFi.getMode() != WIFI_STA) startStation();
if (!_settings->everConnected()) _settings->setEverConnected(true);
return;
}
if (!_settings->hasWiFi()) {
if (_mode != NET_AP_SETUP) startSetupAP();
return;
}
if (!_settings->everConnected() && _bootConnectStartMs != 0 &&
(millis() - _bootConnectStartMs) > BOOT_CONNECT_TIMEOUT_MS) {
_bootConnectStartMs = 0;
startSetupAP();
return;
}
if (_settings->everConnected()) {
unsigned long now = millis();
if ((long)(now - _nextReconnectMs) >= 0) {
startStationAttempt();
_nextReconnectMs = now + reconnectDelayMs(_reconnectBackoffStep++);
}
}
}

49
src/net/WiFiManager.h Normal file
View File

@@ -0,0 +1,49 @@
#pragma once
#include <Arduino.h>
#include <WiFi.h>
#include "../settings/Settings.h"
#include "CaptivePortal.h"
enum NetMode { NET_STA, NET_AP_SETUP };
class WiFiManager {
public:
void begin(Settings& settings, bool forceSetupAP);
void loop();
NetMode mode() const { return _mode; }
bool connected() const { return WiFi.status() == WL_CONNECTED; }
IPAddress ip() const;
String ssid() const;
void requestSetupAP();
void saveAndConnect(const String& ssid, const String& pass);
void clearAndStartSetupAP();
IPAddress apIp() const { return _apIP; }
const char* setupSsid() const { return _setupSsid; }
private:
void startSetupAP();
void startStation();
void startStationAttempt();
Settings* _settings = nullptr;
NetMode _mode = NET_AP_SETUP;
CaptivePortal _portal;
IPAddress _apIP = IPAddress(192,168,4,1);
IPAddress _apMask = IPAddress(255,255,255,0);
const char* _setupSsid = "FacePlant-Setup";
const char* _setupPass = "faceplant";
unsigned long _bootConnectStartMs = 0;
static constexpr unsigned long BOOT_CONNECT_TIMEOUT_MS = 20000;
unsigned long _nextReconnectMs = 0;
int _reconnectBackoffStep = 0;
bool _setupRequested = false;
};

View File

@@ -0,0 +1,44 @@
#include "MoistureSensor.h"
int MoistureSensor::clampInt(int v, int lo, int hi) const {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
int MoistureSensor::rawToPercent(int raw) const {
int dry = _settings->dryRaw();
int wet = _settings->wetRaw();
long denom = (long)(dry - wet);
if (denom == 0) return 0;
long pct = (long)(dry - raw) * 100L / denom;
return clampInt((int)pct, 0, 100);
}
void MoistureSensor::begin(Settings& settings) {
_settings = &settings;
analogReadResolution(12);
int first = analogRead(PIN_MOISTURE);
for (int i = 0; i < NUM_SAMPLES; i++) { _samples[i] = first; _sum += first; }
_avgRaw = first;
_pct = rawToPercent(_avgRaw);
_nextMs = millis();
_lastUpdateMs = millis();
}
void MoistureSensor::loop() {
unsigned long now = millis();
if ((long)(now - _nextMs) < 0) return;
_nextMs = now + INTERVAL_MS;
int raw = analogRead(PIN_MOISTURE);
_sum -= _samples[_idx];
_samples[_idx] = raw;
_sum += raw;
_idx = (_idx + 1) % NUM_SAMPLES;
_avgRaw = (int)(_sum / NUM_SAMPLES);
_pct = rawToPercent(_avgRaw);
_lastUpdateMs = now;
}

View File

@@ -0,0 +1,33 @@
#pragma once
#include <Arduino.h>
#include "../settings/Settings.h"
class MoistureSensor {
public:
void begin(Settings& settings);
void loop();
int raw() const { return _avgRaw; }
int percent() const { return _pct; }
unsigned long lastUpdateMs() const { return _lastUpdateMs; }
private:
static constexpr int PIN_MOISTURE = 34;
Settings* _settings = nullptr;
static constexpr int NUM_SAMPLES = 12;
int _samples[NUM_SAMPLES]{};
int _idx = 0;
long _sum = 0;
int _avgRaw = 0;
int _pct = 0;
unsigned long _lastUpdateMs = 0;
static constexpr unsigned long INTERVAL_MS = 600;
unsigned long _nextMs = 0;
int rawToPercent(int raw) const;
int clampInt(int v, int lo, int hi) const;
};

46
src/settings/Settings.cpp Normal file
View File

@@ -0,0 +1,46 @@
#include "Settings.h"
static PlantThresholds thresholdsForProfile(const String& key) {
if (key == "succulent") return {20, 28, 80};
if (key == "herbs") return {35, 42, 88};
if (key == "tropical") return {45, 52, 90};
if (key == "fern") return {55, 62, 92};
if (key == "veg") return {40, 48, 90};
return {35, 42, 85}; // houseplant default
}
void Settings::begin() {
_prefs.begin("faceplant", false);
}
bool Settings::everConnected() const { return _prefs.getBool("ever_ok", false); }
void Settings::setEverConnected(bool v) { _prefs.putBool("ever_ok", v); }
bool Settings::hasWiFi() const { return _prefs.getString("wifi_ssid", "").length() > 0; }
String Settings::wifiSsid() const { return _prefs.getString("wifi_ssid", ""); }
String Settings::wifiPass() const { return _prefs.getString("wifi_pass", ""); }
void Settings::saveWiFi(const String& ssid, const String& pass) {
_prefs.putString("wifi_ssid", ssid);
_prefs.putString("wifi_pass", pass);
}
void Settings::clearWiFi() { _prefs.remove("wifi_ssid"); _prefs.remove("wifi_pass"); }
String Settings::plantProfile() const { 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); }
void Settings::setCalibration(int dryRaw, int wetRaw) {
_prefs.putInt("cal_dry_raw", dryRaw);
_prefs.putInt("cal_wet_raw", wetRaw);
}
bool Settings::kidsMode() const { return _prefs.getBool("kids_mode", false); }
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", ""); }
void Settings::setWebhookUrl(const String& url) { _prefs.putString("wh_url", url); }

48
src/settings/Settings.h Normal file
View File

@@ -0,0 +1,48 @@
#pragma once
#include <Arduino.h>
#include <Preferences.h>
struct PlantThresholds {
int dryPct;
int okPct;
int tooWetPct;
};
class Settings {
public:
void begin();
// Product state
bool everConnected() const;
void setEverConnected(bool v);
// Wi-Fi
bool hasWiFi() const;
String wifiSsid() const;
String wifiPass() const;
void saveWiFi(const String& ssid, const String& pass);
void clearWiFi();
// Plant profile
String plantProfile() const;
void setPlantProfile(const String& key);
PlantThresholds thresholds() const;
// Calibration
int dryRaw() const;
int wetRaw() const;
void setCalibration(int dryRaw, int wetRaw);
// Kids mode
bool kidsMode() const;
void setKidsMode(bool v);
// Webhook
bool webhookEnabled() const;
void setWebhookEnabled(bool v);
String webhookUrl() const;
void setWebhookUrl(const String& url);
private:
mutable Preferences _prefs;
};

61
src/ui/Display.cpp Normal file
View File

@@ -0,0 +1,61 @@
#include "Display.h"
void Display::begin() {
Wire.begin(PIN_SDA, PIN_SCL);
_ok = _oled.begin(OLED_ADDR, true);
if (!_ok) return;
bootAnimation();
}
void Display::showStatus(const String& line1, const String& line2) {
if (!_ok) return;
_oled.clearDisplay();
_oled.setTextSize(1);
_oled.setTextColor(1);
_oled.setCursor(0, 0);
_oled.print(line1);
_oled.setCursor(0, 12);
_oled.print(line2);
_oled.display();
}
void Display::bootAnimation() {
if (!_ok) return;
_oled.clearDisplay();
_oled.fillCircle(40, 24, 8, 1);
_oled.fillCircle(88, 24, 8, 1);
_oled.display();
delay(200);
_oled.drawLine(32, 24, 48, 24, 1);
_oled.drawLine(80, 24, 96, 24, 1);
_oled.display();
delay(120);
_oled.clearDisplay();
_oled.fillCircle(40, 24, 8, 1);
_oled.fillCircle(88, 24, 8, 1);
for (int i = 0; i < 6; i++) {
_oled.drawLine(52+i, 44+i/2, 76-i, 44+i/2, 1);
_oled.display();
delay(60);
}
// Name + version (keep on screen for ~3 seconds total)
_oled.setTextSize(1);
_oled.setTextColor(1);
_oled.setCursor(34, 50);
_oled.print("FacePlant");
_oled.setCursor(44, 58);
_oled.print("v");
_oled.print(PB_VERSION);
_oled.display();
// The animation above already takes ~0.9s; hold this screen ~2.1s more.
delay(2100);
}

21
src/ui/Display.h Normal file
View File

@@ -0,0 +1,21 @@
#pragma once
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
class Display {
public:
void begin();
Adafruit_SH1106G& oled() { return _oled; }
void showStatus(const String& line1, const String& line2);
void bootAnimation();
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);
bool _ok = false;
};

275
src/ui/FaceRenderer.cpp Normal file
View File

@@ -0,0 +1,275 @@
#include "FaceRenderer.h"
static int clampInt(int v, int lo, int hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
unsigned long FaceRenderer::randRange(unsigned long lo, unsigned long hi) {
if (hi <= lo) return lo;
return lo + (unsigned long)random((long)(hi - lo + 1UL));
}
int8_t FaceRenderer::randRangeI8(int8_t lo, int8_t hi) {
if (hi <= lo) return lo;
return (int8_t)(lo + random((int)(hi - lo + 1)));
}
void FaceRenderer::begin(Display& display, Settings& settings) {
_display = &display;
_settings = &settings;
randomSeed((uint32_t)esp_random());
unsigned long now = millis();
_nextFrameMs = now;
_nextBlinkMs = now + randRange(3000, 9000);
_nextSillyMs = now + randRange(20000, 60000);
_nextGazeMs = now + randRange(800, 2000);
}
void FaceRenderer::loop(const MoistureSensor& moisture) {
unsigned long now = millis();
if ((long)(now - _nextFrameMs) < 0) return;
_nextFrameMs = now + FRAME_MS;
updateMood(moisture.percent());
updateDeathMode(now);
if (_deadMode) {
renderDead(now);
return;
}
if (_mood == HAPPY) updateHappy(now);
else if (_mood == DRY) updateDry(now);
else updateTooWet(now);
renderNormal(now);
}
void FaceRenderer::updateMood(int moisturePct) {
PlantThresholds t = _settings->thresholds();
if (moisturePct >= t.tooWetPct) _mood = TOO_WET;
else if (moisturePct <= t.dryPct) _mood = DRY;
else _mood = HAPPY;
}
void FaceRenderer::updateDeathMode(unsigned long now) {
if (_settings->kidsMode()) {
_deadMode = false;
_dryStartMs = 0;
return;
}
if (_mood == DRY) {
if (_dryStartMs == 0) _dryStartMs = now;
if ((now - _dryStartMs) > DRY_DEADLINE_MS) {
_deadMode = true;
_nextDeadToggleMs = now + 3000;
}
} else {
_dryStartMs = 0;
_deadMode = false;
}
}
void FaceRenderer::updateHappy(unsigned long now) {
if ((long)(now - _nextGazeMs) >= 0) {
_targetX = randRangeI8(-3, 3);
_targetY = randRangeI8(-2, 2);
_nextGazeMs = now + randRange(800, 2200);
}
_gazeX += clampInt(_targetX - _gazeX, -1, 1);
_gazeY += clampInt(_targetY - _gazeY, -1, 1);
bool sillyDue = (long)(now - _nextSillyMs) >= 0;
if (!_silly && sillyDue) {
_silly = true;
_sillyVariant = random(0, 3);
_sillyUntilMs = now + (_settings->kidsMode() ? 4000 : 2000);
_nextSillyMs = now + (_settings->kidsMode() ? randRange(15000, 35000)
: randRange(60000, 180000));
}
if (_silly && (long)(now - _sillyUntilMs) >= 0) _silly = false;
if ((now - _lastBlinkMs) > 60000 || (long)(now - _nextBlinkMs) >= 0) {
_blinking = true;
_blinkUntilMs = now + 90;
_lastBlinkMs = now;
_nextBlinkMs = now + randRange(5000, 15000);
}
if (_blinking && (long)(now - _blinkUntilMs) >= 0) _blinking = false;
}
void FaceRenderer::updateDry(unsigned long now) {
if ((now - _lastBlinkMs) > 60000) {
_blinking = true;
_blinkUntilMs = now + 120;
_lastBlinkMs = now;
}
if (_blinking && (long)(now - _blinkUntilMs) >= 0) _blinking = false;
}
void FaceRenderer::updateTooWet(unsigned long now) {
if ((now - _lastBlinkMs) > 60000) {
_blinking = true;
_blinkUntilMs = now + 90;
_lastBlinkMs = now;
}
if (_blinking && (long)(now - _blinkUntilMs) >= 0) _blinking = false;
}
void FaceRenderer::renderDead(unsigned long now) {
auto &d = _display->oled();
d.clearDisplay();
if ((long)(now - _nextDeadToggleMs) >= 0) {
_deadShowTombstone = !_deadShowTombstone;
_nextDeadToggleMs = now + 3000;
}
if (_deadShowTombstone) {
d.drawRoundRect(38, 12, 52, 40, 8, 1);
d.setCursor(52, 28);
d.setTextSize(2);
d.print("RIP");
} else {
drawXEye(40, 24);
drawXEye(88, 24);
drawMouthFlat();
}
d.display();
}
void FaceRenderer::renderNormal(unsigned long now) {
auto &d = _display->oled();
d.clearDisplay();
if (_mood == HAPPY) {
if (_silly) drawSilly(_sillyVariant);
else if (_blinking) drawEyesClosed();
else drawEyesOpen(_gazeX, _gazeY);
drawMouthHappy();
drawSparkles(now);
}
else if (_mood == DRY) {
if (_blinking) drawEyesClosed();
else drawEyesSmallPupils(6, 0, 0);
drawBrowsVerySad();
drawMouthFrown();
drawBigWaterText(now);
}
else {
if (_blinking) drawEyesClosed();
else drawEyesOpen(0, 0);
drawMouthFlat();
drawBubbles((now / 100) % 12 - 6);
}
d.display();
}
/* --- Drawing helpers --- */
void FaceRenderer::drawEyesOpen(int dx, int dy) {
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);
}
void FaceRenderer::drawEyesClosed() {
auto &d = _display->oled();
d.drawLine(28, 24, 52, 24, 1);
d.drawLine(76, 24, 100, 24, 1);
}
void FaceRenderer::drawEyesSmallPupils(int dy, int sx, int sy) {
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);
}
void FaceRenderer::drawBrowsVerySad() {
auto &d = _display->oled();
d.drawLine(26, 12, 48, 18, 1);
d.drawLine(80, 18, 102, 12, 1);
}
void FaceRenderer::drawMouthHappy() {
auto &d = _display->oled();
for (int i = 0; i < 5; i++)
d.drawLine(54 + i, 46 + i / 2, 74 - i, 46 + i / 2, 1);
}
void FaceRenderer::drawMouthFrown() {
auto &d = _display->oled();
for (int i = 0; i < 5; i++)
d.drawLine(54 - i, 48 + i / 2, 74 + i, 48 + i / 2, 1);
}
void FaceRenderer::drawMouthFlat() {
auto &d = _display->oled();
d.drawLine(52, 48, 76, 48, 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 FaceRenderer::drawSparkles(unsigned long now) {
auto &d = _display->oled();
if ((now / 250) % 2 == 0) {
d.drawPixel(10, 10, 1);
d.drawPixel(118, 12, 1);
}
}
void FaceRenderer::drawSilly(uint8_t v) {
auto &d = _display->oled();
if (v == 0) {
drawEyesOpen(2, 2);
d.drawCircle(64, 48, 6, 1);
} else if (v == 1) {
drawEyesClosed();
d.drawRect(58, 44, 12, 8, 1);
} else {
drawEyesOpen(-2, 1);
d.drawLine(52, 48, 76, 48, 1);
d.drawPixel(64, 54, 1);
}
}
void FaceRenderer::drawXEye(int cx, int cy) {
auto &d = _display->oled();
d.drawLine(cx - 6, cy - 6, cx + 6, cy + 6, 1);
d.drawLine(cx - 6, cy + 6, cx + 6, cy - 6, 1);
}
void FaceRenderer::drawBigWaterText(unsigned long now) {
auto &d = _display->oled();
const char* msg = ((now / 2200UL) % 2UL == 0UL) ? "Need...water..." : "So...thirsty...";
d.setTextSize(1);
d.setTextColor(1);
int16_t x1 = 0, y1 = 0;
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);
d.print(msg);
}

77
src/ui/FaceRenderer.h Normal file
View File

@@ -0,0 +1,77 @@
#pragma once
#include <Arduino.h>
#include "../ui/Display.h"
#include "../settings/Settings.h"
#include "../sensors/MoistureSensor.h"
class FaceRenderer {
public:
enum Mood { HAPPY, DRY, TOO_WET };
void begin(Display& display, Settings& settings);
void loop(const MoistureSensor& moisture);
bool isDeadMode() const { return _deadMode; }
Mood mood() const { return _mood; }
private:
Display* _display = nullptr;
Settings* _settings = nullptr;
Mood _mood = HAPPY;
bool _deadMode = false;
unsigned long _dryStartMs = 0;
static constexpr unsigned long DRY_DEADLINE_MS = 72UL * 60UL * 60UL * 1000UL;
unsigned long _nextDeadToggleMs = 0;
bool _deadShowTombstone = false;
unsigned long _nextFrameMs = 0;
static constexpr unsigned long FRAME_MS = 80;
int8_t _gazeX = 2, _gazeY = 2;
int8_t _targetX = 2, _targetY = 2;
unsigned long _nextGazeMs = 0;
unsigned long _lastBlinkMs = 0;
unsigned long _nextBlinkMs = 0;
bool _blinking = false;
uint8_t _blinkStep = 0;
unsigned long _blinkUntilMs = 0;
bool _silly = false;
uint8_t _sillyVariant = 0;
unsigned long _sillyUntilMs = 0;
unsigned long _nextSillyMs = 0;
unsigned long _lastSillyMs = 0;
static constexpr unsigned long SILLY_MAX_GAP_MS = 180000UL;
int8_t _shakeX = 0, _shakeY = 0;
unsigned long _nextShakeMs = 0;
void updateMood(int moisturePct);
void updateDeathMode(unsigned long now);
void updateHappy(unsigned long now);
void updateDry(unsigned long now);
void updateTooWet(unsigned long now);
void renderDead(unsigned long now);
void renderNormal(unsigned long now);
void drawEyesOpen(int pupilDx, int pupilDy);
void drawEyesClosed();
void drawEyesSmallPupils(int pupilDy, int sx, int sy);
void drawBrowsVerySad();
void drawMouthHappy();
void drawMouthFrown();
void drawMouthFlat();
void drawDroplet(int x, int y, bool pulse);
void drawBubbles(int off);
void drawSparkles(unsigned long now);
void drawSilly(uint8_t v);
void drawXEye(int cx, int cy);
void drawBigWaterText(unsigned long now);
unsigned long randRange(unsigned long lo, unsigned long hi);
int8_t randRangeI8(int8_t lo, int8_t hi);
};

22
src/util/BootTrigger.cpp Normal file
View File

@@ -0,0 +1,22 @@
#include "BootTrigger.h"
RTC_DATA_ATTR uint32_t fp_bootBurstCount = 0;
RTC_DATA_ATTR uint32_t fp_bootBurstMagic = 0xA5A5A5A5;
bool BootTrigger::checkAndConsume() {
if (fp_bootBurstMagic != 0xA5A5A5A5) {
fp_bootBurstMagic = 0xA5A5A5A5;
fp_bootBurstCount = 1;
return false;
}
fp_bootBurstCount++;
if (fp_bootBurstCount >= 3) {
fp_bootBurstCount = 0;
return true;
}
return false;
}
void BootTrigger::clearAfterStableUptime() {
if (millis() > 15000) fp_bootBurstCount = 0;
}

12
src/util/BootTrigger.h Normal file
View File

@@ -0,0 +1,12 @@
#pragma once
#include <Arduino.h>
class BootTrigger {
public:
// Best-effort 3 quick resets trigger (RTC memory).
// May not survive full power removal.
static bool checkAndConsume();
// Clear burst state after stable uptime (~15s)
static void clearAfterStableUptime();
};

7
src/util/Version.h Normal file
View File

@@ -0,0 +1,7 @@
#pragma once
#ifndef PB_VERSION
#define PB_VERSION "1.0.0"
#endif
#ifndef PB_HOSTNAME
#define PB_HOSTNAME "faceplant"
#endif