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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
.vscode/extensions.json vendored Normal file
View File

@@ -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"
]
}

17
README.md Normal file
View File

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

37
include/README Normal file
View File

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

46
lib/README Normal file
View File

@@ -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 <Foo.h>
#include <Bar.h>
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

15
platformio.ini Normal file
View File

@@ -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\"

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

11
test/README Normal file
View File

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