From 63598bc45370edbf715ac0c9393c7cbe90ad4c2c Mon Sep 17 00:00:00 2001 From: Seth Freiberg Date: Mon, 16 Mar 2026 22:36:33 -0400 Subject: [PATCH] Reset idleSinceMs on mode transitions to prevent stale timer carryover --- src/main.cpp | 465 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 429 insertions(+), 36 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 8f8a8ba..585a96f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,10 +1,12 @@ #include #include +#include #include #include #include #include #include +#include #include #include @@ -14,7 +16,7 @@ #include "ui_assets.h" struct HistoryPoint { - unsigned long timestamp; + double timestamp; float temp; float setpoint; float flightSetpoint; @@ -30,7 +32,7 @@ struct Preset { struct FlightConfig { float takeoffSeconds = 300.0f; - float descentSeconds = 300.0f; + float descentSeconds = 600.0f; bool turbo = false; float descentTargetF = 120.0f; }; @@ -61,16 +63,25 @@ struct AutotuneState { std::vector highTimes; }; -static constexpr int PIN_RELAY = D10; -static constexpr int PIN_MAX6675_SCK = D9; -static constexpr int PIN_MAX6675_CS = D8; -static constexpr int PIN_MAX6675_SO = D7; +struct EventEntry { + unsigned long id; + double ts; + String type; + String detail; +}; + +static constexpr int PIN_RELAY = 10; // XIAO D10 (GPIO10) +static constexpr int PIN_MAX6675_SCK = 9; // XIAO D9 (GPIO9) +static constexpr int PIN_MAX6675_CS = 8; // XIAO D8 (GPIO8) +static constexpr int PIN_MAX6675_SO = 20; // XIAO D7 (GPIO20) +static constexpr int PIN_BOOT_BUTTON = 9; +static constexpr unsigned long WIFI_RESET_HOLD_MS = 5000; static constexpr float MAX_TEMP_F = 800.0f; static constexpr float MIN_TEMP_F = 0.0f; static constexpr float SPIKE_THRESHOLD_F = 50.0f; static constexpr int IDLE_SHUTOFF_MINUTES = 30; -static constexpr float IDLE_NEAR_SETPOINT_F = 8.0f; +static constexpr float IDLE_NEAR_SETPOINT_F = 1.0f; static constexpr bool IDLE_ONLY_IN_CRUISE = true; static constexpr int LOOP_MIN_MS = 1500; @@ -89,6 +100,22 @@ double ki = 3.5335; double kd = 500.0; PID pid(&inputF, &outputMs, &pidSetpointF, kp, ki, kd, DIRECT); +bool hasSensorBaseline = false; +int spikeRejectStreak = 0; +double lastHistoryTs = 0.0; + +bool parseRequestJson(DynamicJsonDocument& req); +void setupMdns(); +double nowTimestampSec(); + +void factoryResetWifiAndRestart() { + WiFiManager wm; + wm.resetSettings(); + WiFi.disconnect(true, true); + delay(300); + ESP.restart(); +} + bool enabled = false; bool relayOn = false; bool safetyTripped = false; @@ -119,8 +146,147 @@ AutotuneState autotune; std::vector presets; std::vector history; +std::vector events; +unsigned long nextEventId = 1; String instanceId; +String mdnsHost = "pinail"; +bool mdnsReady = false; +uint32_t bootCount = 0; +String resetReasonText = "unknown"; +String lastPowerOffReason = ""; +double lastPowerOffTs = 0.0; + +bool onboardingApActive = false; +unsigned long onboardingApEndsMs = 0; +static constexpr unsigned long ONBOARDING_AP_GRACE_MS = 60000; +static constexpr const char* ONBOARDING_AP_SSID = "piNail-Setup"; +static constexpr const char* ONBOARDING_AP_PASS = "pinailsetup"; + +String localIpString() { + IPAddress ip = WiFi.localIP(); + if (ip == INADDR_NONE || ip.toString() == "0.0.0.0") { + return ""; + } + return ip.toString(); +} + +void pushEvent(const String& type, const String& detail) { + EventEntry e; + e.id = nextEventId++; + e.ts = nowTimestampSec(); + e.type = type; + e.detail = detail; + events.push_back(e); + if (events.size() > 250) { + events.erase(events.begin(), events.begin() + (events.size() - 250)); + } +} + +String resetReasonToString(esp_reset_reason_t r) { + switch (r) { + case ESP_RST_UNKNOWN: return "unknown"; + case ESP_RST_POWERON: return "poweron"; + case ESP_RST_EXT: return "external"; + case ESP_RST_SW: return "software"; + case ESP_RST_PANIC: return "panic"; + case ESP_RST_INT_WDT: return "int_wdt"; + case ESP_RST_TASK_WDT: return "task_wdt"; + case ESP_RST_WDT: return "wdt"; + case ESP_RST_DEEPSLEEP: return "deepsleep"; + case ESP_RST_BROWNOUT: return "brownout"; + case ESP_RST_SDIO: return "sdio"; + default: return "other"; + } +} + +void recordPowerOffReason(const String& reason) { + lastPowerOffReason = reason; + lastPowerOffTs = nowTimestampSec(); + prefs.putString("last_off_reason", reason); + prefs.putString("last_off_ts", String(lastPowerOffTs, 3)); + pushEvent("power_off", reason); +} + +String onboardingHtml() { + String ip = localIpString(); + if (ip.isEmpty()) ip = "(waiting for DHCP)"; + String body; + body.reserve(1200); + body += ""; + body += ""; + body += "piNail Connected"; + body += "
"; + body += "

