Initial Commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal 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
10
.vscode/extensions.json
vendored
Normal 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
17
README.md
Normal 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
37
include/README
Normal 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
46
lib/README
Normal 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
15
platformio.ini
Normal 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
83
src/app/App.cpp
Normal 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
6
src/app/App.h
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#pragma once
|
||||||
|
class App {
|
||||||
|
public:
|
||||||
|
void setup();
|
||||||
|
void loop();
|
||||||
|
};
|
||||||
11
src/main.cpp
Normal file
11
src/main.cpp
Normal 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
18
src/net/CaptivePortal.cpp
Normal 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
16
src/net/CaptivePortal.h
Normal 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
46
src/net/OtaService.cpp
Normal 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
11
src/net/OtaService.h
Normal 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
133
src/net/WebUI.cpp
Normal 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
23
src/net/WebUI.h
Normal 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
26
src/net/WebhookService.h
Normal 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;
|
||||||
|
};
|
||||||
47
src/net/WebshookService.cpp
Normal file
47
src/net/WebshookService.cpp
Normal 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
143
src/net/WiFiManager.cpp
Normal 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
49
src/net/WiFiManager.h
Normal 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;
|
||||||
|
};
|
||||||
44
src/sensors/MoistureSensor.cpp
Normal file
44
src/sensors/MoistureSensor.cpp
Normal 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;
|
||||||
|
}
|
||||||
33
src/sensors/MoistureSensor.h
Normal file
33
src/sensors/MoistureSensor.h
Normal 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
46
src/settings/Settings.cpp
Normal 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
48
src/settings/Settings.h
Normal 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
61
src/ui/Display.cpp
Normal 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
21
src/ui/Display.h
Normal 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
275
src/ui/FaceRenderer.cpp
Normal 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
77
src/ui/FaceRenderer.h
Normal 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
22
src/util/BootTrigger.cpp
Normal 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
12
src/util/BootTrigger.h
Normal 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
7
src/util/Version.h
Normal 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
11
test/README
Normal 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
|
||||||
Reference in New Issue
Block a user