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 "../sensors/MoistureSensor.h"
|
||||
#include "../sensors/BatterySensor.h"
|
||||
#include "../ui/FaceRenderer.h"
|
||||
|
||||
static Settings settings;
|
||||
@@ -21,6 +22,7 @@ static WebhookService webhook;
|
||||
|
||||
static Display display;
|
||||
static MoistureSensor moisture;
|
||||
static BatterySensor battery;
|
||||
static FaceRenderer face;
|
||||
|
||||
static unsigned long bootMs = 0;
|
||||
@@ -37,15 +39,27 @@ static PlantEventType moodToEvent(FaceRenderer::Mood m) {
|
||||
void App::setup() {
|
||||
bootMs = millis();
|
||||
|
||||
Serial.begin(115200);
|
||||
delay(100);
|
||||
Serial.println("\n\n=== FacePlant Starting ===");
|
||||
Serial.print("Firmware: ");
|
||||
Serial.println(PB_VERSION);
|
||||
|
||||
settings.begin();
|
||||
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.showStatus("FacePlant", "Starting...");
|
||||
|
||||
wifi.begin(settings, forceSetup);
|
||||
|
||||
moisture.begin(settings);
|
||||
battery.begin();
|
||||
face.begin(display, settings);
|
||||
|
||||
webhook.begin(settings);
|
||||
@@ -55,16 +69,39 @@ void App::setup() {
|
||||
|
||||
lastMood = face.mood();
|
||||
lastDead = face.isDeadMode();
|
||||
|
||||
Serial.println("=== Setup Complete ===\n");
|
||||
}
|
||||
|
||||
void App::loop() {
|
||||
static bool lastConnected = false;
|
||||
static unsigned long lastDisplayUpdate = 0;
|
||||
|
||||
BootTrigger::clearAfterStableUptime();
|
||||
|
||||
wifi.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();
|
||||
face.loop(moisture);
|
||||
battery.loop();
|
||||
face.loop(moisture, battery);
|
||||
|
||||
// Webhook events on state transitions
|
||||
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 wifiSsid = settings.wifiSsid();
|
||||
String currentSsid = wifi.ssid();
|
||||
String plantProfile = settings.plantProfile();
|
||||
|
||||
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>"
|
||||
"<!DOCTYPE html><html><head>"
|
||||
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
"<title>FacePlant</title>"
|
||||
"<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css' "
|
||||
"integrity='sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==' "
|
||||
"crossorigin='anonymous' referrerpolicy='no-referrer' />"
|
||||
"<style>"
|
||||
"* { margin: 0; padding: 0; box-sizing: border-box; }"
|
||||
"body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; "
|
||||
"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; }"
|
||||
".container { max-width: 600px; margin: 0 auto; }"
|
||||
".card { background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); "
|
||||
"border-radius: 20px; padding: 30px; margin-bottom: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }"
|
||||
".header { text-align: center; margin-bottom: 10px; }"
|
||||
".header h1 { font-size: 2.5em; font-weight: 700; color: #1d1d1f; margin-bottom: 5px; "
|
||||
"background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; "
|
||||
"-webkit-text-fill-color: transparent; }"
|
||||
".version { color: #86868b; font-size: 0.9em; margin-bottom: 20px; }"
|
||||
".status-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin: 20px 0; }"
|
||||
".status-item { background: #f5f5f7; padding: 15px; border-radius: 12px; }"
|
||||
".status-label { font-size: 0.85em; color: #86868b; margin-bottom: 5px; }"
|
||||
".status-value { font-size: 1em; color: #1d1d1f; font-weight: 500; word-break: break-word; }"
|
||||
".status-value.connected { color: #34c759; }"
|
||||
".status-value.disconnected { color: #ff3b30; }"
|
||||
"h2 { font-size: 1.5em; font-weight: 600; color: #1d1d1f; margin: 25px 0 15px 0; }"
|
||||
"label { display: block; font-size: 0.95em; font-weight: 500; color: #1d1d1f; margin-bottom: 8px; }"
|
||||
"input[type='text'], input[type='password'], select { width: 100%; padding: 12px 16px; "
|
||||
"border: 1px solid #d2d2d7; border-radius: 10px; font-size: 1em; "
|
||||
"transition: all 0.2s; background: white; }"
|
||||
"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>"
|
||||
"<p><a href='/status'>Status (JSON)</a></p>"
|
||||
"<p><a href='/update'>OTA Update</a></p>";
|
||||
"</div>"
|
||||
"<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);
|
||||
});
|
||||
|
||||
@@ -69,6 +217,10 @@ void WebUI::begin(Settings& settings,
|
||||
pass = _server.arg("pass");
|
||||
} else if (ssid == settings.wifiSsid()) {
|
||||
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);
|
||||
@@ -87,6 +239,56 @@ void WebUI::begin(Settings& settings,
|
||||
_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, [&]() {
|
||||
JsonDocument doc;
|
||||
doc["device"] = "FacePlant";
|
||||
|
||||
@@ -20,18 +20,26 @@ String WiFiManager::ssid() const {
|
||||
void WiFiManager::begin(Settings& settings, bool forceSetupAP) {
|
||||
_settings = &settings;
|
||||
|
||||
Serial.println("[WiFi] Initializing...");
|
||||
WiFi.persistent(false);
|
||||
WiFi.setAutoReconnect(false);
|
||||
|
||||
if (forceSetupAP) { startSetupAP(); return; }
|
||||
if (forceSetupAP) {
|
||||
Serial.println("[WiFi] Forced setup AP mode");
|
||||
startSetupAP();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_settings->hasWiFi()) {
|
||||
Serial.print("[WiFi] Connecting to: ");
|
||||
Serial.println(_settings->wifiSsid());
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setHostname(PB_HOSTNAME);
|
||||
WiFi.begin(_settings->wifiSsid().c_str(), _settings->wifiPass().c_str());
|
||||
_bootConnectStartMs = millis();
|
||||
_mode = NET_STA;
|
||||
} else {
|
||||
Serial.println("[WiFi] No saved credentials, starting setup AP");
|
||||
startSetupAP();
|
||||
}
|
||||
}
|
||||
@@ -46,12 +54,23 @@ void WiFiManager::saveAndConnect(const String& ssid, const String& pass) {
|
||||
if (cleanSsid.length() == 0) return;
|
||||
|
||||
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);
|
||||
if (changed) _settings->setEverConnected(false);
|
||||
|
||||
_bootConnectStartMs = millis();
|
||||
_reconnectBackoffStep = 0;
|
||||
_nextReconnectMs = _bootConnectStartMs + 5000UL;
|
||||
|
||||
Serial.println("[WiFi] Starting connection attempt...");
|
||||
startStationAttempt();
|
||||
}
|
||||
|
||||
@@ -63,6 +82,7 @@ void WiFiManager::clearAndStartSetupAP() {
|
||||
}
|
||||
|
||||
void WiFiManager::startSetupAP() {
|
||||
Serial.println("[WiFi] Starting Setup AP mode");
|
||||
_mode = NET_AP_SETUP;
|
||||
_setupRequested = false;
|
||||
|
||||
@@ -71,6 +91,11 @@ void WiFiManager::startSetupAP() {
|
||||
WiFi.softAPConfig(_apIP, _apIP, _apMask);
|
||||
WiFi.softAP(_setupSsid, _setupPass);
|
||||
|
||||
Serial.print("[WiFi] AP SSID: ");
|
||||
Serial.println(_setupSsid);
|
||||
Serial.print("[WiFi] AP IP: ");
|
||||
Serial.println(_apIP);
|
||||
|
||||
_portal.start(_apIP);
|
||||
|
||||
MDNS.end();
|
||||
@@ -104,20 +129,56 @@ void WiFiManager::startStation() {
|
||||
}
|
||||
|
||||
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);
|
||||
else WiFi.mode(WIFI_STA);
|
||||
WiFi.setHostname(PB_HOSTNAME);
|
||||
|
||||
WiFi.begin(_settings->wifiSsid().c_str(), _settings->wifiPass().c_str());
|
||||
|
||||
Serial.println("[WiFi] WiFi.begin() called");
|
||||
}
|
||||
|
||||
void WiFiManager::loop() {
|
||||
static unsigned long lastStatusLog = 0;
|
||||
static wl_status_t lastStatus = WL_IDLE_STATUS;
|
||||
|
||||
if (_mode == NET_AP_SETUP) _portal.loop();
|
||||
|
||||
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 (_mode != NET_STA || WiFi.getMode() != WIFI_STA) startStation();
|
||||
if (!_settings->everConnected()) _settings->setEverConnected(true);
|
||||
if (_mode != NET_STA || WiFi.getMode() != WIFI_STA) {
|
||||
Serial.print("[WiFi] CONNECTED! IP: ");
|
||||
Serial.println(WiFi.localIP());
|
||||
startStation();
|
||||
}
|
||||
if (!_settings->everConnected()) {
|
||||
Serial.println("[WiFi] First successful connection!");
|
||||
_settings->setEverConnected(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,6 +189,10 @@ void WiFiManager::loop() {
|
||||
|
||||
if (!_settings->everConnected() && _bootConnectStartMs != 0 &&
|
||||
(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;
|
||||
startSetupAP();
|
||||
return;
|
||||
@@ -136,6 +201,9 @@ void WiFiManager::loop() {
|
||||
if (_settings->everConnected()) {
|
||||
unsigned long now = millis();
|
||||
if ((long)(now - _nextReconnectMs) >= 0) {
|
||||
Serial.print("[WiFi] Retry attempt (backoff step ");
|
||||
Serial.print(_reconnectBackoffStep);
|
||||
Serial.println(")");
|
||||
startStationAttempt();
|
||||
_nextReconnectMs = now + reconnectDelayMs(_reconnectBackoffStep++);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ private:
|
||||
const char* _setupPass = "faceplant";
|
||||
|
||||
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;
|
||||
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); }
|
||||
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); }
|
||||
int Settings::dryRaw() const { return _prefs.getInt("cal_dry_raw", 3772); }
|
||||
int Settings::wetRaw() const { return _prefs.getInt("cal_wet_raw", 1416); }
|
||||
void Settings::setCalibration(int dryRaw, int wetRaw) {
|
||||
_prefs.putInt("cal_dry_raw", dryRaw);
|
||||
_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", ""); }
|
||||
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;
|
||||
void setWebhookUrl(const String& url);
|
||||
|
||||
// Factory reset
|
||||
void factoryReset();
|
||||
|
||||
private:
|
||||
mutable Preferences _prefs;
|
||||
};
|
||||
|
||||
@@ -59,3 +59,28 @@ _oled.display();
|
||||
// The animation above already takes ~0.9s; hold this screen ~2.1s more.
|
||||
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 bootAnimation();
|
||||
void drawBatteryIcon(int x, int y, int percent, bool blink);
|
||||
|
||||
private:
|
||||
static constexpr int PIN_SDA = 6;
|
||||
|
||||
@@ -29,7 +29,7 @@ void FaceRenderer::begin(Display& display, Settings& settings) {
|
||||
_nextGazeMs = now + randRange(800, 2000);
|
||||
}
|
||||
|
||||
void FaceRenderer::loop(const MoistureSensor& moisture) {
|
||||
void FaceRenderer::loop(const MoistureSensor& moisture, const BatterySensor& battery) {
|
||||
unsigned long now = millis();
|
||||
if ((long)(now - _nextFrameMs) < 0) return;
|
||||
_nextFrameMs = now + FRAME_MS;
|
||||
@@ -38,7 +38,7 @@ void FaceRenderer::loop(const MoistureSensor& moisture) {
|
||||
updateDeathMode(now);
|
||||
|
||||
if (_deadMode) {
|
||||
renderDead(now);
|
||||
renderDead(now, battery);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ void FaceRenderer::loop(const MoistureSensor& moisture) {
|
||||
else if (_mood == DRY) updateDry(now);
|
||||
else updateTooWet(now);
|
||||
|
||||
renderNormal(now);
|
||||
renderNormal(now, battery);
|
||||
}
|
||||
|
||||
void FaceRenderer::updateMood(int moisturePct) {
|
||||
@@ -125,7 +125,7 @@ void FaceRenderer::updateTooWet(unsigned long now) {
|
||||
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();
|
||||
d.clearDisplay();
|
||||
|
||||
@@ -145,10 +145,13 @@ void FaceRenderer::renderDead(unsigned long now) {
|
||||
drawMouthFlat();
|
||||
}
|
||||
|
||||
// Draw battery icon in top-right corner
|
||||
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink());
|
||||
|
||||
d.display();
|
||||
}
|
||||
|
||||
void FaceRenderer::renderNormal(unsigned long now) {
|
||||
void FaceRenderer::renderNormal(unsigned long now, const BatterySensor& battery) {
|
||||
auto &d = _display->oled();
|
||||
d.clearDisplay();
|
||||
|
||||
@@ -173,6 +176,9 @@ void FaceRenderer::renderNormal(unsigned long now) {
|
||||
drawBubbles((now / 100) % 12 - 6);
|
||||
}
|
||||
|
||||
// Draw battery icon in top-right corner
|
||||
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink());
|
||||
|
||||
d.display();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
#include "../ui/Display.h"
|
||||
#include "../settings/Settings.h"
|
||||
#include "../sensors/MoistureSensor.h"
|
||||
#include "../sensors/BatterySensor.h"
|
||||
|
||||
class FaceRenderer {
|
||||
public:
|
||||
enum Mood { HAPPY, DRY, TOO_WET };
|
||||
|
||||
void begin(Display& display, Settings& settings);
|
||||
void loop(const MoistureSensor& moisture);
|
||||
void loop(const MoistureSensor& moisture, const BatterySensor& battery);
|
||||
|
||||
bool isDeadMode() const { return _deadMode; }
|
||||
Mood mood() const { return _mood; }
|
||||
@@ -55,8 +56,8 @@ private:
|
||||
void updateDry(unsigned long now);
|
||||
void updateTooWet(unsigned long now);
|
||||
|
||||
void renderDead(unsigned long now);
|
||||
void renderNormal(unsigned long now);
|
||||
void renderDead(unsigned long now, const BatterySensor& battery);
|
||||
void renderNormal(unsigned long now, const BatterySensor& battery);
|
||||
|
||||
void drawEyesOpen(int pupilDx, int pupilDy);
|
||||
void drawEyesClosed();
|
||||
|
||||
Reference in New Issue
Block a user