commit 558c209b6c0dc062d648a141efd0873e53d01faf Author: Joshua King Date: Mon Feb 9 11:41:12 2026 -0500 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d613d1 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# FacePlant (v1.0.0) + +ESP32 + SH1106 OLED + soil moisture buddy with expressions. + +## Features +- Happy / Dry (frown + droplet) / Too-wet (bubbles) +- Random blinks (>= 1/min) + silly faces (more frequent in Kids Mode) +- 72-hour "RIP" mode when dry (disabled in Kids Mode) +- Captive-portal setup AP, then joins home Wi-Fi +- mDNS hostname: http://faceplant.local +- Web UI at `/` + JSON at `/status` +- OTA update at `/update` +- Webhook (optional) posts JSON on events + +## Wiring +- OLED (I2C): SDA=GPIO21, SCL=GPIO22, VCC=3.3V, GND=GND +- Moisture sensor analog out: GPIO34 diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..0d08560 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,15 @@ +[env:esp32] +platform = espressif32 +board = esp32dev +framework = arduino + +monitor_speed = 115200 + +lib_deps = + adafruit/Adafruit GFX Library + adafruit/Adafruit SH110X + bblanchon/ArduinoJson + +build_flags = + -D PB_VERSION=\"1.0.0\" + -D PB_HOSTNAME=\"faceplant\" diff --git a/src/app/App.cpp b/src/app/App.cpp new file mode 100644 index 0000000..eb07454 --- /dev/null +++ b/src/app/App.cpp @@ -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; +} diff --git a/src/app/App.h b/src/app/App.h new file mode 100644 index 0000000..1b34b3e --- /dev/null +++ b/src/app/App.h @@ -0,0 +1,6 @@ +#pragma once +class App { +public: + void setup(); + void loop(); +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..8f3e1c3 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,11 @@ +#include "app/App.h" + +App app; + +void setup() { + app.setup(); +} + +void loop() { + app.loop(); +} diff --git a/src/net/CaptivePortal.cpp b/src/net/CaptivePortal.cpp new file mode 100644 index 0000000..55a6f30 --- /dev/null +++ b/src/net/CaptivePortal.cpp @@ -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(); +} diff --git a/src/net/CaptivePortal.h b/src/net/CaptivePortal.h new file mode 100644 index 0000000..3830f9c --- /dev/null +++ b/src/net/CaptivePortal.h @@ -0,0 +1,16 @@ +#pragma once +#include +#include + +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; +}; diff --git a/src/net/OtaService.cpp b/src/net/OtaService.cpp new file mode 100644 index 0000000..369b905 --- /dev/null +++ b/src/net/OtaService.cpp @@ -0,0 +1,46 @@ +#include "OtaService.h" +#include + +static const char* OTA_FORM = + "" + "PlanterBuddy OTA" + "" + "

Firmware Update

" + "

Upload a .bin built for ESP32. Do not unplug during update.

" + "
" + "" + "

" + "
"; + +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."); + } + } + } + ); +} diff --git a/src/net/OtaService.h b/src/net/OtaService.h new file mode 100644 index 0000000..2cce4c7 --- /dev/null +++ b/src/net/OtaService.h @@ -0,0 +1,11 @@ +#pragma once +#include +#include "../ui/Display.h" + +class OtaService { +public: + void begin(WebServer& server, Display* display = nullptr); + +private: + Display* _display = nullptr; +}; diff --git a/src/net/WebUI.cpp b/src/net/WebUI.cpp new file mode 100644 index 0000000..1a88429 --- /dev/null +++ b/src/net/WebUI.cpp @@ -0,0 +1,133 @@ +#include "WebUI.h" +#include "../util/Version.h" +#include + +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 = + "

FacePlant

" + "

Firmware v" + String(PB_VERSION) + "

" + "

Wi-Fi

" + "

Mode: " + mode + "

" + "

Connected SSID: " + (currentSsid.length() ? currentSsid : "(not connected)") + "

" + "

Saved SSID: " + (wifiSsid.length() ? wifiSsid : "(none)") + "

" + "

Setup AP: " + String(wifi.setupSsid()) + " / " + wifi.apIp().toString() + "

" + "
" + "
" + "
" + "
" + "
" + "Leave password blank to keep saved password for the same SSID.

" + " " + "" + "

" + "
" + "
" + "

" + "

" + "
" + "
" + "

