Add Battery Sensor functionality and integrate with display and app
- Implement BatterySensor class to monitor battery voltage and status. - Update App to initialize and loop BatterySensor. - Modify FaceRenderer to display battery status on the screen. - Enhance Display class with a method to draw battery icon. - Update WebUI to include battery status in the interface. - Refactor WiFiManager to improve connection handling and logging. - Adjust Settings to include factory reset functionality. - Improve HTML structure and styling in WebUI for better user experience.
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
#include "../ui/Display.h"
|
#include "../ui/Display.h"
|
||||||
#include "../sensors/MoistureSensor.h"
|
#include "../sensors/MoistureSensor.h"
|
||||||
|
#include "../sensors/BatterySensor.h"
|
||||||
#include "../ui/FaceRenderer.h"
|
#include "../ui/FaceRenderer.h"
|
||||||
|
|
||||||
static Settings settings;
|
static Settings settings;
|
||||||
@@ -21,6 +22,7 @@ static WebhookService webhook;
|
|||||||
|
|
||||||
static Display display;
|
static Display display;
|
||||||
static MoistureSensor moisture;
|
static MoistureSensor moisture;
|
||||||
|
static BatterySensor battery;
|
||||||
static FaceRenderer face;
|
static FaceRenderer face;
|
||||||
|
|
||||||
static unsigned long bootMs = 0;
|
static unsigned long bootMs = 0;
|
||||||
@@ -37,15 +39,27 @@ static PlantEventType moodToEvent(FaceRenderer::Mood m) {
|
|||||||
void App::setup() {
|
void App::setup() {
|
||||||
bootMs = millis();
|
bootMs = millis();
|
||||||
|
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(100);
|
||||||
|
Serial.println("\n\n=== FacePlant Starting ===");
|
||||||
|
Serial.print("Firmware: ");
|
||||||
|
Serial.println(PB_VERSION);
|
||||||
|
|
||||||
settings.begin();
|
settings.begin();
|
||||||
bool forceSetup = BootTrigger::checkAndConsume();
|
bool forceSetup = BootTrigger::checkAndConsume();
|
||||||
|
|
||||||
|
Serial.print("Force setup mode: ");
|
||||||
|
Serial.println(forceSetup ? "YES" : "NO");
|
||||||
|
Serial.print("Saved WiFi SSID: ");
|
||||||
|
Serial.println(settings.hasWiFi() ? settings.wifiSsid() : "(none)");
|
||||||
|
|
||||||
display.begin();
|
display.begin();
|
||||||
display.showStatus("FacePlant", "Starting...");
|
display.showStatus("FacePlant", "Starting...");
|
||||||
|
|
||||||
wifi.begin(settings, forceSetup);
|
wifi.begin(settings, forceSetup);
|
||||||
|
|
||||||
moisture.begin(settings);
|
moisture.begin(settings);
|
||||||
|
battery.begin();
|
||||||
face.begin(display, settings);
|
face.begin(display, settings);
|
||||||
|
|
||||||
webhook.begin(settings);
|
webhook.begin(settings);
|
||||||
@@ -55,16 +69,39 @@ void App::setup() {
|
|||||||
|
|
||||||
lastMood = face.mood();
|
lastMood = face.mood();
|
||||||
lastDead = face.isDeadMode();
|
lastDead = face.isDeadMode();
|
||||||
|
|
||||||
|
Serial.println("=== Setup Complete ===\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
void App::loop() {
|
void App::loop() {
|
||||||
|
static bool lastConnected = false;
|
||||||
|
static unsigned long lastDisplayUpdate = 0;
|
||||||
|
|
||||||
BootTrigger::clearAfterStableUptime();
|
BootTrigger::clearAfterStableUptime();
|
||||||
|
|
||||||
wifi.loop();
|
wifi.loop();
|
||||||
web.loop();
|
web.loop();
|
||||||
|
|
||||||
|
// Show WiFi status on display during connection attempts
|
||||||
|
bool currentConnected = wifi.connected();
|
||||||
|
if (currentConnected != lastConnected) {
|
||||||
|
if (currentConnected) {
|
||||||
|
Serial.println("[App] WiFi connected - showing on display");
|
||||||
|
display.showStatus("WiFi Connected!", wifi.ssid().c_str());
|
||||||
|
delay(2000);
|
||||||
|
} else if (wifi.mode() == NET_STA) {
|
||||||
|
Serial.println("[App] WiFi disconnected");
|
||||||
|
if (millis() - lastDisplayUpdate > 5000) {
|
||||||
|
display.showStatus("WiFi", "Connecting...");
|
||||||
|
lastDisplayUpdate = millis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastConnected = currentConnected;
|
||||||
|
}
|
||||||
|
|
||||||
moisture.loop();
|
moisture.loop();
|
||||||
face.loop(moisture);
|
battery.loop();
|
||||||
|
face.loop(moisture, battery);
|
||||||
|
|
||||||
// Webhook events on state transitions
|
// Webhook events on state transitions
|
||||||
FaceRenderer::Mood m = face.mood();
|
FaceRenderer::Mood m = face.mood();
|
||||||
|
|||||||
@@ -19,40 +19,188 @@ void WebUI::begin(Settings& settings,
|
|||||||
String mode = (wifi.mode() == NET_AP_SETUP) ? "Setup AP" : "Station";
|
String mode = (wifi.mode() == NET_AP_SETUP) ? "Setup AP" : "Station";
|
||||||
String wifiSsid = settings.wifiSsid();
|
String wifiSsid = settings.wifiSsid();
|
||||||
String currentSsid = wifi.ssid();
|
String currentSsid = wifi.ssid();
|
||||||
|
String plantProfile = settings.plantProfile();
|
||||||
|
|
||||||
String page =
|
String page =
|
||||||
"<h2>FacePlant</h2>"
|
"<!DOCTYPE html><html><head>"
|
||||||
"<p>Firmware v" + String(PB_VERSION) + "</p>"
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||||
"<h3>Wi-Fi</h3>"
|
"<title>FacePlant</title>"
|
||||||
"<p>Mode: " + mode + "</p>"
|
"<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css' "
|
||||||
"<p>Connected SSID: " + (currentSsid.length() ? currentSsid : "(not connected)") + "</p>"
|
"integrity='sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==' "
|
||||||
"<p>Saved SSID: " + (wifiSsid.length() ? wifiSsid : "(none)") + "</p>"
|
"crossorigin='anonymous' referrerpolicy='no-referrer' />"
|
||||||
"<p>Setup AP: " + String(wifi.setupSsid()) + " / " + wifi.apIp().toString() + "</p>"
|
"<style>"
|
||||||
"<form method='POST' action='/wifi'>"
|
"* { margin: 0; padding: 0; box-sizing: border-box; }"
|
||||||
"<label>SSID</label><br>"
|
"body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; "
|
||||||
"<input name='ssid' size='30' value='" + wifiSsid + "'><br>"
|
"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; }"
|
||||||
"<label>Password</label><br>"
|
".container { max-width: 600px; margin: 0 auto; }"
|
||||||
"<input type='password' name='pass' size='30' value=''><br>"
|
".card { background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); "
|
||||||
"<small>Leave password blank to keep saved password for the same SSID.</small><br><br>"
|
"border-radius: 20px; padding: 30px; margin-bottom: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }"
|
||||||
"<button type='submit' name='action' value='connect'>Connect Wi-Fi</button> "
|
".header { text-align: center; margin-bottom: 10px; }"
|
||||||
"<button type='submit' name='action' value='forget'>Forget Wi-Fi</button>"
|
".header h1 { font-size: 2.5em; font-weight: 700; color: #1d1d1f; margin-bottom: 5px; "
|
||||||
"</form><br>"
|
"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; "
|
||||||
"<form method='POST' action='/config'>"
|
"-webkit-text-fill-color: transparent; }"
|
||||||
"<label>Plant profile</label><br>"
|
".version { color: #86868b; font-size: 0.9em; margin-bottom: 20px; }"
|
||||||
"<select name='plant'>"
|
".status-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin: 20px 0; }"
|
||||||
"<option value='house'>Houseplant</option>"
|
".status-item { background: #f5f5f7; padding: 15px; border-radius: 12px; }"
|
||||||
"<option value='succulent'>Succulent</option>"
|
".status-label { font-size: 0.85em; color: #86868b; margin-bottom: 5px; }"
|
||||||
"<option value='herbs'>Herbs</option>"
|
".status-value { font-size: 1em; color: #1d1d1f; font-weight: 500; word-break: break-word; }"
|
||||||
"<option value='fern'>Fern</option>"
|
".status-value.connected { color: #34c759; }"
|
||||||
"<option value='tropical'>Tropical</option>"
|
".status-value.disconnected { color: #ff3b30; }"
|
||||||
"</select><br><br>"
|
"h2 { font-size: 1.5em; font-weight: 600; color: #1d1d1f; margin: 25px 0 15px 0; }"
|
||||||
"<label><input type='checkbox' name='kids' " + String(settings.kidsMode() ? "checked" : "") + "> Kids Mode</label><br><br>"
|
"label { display: block; font-size: 0.95em; font-weight: 500; color: #1d1d1f; margin-bottom: 8px; }"
|
||||||
"<label>Webhook URL</label><br>"
|
"input[type='text'], input[type='password'], select { width: 100%; padding: 12px 16px; "
|
||||||
"<input name='wh' size='40' value='" + settings.webhookUrl() + "'><br>"
|
"border: 1px solid #d2d2d7; border-radius: 10px; font-size: 1em; "
|
||||||
"<label><input type='checkbox' name='wh_en' " + String(settings.webhookEnabled() ? "checked" : "") + "> Enable webhook</label><br><br>"
|
"transition: all 0.2s; background: white; }"
|
||||||
"<button type='submit'>Save</button>"
|
"input[type='text']:focus, input[type='password']:focus, select:focus { "
|
||||||
|
"outline: none; border-color: #667eea; box-shadow: 0 0 0 4px rgba(102,126,234,0.1); }"
|
||||||
|
".form-hint { font-size: 0.85em; color: #86868b; margin-top: 5px; margin-bottom: 15px; }"
|
||||||
|
".checkbox-label { display: flex; align-items: center; margin: 15px 0; cursor: pointer; }"
|
||||||
|
"input[type='checkbox'] { width: 20px; height: 20px; margin-right: 10px; cursor: pointer; "
|
||||||
|
"accent-color: #667eea; }"
|
||||||
|
".btn { display: inline-block; padding: 12px 24px; border: none; border-radius: 10px; "
|
||||||
|
"font-size: 1em; font-weight: 500; cursor: pointer; transition: all 0.2s; "
|
||||||
|
"text-decoration: none; text-align: center; }"
|
||||||
|
".btn-primary { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); "
|
||||||
|
"color: white; box-shadow: 0 4px 12px rgba(102,126,234,0.4); }"
|
||||||
|
".btn-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(102,126,234,0.5); }"
|
||||||
|
".btn-secondary { background: #f5f5f7; color: #1d1d1f; }"
|
||||||
|
".btn-secondary:hover { background: #e8e8ed; }"
|
||||||
|
".btn-danger { background: #ff3b30; color: white; }"
|
||||||
|
".btn-danger:hover { background: #ff2d20; transform: translateY(-2px); }"
|
||||||
|
".btn-group { display: flex; gap: 10px; margin-top: 20px; }"
|
||||||
|
".btn-group .btn { flex: 1; }"
|
||||||
|
".links { margin-top: 20px; text-align: center; }"
|
||||||
|
".links a { color: #667eea; text-decoration: none; margin: 0 10px; font-weight: 500; }"
|
||||||
|
".links a:hover { text-decoration: underline; }"
|
||||||
|
"@media (max-width: 600px) { .status-grid { grid-template-columns: 1fr; } }"
|
||||||
|
"</style>"
|
||||||
|
"</head><body>"
|
||||||
|
"<div class='container'>"
|
||||||
|
"<div class='card'>"
|
||||||
|
"<div class='header'>"
|
||||||
|
"<h1><i class='fas fa-seedling' style='margin-right: 10px;'></i>FacePlant</h1>"
|
||||||
|
"<div class='version'>Firmware v" + String(PB_VERSION) + "</div>"
|
||||||
|
"</div>"
|
||||||
|
"<div class='status-grid'>"
|
||||||
|
"<div class='status-item'><div class='status-label'>Status</div>"
|
||||||
|
"<div class='status-value " + String(wifi.connected() ? "connected" : "disconnected") + "'>"
|
||||||
|
+ (wifi.connected() ? "Connected" : "Not Connected") + "</div></div>"
|
||||||
|
"<div class='status-item'><div class='status-label'>Mode</div>"
|
||||||
|
"<div class='status-value'>" + mode + "</div></div>"
|
||||||
|
"<div class='status-item'><div class='status-label'>Current Network</div>"
|
||||||
|
"<div class='status-value'>" + (currentSsid.length() ? currentSsid : "None") + "</div></div>"
|
||||||
|
"<div class='status-item'><div class='status-label'>Setup AP</div>"
|
||||||
|
"<div class='status-value'>" + String(wifi.setupSsid()) + "</div></div>"
|
||||||
|
"</div>"
|
||||||
|
"</div>"
|
||||||
|
"<div class='card'>"
|
||||||
|
"<h2><i class='fas fa-wifi' style='margin-right: 10px;'></i>Wi-Fi Settings</h2>"
|
||||||
|
"<form method='POST' action='/wifi' id='wifiForm'>"
|
||||||
|
"<label for='ssid'>Network Name (SSID)</label>"
|
||||||
|
"<input type='text' id='ssid' name='ssid' value='" + wifiSsid + "' required>"
|
||||||
|
"<div class='form-hint'>Enter your Wi-Fi network name</div>"
|
||||||
|
"<label for='pass'>Password</label>"
|
||||||
|
"<input type='password' id='pass' name='pass' placeholder='Enter password'>"
|
||||||
|
"<div class='form-hint'>Leave blank to keep existing password for same SSID</div>"
|
||||||
|
"<div class='btn-group'>"
|
||||||
|
"<button type='submit' name='action' value='connect' class='btn btn-primary'>Connect</button>"
|
||||||
|
"<button type='submit' name='action' value='forget' class='btn btn-danger' "
|
||||||
|
"onclick='return confirm(\"Forget Wi-Fi settings?\")'>Forget</button>"
|
||||||
|
"</div>"
|
||||||
"</form>"
|
"</form>"
|
||||||
"<p><a href='/status'>Status (JSON)</a></p>"
|
"</div>"
|
||||||
"<p><a href='/update'>OTA Update</a></p>";
|
"<div class='card'>"
|
||||||
|
"<h2><i class='fas fa-leaf' style='margin-right: 10px;'></i>Plant Settings</h2>"
|
||||||
|
"<form method='POST' action='/config'>"
|
||||||
|
"<label for='plant'>Plant Profile</label>"
|
||||||
|
"<select id='plant' name='plant'>"
|
||||||
|
"<option value='house'" + String(plantProfile == "house" ? " selected" : "") + ">Houseplant</option>"
|
||||||
|
"<option value='succulent'" + String(plantProfile == "succulent" ? " selected" : "") + ">Succulent</option>"
|
||||||
|
"<option value='herbs'" + String(plantProfile == "herbs" ? " selected" : "") + ">Herbs</option>"
|
||||||
|
"<option value='fern'" + String(plantProfile == "fern" ? " selected" : "") + ">Fern</option>"
|
||||||
|
"<option value='tropical'" + String(plantProfile == "tropical" ? " selected" : "") + ">Tropical</option>"
|
||||||
|
"<option value='veg'" + String(plantProfile == "veg" ? " selected" : "") + ">Vegetables</option>"
|
||||||
|
"</select>"
|
||||||
|
"<div class='form-hint'>Select your plant type for optimal watering thresholds</div>"
|
||||||
|
"<label class='checkbox-label'>"
|
||||||
|
"<input type='checkbox' name='kids' " + String(settings.kidsMode() ? "checked" : "") + "> Kids Mode"
|
||||||
|
"</label>"
|
||||||
|
"<div class='form-hint'>Simplified interface for children</div>"
|
||||||
|
"<label for='wh'>Webhook URL</label>"
|
||||||
|
"<input type='text' id='wh' name='wh' value='" + settings.webhookUrl() + "' placeholder='https://...'>"
|
||||||
|
"<div class='form-hint'>Optional webhook for notifications</div>"
|
||||||
|
"<label class='checkbox-label'>"
|
||||||
|
"<input type='checkbox' name='wh_en' " + String(settings.webhookEnabled() ? "checked" : "") + "> Enable Webhook"
|
||||||
|
"</label>"
|
||||||
|
"<div class='btn-group'>"
|
||||||
|
"<button type='submit' class='btn btn-primary'>Save Settings</button>"
|
||||||
|
"</div>"
|
||||||
|
"</form>"
|
||||||
|
"</div>"
|
||||||
|
"<div class='card'>"
|
||||||
|
"<h2><i class='fas fa-sliders-h' style='margin-right: 10px;'></i>Sensor Calibration</h2>"
|
||||||
|
"<div style='background: #f5f5f7; padding: 15px; border-radius: 12px; margin-bottom: 20px;'>"
|
||||||
|
"<div style='font-size: 0.85em; color: #86868b; margin-bottom: 5px;'>Current Reading</div>"
|
||||||
|
"<div style='font-size: 1.5em; font-weight: 600; color: #667eea;'>" + String(moisture.raw()) + "</div>"
|
||||||
|
"<div style='font-size: 0.85em; color: #86868b; margin-top: 5px;'>Moisture: " + String(moisture.percent()) + "%</div>"
|
||||||
|
"</div>"
|
||||||
|
"<form method='POST' action='/calibrate'>"
|
||||||
|
"<p style='color: #86868b; font-size: 0.9em; margin-bottom: 20px;'>"
|
||||||
|
"<i class='fas fa-lightbulb' style='color: #667eea; margin-right: 5px;'></i><strong>How to calibrate:</strong><br>"
|
||||||
|
"1. For <strong>dry</strong> reading: Remove sensor from soil, wait 30 seconds, note the value above<br>"
|
||||||
|
"2. For <strong>wet</strong> reading: Place sensor in water, wait 30 seconds, note the value above<br>"
|
||||||
|
"3. Enter both values below and save"
|
||||||
|
"</p>"
|
||||||
|
"<label for='dry'>Dry Value (sensor in air)</label>"
|
||||||
|
"<input type='number' id='dry' name='dry' value='" + String(settings.dryRaw()) + "' required min='0' max='4095'>"
|
||||||
|
"<div class='form-hint'>Raw ADC value when sensor is completely dry (typically 3000-4000)</div>"
|
||||||
|
"<label for='wet'>Wet Value (sensor in water)</label>"
|
||||||
|
"<input type='number' id='wet' name='wet' value='" + String(settings.wetRaw()) + "' required min='0' max='4095'>"
|
||||||
|
"<div class='form-hint'>Raw ADC value when sensor is fully wet (typically 1000-2000)</div>"
|
||||||
|
"<div class='btn-group'>"
|
||||||
|
"<button type='submit' class='btn btn-primary'>Save Calibration</button>"
|
||||||
|
"</div>"
|
||||||
|
"</form>"
|
||||||
|
"</div>"
|
||||||
|
"<div class='card' style='border: 2px solid #ff3b30;'>"
|
||||||
|
"<h2 style='color: #ff3b30;'><i class='fas fa-exclamation-triangle' style='margin-right: 10px;'></i>Danger Zone</h2>"
|
||||||
|
"<p style='color: #86868b; margin-bottom: 15px;'>Irreversible actions that will reset your device</p>"
|
||||||
|
"<form method='POST' action='/factory-reset' id='resetForm'>"
|
||||||
|
"<p style='margin-bottom: 15px;'><strong>Factory Reset</strong></p>"
|
||||||
|
"<p style='color: #86868b; font-size: 0.9em; margin-bottom: 15px;'>"
|
||||||
|
"This will erase all settings including Wi-Fi credentials, plant profile, calibration data, and webhooks. "
|
||||||
|
"The device will restart in setup mode.</p>"
|
||||||
|
"<button type='submit' class='btn btn-danger' style='width: 100%;'>Reset to Factory Defaults</button>"
|
||||||
|
"</form>"
|
||||||
|
"</div>"
|
||||||
|
"<div class='card links'>"
|
||||||
|
"<a href='/status'><i class='fas fa-chart-bar'></i> Status (JSON)</a>"
|
||||||
|
"<a href='/update'><i class='fas fa-cloud-upload-alt'></i> OTA Update</a>"
|
||||||
|
"</div>"
|
||||||
|
"</div>"
|
||||||
|
"<script>"
|
||||||
|
"document.getElementById('wifiForm').addEventListener('submit', function(e) {"
|
||||||
|
" if (e.submitter.value === 'connect') {"
|
||||||
|
" const ssid = document.getElementById('ssid').value.trim();"
|
||||||
|
" const pass = document.getElementById('pass').value;"
|
||||||
|
" const savedSsid = '" + wifiSsid + "';"
|
||||||
|
" if (ssid !== savedSsid && pass.length === 0) {"
|
||||||
|
" e.preventDefault();"
|
||||||
|
" alert('Please enter a password for the new network.');"
|
||||||
|
" return false;"
|
||||||
|
" }"
|
||||||
|
" }"
|
||||||
|
"});"
|
||||||
|
"document.getElementById('resetForm').addEventListener('submit', function(e) {"
|
||||||
|
" e.preventDefault();"
|
||||||
|
" if (confirm('Are you sure you want to reset to factory defaults? This cannot be undone.')) {"
|
||||||
|
" if (confirm('Last warning: This will erase ALL settings and restart the device. Continue?')) {"
|
||||||
|
" this.submit();"
|
||||||
|
" }"
|
||||||
|
" }"
|
||||||
|
"});"
|
||||||
|
"</script>"
|
||||||
|
"</body></html>";
|
||||||
_server.send(200, "text/html", page);
|
_server.send(200, "text/html", page);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,6 +217,10 @@ void WebUI::begin(Settings& settings,
|
|||||||
pass = _server.arg("pass");
|
pass = _server.arg("pass");
|
||||||
} else if (ssid == settings.wifiSsid()) {
|
} else if (ssid == settings.wifiSsid()) {
|
||||||
pass = settings.wifiPass();
|
pass = settings.wifiPass();
|
||||||
|
} else {
|
||||||
|
// New SSID but no password provided - reject
|
||||||
|
_server.send(400, "text/plain", "Password required for new network");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ssid.length() > 0) wifi.saveAndConnect(ssid, pass);
|
if (ssid.length() > 0) wifi.saveAndConnect(ssid, pass);
|
||||||
@@ -87,6 +239,56 @@ void WebUI::begin(Settings& settings,
|
|||||||
_server.send(303);
|
_server.send(303);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_server.on("/calibrate", HTTP_POST, [&]() {
|
||||||
|
if (_server.hasArg("dry") && _server.hasArg("wet")) {
|
||||||
|
int dryVal = _server.arg("dry").toInt();
|
||||||
|
int wetVal = _server.arg("wet").toInt();
|
||||||
|
|
||||||
|
Serial.println("[WebUI] Calibration update requested");
|
||||||
|
Serial.print("[WebUI] Dry value: ");
|
||||||
|
Serial.println(dryVal);
|
||||||
|
Serial.print("[WebUI] Wet value: ");
|
||||||
|
Serial.println(wetVal);
|
||||||
|
|
||||||
|
if (dryVal > wetVal && dryVal <= 4095 && wetVal >= 0) {
|
||||||
|
settings.setCalibration(dryVal, wetVal);
|
||||||
|
Serial.println("[WebUI] Calibration saved");
|
||||||
|
} else {
|
||||||
|
Serial.println("[WebUI] Invalid calibration values (dry must be > wet)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_server.sendHeader("Location", "/");
|
||||||
|
_server.send(303);
|
||||||
|
});
|
||||||
|
|
||||||
|
_server.on("/factory-reset", HTTP_POST, [&]() {
|
||||||
|
Serial.println("[WebUI] Factory reset requested");
|
||||||
|
_server.send(200, "text/html",
|
||||||
|
"<!DOCTYPE html><html><head><meta http-equiv='refresh' content='10;url=http://192.168.4.1'>"
|
||||||
|
"<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'>"
|
||||||
|
"<style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"
|
||||||
|
"background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);min-height:100vh;display:flex;"
|
||||||
|
"align-items:center;justify-content:center;margin:0;padding:20px;}"
|
||||||
|
".card{background:rgba(255,255,255,0.95);border-radius:20px;padding:40px;text-align:center;"
|
||||||
|
"box-shadow:0 20px 60px rgba(0,0,0,0.3);max-width:400px;}"
|
||||||
|
"h1{font-size:2em;margin-bottom:10px;color:#1d1d1f;}"
|
||||||
|
"p{color:#86868b;margin:10px 0;}"
|
||||||
|
".spinner{font-size:3em;color:#667eea;margin:20px 0;animation:spin 2s linear infinite;}"
|
||||||
|
"@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}</style></head><body>"
|
||||||
|
"<div class='card'>"
|
||||||
|
"<div class='spinner'><i class='fas fa-sync-alt'></i></div>"
|
||||||
|
"<h1>Factory Reset</h1>"
|
||||||
|
"<p>Erasing all settings...</p>"
|
||||||
|
"<p>Device will restart in setup mode.</p>"
|
||||||
|
"<p>Reconnect to <strong>FacePlant-Setup</strong> network in 10 seconds.</p>"
|
||||||
|
"</div></body></html>");
|
||||||
|
delay(1000);
|
||||||
|
settings.factoryReset();
|
||||||
|
delay(1000);
|
||||||
|
Serial.println("[WebUI] Restarting device...");
|
||||||
|
ESP.restart();
|
||||||
|
});
|
||||||
|
|
||||||
_server.on("/status", HTTP_GET, [&]() {
|
_server.on("/status", HTTP_GET, [&]() {
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
doc["device"] = "FacePlant";
|
doc["device"] = "FacePlant";
|
||||||
|
|||||||
@@ -20,18 +20,26 @@ String WiFiManager::ssid() const {
|
|||||||
void WiFiManager::begin(Settings& settings, bool forceSetupAP) {
|
void WiFiManager::begin(Settings& settings, bool forceSetupAP) {
|
||||||
_settings = &settings;
|
_settings = &settings;
|
||||||
|
|
||||||
|
Serial.println("[WiFi] Initializing...");
|
||||||
WiFi.persistent(false);
|
WiFi.persistent(false);
|
||||||
WiFi.setAutoReconnect(false);
|
WiFi.setAutoReconnect(false);
|
||||||
|
|
||||||
if (forceSetupAP) { startSetupAP(); return; }
|
if (forceSetupAP) {
|
||||||
|
Serial.println("[WiFi] Forced setup AP mode");
|
||||||
|
startSetupAP();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_settings->hasWiFi()) {
|
if (_settings->hasWiFi()) {
|
||||||
|
Serial.print("[WiFi] Connecting to: ");
|
||||||
|
Serial.println(_settings->wifiSsid());
|
||||||
WiFi.mode(WIFI_STA);
|
WiFi.mode(WIFI_STA);
|
||||||
WiFi.setHostname(PB_HOSTNAME);
|
WiFi.setHostname(PB_HOSTNAME);
|
||||||
WiFi.begin(_settings->wifiSsid().c_str(), _settings->wifiPass().c_str());
|
WiFi.begin(_settings->wifiSsid().c_str(), _settings->wifiPass().c_str());
|
||||||
_bootConnectStartMs = millis();
|
_bootConnectStartMs = millis();
|
||||||
_mode = NET_STA;
|
_mode = NET_STA;
|
||||||
} else {
|
} else {
|
||||||
|
Serial.println("[WiFi] No saved credentials, starting setup AP");
|
||||||
startSetupAP();
|
startSetupAP();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,12 +54,23 @@ void WiFiManager::saveAndConnect(const String& ssid, const String& pass) {
|
|||||||
if (cleanSsid.length() == 0) return;
|
if (cleanSsid.length() == 0) return;
|
||||||
|
|
||||||
bool changed = (cleanSsid != _settings->wifiSsid()) || (pass != _settings->wifiPass());
|
bool changed = (cleanSsid != _settings->wifiSsid()) || (pass != _settings->wifiPass());
|
||||||
|
|
||||||
|
Serial.println("[WiFi] Saving credentials...");
|
||||||
|
Serial.print("[WiFi] SSID: ");
|
||||||
|
Serial.println(cleanSsid);
|
||||||
|
Serial.print("[WiFi] Password length: ");
|
||||||
|
Serial.println(pass.length());
|
||||||
|
Serial.print("[WiFi] Changed: ");
|
||||||
|
Serial.println(changed ? "YES" : "NO");
|
||||||
|
|
||||||
_settings->saveWiFi(cleanSsid, pass);
|
_settings->saveWiFi(cleanSsid, pass);
|
||||||
if (changed) _settings->setEverConnected(false);
|
if (changed) _settings->setEverConnected(false);
|
||||||
|
|
||||||
_bootConnectStartMs = millis();
|
_bootConnectStartMs = millis();
|
||||||
_reconnectBackoffStep = 0;
|
_reconnectBackoffStep = 0;
|
||||||
_nextReconnectMs = _bootConnectStartMs + 5000UL;
|
_nextReconnectMs = _bootConnectStartMs + 5000UL;
|
||||||
|
|
||||||
|
Serial.println("[WiFi] Starting connection attempt...");
|
||||||
startStationAttempt();
|
startStationAttempt();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +82,7 @@ void WiFiManager::clearAndStartSetupAP() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WiFiManager::startSetupAP() {
|
void WiFiManager::startSetupAP() {
|
||||||
|
Serial.println("[WiFi] Starting Setup AP mode");
|
||||||
_mode = NET_AP_SETUP;
|
_mode = NET_AP_SETUP;
|
||||||
_setupRequested = false;
|
_setupRequested = false;
|
||||||
|
|
||||||
@@ -71,6 +91,11 @@ void WiFiManager::startSetupAP() {
|
|||||||
WiFi.softAPConfig(_apIP, _apIP, _apMask);
|
WiFi.softAPConfig(_apIP, _apIP, _apMask);
|
||||||
WiFi.softAP(_setupSsid, _setupPass);
|
WiFi.softAP(_setupSsid, _setupPass);
|
||||||
|
|
||||||
|
Serial.print("[WiFi] AP SSID: ");
|
||||||
|
Serial.println(_setupSsid);
|
||||||
|
Serial.print("[WiFi] AP IP: ");
|
||||||
|
Serial.println(_apIP);
|
||||||
|
|
||||||
_portal.start(_apIP);
|
_portal.start(_apIP);
|
||||||
|
|
||||||
MDNS.end();
|
MDNS.end();
|
||||||
@@ -104,20 +129,56 @@ void WiFiManager::startStation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WiFiManager::startStationAttempt() {
|
void WiFiManager::startStationAttempt() {
|
||||||
|
Serial.print("[WiFi] Starting connection attempt to: ");
|
||||||
|
Serial.println(_settings->wifiSsid());
|
||||||
|
Serial.print("[WiFi] Mode: ");
|
||||||
|
Serial.println(_mode == NET_AP_SETUP ? "AP+STA (dual)" : "STA only");
|
||||||
|
|
||||||
|
WiFi.disconnect();
|
||||||
|
delay(100);
|
||||||
|
|
||||||
if (_mode == NET_AP_SETUP) WiFi.mode(WIFI_AP_STA);
|
if (_mode == NET_AP_SETUP) WiFi.mode(WIFI_AP_STA);
|
||||||
else WiFi.mode(WIFI_STA);
|
else WiFi.mode(WIFI_STA);
|
||||||
WiFi.setHostname(PB_HOSTNAME);
|
WiFi.setHostname(PB_HOSTNAME);
|
||||||
|
|
||||||
WiFi.begin(_settings->wifiSsid().c_str(), _settings->wifiPass().c_str());
|
WiFi.begin(_settings->wifiSsid().c_str(), _settings->wifiPass().c_str());
|
||||||
|
|
||||||
|
Serial.println("[WiFi] WiFi.begin() called");
|
||||||
}
|
}
|
||||||
|
|
||||||
void WiFiManager::loop() {
|
void WiFiManager::loop() {
|
||||||
|
static unsigned long lastStatusLog = 0;
|
||||||
|
static wl_status_t lastStatus = WL_IDLE_STATUS;
|
||||||
|
|
||||||
if (_mode == NET_AP_SETUP) _portal.loop();
|
if (_mode == NET_AP_SETUP) _portal.loop();
|
||||||
|
|
||||||
if (_setupRequested) { startSetupAP(); return; }
|
if (_setupRequested) { startSetupAP(); return; }
|
||||||
|
|
||||||
|
wl_status_t currentStatus = WiFi.status();
|
||||||
|
if (currentStatus != lastStatus || (millis() - lastStatusLog > 5000)) {
|
||||||
|
Serial.print("[WiFi] Status: ");
|
||||||
|
switch (currentStatus) {
|
||||||
|
case WL_CONNECTED: Serial.println("CONNECTED"); break;
|
||||||
|
case WL_NO_SSID_AVAIL: Serial.println("SSID not found"); break;
|
||||||
|
case WL_CONNECT_FAILED: Serial.println("Connection FAILED (wrong password?)"); break;
|
||||||
|
case WL_IDLE_STATUS: Serial.println("Idle"); break;
|
||||||
|
case WL_DISCONNECTED: Serial.println("Disconnected"); break;
|
||||||
|
default: Serial.print("Other ("); Serial.print(currentStatus); Serial.println(")"); break;
|
||||||
|
}
|
||||||
|
lastStatus = currentStatus;
|
||||||
|
lastStatusLog = millis();
|
||||||
|
}
|
||||||
|
|
||||||
if (WiFi.status() == WL_CONNECTED) {
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
if (_mode != NET_STA || WiFi.getMode() != WIFI_STA) startStation();
|
if (_mode != NET_STA || WiFi.getMode() != WIFI_STA) {
|
||||||
if (!_settings->everConnected()) _settings->setEverConnected(true);
|
Serial.print("[WiFi] CONNECTED! IP: ");
|
||||||
|
Serial.println(WiFi.localIP());
|
||||||
|
startStation();
|
||||||
|
}
|
||||||
|
if (!_settings->everConnected()) {
|
||||||
|
Serial.println("[WiFi] First successful connection!");
|
||||||
|
_settings->setEverConnected(true);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +189,10 @@ void WiFiManager::loop() {
|
|||||||
|
|
||||||
if (!_settings->everConnected() && _bootConnectStartMs != 0 &&
|
if (!_settings->everConnected() && _bootConnectStartMs != 0 &&
|
||||||
(millis() - _bootConnectStartMs) > BOOT_CONNECT_TIMEOUT_MS) {
|
(millis() - _bootConnectStartMs) > BOOT_CONNECT_TIMEOUT_MS) {
|
||||||
|
Serial.println("[WiFi] Boot connection timeout - returning to setup AP");
|
||||||
|
Serial.print("[WiFi] Elapsed time: ");
|
||||||
|
Serial.print((millis() - _bootConnectStartMs) / 1000);
|
||||||
|
Serial.println(" seconds");
|
||||||
_bootConnectStartMs = 0;
|
_bootConnectStartMs = 0;
|
||||||
startSetupAP();
|
startSetupAP();
|
||||||
return;
|
return;
|
||||||
@@ -136,6 +201,9 @@ void WiFiManager::loop() {
|
|||||||
if (_settings->everConnected()) {
|
if (_settings->everConnected()) {
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
if ((long)(now - _nextReconnectMs) >= 0) {
|
if ((long)(now - _nextReconnectMs) >= 0) {
|
||||||
|
Serial.print("[WiFi] Retry attempt (backoff step ");
|
||||||
|
Serial.print(_reconnectBackoffStep);
|
||||||
|
Serial.println(")");
|
||||||
startStationAttempt();
|
startStationAttempt();
|
||||||
_nextReconnectMs = now + reconnectDelayMs(_reconnectBackoffStep++);
|
_nextReconnectMs = now + reconnectDelayMs(_reconnectBackoffStep++);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ private:
|
|||||||
const char* _setupPass = "faceplant";
|
const char* _setupPass = "faceplant";
|
||||||
|
|
||||||
unsigned long _bootConnectStartMs = 0;
|
unsigned long _bootConnectStartMs = 0;
|
||||||
static constexpr unsigned long BOOT_CONNECT_TIMEOUT_MS = 20000;
|
static constexpr unsigned long BOOT_CONNECT_TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
unsigned long _nextReconnectMs = 0;
|
unsigned long _nextReconnectMs = 0;
|
||||||
int _reconnectBackoffStep = 0;
|
int _reconnectBackoffStep = 0;
|
||||||
|
|||||||
100
src/sensors/BatterySensor.cpp
Normal file
100
src/sensors/BatterySensor.cpp
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
#include "BatterySensor.h"
|
||||||
|
|
||||||
|
void BatterySensor::begin() {
|
||||||
|
pinMode(PIN_BATTERY_ADC, INPUT);
|
||||||
|
pinMode(PIN_LBO, INPUT);
|
||||||
|
|
||||||
|
analogReadResolution(12); // 12-bit ADC (0-4095)
|
||||||
|
|
||||||
|
Serial.println("[Battery] Initialized");
|
||||||
|
Serial.println("[Battery] ADC on GPIO 2, LBO on GPIO 5");
|
||||||
|
|
||||||
|
// Initial reading
|
||||||
|
_voltage = readBatteryVoltage();
|
||||||
|
_percent = voltageToPercent(_voltage);
|
||||||
|
_isLow = (_voltage < VOLTAGE_LOW);
|
||||||
|
|
||||||
|
Serial.print("[Battery] Initial voltage: ");
|
||||||
|
Serial.print(_voltage);
|
||||||
|
Serial.print("V, ");
|
||||||
|
Serial.print(_percent);
|
||||||
|
Serial.println("%");
|
||||||
|
}
|
||||||
|
|
||||||
|
void BatterySensor::loop() {
|
||||||
|
unsigned long now = millis();
|
||||||
|
if (now - _lastCheckMs < CHECK_INTERVAL_MS) return;
|
||||||
|
_lastCheckMs = now;
|
||||||
|
|
||||||
|
float prevVoltage = _voltage;
|
||||||
|
int prevPercent = _percent;
|
||||||
|
bool wasLow = _isLow;
|
||||||
|
|
||||||
|
// Read battery voltage via ADC
|
||||||
|
_voltage = readBatteryVoltage();
|
||||||
|
_percent = voltageToPercent(_voltage);
|
||||||
|
|
||||||
|
// Check LBO pin as backup confirmation
|
||||||
|
bool lboHigh = digitalRead(PIN_LBO);
|
||||||
|
bool lboIndicatesLow = !lboHigh;
|
||||||
|
|
||||||
|
// Battery is low if either voltage is low OR LBO pin indicates low
|
||||||
|
_isLow = (_voltage < VOLTAGE_LOW) || lboIndicatesLow;
|
||||||
|
|
||||||
|
// Log significant changes
|
||||||
|
if (abs(_percent - prevPercent) >= 5 || _isLow != wasLow) {
|
||||||
|
Serial.print("[Battery] Voltage: ");
|
||||||
|
Serial.print(_voltage, 2);
|
||||||
|
Serial.print("V (");
|
||||||
|
Serial.print(_percent);
|
||||||
|
Serial.print("%) LBO: ");
|
||||||
|
Serial.println(lboHigh ? "OK" : "LOW");
|
||||||
|
|
||||||
|
if (_isLow && !wasLow) {
|
||||||
|
Serial.println("[Battery] ⚠️ LOW BATTERY WARNING - Please recharge!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float BatterySensor::readBatteryVoltage() {
|
||||||
|
// Take multiple samples and average them for stability
|
||||||
|
long sum = 0;
|
||||||
|
for (int i = 0; i < SAMPLES; i++) {
|
||||||
|
sum += analogRead(PIN_BATTERY_ADC);
|
||||||
|
delay(5);
|
||||||
|
}
|
||||||
|
int avgRaw = sum / SAMPLES;
|
||||||
|
|
||||||
|
// Convert ADC value to voltage at the pin
|
||||||
|
// ESP32-C3 ADC: 12-bit (0-4095) maps to 0-3.3V
|
||||||
|
float adcVoltage = (avgRaw / 4095.0) * 3.3;
|
||||||
|
|
||||||
|
// Multiply by divider ratio to get actual battery voltage
|
||||||
|
float batteryVoltage = adcVoltage * DIVIDER_RATIO;
|
||||||
|
|
||||||
|
return batteryVoltage;
|
||||||
|
}
|
||||||
|
|
||||||
|
int BatterySensor::voltageToPercent(float voltage) {
|
||||||
|
// Clamp voltage to valid range
|
||||||
|
if (voltage >= VOLTAGE_MAX) return 100;
|
||||||
|
if (voltage <= VOLTAGE_MIN) return 0;
|
||||||
|
|
||||||
|
// Linear mapping from voltage to percentage
|
||||||
|
// This is a simplified model; LiPo discharge curves are non-linear
|
||||||
|
// For better accuracy, you could use a lookup table
|
||||||
|
float percent = ((voltage - VOLTAGE_MIN) / (VOLTAGE_MAX - VOLTAGE_MIN)) * 100.0;
|
||||||
|
|
||||||
|
return constrain((int)percent, 0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BatterySensor::shouldBlink() const {
|
||||||
|
// Blink every 500ms when battery is low (< 20%)
|
||||||
|
return (_percent < 20) && ((millis() / 500) % 2 == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int BatterySensor::iconFillWidth(int maxWidth) const {
|
||||||
|
// Calculate fill width for battery icon
|
||||||
|
// Returns 0 to maxWidth based on percentage
|
||||||
|
return (_percent * maxWidth) / 100;
|
||||||
|
}
|
||||||
40
src/sensors/BatterySensor.h
Normal file
40
src/sensors/BatterySensor.h
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
class BatterySensor {
|
||||||
|
public:
|
||||||
|
void begin();
|
||||||
|
void loop();
|
||||||
|
|
||||||
|
// Battery status
|
||||||
|
int percent() const { return _percent; }
|
||||||
|
float voltage() const { return _voltage; }
|
||||||
|
bool isLow() const { return _isLow; }
|
||||||
|
bool shouldBlink() const;
|
||||||
|
|
||||||
|
// For display
|
||||||
|
int iconFillWidth(int maxWidth) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr int PIN_BATTERY_ADC = 2; // Voltage divider input
|
||||||
|
static constexpr int PIN_LBO = 5; // PowerBoost LBO pin
|
||||||
|
|
||||||
|
static constexpr unsigned long CHECK_INTERVAL_MS = 2000;
|
||||||
|
static constexpr int SAMPLES = 10; // Number of ADC samples to average
|
||||||
|
|
||||||
|
// LiPo voltage thresholds
|
||||||
|
static constexpr float VOLTAGE_MIN = 3.0; // 0%
|
||||||
|
static constexpr float VOLTAGE_MAX = 4.2; // 100%
|
||||||
|
static constexpr float VOLTAGE_LOW = 3.2; // Low battery threshold
|
||||||
|
|
||||||
|
// Voltage divider: R1=100k, R2=100k (2:1 ratio)
|
||||||
|
static constexpr float DIVIDER_RATIO = 2.0;
|
||||||
|
|
||||||
|
int _percent = 100;
|
||||||
|
float _voltage = 3.7;
|
||||||
|
bool _isLow = false;
|
||||||
|
unsigned long _lastCheckMs = 0;
|
||||||
|
|
||||||
|
float readBatteryVoltage();
|
||||||
|
int voltageToPercent(float voltage);
|
||||||
|
};
|
||||||
@@ -29,8 +29,8 @@ String Settings::plantProfile() const { return _prefs.getString("plant_profile",
|
|||||||
void Settings::setPlantProfile(const String& key) { _prefs.putString("plant_profile", key); }
|
void Settings::setPlantProfile(const String& key) { _prefs.putString("plant_profile", key); }
|
||||||
PlantThresholds Settings::thresholds() const { return thresholdsForProfile(plantProfile()); }
|
PlantThresholds Settings::thresholds() const { return thresholdsForProfile(plantProfile()); }
|
||||||
|
|
||||||
int Settings::dryRaw() const { return _prefs.getInt("cal_dry_raw", 3000); }
|
int Settings::dryRaw() const { return _prefs.getInt("cal_dry_raw", 3772); }
|
||||||
int Settings::wetRaw() const { return _prefs.getInt("cal_wet_raw", 1600); }
|
int Settings::wetRaw() const { return _prefs.getInt("cal_wet_raw", 1416); }
|
||||||
void Settings::setCalibration(int dryRaw, int wetRaw) {
|
void Settings::setCalibration(int dryRaw, int wetRaw) {
|
||||||
_prefs.putInt("cal_dry_raw", dryRaw);
|
_prefs.putInt("cal_dry_raw", dryRaw);
|
||||||
_prefs.putInt("cal_wet_raw", wetRaw);
|
_prefs.putInt("cal_wet_raw", wetRaw);
|
||||||
@@ -44,3 +44,9 @@ void Settings::setWebhookEnabled(bool v) { _prefs.putBool("wh_en", v); }
|
|||||||
|
|
||||||
String Settings::webhookUrl() const { return _prefs.getString("wh_url", ""); }
|
String Settings::webhookUrl() const { return _prefs.getString("wh_url", ""); }
|
||||||
void Settings::setWebhookUrl(const String& url) { _prefs.putString("wh_url", url); }
|
void Settings::setWebhookUrl(const String& url) { _prefs.putString("wh_url", url); }
|
||||||
|
|
||||||
|
void Settings::factoryReset() {
|
||||||
|
Serial.println("[Settings] Factory reset - clearing all settings");
|
||||||
|
_prefs.clear();
|
||||||
|
Serial.println("[Settings] All settings cleared");
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ public:
|
|||||||
String webhookUrl() const;
|
String webhookUrl() const;
|
||||||
void setWebhookUrl(const String& url);
|
void setWebhookUrl(const String& url);
|
||||||
|
|
||||||
|
// Factory reset
|
||||||
|
void factoryReset();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
mutable Preferences _prefs;
|
mutable Preferences _prefs;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,3 +59,28 @@ _oled.display();
|
|||||||
// The animation above already takes ~0.9s; hold this screen ~2.1s more.
|
// The animation above already takes ~0.9s; hold this screen ~2.1s more.
|
||||||
delay(2100);
|
delay(2100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Display::drawBatteryIcon(int x, int y, int percent, bool blink) {
|
||||||
|
if (!_ok) return;
|
||||||
|
|
||||||
|
// Skip if blinking and blink state is off
|
||||||
|
if (blink && ((millis() / 500) % 2 == 1)) return;
|
||||||
|
|
||||||
|
// Battery body: 14x7 rectangle
|
||||||
|
_oled.drawRect(x, y, 14, 7, 1);
|
||||||
|
|
||||||
|
// Battery terminal: small nub on right
|
||||||
|
_oled.fillRect(x + 14, y + 2, 2, 3, 1);
|
||||||
|
|
||||||
|
// Fill level: 12 pixels max width inside battery
|
||||||
|
int fillWidth = (percent * 12) / 100;
|
||||||
|
if (fillWidth > 0) {
|
||||||
|
_oled.fillRect(x + 1, y + 1, fillWidth, 5, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low battery warning: blink outline
|
||||||
|
if (percent < 20 && blink) {
|
||||||
|
// Draw thicker outline for emphasis
|
||||||
|
_oled.drawRect(x - 1, y - 1, 16, 9, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public:
|
|||||||
|
|
||||||
void showStatus(const String& line1, const String& line2);
|
void showStatus(const String& line1, const String& line2);
|
||||||
void bootAnimation();
|
void bootAnimation();
|
||||||
|
void drawBatteryIcon(int x, int y, int percent, bool blink);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static constexpr int PIN_SDA = 6;
|
static constexpr int PIN_SDA = 6;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ void FaceRenderer::begin(Display& display, Settings& settings) {
|
|||||||
_nextGazeMs = now + randRange(800, 2000);
|
_nextGazeMs = now + randRange(800, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
void FaceRenderer::loop(const MoistureSensor& moisture) {
|
void FaceRenderer::loop(const MoistureSensor& moisture, const BatterySensor& battery) {
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
if ((long)(now - _nextFrameMs) < 0) return;
|
if ((long)(now - _nextFrameMs) < 0) return;
|
||||||
_nextFrameMs = now + FRAME_MS;
|
_nextFrameMs = now + FRAME_MS;
|
||||||
@@ -38,7 +38,7 @@ void FaceRenderer::loop(const MoistureSensor& moisture) {
|
|||||||
updateDeathMode(now);
|
updateDeathMode(now);
|
||||||
|
|
||||||
if (_deadMode) {
|
if (_deadMode) {
|
||||||
renderDead(now);
|
renderDead(now, battery);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ void FaceRenderer::loop(const MoistureSensor& moisture) {
|
|||||||
else if (_mood == DRY) updateDry(now);
|
else if (_mood == DRY) updateDry(now);
|
||||||
else updateTooWet(now);
|
else updateTooWet(now);
|
||||||
|
|
||||||
renderNormal(now);
|
renderNormal(now, battery);
|
||||||
}
|
}
|
||||||
|
|
||||||
void FaceRenderer::updateMood(int moisturePct) {
|
void FaceRenderer::updateMood(int moisturePct) {
|
||||||
@@ -125,7 +125,7 @@ void FaceRenderer::updateTooWet(unsigned long now) {
|
|||||||
if (_blinking && (long)(now - _blinkUntilMs) >= 0) _blinking = false;
|
if (_blinking && (long)(now - _blinkUntilMs) >= 0) _blinking = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void FaceRenderer::renderDead(unsigned long now) {
|
void FaceRenderer::renderDead(unsigned long now, const BatterySensor& battery) {
|
||||||
auto &d = _display->oled();
|
auto &d = _display->oled();
|
||||||
d.clearDisplay();
|
d.clearDisplay();
|
||||||
|
|
||||||
@@ -145,10 +145,13 @@ void FaceRenderer::renderDead(unsigned long now) {
|
|||||||
drawMouthFlat();
|
drawMouthFlat();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw battery icon in top-right corner
|
||||||
|
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink());
|
||||||
|
|
||||||
d.display();
|
d.display();
|
||||||
}
|
}
|
||||||
|
|
||||||
void FaceRenderer::renderNormal(unsigned long now) {
|
void FaceRenderer::renderNormal(unsigned long now, const BatterySensor& battery) {
|
||||||
auto &d = _display->oled();
|
auto &d = _display->oled();
|
||||||
d.clearDisplay();
|
d.clearDisplay();
|
||||||
|
|
||||||
@@ -173,6 +176,9 @@ void FaceRenderer::renderNormal(unsigned long now) {
|
|||||||
drawBubbles((now / 100) % 12 - 6);
|
drawBubbles((now / 100) % 12 - 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw battery icon in top-right corner
|
||||||
|
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink());
|
||||||
|
|
||||||
d.display();
|
d.display();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
#include "../ui/Display.h"
|
#include "../ui/Display.h"
|
||||||
#include "../settings/Settings.h"
|
#include "../settings/Settings.h"
|
||||||
#include "../sensors/MoistureSensor.h"
|
#include "../sensors/MoistureSensor.h"
|
||||||
|
#include "../sensors/BatterySensor.h"
|
||||||
|
|
||||||
class FaceRenderer {
|
class FaceRenderer {
|
||||||
public:
|
public:
|
||||||
enum Mood { HAPPY, DRY, TOO_WET };
|
enum Mood { HAPPY, DRY, TOO_WET };
|
||||||
|
|
||||||
void begin(Display& display, Settings& settings);
|
void begin(Display& display, Settings& settings);
|
||||||
void loop(const MoistureSensor& moisture);
|
void loop(const MoistureSensor& moisture, const BatterySensor& battery);
|
||||||
|
|
||||||
bool isDeadMode() const { return _deadMode; }
|
bool isDeadMode() const { return _deadMode; }
|
||||||
Mood mood() const { return _mood; }
|
Mood mood() const { return _mood; }
|
||||||
@@ -55,8 +56,8 @@ private:
|
|||||||
void updateDry(unsigned long now);
|
void updateDry(unsigned long now);
|
||||||
void updateTooWet(unsigned long now);
|
void updateTooWet(unsigned long now);
|
||||||
|
|
||||||
void renderDead(unsigned long now);
|
void renderDead(unsigned long now, const BatterySensor& battery);
|
||||||
void renderNormal(unsigned long now);
|
void renderNormal(unsigned long now, const BatterySensor& battery);
|
||||||
|
|
||||||
void drawEyesOpen(int pupilDx, int pupilDy);
|
void drawEyesOpen(int pupilDx, int pupilDy);
|
||||||
void drawEyesClosed();
|
void drawEyesClosed();
|
||||||
|
|||||||
Reference in New Issue
Block a user