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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user