Wi-Fi Connected

"; + body += "
Device IP
" + ip + "
"; + body += "
Local alias
http://" + mdnsHost + ".local/
"; + body += "

This setup AP stays online briefly so you can note the address, then turns off automatically.

"; + body += "

Factory reset Wi-Fi credentials

"; + body += "

Open via alias
"; + if (ip != "(waiting for DHCP)") { + body += "Open via IP"; + } + body += "

"; + return body; +} + +void maybeStopOnboardingAp() { + if (!onboardingApActive) return; + if (millis() < onboardingApEndsMs) return; + WiFi.softAPdisconnect(true); + onboardingApActive = false; +} + +void startOnboardingGraceAp() { + WiFi.mode(WIFI_AP_STA); + if (WiFi.softAP(ONBOARDING_AP_SSID, ONBOARDING_AP_PASS)) { + onboardingApActive = true; + onboardingApEndsMs = millis() + ONBOARDING_AP_GRACE_MS; + } +} + +void setupMdns() { + MDNS.end(); + String mac = WiFi.macAddress(); + mac.replace(":", ""); + String fallback = "pinail-" + mac.substring(std::max(0, static_cast(mac.length()) - 4)); + + if (MDNS.begin(mdnsHost.c_str())) { + mdnsReady = true; + } else if (MDNS.begin(fallback.c_str())) { + mdnsHost = fallback; + mdnsReady = true; + } else { + mdnsReady = false; + return; + } + + MDNS.addService("http", "tcp", 80); +} + +bool handleBootWifiResetHold() { + pinMode(PIN_BOOT_BUTTON, INPUT_PULLUP); + if (digitalRead(PIN_BOOT_BUTTON) != LOW) { + return false; + } + + unsigned long start = millis(); + while ((millis() - start) < WIFI_RESET_HOLD_MS) { + if (digitalRead(PIN_BOOT_BUTTON) != LOW) { + return false; + } + delay(20); + } + + WiFiManager wm; + wm.resetSettings(); + delay(200); + ESP.restart(); + return true; +} unsigned long nowEpochSec() { time_t now = time(nullptr); @@ -130,6 +296,10 @@ unsigned long nowEpochSec() { return millis() / 1000; } +double nowTimestampSec() { + return static_cast(millis()) / 1000.0; +} + int controlTickMs() { int tick = static_cast(sleepTimeS * 1000.0f); if (tick < 80) tick = 80; @@ -272,6 +442,8 @@ float readMax6675F() { } void tripSafety(const String& reason) { + recordPowerOffReason(String("safety: ") + reason); + pushEvent("safety_trip", reason); safetyTripped = true; safetyReason = reason; enabled = false; @@ -285,6 +457,8 @@ void enterMode(const String& newMode) { modeSinceMs = millis(); modeStartTemp = static_cast(inputF); modeStartEpochSec = nowEpochSec(); + // Reset idle timer on any mode transition so prior accumulation doesn't carry over + idleSinceMs = 0; } void startTakeoff() { @@ -304,14 +478,17 @@ void startDescent() { enterMode("descent"); } -void setPower(bool on) { +void setPower(bool on, const String& offReason = "manual/api power off") { if (!on) { + recordPowerOffReason(offReason); + pushEvent("power", "set false"); enabled = false; relayOn = false; digitalWrite(PIN_RELAY, LOW); enterMode("grounded"); return; } + pushEvent("power", "set true"); safetyTripped = false; safetyReason = ""; if (mode == "grounded") { @@ -344,7 +521,8 @@ void updateFlightSetpoint(unsigned long nowMs) { float p = (nowMs - modeSinceMs) / (duration * 1000.0f); if (p >= 1.0f) { effectiveSetpointF = flight.descentTargetF; - setPower(false); + pushEvent("flight", "descent complete -> grounded"); + setPower(false, "flight descent complete"); } else { effectiveSetpointF = modeStartTemp + (flight.descentTargetF - modeStartTemp) * p; } @@ -415,6 +593,7 @@ void updateScheduler() { if (!parseHm(t, h, m)) continue; if (h == nowH && m == nowM) { schedulerLastTriggerMinute = minuteKey; + pushEvent("scheduler", String("cutoff triggered at ") + t); startDescent(); break; } @@ -429,6 +608,7 @@ String classifyTimingProfile(int loopMs, float sleepS) { } void stopAutotune(const String& message) { + pushEvent("autotune", message); autotune.active = false; autotune.message = message; autotune.phase = ""; @@ -480,6 +660,7 @@ void completeAutotune() { autotune.resKD = kd; autotune.avgHigh = avgHigh; autotune.avgLow = avgLow; + pushEvent("autotune", "complete"); stopAutotune("Autotune complete"); } @@ -519,7 +700,12 @@ void updateAutotune(unsigned long nowMs) { } } -void pushHistory(unsigned long ts) { +void pushHistory(double ts) { + if (ts <= lastHistoryTs) { + ts = lastHistoryTs + 0.001; + } + lastHistoryTs = ts; + HistoryPoint p; p.timestamp = ts; p.temp = static_cast(inputF); @@ -548,10 +734,20 @@ void updateControl() { return; } - if (fabs(t - inputF) > SPIKE_THRESHOLD_F && inputF > 0) { - t = static_cast(inputF); + if (!hasSensorBaseline) { + inputF = t; + hasSensorBaseline = true; + spikeRejectStreak = 0; + } else if (fabs(t - inputF) > SPIKE_THRESHOLD_F) { + spikeRejectStreak++; + if (spikeRejectStreak >= 3) { + inputF = t; + spikeRejectStreak = 0; + } + } else { + inputF = t; + spikeRejectStreak = 0; } - inputF = t; if (inputF > MAX_TEMP_F) { tripSafety("Temperature exceeds max 800F"); @@ -562,7 +758,7 @@ void updateControl() { relayOn = false; outputMs = 0; digitalWrite(PIN_RELAY, LOW); - pushHistory(nowEpochSec()); + pushHistory(nowTimestampSec()); return; } @@ -576,7 +772,7 @@ void updateControl() { if (idleSinceMs == 0) { idleSinceMs = nowMs; } else if ((nowMs - idleSinceMs) > static_cast(IDLE_SHUTOFF_MINUTES) * 60000UL) { - tripSafety("Idle shutoff: within +/-8F for 30 minutes"); + tripSafety("Idle shutoff: within +/-1F for 30 minutes"); return; } } else { @@ -600,7 +796,7 @@ void updateControl() { digitalWrite(PIN_RELAY, relayOn ? HIGH : LOW); } - pushHistory(nowEpochSec()); + pushHistory(nowTimestampSec()); } void sendError(int code, const String& msg) { @@ -608,12 +804,18 @@ void sendError(int code, const String& msg) { doc["error"] = msg; String out; serializeJson(doc, out); + server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); + server.sendHeader("Pragma", "no-cache"); + server.sendHeader("Expires", "0"); server.send(code, "application/json", out); } void sendJson(const JsonDocument& doc) { String body; serializeJson(doc, body); + server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); + server.sendHeader("Pragma", "no-cache"); + server.sendHeader("Expires", "0"); server.send(200, "application/json", body); } @@ -633,6 +835,13 @@ void appendStatus(JsonObject obj, const String& nailName) { obj["safety_reason"] = safetyReason; obj["thermocouple_connected"] = thermocoupleConnected; obj["instance_id"] = instanceId; + obj["ip"] = localIpString(); + obj["mdns_host"] = mdnsHost; + obj["mdns_url"] = String("http://") + mdnsHost + ".local/"; + obj["boot_count"] = bootCount; + obj["reset_reason"] = resetReasonText; + obj["last_power_off_reason"] = lastPowerOffReason; + obj["last_power_off_ts"] = lastPowerOffTs; float modeEta = secondsToModeCompletion(nowMs); if (modeEta >= 0) obj["mode_eta_seconds"] = modeEta; @@ -719,6 +928,11 @@ void handleHeartbeat() { doc["ok"] = true; doc["instance_id"] = instanceId; doc["ts"] = nowEpochSec(); + doc["ip"] = localIpString(); + doc["mdns_host"] = mdnsHost; + doc["mdns_url"] = String("http://") + mdnsHost + ".local/"; + doc["boot_count"] = bootCount; + doc["reset_reason"] = resetReasonText; JsonObject ca = doc.createNestedObject("controller_alive"); ca["nail1"] = true; ca["nail2"] = true; @@ -728,23 +942,166 @@ void handleHeartbeat() { sendJson(doc); } +void handleOnboardingStatus() { + DynamicJsonDocument out(512); + out["connected"] = WiFi.status() == WL_CONNECTED; + out["ip"] = localIpString(); + out["mdns_host"] = mdnsHost; + out["mdns_url"] = String("http://") + mdnsHost + ".local/"; + out["ap_active"] = onboardingApActive; + out["ap_ssid"] = ONBOARDING_AP_SSID; + out["ap_ip"] = WiFi.softAPIP().toString(); + long secondsLeft = onboardingApActive ? static_cast((onboardingApEndsMs - millis()) / 1000UL) : 0; + if (secondsLeft < 0) secondsLeft = 0; + out["ap_grace_seconds_remaining"] = secondsLeft; + sendJson(out); +} + +void handleWifiResetPage() { + String body; + body.reserve(1200); + body += ""; + body += ""; + body += "Factory Reset Wi-Fi"; + body += "
"; + body += "

