2026-02-09 11:41:12 -05:00
|
|
|
#include "WebUI.h"
|
|
|
|
|
#include "../util/Version.h"
|
|
|
|
|
#include <ArduinoJson.h>
|
|
|
|
|
|
|
|
|
|
void WebUI::begin(Settings& settings,
|
|
|
|
|
WiFiManager& wifi,
|
|
|
|
|
MoistureSensor& moisture,
|
2026-02-22 13:58:59 -05:00
|
|
|
MotionSensor& motion,
|
2026-02-09 11:41:12 -05:00
|
|
|
FaceRenderer& face,
|
|
|
|
|
WebhookService& webhook,
|
|
|
|
|
unsigned long bootMs) {
|
|
|
|
|
|
|
|
|
|
auto sendPortalRedirect = [&]() {
|
|
|
|
|
String target = String("http://") + wifi.apIp().toString() + "/";
|
|
|
|
|
_server.sendHeader("Location", target, true);
|
|
|
|
|
_server.send(302, "text/plain", "");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_server.on("/", HTTP_GET, [&]() {
|
|
|
|
|
String mode = (wifi.mode() == NET_AP_SETUP) ? "Setup AP" : "Station";
|
|
|
|
|
String wifiSsid = settings.wifiSsid();
|
|
|
|
|
String currentSsid = wifi.ssid();
|
2026-02-10 17:05:42 -05:00
|
|
|
String plantProfile = settings.plantProfile();
|
2026-02-21 20:25:30 -05:00
|
|
|
String timezone = settings.timezone();
|
|
|
|
|
String bedtime = settings.bedtime();
|
|
|
|
|
String wakeTime = settings.wakeTime();
|
2026-02-22 13:58:59 -05:00
|
|
|
MotionCalibration mc = settings.motionCalibration();
|
2026-02-10 17:05:42 -05:00
|
|
|
|
2026-02-09 11:41:12 -05:00
|
|
|
String page =
|
2026-02-10 17:05:42 -05:00
|
|
|
"<!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>"
|
2026-02-09 11:41:12 -05:00
|
|
|
"</form>"
|
2026-02-10 17:05:42 -05:00
|
|
|
"</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>"
|
2026-02-21 20:25:30 -05:00
|
|
|
"<h2><i class='fas fa-clock' style='margin-right: 10px;'></i>Sleep Schedule</h2>"
|
|
|
|
|
"<label for='bed'>Bedtime</label>"
|
|
|
|
|
"<input type='time' id='bed' name='bed' value='" + bedtime + "' required>"
|
|
|
|
|
"<div class='form-hint'>FacePlant starts falling asleep 5 minutes before bedtime</div>"
|
|
|
|
|
"<label for='wake'>Wake Time</label>"
|
|
|
|
|
"<input type='time' id='wake' name='wake' value='" + wakeTime + "' required>"
|
|
|
|
|
"<div class='form-hint'>FacePlant shows wake-up animation for 5 minutes after this time</div>"
|
|
|
|
|
"<label for='tz'>Timezone</label>"
|
|
|
|
|
"<select id='tz' name='tz'>"
|
|
|
|
|
"<option value='America/New_York'" + String(timezone == "America/New_York" ? " selected" : "") + ">America/New_York (Eastern)</option>"
|
|
|
|
|
"<option value='America/Chicago'" + String(timezone == "America/Chicago" ? " selected" : "") + ">America/Chicago (Central)</option>"
|
|
|
|
|
"<option value='America/Denver'" + String(timezone == "America/Denver" ? " selected" : "") + ">America/Denver (Mountain)</option>"
|
|
|
|
|
"<option value='America/Los_Angeles'" + String(timezone == "America/Los_Angeles" ? " selected" : "") + ">America/Los_Angeles (Pacific)</option>"
|
|
|
|
|
"<option value='UTC0'" + String(timezone == "UTC0" ? " selected" : "") + ">UTC</option>"
|
|
|
|
|
"</select>"
|
|
|
|
|
"<div class='form-hint'>Default is America/New_York</div>"
|
2026-02-10 17:05:42 -05:00
|
|
|
"<div class='btn-group'>"
|
|
|
|
|
"<button type='submit' class='btn btn-primary'>Save Settings</button>"
|
|
|
|
|
"</div>"
|
|
|
|
|
"</form>"
|
|
|
|
|
"</div>"
|
|
|
|
|
"<div class='card'>"
|
2026-02-22 13:58:59 -05:00
|
|
|
"<h2><i class='fas fa-compass' style='margin-right: 10px;'></i>Accelerometer 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 Tilt</div>"
|
|
|
|
|
"<div style='font-size: 1em; color: #1d1d1f;'>Roll: " + String(motion.rollDeg(), 1) + "°</div>"
|
|
|
|
|
"<div style='font-size: 1em; color: #1d1d1f;'>Pitch: " + String(motion.pitchDeg(), 1) + "°</div>"
|
|
|
|
|
"<div style='font-size: 0.85em; color: #86868b; margin-top: 8px;'>"
|
|
|
|
|
"Neutral offsets: roll " + String(mc.rollZeroDeg, 1) + "°, pitch " + String(mc.pitchZeroDeg, 1) + "°</div>"
|
|
|
|
|
"</div>"
|
|
|
|
|
"<form method='POST' action='/motion-calibrate' style='margin-bottom: 12px;'>"
|
|
|
|
|
"<div class='btn-group'>"
|
|
|
|
|
"<button type='submit' name='action' value='zero_now' class='btn btn-primary'>Set Current Position as Neutral</button>"
|
|
|
|
|
"</div>"
|
|
|
|
|
"</form>"
|
|
|
|
|
"<form method='POST' action='/motion-calibrate'>"
|
|
|
|
|
"<div class='btn-group'>"
|
|
|
|
|
"<button type='submit' name='action' value='reset' class='btn btn-secondary'>Reset Accelerometer Calibration</button>"
|
|
|
|
|
"</div>"
|
|
|
|
|
"</form>"
|
|
|
|
|
"</div>"
|
|
|
|
|
"<div class='card'>"
|
2026-02-10 17:05:42 -05:00
|
|
|
"<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>"
|
2026-02-22 13:58:59 -05:00
|
|
|
"<form method='POST' action='/restart' id='restartForm' style='margin-bottom: 20px;'>"
|
|
|
|
|
"<p style='margin-bottom: 15px;'><strong>Restart Device</strong></p>"
|
|
|
|
|
"<p style='color: #86868b; font-size: 0.9em; margin-bottom: 15px;'>"
|
|
|
|
|
"Reboots FacePlant without erasing settings.</p>"
|
|
|
|
|
"<button type='submit' class='btn btn-secondary' style='width: 100%;'>Restart</button>"
|
|
|
|
|
"</form>"
|
2026-02-10 17:05:42 -05:00
|
|
|
"<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;"
|
|
|
|
|
" }"
|
|
|
|
|
" }"
|
|
|
|
|
"});"
|
2026-02-22 13:58:59 -05:00
|
|
|
"document.getElementById('restartForm').addEventListener('submit', function(e) {"
|
|
|
|
|
" e.preventDefault();"
|
|
|
|
|
" if (confirm('Restart FacePlant now?')) {"
|
|
|
|
|
" this.submit();"
|
|
|
|
|
" }"
|
|
|
|
|
"});"
|
2026-02-10 17:05:42 -05:00
|
|
|
"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>";
|
2026-02-09 11:41:12 -05:00
|
|
|
_server.send(200, "text/html", page);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_server.on("/wifi", HTTP_POST, [&]() {
|
|
|
|
|
String action = _server.hasArg("action") ? _server.arg("action") : "connect";
|
|
|
|
|
|
|
|
|
|
if (action == "forget") {
|
|
|
|
|
wifi.clearAndStartSetupAP();
|
|
|
|
|
} else {
|
|
|
|
|
String ssid = _server.hasArg("ssid") ? _server.arg("ssid") : settings.wifiSsid();
|
|
|
|
|
String pass = "";
|
|
|
|
|
|
|
|
|
|
if (_server.hasArg("pass") && _server.arg("pass").length() > 0) {
|
|
|
|
|
pass = _server.arg("pass");
|
|
|
|
|
} else if (ssid == settings.wifiSsid()) {
|
|
|
|
|
pass = settings.wifiPass();
|
2026-02-10 17:05:42 -05:00
|
|
|
} else {
|
|
|
|
|
// New SSID but no password provided - reject
|
|
|
|
|
_server.send(400, "text/plain", "Password required for new network");
|
|
|
|
|
return;
|
2026-02-09 11:41:12 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ssid.length() > 0) wifi.saveAndConnect(ssid, pass);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_server.sendHeader("Location", "/");
|
|
|
|
|
_server.send(303);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_server.on("/config", HTTP_POST, [&]() {
|
|
|
|
|
if (_server.hasArg("plant")) settings.setPlantProfile(_server.arg("plant"));
|
|
|
|
|
settings.setKidsMode(_server.hasArg("kids"));
|
|
|
|
|
settings.setWebhookEnabled(_server.hasArg("wh_en"));
|
|
|
|
|
if (_server.hasArg("wh")) settings.setWebhookUrl(_server.arg("wh"));
|
2026-02-21 20:25:30 -05:00
|
|
|
if (_server.hasArg("tz")) settings.setTimezone(_server.arg("tz"));
|
|
|
|
|
if (_server.hasArg("bed")) settings.setBedtime(_server.arg("bed"));
|
|
|
|
|
if (_server.hasArg("wake")) settings.setWakeTime(_server.arg("wake"));
|
2026-02-09 11:41:12 -05:00
|
|
|
_server.sendHeader("Location", "/");
|
|
|
|
|
_server.send(303);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-10 17:05:42 -05:00
|
|
|
_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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-22 13:58:59 -05:00
|
|
|
_server.on("/motion-calibrate", HTTP_POST, [&]() {
|
|
|
|
|
String action = _server.hasArg("action") ? _server.arg("action") : "zero_now";
|
|
|
|
|
if (action == "reset") {
|
|
|
|
|
settings.clearMotionCalibration();
|
|
|
|
|
motion.setZeroOffsets(0.0f, 0.0f);
|
|
|
|
|
Serial.println("[WebUI] Accelerometer calibration reset");
|
|
|
|
|
} else {
|
|
|
|
|
if (motion.available()) {
|
|
|
|
|
settings.setMotionCalibration(motion.rawRollDeg(), motion.rawPitchDeg());
|
|
|
|
|
motion.setZeroOffsets(motion.rawRollDeg(), motion.rawPitchDeg());
|
|
|
|
|
Serial.print("[WebUI] Accelerometer neutral set from current position: roll=");
|
|
|
|
|
Serial.print(motion.rawRollDeg(), 2);
|
|
|
|
|
Serial.print(" pitch=");
|
|
|
|
|
Serial.println(motion.rawPitchDeg(), 2);
|
|
|
|
|
} else {
|
|
|
|
|
Serial.println("[WebUI] Accelerometer calibration requested but MPU6050 unavailable");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_server.sendHeader("Location", "/");
|
|
|
|
|
_server.send(303);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-10 17:05:42 -05:00
|
|
|
_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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-22 13:58:59 -05:00
|
|
|
_server.on("/restart", HTTP_POST, [&]() {
|
|
|
|
|
Serial.println("[WebUI] Restart requested");
|
|
|
|
|
_server.send(200, "text/html",
|
|
|
|
|
"<!DOCTYPE html><html><head><meta http-equiv='refresh' content='8;url=/'>"
|
|
|
|
|
"<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>Restarting</h1>"
|
|
|
|
|
"<p>FacePlant is rebooting...</p>"
|
|
|
|
|
"<p>Settings are preserved.</p>"
|
|
|
|
|
"</div></body></html>");
|
|
|
|
|
delay(500);
|
|
|
|
|
ESP.restart();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-09 11:41:12 -05:00
|
|
|
_server.on("/status", HTTP_GET, [&]() {
|
|
|
|
|
JsonDocument doc;
|
|
|
|
|
doc["device"] = "FacePlant";
|
|
|
|
|
doc["version"] = PB_VERSION;
|
|
|
|
|
doc["net_mode"] = (wifi.mode() == NET_AP_SETUP) ? "setup_ap" : "sta";
|
|
|
|
|
doc["setup_ssid"] = wifi.setupSsid();
|
|
|
|
|
doc["ip"] = wifi.ip().toString();
|
|
|
|
|
doc["ssid"] = wifi.ssid();
|
|
|
|
|
doc["saved_ssid"] = settings.wifiSsid();
|
|
|
|
|
doc["wifi_connected"] = wifi.connected();
|
|
|
|
|
doc["moisture_pct"] = moisture.percent();
|
|
|
|
|
doc["raw"] = moisture.raw();
|
|
|
|
|
doc["kids_mode"] = settings.kidsMode();
|
2026-02-21 20:25:30 -05:00
|
|
|
doc["timezone"] = settings.timezone();
|
|
|
|
|
doc["bedtime"] = settings.bedtime();
|
|
|
|
|
doc["wake_time"] = settings.wakeTime();
|
2026-02-22 13:58:59 -05:00
|
|
|
doc["motion_ok"] = motion.available();
|
|
|
|
|
doc["motion_roll_deg"] = motion.rollDeg();
|
|
|
|
|
doc["motion_pitch_deg"] = motion.pitchDeg();
|
|
|
|
|
doc["motion_roll_zero_deg"] = motion.rollZeroDeg();
|
|
|
|
|
doc["motion_pitch_zero_deg"] = motion.pitchZeroDeg();
|
2026-02-09 11:41:12 -05:00
|
|
|
doc["dead_mode"] = face.isDeadMode();
|
|
|
|
|
doc["uptime_ms"] = millis() - bootMs;
|
|
|
|
|
|
|
|
|
|
String out;
|
|
|
|
|
serializeJson(doc, out);
|
|
|
|
|
_server.send(200, "application/json", out);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Captive portal compatibility endpoints used by common OS network checkers.
|
|
|
|
|
_server.on("/generate_204", HTTP_GET, [&]() { sendPortalRedirect(); }); // Android
|
|
|
|
|
_server.on("/gen_204", HTTP_GET, [&]() { sendPortalRedirect(); }); // Android alt
|
|
|
|
|
_server.on("/hotspot-detect.html", HTTP_GET, [&]() { sendPortalRedirect(); }); // iOS/macOS
|
|
|
|
|
_server.on("/library/test/success.html", HTTP_GET, [&]() { sendPortalRedirect(); });
|
|
|
|
|
_server.on("/ncsi.txt", HTTP_GET, [&]() { sendPortalRedirect(); }); // Windows
|
|
|
|
|
_server.on("/connecttest.txt", HTTP_GET, [&]() { sendPortalRedirect(); }); // Windows alt
|
|
|
|
|
_server.on("/fwlink", HTTP_GET, [&]() { sendPortalRedirect(); }); // Windows alt
|
|
|
|
|
|
|
|
|
|
_server.onNotFound([&]() {
|
|
|
|
|
if (wifi.mode() == NET_AP_SETUP) {
|
|
|
|
|
sendPortalRedirect();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_server.send(404, "text/plain", "Not found");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_server.begin();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void WebUI::loop() {
|
|
|
|
|
_server.handleClient();
|
|
|
|
|
}
|