" + "" + "
" + "

Status (JSON)

" + "

OTA Update

"; + _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(); +} diff --git a/src/net/WebUI.h b/src/net/WebUI.h new file mode 100644 index 0000000..01975ed --- /dev/null +++ b/src/net/WebUI.h @@ -0,0 +1,23 @@ +#pragma once +#include +#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}; +}; \ No newline at end of file diff --git a/src/net/WebhookService.h b/src/net/WebhookService.h new file mode 100644 index 0000000..522a2fc --- /dev/null +++ b/src/net/WebhookService.h @@ -0,0 +1,26 @@ +#pragma once +#include +#include +#include +#include + +#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; +}; diff --git a/src/net/WebshookService.cpp b/src/net/WebshookService.cpp new file mode 100644 index 0000000..a2b25af --- /dev/null +++ b/src/net/WebshookService.cpp @@ -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); +} diff --git a/src/net/WiFiManager.cpp b/src/net/WiFiManager.cpp new file mode 100644 index 0000000..fe691dd --- /dev/null +++ b/src/net/WiFiManager.cpp @@ -0,0 +1,143 @@ +#include "WiFiManager.h" +#include +#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++); + } + } +} diff --git a/src/net/WiFiManager.h b/src/net/WiFiManager.h new file mode 100644 index 0000000..2e7502f --- /dev/null +++ b/src/net/WiFiManager.h @@ -0,0 +1,49 @@ +#pragma once +#include +#include +#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; +}; diff --git a/src/sensors/MoistureSensor.cpp b/src/sensors/MoistureSensor.cpp new file mode 100644 index 0000000..1ae488f --- /dev/null +++ b/src/sensors/MoistureSensor.cpp @@ -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; +} diff --git a/src/sensors/MoistureSensor.h b/src/sensors/MoistureSensor.h new file mode 100644 index 0000000..ff640b9 --- /dev/null +++ b/src/sensors/MoistureSensor.h @@ -0,0 +1,33 @@ +#pragma once +#include +#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; +}; diff --git a/src/settings/Settings.cpp b/src/settings/Settings.cpp new file mode 100644 index 0000000..ff7c1d5 --- /dev/null +++ b/src/settings/Settings.cpp @@ -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); } diff --git a/src/settings/Settings.h b/src/settings/Settings.h new file mode 100644 index 0000000..c729a08 --- /dev/null +++ b/src/settings/Settings.h @@ -0,0 +1,48 @@ +#pragma once +#include +#include + +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; +}; diff --git a/src/ui/Display.cpp b/src/ui/Display.cpp new file mode 100644 index 0000000..7ce57b3 --- /dev/null +++ b/src/ui/Display.cpp @@ -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); +} diff --git a/src/ui/Display.h b/src/ui/Display.h new file mode 100644 index 0000000..3e1960f --- /dev/null +++ b/src/ui/Display.h @@ -0,0 +1,21 @@ +#pragma once +#include +#include +#include +#include + +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; +}; diff --git a/src/ui/FaceRenderer.cpp b/src/ui/FaceRenderer.cpp new file mode 100644 index 0000000..09acf97 --- /dev/null +++ b/src/ui/FaceRenderer.cpp @@ -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); +} diff --git a/src/ui/FaceRenderer.h b/src/ui/FaceRenderer.h new file mode 100644 index 0000000..7485b2e --- /dev/null +++ b/src/ui/FaceRenderer.h @@ -0,0 +1,77 @@ +#pragma once +#include +#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); +}; diff --git a/src/util/BootTrigger.cpp b/src/util/BootTrigger.cpp new file mode 100644 index 0000000..b73bb6b --- /dev/null +++ b/src/util/BootTrigger.cpp @@ -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; +} diff --git a/src/util/BootTrigger.h b/src/util/BootTrigger.h new file mode 100644 index 0000000..717780c --- /dev/null +++ b/src/util/BootTrigger.h @@ -0,0 +1,12 @@ +#pragma once +#include + +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(); +}; diff --git a/src/util/Version.h b/src/util/Version.h new file mode 100644 index 0000000..83adb4b --- /dev/null +++ b/src/util/Version.h @@ -0,0 +1,7 @@ +#pragma once +#ifndef PB_VERSION + #define PB_VERSION "1.0.0" +#endif +#ifndef PB_HOSTNAME + #define PB_HOSTNAME "faceplant" +#endif diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html