Factory Reset Wi-Fi

"; + body += "

This clears saved Wi-Fi credentials and restarts the controller into onboarding mode.

"; + body += "

Control settings and presets are preserved.

"; + body += ""; + body += "

Back

"; + body += "
"; + server.send(200, "text/html", body); +} + +void handleWifiResetApi() { + DynamicJsonDocument out(256); + out["ok"] = true; + out["message"] = "Resetting Wi-Fi credentials and rebooting"; + sendJson(out); + pushEvent("wifi", "factory reset requested"); + delay(250); + factoryResetWifiAndRestart(); +} + +void handleEvents() { + unsigned long sinceId = 0; + if (server.hasArg("since_id")) { + sinceId = static_cast(server.arg("since_id").toInt()); + } + int limit = 100; + if (server.hasArg("limit")) { + limit = server.arg("limit").toInt(); + } + if (limit < 10) limit = 10; + if (limit > 250) limit = 250; + + std::vector outEvents; + outEvents.reserve(limit); + for (const auto& e : events) { + if (e.id <= sinceId) continue; + outEvents.push_back(&e); + if (static_cast(outEvents.size()) > limit) { + outEvents.erase(outEvents.begin()); + } + } + + DynamicJsonDocument doc(128 + outEvents.size() * 120); + JsonArray arr = doc.to(); + for (const auto* e : outEvents) { + JsonObject o = arr.createNestedObject(); + o["id"] = e->id; + o["ts"] = e->ts; + o["type"] = e->type; + o["detail"] = e->detail; + } + sendJson(doc); +} + +void sendAppJsWithExtras() { + String body = FPSTR(APP_JS); + body += R"JS( +; +(function() { + function addWifiResetButton() { + if (document.getElementById('wifi-factory-reset-btn')) return; + var btn = document.createElement('button'); + btn.id = 'wifi-factory-reset-btn'; + btn.textContent = 'Factory Reset Wi-Fi'; + btn.style.position = 'fixed'; + btn.style.right = '12px'; + btn.style.bottom = '12px'; + btn.style.zIndex = '9999'; + btn.style.background = '#8e2a2a'; + btn.style.color = '#fff'; + btn.style.border = '0'; + btn.style.borderRadius = '8px'; + btn.style.padding = '10px 12px'; + btn.style.fontWeight = '700'; + btn.style.cursor = 'pointer'; + btn.onclick = function() { + var ok = confirm('Factory reset Wi-Fi credentials and reboot?\n\nYou will need to reconnect to piNail-Setup.'); + if (!ok) return; + fetch('/api/wifi/reset', { method: 'POST' }) + .then(function() { + alert('Rebooting. Reconnect to piNail-Setup in a few seconds.'); + }) + .catch(function() { + alert('Reset request failed. Try again from /wifi/reset'); + }); + }; + document.body.appendChild(btn); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', addWifiResetButton); + } else { + addWifiResetButton(); + } +})(); +)JS"; + server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); + server.sendHeader("Pragma", "no-cache"); + server.sendHeader("Expires", "0"); + server.send(200, "application/javascript", body); +} + void handleHistory() { - float since = 0; + double since = 0; if (server.hasArg("since")) { since = server.arg("since").toFloat(); } - DynamicJsonDocument doc(24576); - JsonArray arr = doc.to(); + int limit = 240; + if (server.hasArg("limit")) { + limit = server.arg("limit").toInt(); + } + if (limit < 20) limit = 20; + if (limit > 500) limit = 500; + + std::vector points; + points.reserve(limit); for (const auto& p : history) { if (p.timestamp <= since) continue; + points.push_back(&p); + if (static_cast(points.size()) > limit) { + points.erase(points.begin()); + } + } + + size_t cap = 512 + points.size() * 128; + DynamicJsonDocument doc(cap); + JsonArray arr = doc.to(); + for (const auto* p : points) { JsonObject o = arr.createNestedObject(); - o["timestamp"] = p.timestamp; - o["temp"] = p.temp; - o["setpoint"] = p.setpoint; - o["flight_setpoint"] = p.flightSetpoint; - o["mode"] = p.mode; - o["output"] = p.output; - o["relay"] = p.relay; + o["timestamp"] = p->timestamp; + o["temp"] = p->temp; + o["setpoint"] = p->setpoint; + o["flight_setpoint"] = p->flightSetpoint; + o["mode"] = p->mode; + o["output"] = p->output; + o["relay"] = p->relay; } sendJson(doc); } @@ -762,8 +1119,12 @@ bool parseRequestJson(DynamicJsonDocument& req) { void handlePower() { DynamicJsonDocument req(512); if (!parseRequestJson(req)) return sendError(400, "invalid json"); - bool target = req["enabled"].is() ? req["enabled"].as() : !enabled; - setPower(target); + if (!req["enabled"].is()) { + return sendError(400, "Missing boolean 'enabled' field"); + } + bool target = req["enabled"].as(); + pushEvent("api_power", String("request enabled=") + (target ? "true" : "false")); + setPower(target, "api power off"); DynamicJsonDocument out(256); out["enabled"] = enabled; out["ok"] = true; @@ -929,6 +1290,7 @@ void handleFlight() { if (req["mode"].is()) { String newMode = req["mode"].as(); + pushEvent("api_flight", String("mode=") + newMode); if (newMode == "takeoff") startTakeoff(); else if (newMode == "cruise") startCruise(); else if (newMode == "descent") startDescent(); @@ -966,6 +1328,7 @@ void handleScheduler() { scheduler.cutoffTimes = times; } saveSchedulerTimes(); + pushEvent("scheduler", String("updated enabled=") + (scheduler.enabled ? "true" : "false")); DynamicJsonDocument out(1024); out["ok"] = true; @@ -1164,11 +1527,26 @@ void handleNotFound() { } void setupRoutes() { - server.on("/", HTTP_GET, []() { server.send_P(200, "text/html", INDEX_HTML); }); + server.on("/", HTTP_GET, []() { + if (onboardingApActive && server.hostHeader().indexOf("192.168.4.1") >= 0) { + String page = onboardingHtml(); + server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); + server.sendHeader("Pragma", "no-cache"); + server.sendHeader("Expires", "0"); + server.send(200, "text/html", page); + return; + } + server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); + server.sendHeader("Pragma", "no-cache"); + server.sendHeader("Expires", "0"); + server.send_P(200, "text/html", INDEX_HTML); + }); + server.on("/wifi/reset", HTTP_GET, handleWifiResetPage); server.on("/api/status", HTTP_GET, handleStatus); server.on("/api/status/all", HTTP_GET, handleStatusAll); server.on("/api/heartbeat", HTTP_GET, handleHeartbeat); server.on("/api/history", HTTP_GET, handleHistory); + server.on("/api/events", HTTP_GET, handleEvents); server.on("/api/power", HTTP_POST, handlePower); server.on("/api/setpoint", HTTP_POST, handleSetpoint); server.on("/api/control", HTTP_POST, handleControl); @@ -1180,12 +1558,14 @@ void setupRoutes() { server.on("/api/presets", HTTP_GET, handlePresetsGet); server.on("/api/presets", HTTP_POST, handlePresetsPost); server.on("/api/config", HTTP_GET, handleConfigGet); + server.on("/api/onboarding", HTTP_GET, handleOnboardingStatus); + server.on("/api/wifi/reset", HTTP_POST, handleWifiResetApi); server.on("/api/autotune", HTTP_GET, handleAutotuneStatus); server.on("/api/autotune/start", HTTP_POST, handleAutotuneStart); server.on("/api/autotune/stop", HTTP_POST, handleAutotuneStop); server.on("/static/style.css", HTTP_GET, []() { server.send_P(200, "text/css", STYLE_CSS); }); - server.on("/static/app.js", HTTP_GET, []() { server.send_P(200, "application/javascript", APP_JS); }); + server.on("/static/app.js", HTTP_GET, sendAppJsWithExtras); server.on("/static/manifest.webmanifest", HTTP_GET, []() { server.send_P(200, "application/manifest+json", MANIFEST_JSON); }); server.on("/static/sw.js", HTTP_GET, []() { server.send_P(200, "application/javascript", SW_JS); }); server.on("/static/img/pinail_logo.svg", HTTP_GET, []() { server.send_P(200, "image/svg+xml", LOGO_SVG); }); @@ -1195,6 +1575,15 @@ void setupRoutes() { } void setup() { + Serial.begin(115200); + delay(250); + bootMs = millis(); + instanceId = String(bootMs); + + if (handleBootWifiResetHold()) { + return; + } + pinMode(PIN_RELAY, OUTPUT); digitalWrite(PIN_RELAY, LOW); pinMode(PIN_MAX6675_SCK, OUTPUT); @@ -1203,12 +1592,12 @@ void setup() { digitalWrite(PIN_MAX6675_SCK, HIGH); digitalWrite(PIN_MAX6675_CS, HIGH); - Serial.begin(115200); - delay(250); - bootMs = millis(); - instanceId = String(bootMs); - prefs.begin("pinail", false); + resetReasonText = resetReasonToString(esp_reset_reason()); + bootCount = prefs.getUInt("boot_count", 0) + 1; + prefs.putUInt("boot_count", bootCount); + lastPowerOffReason = prefs.getString("last_off_reason", ""); + lastPowerOffTs = prefs.getString("last_off_ts", "0").toFloat(); setpointF = prefs.getFloat("setpoint", 530.0f); kp = prefs.getFloat("kP", 113.1768f); ki = prefs.getFloat("kI", 3.5335f); @@ -1219,7 +1608,7 @@ void setup() { if (sleepTimeS < SLEEP_MIN_S || sleepTimeS > SLEEP_MAX_S) sleepTimeS = 0.2f; flight.takeoffSeconds = prefs.getFloat("takeoff_s", 300.0f); - flight.descentSeconds = prefs.getFloat("descent_s", 300.0f); + flight.descentSeconds = prefs.getFloat("descent_s", 600.0f); flight.descentTargetF = prefs.getFloat("descent_t", 120.0f); flight.turbo = prefs.getBool("turbo", false); @@ -1237,6 +1626,9 @@ void setup() { if (!wifiOk) { ESP.restart(); } + + setupMdns(); + startOnboardingGraceAp(); configTime(0, 0, "pool.ntp.org", "time.nist.gov"); setupRoutes(); @@ -1247,6 +1639,7 @@ void setup() { } void loop() { + maybeStopOnboardingAp(); server.handleClient(); updateControl(); delay(2);