From acce74e2aded02c9b2115891bdb418c7abde48ca Mon Sep 17 00:00:00 2001 From: Seth Freiberg Date: Fri, 13 Mar 2026 23:21:34 +0000 Subject: [PATCH] Port piNail2 web UI and API parity to ESP32-C3 with one-side pin mapping --- HARDWARE.md | 14 +- README.md | 29 +- src/main.cpp | 1304 +++++++++++++++++++++++------- src/ui_assets.h | 2045 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 3098 insertions(+), 294 deletions(-) create mode 100644 src/ui_assets.h diff --git a/HARDWARE.md b/HARDWARE.md index 0881bd4..8f73156 100644 --- a/HARDWARE.md +++ b/HARDWARE.md @@ -9,16 +9,16 @@ - Relay: SSR/mechanical relay, active HIGH. ## GPIO -- `GPIO2`: relay output -- `GPIO4`: MAX6675 SCK -- `GPIO5`: MAX6675 CS -- `GPIO6`: MAX6675 SO +- `D10`: relay output +- `D9`: MAX6675 SCK +- `D8`: MAX6675 CS +- `D7`: MAX6675 SO ## Safety defaults - Max temp cutoff: `800F` - Idle shutoff: `30 min` - Near setpoint band for idle: `+/-8F` -## Fixed control timing -- PWM/control loop window: `1800ms` -- Control tick: `200ms` +## Control timing +- Adjustable via API/UI (`loop_size_ms` and `sleep_time`) +- Default profile is responsive (`1800ms` loop window, `0.2s` tick) diff --git a/README.md b/README.md index 54060d5..bd5e3d0 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,7 @@ ESP32-C3 firmware for a single-nail piNail controller. ## What changed vs piNail2 -- Single nail only. -- Loop timing is fixed for responsiveness (`1800ms` cycle, `200ms` tick). -- Loop timing controls are intentionally not exposed in API/UI. +- Single physical nail hardware (nail2 UI/API is mirrored for compatibility). - Wi-Fi onboarding uses captive portal (`piNail-Setup` / `pinailsetup`). ## Hardware target @@ -14,29 +12,38 @@ ESP32-C3 firmware for a single-nail piNail controller. - Relay output: active HIGH relay module. ## GPIO mapping -- Relay: `GPIO2` -- MAX6675 SCK: `GPIO4` -- MAX6675 CS: `GPIO5` -- MAX6675 SO: `GPIO6` +- Relay: `D10` +- MAX6675 SCK: `D9` +- MAX6675 CS: `D8` +- MAX6675 SO: `D7` ## API compatibility Implemented endpoints: - `GET /api/status` +- `GET /api/status/all` - `GET /api/history` - `POST /api/power` - `POST /api/setpoint` +- `POST /api/control` +- `POST /api/flight` +- `POST /api/scheduler` - `POST /api/pid` - `POST /api/pid/reset` - `POST /api/safety/reset` - `GET /api/heartbeat` +- `GET /api/autotune` +- `POST /api/autotune/start` +- `POST /api/autotune/stop` +- `GET/POST /api/presets` +- `POST /api/preset/` +- `DELETE /api/preset/` +- `GET /api/config` Built-in web interface: -- `GET /` serves a lightweight control UI for power, setpoint, PID, and safety reset. +- `GET /` serves the piNail2-style dashboard UI (embedded static assets). Not implemented in this firmware: -- Scheduler and dual-nail routing. -- Autotune endpoints. -- Loop timing reconfiguration endpoint. +- True independent dual-zone control (nail2 is API/UI-compatible mirror of nail1). ## Build and flash ```bash diff --git a/src/main.cpp b/src/main.cpp index 1d98d08..8f8a8ba 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,38 +5,89 @@ #include #include #include + +#include +#include +#include #include +#include "ui_assets.h" + struct HistoryPoint { - unsigned long ts; + unsigned long timestamp; float temp; float setpoint; + float flightSetpoint; float output; bool relay; + String mode; }; -static constexpr int PIN_RELAY = 2; -static constexpr int PIN_MAX6675_SCK = 4; -static constexpr int PIN_MAX6675_CS = 5; -static constexpr int PIN_MAX6675_SO = 6; +struct Preset { + String name; + float temp; +}; + +struct FlightConfig { + float takeoffSeconds = 300.0f; + float descentSeconds = 300.0f; + bool turbo = false; + float descentTargetF = 120.0f; +}; + +struct SchedulerConfig { + bool enabled = false; + std::vector cutoffTimes; +}; + +struct AutotuneState { + bool active = false; + String message = "Idle"; + String phase = ""; + float setpoint = 530.0f; + float hysteresis = 8.0f; + int cycles = 4; + int highPeaks = 0; + int lowPeaks = 0; + unsigned long startedMs = 0; + bool hasLastResult = false; + float resKP = 0.0f; + float resKI = 0.0f; + float resKD = 0.0f; + float avgHigh = 0.0f; + float avgLow = 0.0f; + std::vector highs; + std::vector lows; + 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; 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 bool IDLE_ONLY_IN_CRUISE = true; -static constexpr unsigned long LOOP_SIZE_MS = 1800; -static constexpr unsigned long CONTROL_TICK_MS = 200; + +static constexpr int LOOP_MIN_MS = 1500; +static constexpr int LOOP_MAX_MS = 5000; +static constexpr float SLEEP_MIN_S = 0.15f; +static constexpr float SLEEP_MAX_S = 0.6f; WebServer server(80); Preferences prefs; double inputF = 70.0; double outputMs = 0.0; -double setpointF = 530.0; +double pidSetpointF = 530.0; double kp = 113.1768; double ki = 3.5335; double kd = 500.0; -PID pid(&inputF, &outputMs, &setpointF, kp, ki, kd, DIRECT); +PID pid(&inputF, &outputMs, &pidSetpointF, kp, ki, kd, DIRECT); bool enabled = false; bool relayOn = false; @@ -44,12 +95,157 @@ bool safetyTripped = false; bool thermocoupleConnected = true; String safetyReason = ""; String mode = "grounded"; +bool proportionalOnMeasurement = false; +String proportionalMode = "error"; + unsigned long cycleStartMs = 0; unsigned long lastControlTickMs = 0; unsigned long idleSinceMs = 0; -unsigned long startMs = 0; +unsigned long bootMs = 0; +unsigned long modeSinceMs = 0; +unsigned long modeStartEpochSec = 0; +unsigned long loopCount = 0; +unsigned long schedulerLastTriggerMinute = 0; +float modeStartTemp = 70.0f; + +float setpointF = 530.0f; +float effectiveSetpointF = 530.0f; +int loopSizeMs = 1800; +float sleepTimeS = 0.2f; + +FlightConfig flight; +SchedulerConfig scheduler; +AutotuneState autotune; + +std::vector presets; std::vector history; +String instanceId; + +unsigned long nowEpochSec() { + time_t now = time(nullptr); + if (now > 1700000000) { + return static_cast(now); + } + return millis() / 1000; +} + +int controlTickMs() { + int tick = static_cast(sleepTimeS * 1000.0f); + if (tick < 80) tick = 80; + return tick; +} + +String decodeUriComponent(const String& in) { + String out; + out.reserve(in.length()); + for (size_t i = 0; i < in.length(); i++) { + char c = in[i]; + if (c == '+') { + out += ' '; + } else if (c == '%' && i + 2 < in.length()) { + char h1 = in[i + 1]; + char h2 = in[i + 2]; + auto hexv = [](char h) -> int { + if (h >= '0' && h <= '9') return h - '0'; + if (h >= 'a' && h <= 'f') return 10 + (h - 'a'); + if (h >= 'A' && h <= 'F') return 10 + (h - 'A'); + return -1; + }; + int v1 = hexv(h1); + int v2 = hexv(h2); + if (v1 >= 0 && v2 >= 0) { + out += static_cast((v1 << 4) | v2); + i += 2; + } else { + out += c; + } + } else { + out += c; + } + } + return out; +} + +String nailFromRequest(const JsonDocument* req) { + String n = "nail1"; + if (server.hasArg("nail")) { + String q = server.arg("nail"); + if (q == "2" || q == "nail2") return "nail2"; + return "nail1"; + } + if (req && (*req)["nail"].is()) { + String bodyN = (*req)["nail"].as(); + if (bodyN == "2" || bodyN == "nail2") return "nail2"; + } + return n; +} + +void addOrUpdatePreset(const String& name, float temp) { + for (auto& p : presets) { + if (p.name == name) { + p.temp = temp; + return; + } + } + presets.push_back({name, temp}); +} + +void savePresets() { + DynamicJsonDocument doc(2048); + JsonObject obj = doc.to(); + for (const auto& p : presets) { + obj[p.name] = p.temp; + } + String out; + serializeJson(doc, out); + prefs.putString("presets_json", out); +} + +void loadPresets() { + presets.clear(); + String raw = prefs.getString("presets_json", ""); + if (raw.length() > 2) { + DynamicJsonDocument doc(2048); + if (!deserializeJson(doc, raw)) { + JsonObject obj = doc.as(); + for (JsonPair kv : obj) { + presets.push_back({String(kv.key().c_str()), kv.value().as()}); + } + } + } + if (presets.empty()) { + presets.push_back({"low", 450.0f}); + presets.push_back({"medium", 530.0f}); + presets.push_back({"high", 610.0f}); + } +} + +void saveSchedulerTimes() { + String csv; + for (size_t i = 0; i < scheduler.cutoffTimes.size(); i++) { + if (i) csv += ','; + csv += scheduler.cutoffTimes[i]; + } + prefs.putString("sched_times", csv); + prefs.putBool("sched_enabled", scheduler.enabled); +} + +void loadSchedulerTimes() { + scheduler.enabled = prefs.getBool("sched_enabled", false); + scheduler.cutoffTimes.clear(); + String csv = prefs.getString("sched_times", ""); + int start = 0; + while (start < static_cast(csv.length())) { + int comma = csv.indexOf(',', start); + String tok = (comma < 0) ? csv.substring(start) : csv.substring(start, comma); + tok.trim(); + if (tok.length() == 5 && tok[2] == ':') scheduler.cutoffTimes.push_back(tok); + if (comma < 0) break; + start = comma + 1; + } +} + float readMax6675F() { uint16_t v = 0; digitalWrite(PIN_MAX6675_CS, LOW); @@ -84,18 +280,277 @@ void tripSafety(const String& reason) { digitalWrite(PIN_RELAY, LOW); } -void updateControl() { - unsigned long now = millis(); - if (now - lastControlTickMs < CONTROL_TICK_MS) { +void enterMode(const String& newMode) { + mode = newMode; + modeSinceMs = millis(); + modeStartTemp = static_cast(inputF); + modeStartEpochSec = nowEpochSec(); +} + +void startTakeoff() { + if (!enabled) { + enabled = true; + } + enterMode("takeoff"); +} + +void startCruise() { + if (!enabled) enabled = true; + enterMode("cruise"); +} + +void startDescent() { + if (!enabled) enabled = true; + enterMode("descent"); +} + +void setPower(bool on) { + if (!on) { + enabled = false; + relayOn = false; + digitalWrite(PIN_RELAY, LOW); + enterMode("grounded"); return; } - lastControlTickMs = now; + safetyTripped = false; + safetyReason = ""; + if (mode == "grounded") { + startTakeoff(); + } else { + enabled = true; + } +} + +void updateFlightSetpoint(unsigned long nowMs) { + if (!enabled) { + effectiveSetpointF = setpointF; + return; + } + + if (mode == "takeoff") { + float duration = std::max(5.0f, flight.turbo ? flight.takeoffSeconds * 0.6f : flight.takeoffSeconds); + float p = (nowMs - modeSinceMs) / (duration * 1000.0f); + if (p >= 1.0f) { + enterMode("cruise"); + effectiveSetpointF = setpointF; + } else { + effectiveSetpointF = modeStartTemp + (setpointF - modeStartTemp) * p; + } + return; + } + + if (mode == "descent") { + float duration = std::max(5.0f, flight.descentSeconds); + float p = (nowMs - modeSinceMs) / (duration * 1000.0f); + if (p >= 1.0f) { + effectiveSetpointF = flight.descentTargetF; + setPower(false); + } else { + effectiveSetpointF = modeStartTemp + (flight.descentTargetF - modeStartTemp) * p; + } + return; + } + + effectiveSetpointF = setpointF; +} + +float secondsToModeCompletion(unsigned long nowMs) { + if (mode == "takeoff") { + float duration = std::max(5.0f, flight.turbo ? flight.takeoffSeconds * 0.6f : flight.takeoffSeconds); + float left = duration - (nowMs - modeSinceMs) / 1000.0f; + return left > 0 ? left : 0; + } + if (mode == "descent") { + float duration = std::max(5.0f, flight.descentSeconds); + float left = duration - (nowMs - modeSinceMs) / 1000.0f; + return left > 0 ? left : 0; + } + return -1; +} + +bool parseHm(const String& s, int& h, int& m) { + if (s.length() != 5 || s[2] != ':') return false; + String hs = s.substring(0, 2); + String ms = s.substring(3, 5); + for (int i = 0; i < 2; i++) { + if (!isDigit(hs[i]) || !isDigit(ms[i])) return false; + } + h = hs.toInt(); + m = ms.toInt(); + if (h < 0 || h > 23 || m < 0 || m > 59) return false; + return true; +} + +float computeNextCutoffSeconds() { + if (!scheduler.enabled || scheduler.cutoffTimes.empty()) return -1; + time_t nowT = time(nullptr); + if (nowT < 1700000000) return -1; + struct tm nowTm; + localtime_r(&nowT, &nowTm); + int nowMin = nowTm.tm_hour * 60 + nowTm.tm_min; + int bestDelta = 24 * 60; + for (const auto& t : scheduler.cutoffTimes) { + int h = 0, m = 0; + if (!parseHm(t, h, m)) continue; + int target = h * 60 + m; + int delta = target - nowMin; + if (delta < 0) delta += 24 * 60; + if (delta < bestDelta) bestDelta = delta; + } + return static_cast(bestDelta * 60); +} + +void updateScheduler() { + if (!enabled || !scheduler.enabled || mode != "cruise") return; + time_t nowT = time(nullptr); + if (nowT < 1700000000) return; + struct tm nowTm; + localtime_r(&nowT, &nowTm); + int nowH = nowTm.tm_hour; + int nowM = nowTm.tm_min; + unsigned long minuteKey = static_cast(nowT / 60); + if (minuteKey == schedulerLastTriggerMinute) return; + for (const auto& t : scheduler.cutoffTimes) { + int h = 0, m = 0; + if (!parseHm(t, h, m)) continue; + if (h == nowH && m == nowM) { + schedulerLastTriggerMinute = minuteKey; + startDescent(); + break; + } + } +} + +String classifyTimingProfile(int loopMs, float sleepS) { + if (loopMs <= 1400 || sleepS <= 0.14f) return "extreme"; + if (loopMs <= 1800 || sleepS <= 0.20f) return "responsive"; + if (loopMs <= 2800 || sleepS <= 0.32f) return "balanced"; + return "conservative"; +} + +void stopAutotune(const String& message) { + autotune.active = false; + autotune.message = message; + autotune.phase = ""; +} + +void completeAutotune() { + if (autotune.highs.empty() || autotune.lows.empty() || autotune.highTimes.size() < 2) { + stopAutotune("Autotune failed: insufficient oscillation"); + return; + } + + float sumHigh = 0.0f; + float sumLow = 0.0f; + for (float v : autotune.highs) sumHigh += v; + for (float v : autotune.lows) sumLow += v; + float avgHigh = sumHigh / autotune.highs.size(); + float avgLow = sumLow / autotune.lows.size(); + float a = std::max(0.5f, (avgHigh - avgLow) * 0.5f); + + float period = 0.0f; + int periods = 0; + for (size_t i = 1; i < autotune.highTimes.size(); i++) { + period += (autotune.highTimes[i] - autotune.highTimes[i - 1]) / 1000.0f; + periods++; + } + if (periods <= 0) { + stopAutotune("Autotune failed: no period data"); + return; + } + float Tu = std::max(2.0f, period / periods); + float d = 50.0f; + float Ku = (4.0f * d) / (static_cast(M_PI) * a); + + float newKp = std::max(0.01f, 0.6f * Ku); + float newKi = std::max(0.001f, 1.2f * Ku / Tu); + float newKd = std::max(0.0f, 0.075f * Ku * Tu); + + kp = newKp; + ki = newKi; + kd = newKd; + pid.SetTunings(kp, ki, kd); + prefs.putFloat("kP", kp); + prefs.putFloat("kI", ki); + prefs.putFloat("kD", kd); + + autotune.hasLastResult = true; + autotune.resKP = kp; + autotune.resKI = ki; + autotune.resKD = kd; + autotune.avgHigh = avgHigh; + autotune.avgLow = avgLow; + stopAutotune("Autotune complete"); +} + +void updateAutotune(unsigned long nowMs) { + if (!autotune.active) return; + if ((nowMs - autotune.startedMs) > (20UL * 60UL * 1000UL)) { + stopAutotune("Autotune timeout"); + return; + } + + float upper = autotune.setpoint + autotune.hysteresis; + float lower = autotune.setpoint - autotune.hysteresis; + + if (autotune.phase == "heating") { + relayOn = true; + if (inputF >= upper) { + autotune.highPeaks++; + autotune.highs.push_back(static_cast(inputF)); + autotune.highTimes.push_back(nowMs); + autotune.phase = "cooling"; + } + } else { + relayOn = false; + if (inputF <= lower) { + autotune.lowPeaks++; + autotune.lows.push_back(static_cast(inputF)); + autotune.phase = "heating"; + } + } + + digitalWrite(PIN_RELAY, relayOn ? HIGH : LOW); + outputMs = relayOn ? loopSizeMs : 0; + effectiveSetpointF = autotune.setpoint; + + if (autotune.highPeaks >= autotune.cycles && autotune.lowPeaks >= autotune.cycles) { + completeAutotune(); + } +} + +void pushHistory(unsigned long ts) { + HistoryPoint p; + p.timestamp = ts; + p.temp = static_cast(inputF); + p.setpoint = setpointF; + p.flightSetpoint = effectiveSetpointF; + p.output = static_cast(outputMs); + p.relay = relayOn; + p.mode = mode; + history.push_back(p); + if (history.size() > 1200) { + history.erase(history.begin(), history.begin() + (history.size() - 1200)); + } +} + +void updateControl() { + unsigned long nowMs = millis(); + if (nowMs - lastControlTickMs < static_cast(controlTickMs())) { + return; + } + lastControlTickMs = nowMs; + loopCount++; float t = readMax6675F(); if (isnan(t)) { tripSafety("Thermocouple disconnected"); return; } + + if (fabs(t - inputF) > SPIKE_THRESHOLD_F && inputF > 0) { + t = static_cast(inputF); + } inputF = t; if (inputF > MAX_TEMP_F) { @@ -105,21 +560,22 @@ void updateControl() { if (!enabled || safetyTripped) { relayOn = false; + outputMs = 0; digitalWrite(PIN_RELAY, LOW); + pushHistory(nowEpochSec()); return; } - if ((now - cycleStartMs) >= LOOP_SIZE_MS) { - cycleStartMs = now; - } + updateScheduler(); + updateFlightSetpoint(nowMs); - if (IDLE_SHUTOFF_MINUTES > 0) { + if (IDLE_SHUTOFF_MINUTES > 0 && !autotune.active) { bool idleEligible = (!IDLE_ONLY_IN_CRUISE) || (mode == "cruise"); - bool nearSetpoint = fabs(inputF - setpointF) <= IDLE_NEAR_SETPOINT_F; + bool nearSetpoint = fabs(inputF - effectiveSetpointF) <= IDLE_NEAR_SETPOINT_F; if (idleEligible && nearSetpoint) { if (idleSinceMs == 0) { - idleSinceMs = now; - } else if ((now - idleSinceMs) > static_cast(IDLE_SHUTOFF_MINUTES) * 60000UL) { + idleSinceMs = nowMs; + } else if ((nowMs - idleSinceMs) > static_cast(IDLE_SHUTOFF_MINUTES) * 60000UL) { tripSafety("Idle shutoff: within +/-8F for 30 minutes"); return; } @@ -128,163 +584,246 @@ void updateControl() { } } - pid.Compute(); - if (outputMs < 0) outputMs = 0; - if (outputMs > LOOP_SIZE_MS) outputMs = LOOP_SIZE_MS; - - unsigned long elapsed = now - cycleStartMs; - relayOn = (elapsed < static_cast(outputMs)); - digitalWrite(PIN_RELAY, relayOn ? HIGH : LOW); - - HistoryPoint p; - p.ts = millis() / 1000; - p.temp = static_cast(inputF); - p.setpoint = static_cast(setpointF); - p.output = static_cast(outputMs); - p.relay = relayOn; - history.push_back(p); - if (history.size() > 600) { - history.erase(history.begin(), history.begin() + (history.size() - 600)); + if ((nowMs - cycleStartMs) >= static_cast(loopSizeMs)) { + cycleStartMs = nowMs; } + + if (autotune.active) { + updateAutotune(nowMs); + } else { + pidSetpointF = effectiveSetpointF; + pid.Compute(); + if (outputMs < 0) outputMs = 0; + if (outputMs > loopSizeMs) outputMs = loopSizeMs; + unsigned long elapsed = nowMs - cycleStartMs; + relayOn = (elapsed < static_cast(outputMs)); + digitalWrite(PIN_RELAY, relayOn ? HIGH : LOW); + } + + pushHistory(nowEpochSec()); } -void sendJson(DynamicJsonDocument& doc) { +void sendError(int code, const String& msg) { + DynamicJsonDocument doc(256); + doc["error"] = msg; + String out; + serializeJson(doc, out); + server.send(code, "application/json", out); +} + +void sendJson(const JsonDocument& doc) { String body; serializeJson(doc, body); server.send(200, "application/json", body); } -void handleStatus() { - DynamicJsonDocument doc(2048); - doc["enabled"] = enabled; - doc["mode"] = mode; - doc["temp"] = inputF; - doc["setpoint"] = setpointF; - doc["effective_setpoint"] = setpointF; - doc["output"] = outputMs; - doc["relay_on"] = relayOn; - doc["loop_count"] = millis() / CONTROL_TICK_MS; - doc["uptime_seconds"] = enabled ? ((millis() - startMs) / 1000.0) : 0; - doc["safety_tripped"] = safetyTripped; - doc["safety_reason"] = safetyReason; - doc["thermocouple_connected"] = thermocoupleConnected; - JsonObject pidObj = doc.createNestedObject("pid"); +void appendStatus(JsonObject obj, const String& nailName) { + unsigned long nowMs = millis(); + obj["nail"] = nailName; + obj["enabled"] = enabled; + obj["mode"] = mode; + obj["temp"] = inputF; + obj["setpoint"] = setpointF; + obj["effective_setpoint"] = effectiveSetpointF; + obj["output"] = outputMs; + obj["relay_on"] = relayOn; + obj["loop_count"] = loopCount; + obj["uptime_seconds"] = enabled ? ((nowMs - bootMs) / 1000.0f) : 0; + obj["safety_tripped"] = safetyTripped; + obj["safety_reason"] = safetyReason; + obj["thermocouple_connected"] = thermocoupleConnected; + obj["instance_id"] = instanceId; + + float modeEta = secondsToModeCompletion(nowMs); + if (modeEta >= 0) obj["mode_eta_seconds"] = modeEta; + else obj["mode_eta_seconds"] = nullptr; + + float nextCutoff = computeNextCutoffSeconds(); + if (nextCutoff >= 0) obj["next_cutoff_seconds"] = nextCutoff; + else obj["next_cutoff_seconds"] = nullptr; + + JsonObject pidObj = obj.createNestedObject("pid"); pidObj["kP"] = kp; pidObj["kI"] = ki; pidObj["kD"] = kd; - pidObj["proportional_on_measurement"] = false; - pidObj["proportional_mode"] = "error"; - JsonObject flightObj = doc.createNestedObject("flight"); + pidObj["proportional_on_measurement"] = proportionalOnMeasurement; + pidObj["proportional_mode"] = proportionalMode; + + JsonObject cfgObj = obj.createNestedObject("config"); + cfgObj["loop_size_ms"] = loopSizeMs; + cfgObj["sleep_time"] = sleepTimeS; + JsonObject safetyObj = cfgObj.createNestedObject("safety"); + safetyObj["max_temp_f"] = MAX_TEMP_F; + safetyObj["min_temp_f"] = MIN_TEMP_F; + safetyObj["spike_threshold_f"] = SPIKE_THRESHOLD_F; + safetyObj["idle_shutoff_minutes"] = IDLE_SHUTOFF_MINUTES; + safetyObj["idle_near_setpoint_f"] = IDLE_NEAR_SETPOINT_F; + safetyObj["idle_only_in_cruise"] = IDLE_ONLY_IN_CRUISE; + + JsonObject flightObj = obj.createNestedObject("flight"); flightObj["mode"] = mode; - flightObj["takeoff_seconds"] = 300; - flightObj["descent_seconds"] = 300; - flightObj["turbo"] = false; - flightObj["descent_target_f"] = 120; - JsonObject schedObj = doc.createNestedObject("scheduler"); - schedObj["enabled"] = false; - schedObj.createNestedArray("cutoff_times"); - doc["mode_eta_seconds"] = nullptr; - doc["next_cutoff_seconds"] = nullptr; + flightObj["takeoff_seconds"] = flight.takeoffSeconds; + flightObj["descent_seconds"] = flight.descentSeconds; + flightObj["turbo"] = flight.turbo; + flightObj["descent_target_f"] = flight.descentTargetF; + + JsonObject schedObj = obj.createNestedObject("scheduler"); + schedObj["enabled"] = scheduler.enabled; + JsonArray times = schedObj.createNestedArray("cutoff_times"); + for (const auto& t : scheduler.cutoffTimes) times.add(t); + + JsonObject autoObj = obj.createNestedObject("autotune"); + autoObj["active"] = autotune.active; + autoObj["message"] = autotune.message; + autoObj["phase"] = autotune.phase; + autoObj["cycles"] = autotune.cycles; + autoObj["high_peaks"] = autotune.highPeaks; + autoObj["low_peaks"] = autotune.lowPeaks; + if (autotune.hasLastResult) { + JsonObject last = autoObj.createNestedObject("last_result"); + last["kP"] = autotune.resKP; + last["kI"] = autotune.resKI; + last["kD"] = autotune.resKD; + last["avg_high"] = autotune.avgHigh; + last["avg_low"] = autotune.avgLow; + } else { + autoObj["last_result"] = nullptr; + } +} + +void handleStatus() { + DynamicJsonDocument doc(4096); + JsonObject obj = doc.to(); + appendStatus(obj, "nail1"); + JsonObject presetsObj = obj.createNestedObject("presets"); + for (const auto& p : presets) presetsObj[p.name] = p.temp; + sendJson(doc); +} + +void handleStatusAll() { + DynamicJsonDocument doc(8192); + doc["instance_id"] = instanceId; + JsonObject presetsObj = doc.createNestedObject("presets"); + for (const auto& p : presets) presetsObj[p.name] = p.temp; + + JsonObject nails = doc.createNestedObject("nails"); + JsonObject n1 = nails.createNestedObject("nail1"); + appendStatus(n1, "nail1"); + JsonObject n2 = nails.createNestedObject("nail2"); + appendStatus(n2, "nail2"); sendJson(doc); } void handleHeartbeat() { - DynamicJsonDocument doc(512); - JsonObject s = doc.createNestedObject("safety"); - s["temp_ok"] = (inputF <= MAX_TEMP_F); - s["tc_ok"] = thermocoupleConnected; - s["watchdog_ok"] = true; - s["tripped"] = safetyTripped; - doc["enabled"] = enabled; - doc["loop_alive"] = true; + DynamicJsonDocument doc(1024); + doc["ok"] = true; + doc["instance_id"] = instanceId; + doc["ts"] = nowEpochSec(); + JsonObject ca = doc.createNestedObject("controller_alive"); + ca["nail1"] = true; + ca["nail2"] = true; + JsonObject wo = doc.createNestedObject("watchdog_ok"); + wo["nail1"] = !safetyTripped; + wo["nail2"] = !safetyTripped; sendJson(doc); } void handleHistory() { - DynamicJsonDocument doc(8192); - JsonArray arr = doc.to(); - unsigned long since = 0; + float since = 0; if (server.hasArg("since")) { - since = static_cast(server.arg("since").toInt()); + since = server.arg("since").toFloat(); } + DynamicJsonDocument doc(24576); + JsonArray arr = doc.to(); for (const auto& p : history) { - if (p.ts <= since) continue; + if (p.timestamp <= since) continue; JsonObject o = arr.createNestedObject(); - o["timestamp"] = p.ts; + o["timestamp"] = p.timestamp; o["temp"] = p.temp; o["setpoint"] = p.setpoint; - o["flight_setpoint"] = p.setpoint; - o["mode"] = mode; + o["flight_setpoint"] = p.flightSetpoint; + o["mode"] = p.mode; o["output"] = p.output; o["relay"] = p.relay; } - String body; - serializeJson(arr, body); - server.send(200, "application/json", body); + sendJson(doc); +} + +bool parseRequestJson(DynamicJsonDocument& req) { + String body = server.arg("plain"); + if (body.isEmpty()) { + req.to(); + return true; + } + auto err = deserializeJson(req, body); + return !err; } void handlePower() { - DynamicJsonDocument req(256); - if (deserializeJson(req, server.arg("plain"))) { - server.send(400, "application/json", "{\"error\":\"invalid json\"}"); - return; - } - enabled = req["enabled"] | false; - if (enabled) { - safetyTripped = false; - safetyReason = ""; - mode = "cruise"; - startMs = millis(); - } else { - mode = "grounded"; - relayOn = false; - digitalWrite(PIN_RELAY, LOW); - } + DynamicJsonDocument req(512); + if (!parseRequestJson(req)) return sendError(400, "invalid json"); + bool target = req["enabled"].is() ? req["enabled"].as() : !enabled; + setPower(target); DynamicJsonDocument out(256); out["enabled"] = enabled; + out["ok"] = true; + out["nail"] = nailFromRequest(&req); sendJson(out); } void handleSetpoint() { - DynamicJsonDocument req(256); - if (deserializeJson(req, server.arg("plain"))) { - server.send(400, "application/json", "{\"error\":\"invalid json\"}"); - return; - } - float v = req["setpoint"] | static_cast(setpointF); - if (v > MAX_TEMP_F) { - server.send(400, "application/json", "{\"error\":\"setpoint too high\"}"); - return; - } - if (v < 0) { - server.send(400, "application/json", "{\"error\":\"setpoint too low\"}"); - return; - } + DynamicJsonDocument req(512); + if (!parseRequestJson(req)) return sendError(400, "invalid json"); + if (!req["setpoint"].is() && !req["setpoint"].is()) return sendError(400, "Missing 'setpoint' field"); + float v = req["setpoint"].as(); + if (v > MAX_TEMP_F) return sendError(400, "Setpoint exceeds max 800F"); + if (v < MIN_TEMP_F) return sendError(400, "Setpoint below min 0F"); setpointF = v; idleSinceMs = 0; - prefs.putFloat("setpoint", static_cast(setpointF)); + prefs.putFloat("setpoint", setpointF); DynamicJsonDocument out(256); out["setpoint"] = setpointF; + out["ok"] = true; + out["nail"] = nailFromRequest(&req); sendJson(out); } void handlePid() { DynamicJsonDocument req(512); - if (deserializeJson(req, server.arg("plain"))) { - server.send(400, "application/json", "{\"error\":\"invalid json\"}"); - return; - } - kp = req["kP"] | kp; - ki = req["kI"] | ki; - kd = req["kD"] | kd; + if (!parseRequestJson(req)) return sendError(400, "invalid json"); + if (!req["kP"].is() && !req["kP"].is()) return sendError(400, "Missing kP, kI, or kD"); + if (!req["kI"].is() && !req["kI"].is()) return sendError(400, "Missing kP, kI, or kD"); + if (!req["kD"].is() && !req["kD"].is()) return sendError(400, "Missing kP, kI, or kD"); + + kp = req["kP"].as(); + ki = req["kI"].as(); + kd = req["kD"].as(); + proportionalMode = req["proportional_mode"].is() ? String(req["proportional_mode"].as()) : proportionalMode; + proportionalOnMeasurement = (proportionalMode == "measurement") || req["proportional_on_measurement"].as(); pid.SetTunings(kp, ki, kd); - prefs.putFloat("kP", static_cast(kp)); - prefs.putFloat("kI", static_cast(ki)); - prefs.putFloat("kD", static_cast(kd)); - DynamicJsonDocument out(256); + prefs.putFloat("kP", kp); + prefs.putFloat("kI", ki); + prefs.putFloat("kD", kd); + + DynamicJsonDocument out(512); out["kP"] = kp; out["kI"] = ki; out["kD"] = kd; + out["proportional_on_measurement"] = proportionalOnMeasurement; + out["proportional_mode"] = proportionalOnMeasurement ? "measurement" : "error"; + out["ok"] = true; + out["nail"] = nailFromRequest(&req); + sendJson(out); +} + +void handlePidReset() { + pid.SetMode(MANUAL); + outputMs = 0; + pid.SetMode(AUTOMATIC); + DynamicJsonDocument out(256); + out["ok"] = true; + out["message"] = "PID reset"; + out["nail"] = nailFromRequest(nullptr); sendJson(out); } @@ -292,171 +831,367 @@ void handleSafetyReset() { safetyTripped = false; safetyReason = ""; idleSinceMs = 0; - DynamicJsonDocument out(128); + DynamicJsonDocument out(256); out["ok"] = true; + out["nail"] = nailFromRequest(nullptr); sendJson(out); } -const char UI_HTML[] PROGMEM = R"HTML( - - - - - - piNail ESP32-C3 - - - -
-
-

piNail ESP32-C3 (Single Nail)

-
Connecting...
-
+void handleControl() { + DynamicJsonDocument req(512); + if (!parseRequestJson(req)) return sendError(400, "invalid json"); -
-
-
Temperature
-
--
-
-
-
Setpoint
-
--
-
-
-
Relay
-
--
-
-
-
Output (ms/window)
-
--
-
-
+ bool changed = false; + int newLoop = loopSizeMs; + float newSleep = sleepTimeS; -
-
- - - -
-
- - -
-
- -
-
PID
-
- - - - -
-
- -
-
Safety
-
--
-
-
-
- - - - -)HTML"; + loopSizeMs = newLoop; + sleepTimeS = newSleep; + prefs.putInt("loop_ms", loopSizeMs); + prefs.putFloat("sleep_s", sleepTimeS); + pid.SetOutputLimits(0, loopSizeMs); + pid.SetSampleTime(controlTickMs()); + + DynamicJsonDocument out(1024); + out["ok"] = true; + JsonObject c = out.createNestedObject("control"); + c["loop_size_ms"] = loopSizeMs; + c["sleep_time"] = sleepTimeS; + out["nail"] = nailFromRequest(&req); + out["profile"] = profile; + out["pid_clamped"] = false; + out["pid_guardrail"] = nullptr; + JsonObject limits = out.createNestedObject("limits"); + JsonObject l1 = limits.createNestedObject("loop_size_ms"); + l1["min"] = LOOP_MIN_MS; + l1["max"] = LOOP_MAX_MS; + JsonObject l2 = limits.createNestedObject("sleep_time"); + l2["min"] = SLEEP_MIN_S; + l2["max"] = SLEEP_MAX_S; + sendJson(out); +} + +void handleFlight() { + DynamicJsonDocument req(1024); + if (!parseRequestJson(req)) return sendError(400, "invalid json"); + + if (req["takeoff_seconds"].is() || req["takeoff_seconds"].is()) { + float v = req["takeoff_seconds"].as(); + if (v < 5 || v > 1800) return sendError(400, "takeoff_seconds out of range (5-1800)"); + flight.takeoffSeconds = v; + prefs.putFloat("takeoff_s", v); + } + if (req["descent_seconds"].is() || req["descent_seconds"].is()) { + float v = req["descent_seconds"].as(); + if (v < 5 || v > 1800) return sendError(400, "descent_seconds out of range (5-1800)"); + flight.descentSeconds = v; + prefs.putFloat("descent_s", v); + } + if (req["descent_target_f"].is() || req["descent_target_f"].is()) { + flight.descentTargetF = req["descent_target_f"].as(); + prefs.putFloat("descent_t", flight.descentTargetF); + } + if (req["turbo"].is()) { + flight.turbo = req["turbo"].as(); + prefs.putBool("turbo", flight.turbo); + } + + if (req["mode"].is()) { + String newMode = req["mode"].as(); + if (newMode == "takeoff") startTakeoff(); + else if (newMode == "cruise") startCruise(); + else if (newMode == "descent") startDescent(); + else if (newMode == "grounded") setPower(false); + } + + DynamicJsonDocument out(4096); + out["ok"] = true; + out["nail"] = nailFromRequest(&req); + JsonObject statusObj = out.createNestedObject("status"); + appendStatus(statusObj, "nail1"); + sendJson(out); +} + +void handleScheduler() { + DynamicJsonDocument req(2048); + if (!parseRequestJson(req)) return sendError(400, "invalid json"); + + if (req["enabled"].is()) { + scheduler.enabled = req["enabled"].as(); + } + if (req["cutoff_times"].is()) { + std::vector times; + for (JsonVariant v : req["cutoff_times"].as()) { + if (!v.is()) return sendError(400, "cutoff_times must be a list of HH:MM strings"); + String t = v.as(); + int h = 0, m = 0; + if (!parseHm(t, h, m)) return sendError(400, String("Invalid time '") + t + "'; expected HH:MM"); + char buf[6]; + snprintf(buf, sizeof(buf), "%02d:%02d", h, m); + times.push_back(String(buf)); + } + std::sort(times.begin(), times.end()); + times.erase(std::unique(times.begin(), times.end()), times.end()); + scheduler.cutoffTimes = times; + } + saveSchedulerTimes(); + + DynamicJsonDocument out(1024); + out["ok"] = true; + out["nail"] = nailFromRequest(&req); + JsonObject sched = out.createNestedObject("scheduler"); + sched["enabled"] = scheduler.enabled; + JsonArray arr = sched.createNestedArray("cutoff_times"); + for (const auto& t : scheduler.cutoffTimes) arr.add(t); + sendJson(out); +} + +void handleAutotuneStatus() { + DynamicJsonDocument doc(2048); + doc["active"] = autotune.active; + doc["message"] = autotune.message; + doc["phase"] = autotune.phase; + doc["cycles"] = autotune.cycles; + doc["high_peaks"] = autotune.highPeaks; + doc["low_peaks"] = autotune.lowPeaks; + doc["nail"] = nailFromRequest(nullptr); + if (autotune.hasLastResult) { + JsonObject r = doc.createNestedObject("last_result"); + r["kP"] = autotune.resKP; + r["kI"] = autotune.resKI; + r["kD"] = autotune.resKD; + } else { + doc["last_result"] = nullptr; + } + sendJson(doc); +} + +void handleAutotuneStart() { + DynamicJsonDocument req(1024); + if (!parseRequestJson(req)) return sendError(400, "invalid json"); + if (!enabled) setPower(true); + + autotune.active = true; + autotune.message = "Running"; + autotune.setpoint = req["setpoint"].is() || req["setpoint"].is() ? req["setpoint"].as() : setpointF; + autotune.hysteresis = req["hysteresis"].is() || req["hysteresis"].is() ? req["hysteresis"].as() : 8.0f; + autotune.cycles = req["cycles"].is() ? req["cycles"].as() : 4; + if (autotune.cycles < 2) autotune.cycles = 2; + if (autotune.cycles > 10) autotune.cycles = 10; + autotune.highPeaks = 0; + autotune.lowPeaks = 0; + autotune.highs.clear(); + autotune.lows.clear(); + autotune.highTimes.clear(); + autotune.startedMs = millis(); + autotune.phase = (inputF > autotune.setpoint) ? "cooling" : "heating"; + startCruise(); + + DynamicJsonDocument out(2048); + out["ok"] = true; + out["nail"] = nailFromRequest(&req); + JsonObject a = out.createNestedObject("autotune"); + a["active"] = autotune.active; + a["message"] = autotune.message; + a["phase"] = autotune.phase; + a["cycles"] = autotune.cycles; + a["high_peaks"] = autotune.highPeaks; + a["low_peaks"] = autotune.lowPeaks; + sendJson(out); +} + +void handleAutotuneStop() { + stopAutotune("Autotune stopped by user"); + DynamicJsonDocument out(1024); + out["ok"] = true; + out["nail"] = nailFromRequest(nullptr); + JsonObject a = out.createNestedObject("autotune"); + a["active"] = autotune.active; + a["message"] = autotune.message; + sendJson(out); +} + +void handlePresetApply(const String& name) { + for (const auto& p : presets) { + if (p.name == name) { + setpointF = p.temp; + prefs.putFloat("setpoint", setpointF); + DynamicJsonDocument out(256); + out["preset"] = name; + out["setpoint"] = setpointF; + out["ok"] = true; + out["nail"] = nailFromRequest(nullptr); + return sendJson(out); + } + } + sendError(404, String("Unknown preset '") + name + "'"); +} + +void handlePresetsGet() { + DynamicJsonDocument out(2048); + JsonObject obj = out.to(); + for (const auto& p : presets) obj[p.name] = p.temp; + sendJson(out); +} + +void handlePresetsPost() { + DynamicJsonDocument req(512); + if (!parseRequestJson(req)) return sendError(400, "invalid json"); + if (!req["name"].is()) return sendError(400, "Missing 'name' or 'setpoint'"); + if (!req["setpoint"].is() && !req["setpoint"].is()) return sendError(400, "Missing 'name' or 'setpoint'"); + String name = req["name"].as(); + float sp = req["setpoint"].as(); + addOrUpdatePreset(name, sp); + savePresets(); + + DynamicJsonDocument out(2048); + out["ok"] = true; + JsonObject pObj = out.createNestedObject("presets"); + for (const auto& p : presets) pObj[p.name] = p.temp; + sendJson(out); +} + +void handlePresetDelete(const String& name) { + auto it = std::remove_if(presets.begin(), presets.end(), [&](const Preset& p) { return p.name == name; }); + if (it == presets.end()) return sendError(404, String("Unknown preset '") + name + "'"); + presets.erase(it, presets.end()); + savePresets(); + DynamicJsonDocument out(2048); + out["ok"] = true; + JsonObject pObj = out.createNestedObject("presets"); + for (const auto& p : presets) pObj[p.name] = p.temp; + sendJson(out); +} + +void handleConfigGet() { + DynamicJsonDocument out(4096); + JsonObject root = out.to(); + JsonObject presetsObj = root.createNestedObject("presets"); + for (const auto& p : presets) presetsObj[p.name] = p.temp; + + JsonObject control = root.createNestedObject("control"); + control["loop_size_ms"] = loopSizeMs; + control["sleep_time"] = sleepTimeS; + + JsonObject flightObj = root.createNestedObject("flight"); + flightObj["takeoff_seconds"] = flight.takeoffSeconds; + flightObj["descent_seconds"] = flight.descentSeconds; + flightObj["turbo"] = flight.turbo; + flightObj["descent_target_f"] = flight.descentTargetF; + + JsonObject sched = root.createNestedObject("scheduler"); + sched["enabled"] = scheduler.enabled; + JsonArray arr = sched.createNestedArray("cutoff_times"); + for (const auto& t : scheduler.cutoffTimes) arr.add(t); + + JsonObject safety = root.createNestedObject("safety"); + safety["max_temp_f"] = MAX_TEMP_F; + safety["min_temp_f"] = MIN_TEMP_F; + safety["spike_threshold_f"] = SPIKE_THRESHOLD_F; + safety["idle_shutoff_minutes"] = IDLE_SHUTOFF_MINUTES; + sendJson(out); +} + +void handleStatic() { + String uri = server.uri(); + if (uri == "/") { + server.send_P(200, "text/html", INDEX_HTML); + return; + } + if (uri == "/static/style.css") { + server.send_P(200, "text/css", STYLE_CSS); + return; + } + if (uri == "/static/app.js") { + server.send_P(200, "application/javascript", APP_JS); + return; + } + if (uri == "/static/manifest.webmanifest") { + server.send_P(200, "application/manifest+json", MANIFEST_JSON); + return; + } + if (uri == "/static/sw.js") { + server.send_P(200, "application/javascript", SW_JS); + return; + } + if (uri == "/static/img/pinail_logo.svg" || uri == "/static/img/pi_favicon.png" || uri == "/apple-touch-icon") { + server.send_P(200, "image/svg+xml", LOGO_SVG); + return; + } +} + +void handleNotFound() { + handleStatic(); + String uri = server.uri(); + if (uri.startsWith("/api/preset/")) { + String raw = uri.substring(String("/api/preset/").length()); + String name = decodeUriComponent(raw); + if (server.method() == HTTP_POST) return handlePresetApply(name); + if (server.method() == HTTP_DELETE) return handlePresetDelete(name); + } + sendError(404, "Not found"); +} void setupRoutes() { - server.on("/", HTTP_GET, []() { - server.send_P(200, "text/html", UI_HTML); - }); + server.on("/", HTTP_GET, []() { server.send_P(200, "text/html", INDEX_HTML); }); server.on("/api/status", HTTP_GET, handleStatus); - server.on("/api/history", HTTP_GET, handleHistory); + 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/power", HTTP_POST, handlePower); server.on("/api/setpoint", HTTP_POST, handleSetpoint); + server.on("/api/control", HTTP_POST, handleControl); + server.on("/api/flight", HTTP_POST, handleFlight); + server.on("/api/scheduler", HTTP_POST, handleScheduler); server.on("/api/pid", HTTP_POST, handlePid); - server.on("/api/pid/reset", HTTP_POST, handleSafetyReset); + server.on("/api/pid/reset", HTTP_POST, handlePidReset); server.on("/api/safety/reset", HTTP_POST, handleSafetyReset); + server.on("/api/presets", HTTP_GET, handlePresetsGet); + server.on("/api/presets", HTTP_POST, handlePresetsPost); + server.on("/api/config", HTTP_GET, handleConfigGet); + 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/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); }); + server.on("/static/img/pi_favicon.png", HTTP_GET, []() { server.send_P(200, "image/svg+xml", LOGO_SVG); }); + + server.onNotFound(handleNotFound); } void setup() { @@ -469,18 +1204,32 @@ void setup() { digitalWrite(PIN_MAX6675_CS, HIGH); Serial.begin(115200); - delay(300); + delay(250); + bootMs = millis(); + instanceId = String(bootMs); prefs.begin("pinail", false); setpointF = prefs.getFloat("setpoint", 530.0f); kp = prefs.getFloat("kP", 113.1768f); ki = prefs.getFloat("kI", 3.5335f); kd = prefs.getFloat("kD", 500.0f); + loopSizeMs = prefs.getInt("loop_ms", 1800); + sleepTimeS = prefs.getFloat("sleep_s", 0.2f); + if (loopSizeMs < LOOP_MIN_MS || loopSizeMs > LOOP_MAX_MS) loopSizeMs = 1800; + 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.descentTargetF = prefs.getFloat("descent_t", 120.0f); + flight.turbo = prefs.getBool("turbo", false); + + loadSchedulerTimes(); + loadPresets(); pid.SetTunings(kp, ki, kd); pid.SetMode(AUTOMATIC); - pid.SetOutputLimits(0, LOOP_SIZE_MS); - pid.SetSampleTime(CONTROL_TICK_MS); + pid.SetOutputLimits(0, loopSizeMs); + pid.SetSampleTime(controlTickMs()); WiFiManager wm; wm.setConfigPortalTimeout(300); @@ -488,14 +1237,17 @@ void setup() { if (!wifiOk) { ESP.restart(); } + configTime(0, 0, "pool.ntp.org", "time.nist.gov"); setupRoutes(); server.begin(); cycleStartMs = millis(); lastControlTickMs = 0; + enterMode("grounded"); } void loop() { server.handleClient(); updateControl(); + delay(2); } diff --git a/src/ui_assets.h b/src/ui_assets.h new file mode 100644 index 0000000..155f370 --- /dev/null +++ b/src/ui_assets.h @@ -0,0 +1,2045 @@ +// Auto-generated UI assets for ESP32 web server +#pragma once + +#include + +static const char INDEX_HTML[] PROGMEM = R"PINAILRAW( + + + + + + piNail2 Controller + + + + + + + + + + + +
+
+ +

Controller

+
+
+
+ + + +
+ + Last command: none + Backend: Online +
+
+
+ +
+ +
+
+

Nail 1

+
---°F
+
Mode: grounded
+
Target: ---°F
+
+ Relay OFF + Output 0 + TC -- + Uptime -- +
+
ETA: --
+
Next descent: --
+
No active safety alerts.
+
+ + + +
+
+ + +
+
+
+

Nail 2

+
---°F
+
Mode: grounded
+
Target: ---°F
+
+ Relay OFF + Output 0 + TC -- + Uptime -- +
+
ETA: --
+
Next descent: --
+
No active safety alerts.
+
+ + + +
+
+ + +
+
+
+ +
+

Combined Temperature View

+ +
+ +
+
+
Nail 1
+
--:--:--
+
+ --- + °F +
+
Err(3m): --
+
+
+
+ Target: ---°F +
+
Mode: grounded
+
ETA: --
+
Next descent: --
+
Autotune: Idle
+ +
+
+ + + + + + + +
+ +
+ + +
+
+

Setpoint

+
+ + + + + + +
+
+ +
+
+
+ + +
+ +
+

Loop Timing

+
+ + + +
+
+ + + + Limits: 1500-5000ms, 0.15-0.6s +
+
+ + +
+

PID Tuning

+
+ + + + + + +
+
+ + + Idle +
+
+
+ +
+
+

Flight Modes

+
+ + + +
+
+ + + +
+
+ +
+

Descent Scheduler

+
+ + + +
+
+
+
+ + +
+
+ Output + 0 +
+
+ Relay + OFF +
+
+ Uptime + -- +
+
+ Loops + 0 +
+
+ TC + -- +
+
+ +
+ v2.1.0-esp32 + Copyright © 2026 SethPC Labs +
+
+ + + + + +)PINAILRAW"; + +static const char STYLE_CSS[] PROGMEM = R"PINAILRAW( +/* piNail2 — Dark theme dashboard */ + +:root { + --bg: #1a1a1a; + --bg-card: #252525; + --bg-input: #2a2a2a; + --text: #e0e0e0; + --text-dim: #cccccc; + --accent-orange: #d35400; + --accent-orange-hover: #e65c00; + --accent-orange-deep: #a84300; + --accent-teal: #d35400; + --accent-blue: #d35400; + --accent-red: #e74c3c; + --accent-green: #2ecc71; + --border: #333333; + --radius: 8px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; + background: radial-gradient(circle at 20% 0%, #222 0%, #1a1a1a 35%, #000 100%); + color: var(--text); + min-height: 100vh; +} + +/* Header */ +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 20px; + background: #000; + border-bottom: 1px solid var(--border); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + height: 48px; +} + +.brand-logo { + height: 100%; + width: auto; + max-width: 56vw; + border: none; + border-radius: 0; + object-fit: contain; +} + +.conn-wrap { + display: flex; + align-items: center; + gap: 10px; +} + +.mode-switch { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 999px; + overflow: hidden; + background: #111; +} + +.mode-btn { + background: transparent; + color: var(--text-dim); + border: none; + padding: 6px 10px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.03em; + cursor: pointer; +} + +.mode-btn.active { + background: var(--accent-orange); + color: #fff; +} + +.install-btn { + background: var(--accent-orange); + color: #fff; + border: 1px solid var(--accent-orange); + border-radius: 999px; + padding: 6px 12px; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.03em; + cursor: pointer; +} + +.install-btn:hover { + background: var(--accent-orange-hover); + border-color: var(--accent-orange-hover); +} + +.backend-status { + font-size: 0.8rem; + color: var(--text-dim); +} + +.last-ack { + font-size: 0.8rem; + color: var(--text-dim); +} + +.last-ack.ok { + color: var(--accent-green); +} + +.last-ack.err { + color: var(--accent-red); +} + +.backend-status.online { color: var(--accent-green); } +.backend-status.reconnecting { color: var(--accent-orange); } +.backend-status.offline { color: var(--accent-red); } + +header h1 { + font-size: 1rem; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.status-dot { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.status-dot.connected { background: var(--accent-green); } +.status-dot.disconnected { background: var(--accent-red); } + +/* Main */ +main { + max-width: 900px; + margin: 0 auto; + padding: 16px; +} + +body.ui-simple main { + max-width: 760px; +} + +body.ui-simple .advanced-only { + display: none !important; +} + +.simple-only { + display: none; +} + +body.ui-simple .simple-only { + display: grid; +} + +body.ui-simple .hero, +body.ui-simple .chart-section, +body.ui-simple .controls-setpoint, +body.ui-simple .app-footer { + display: none; +} + +.dual-simple { + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 12px; +} + +.simple-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; +} + +.simple-card h3 { + font-size: 0.85rem; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 8px; +} + +.simple-temp { + font-size: 2rem; + font-weight: 700; + color: var(--text); + margin-bottom: 8px; +} + +.simple-line { + font-size: 0.82rem; + color: var(--text-dim); + margin-bottom: 6px; +} + +.simple-controls { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.simple-flight-controls { + display: flex; + gap: 8px; + margin-top: 8px; +} + +.simple-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin: 10px 0; +} + +.simple-stat { + font-size: 0.74rem; + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 7px; + color: var(--text-dim); + background: rgba(0, 0, 0, 0.25); +} + +.simple-stat strong { + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.simple-alert { + margin-top: 6px; + margin-bottom: 6px; + font-size: 0.73rem; + color: var(--text-dim); + border-left: 2px solid var(--border); + padding-left: 8px; + min-height: 18px; +} + +.simple-alert.warn { + color: #f5b041; + border-left-color: #f5b041; +} + +.simple-alert.bad { + color: var(--accent-red); + border-left-color: var(--accent-red); +} + +.simple-chart-wrap { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + margin-bottom: 12px; + height: 280px; + box-shadow: inset 0 0 0 1px rgba(211, 84, 0, 0.12); +} + +.simple-chart-wrap h3 { + font-size: 0.82rem; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 8px; + letter-spacing: 0.05em; +} + +.simple-chart-wrap canvas { + width: 100% !important; + height: calc(100% - 26px) !important; +} + +.power-mini { + border-radius: 999px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-dim); + padding: 8px 12px; + font-weight: 700; +} + +.power-mini.on { + color: var(--accent-green); + border-color: var(--accent-green); + background: rgba(46, 204, 113, 0.15); +} + +/* Hero: temp + power */ +.hero { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-card); + border-radius: var(--radius); + padding: 20px 30px; + margin-bottom: 12px; + border: 1px solid var(--border); +} + +.temp-display { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.temp-main { + display: flex; + align-items: baseline; +} + +.wall-clock { + font-size: 0.92rem; + color: var(--accent-orange); + font-variant-numeric: tabular-nums; + letter-spacing: 0.06em; +} + +.temp-value { + font-size: 4rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text-dim); + transition: color 0.3s; +} + +.temp-unit { + font-size: 1.5rem; + color: var(--text-dim); + margin-left: 4px; +} + +.temp-good { color: var(--accent-green); } +.temp-warming { color: var(--accent-orange); } +.temp-cold { color: var(--accent-blue); } + +.error-stats { + font-size: 0.74rem; + color: var(--text-dim); + font-variant-numeric: tabular-nums; +} + +.hero-right { + text-align: right; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 12px; +} + +.autotune-pill { + font-size: 0.8rem; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--border); + letter-spacing: 0.02em; +} + +.autotune-pill.idle { + color: var(--text-dim); + background: rgba(255, 255, 255, 0.05); +} + +.mode-pill { + font-size: 0.8rem; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--text-dim); + background: rgba(255, 255, 255, 0.05); +} + +.mode-pill.takeoff { + color: #111; + background: var(--accent-orange); + border-color: var(--accent-orange); +} + +.mode-pill.cruise { + color: #111; + background: var(--accent-green); + border-color: var(--accent-green); +} + +.mode-pill.descent { + color: #111; + background: #f39c12; + border-color: #f39c12; +} + +.mode-pill.grounded { + color: var(--text-dim); + background: rgba(255, 255, 255, 0.05); + border-color: var(--border); +} + +.mode-eta { + font-size: 0.78rem; + color: var(--text-dim); +} + +.sched-time-list { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.sched-time-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: #1a1a1a; + color: var(--text); + font-size: 0.78rem; +} + +#sched-time-input { + width: 122px; + text-align: center; +} + +.sched-del { + border: none; + background: transparent; + color: var(--accent-red); + font-weight: 700; + cursor: pointer; + padding: 0; +} + +.autotune-pill.running { + color: #111; + background: var(--accent-orange); + border-color: var(--accent-orange); + animation: autotunePulse 1.2s infinite; +} + +.autotune-pill.done { + color: #111; + background: var(--accent-green); + border-color: var(--accent-green); +} + +.autotune-pill.error { + color: #fff; + background: var(--accent-red); + border-color: var(--accent-red); +} + +.setpoint-display { + font-size: 1.1rem; + color: var(--accent-teal); +} + +.power-btn { + width: 80px; + height: 80px; + border-radius: 50%; + border: 3px solid; + font-size: 1.2rem; + font-weight: 700; + cursor: pointer; + transition: all 0.2s; +} + +.power-btn.off { + background: transparent; + border-color: var(--text-dim); + color: var(--text-dim); +} + +.power-btn.on { + background: rgba(46, 204, 113, 0.15); + border-color: var(--accent-green); + color: var(--accent-green); + box-shadow: 0 0 20px rgba(46, 204, 113, 0.3); +} + +.power-btn:hover { opacity: 0.8; } +.power-btn:active { transform: scale(0.95); } + +/* Safety Banner */ +.safety-banner { + background: rgba(231, 76, 60, 0.15); + border: 1px solid var(--accent-red); + border-radius: var(--radius); + padding: 12px 20px; + margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: space-between; + color: var(--accent-red); + font-weight: 600; +} + +.safety-banner.hidden { display: none; } + +.safety-banner button { + background: var(--accent-red); + color: white; + border: none; + padding: 6px 16px; + border-radius: 4px; + cursor: pointer; + font-weight: 600; +} + +.action-banner { + border-radius: var(--radius); + padding: 10px 14px; + margin-bottom: 12px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.06); + color: var(--text); + font-weight: 600; +} + +.action-banner.hidden { display: none; } +.action-banner.info { border-color: var(--accent-blue); color: var(--accent-blue); } +.action-banner.success { border-color: var(--accent-green); color: var(--accent-green); } +.action-banner.error { border-color: var(--accent-red); color: var(--accent-red); } + +/* Chart */ +.chart-section { + background: var(--bg-card); + border-radius: var(--radius); + border: 1px solid var(--border); + padding: 12px; + margin-bottom: 12px; + height: 280px; +} + +.chart-section { + box-shadow: inset 0 0 0 1px rgba(211, 84, 0, 0.12); +} + +.chart-section canvas { + width: 100% !important; + height: 100% !important; +} + +/* Controls */ +.controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 12px; +} + +.controls-setpoint { + grid-template-columns: 1fr; +} + +.controls-advanced-row { + grid-template-columns: 1fr 1fr; +} + +.control-group { + background: var(--bg-card); + border-radius: var(--radius); + border: 1px solid var(--border); + padding: 16px; +} + +.control-group h3 { + font-size: 0.85rem; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 12px; + letter-spacing: 0.05em; +} + +/* Setpoint controls */ +.setpoint-controls { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.adj-btn { + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; +} + +.adj-btn:hover { background: var(--accent-orange-deep); color: #fff; } +.adj-btn:active { transform: scale(0.95); } + +input[type="number"] { + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); + padding: 8px 10px; + border-radius: 4px; + width: 80px; + text-align: center; + font-size: 1rem; + font-family: inherit; +} + +select { + background: var(--bg-input); + color: var(--text); + border: 1px solid var(--border); + padding: 8px 10px; + border-radius: 4px; + font-size: 0.9rem; + font-family: inherit; +} + +select:focus { + outline: none; + border-color: var(--accent-teal); +} + +input[type="number"]:focus { + outline: none; + border-color: var(--accent-teal); +} + +.apply-btn { + background: var(--accent-teal); + color: #fff; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-weight: 700; + font-size: 0.9rem; +} + +.apply-btn:hover { background: var(--accent-orange-hover); opacity: 1; } +.apply-btn:active { transform: scale(0.95); } + +button:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none !important; +} + +/* Presets */ +.presets { + margin-top: 10px; + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.preset-btn { + background: var(--bg-input); + color: var(--accent-teal); + border: 1px solid var(--accent-teal); + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; +} + +.preset-btn:hover { + background: var(--accent-teal); + color: #000; +} + +/* PID controls */ +.pid-controls { + display: flex; + align-items: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.pid-controls label { + display: flex; + flex-direction: column; + font-size: 0.8rem; + color: var(--text-dim); + gap: 4px; +} + +.checkbox-label { + flex-direction: row !important; + align-items: center; + gap: 6px; + color: var(--text); +} + +.checkbox-label input { + width: auto; +} + +.autotune-controls { + margin-top: 12px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.autotune-status { + font-size: 0.85rem; + color: var(--text-dim); + font-weight: 600; +} + +.autotune-status.idle { + color: var(--text-dim); +} + +.autotune-status.running { + color: var(--accent-orange); + animation: autotunePulse 1.2s infinite; +} + +.autotune-status.done { + color: var(--accent-green); +} + +.autotune-status.error { + color: var(--accent-red); +} + +@keyframes autotunePulse { + 0% { opacity: 0.6; } + 50% { opacity: 1; } + 100% { opacity: 0.6; } +} + +.pid-controls input { + width: 70px; +} + +#control-loop-size, +#flight-takeoff-seconds, +#flight-descent-seconds, +#flight-descent-target { + width: 112px; +} + +/* Status Bar */ +.status-bar { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.status-item { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 16px; + flex: 1; + min-width: 100px; + text-align: center; +} + +.status-item .label { + display: block; + font-size: 0.7rem; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 4px; +} + +.status-item .value { + font-size: 1rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.app-footer { + margin-top: 14px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(0, 0, 0, 0.4); + color: var(--text-dim); + font-size: 0.78rem; + display: flex; + justify-content: space-between; + gap: 10px; +} + +.relay-on { color: var(--accent-green); } +.relay-off { color: var(--text-dim); } +.tc-ok { color: var(--accent-green); } +.tc-err { color: var(--accent-red); } + +/* Mobile responsive */ +@media (max-width: 600px) { + .hero { + flex-direction: column; + text-align: center; + gap: 16px; + padding: 16px; + } + + .brand-logo { + height: 100%; + max-width: 62vw; + } + + header h1 { + display: none; + } + + .app-footer { + flex-direction: column; + text-align: center; + } + + .hero-right { + align-items: center; + } + + .temp-value { + font-size: 3rem; + } + + .temp-display { + align-items: center; + } + + .error-stats { + text-align: center; + } + + .power-btn { + width: 64px; + height: 64px; + font-size: 1rem; + } + + .controls { + grid-template-columns: 1fr; + } + + .dual-simple { + grid-template-columns: 1fr; + } + + .simple-chart-wrap { + height: 220px; + } + + .chart-section { + height: 220px; + } + + .setpoint-controls { + justify-content: center; + } + + .pid-controls { + justify-content: center; + } + + .status-bar { + display: grid; + grid-template-columns: 1fr 1fr; + } +} + +)PINAILRAW"; + +static const char APP_JS[] PROGMEM = R"PINAILRAW( +let pollInterval = 500; +let chartMaxPoints = 300; +let lastTimestampByNail = { nail1: 0, nail2: 0 }; +let chart = null; +let firstTimestamp = null; +let simpleChart = null; +let simpleFirstTimestamp = null; +let lastApiError = ''; +let actionBannerTimer = null; +let heartbeatMisses = 0; +let heartbeatInstanceId = null; +let controlsEnabled = true; +let deferredInstallPrompt = null; +let uiMode = 'nail1'; +let activeNailId = 'nail1'; +let historyBuffer = []; +let simpleHistoryByNail = { nail1: [], nail2: [] }; +let schedulerTimesByNail = { nail1: [], nail2: [] }; +let states = { nail1: null, nail2: null }; + +function nailIdFromNum(num) { + return String(num) === '2' ? 'nail2' : 'nail1'; +} + +function nailNumFromId(id) { + return id === 'nail2' ? 2 : 1; +} + +function currentNailId() { + return activeNailId; +} + +function nowHms() { + return new Date().toLocaleTimeString(); +} + +function setLastAck(message, ok=true) { + const el = document.getElementById('last-ack'); + if (!el) return; + el.className = 'last-ack ' + (ok ? 'ok' : 'err'); + el.textContent = 'Last command: ' + message + ' at ' + nowHms(); +} + +function showAction(message, type='info', timeoutMs=3000) { + const banner = document.getElementById('action-banner'); + const msg = document.getElementById('action-message'); + if (!banner || !msg) return; + msg.textContent = message; + banner.className = 'action-banner ' + type; + if (actionBannerTimer) clearTimeout(actionBannerTimer); + if (timeoutMs > 0) { + actionBannerTimer = setTimeout(function() { + banner.className = 'action-banner hidden'; + }, timeoutMs); + } +} + +function setControlsEnabled(enabled) { + controlsEnabled = enabled; + document.querySelectorAll('button').forEach(function(btn) { + if (btn.id === 'autotune-stop-btn' && enabled) return; + btn.disabled = !enabled; + }); +} + +function setBackendStatus(mode, text) { + const el = document.getElementById('backend-status'); + if (!el) return; + el.className = 'backend-status ' + mode; + el.textContent = text; +} + +function setConnectionStatus(connected) { + const dot = document.getElementById('connection-status'); + if (!dot) return; + if (connected) { + dot.className = 'status-dot connected'; + dot.title = 'Connected'; + setBackendStatus('online', 'Backend: Online'); + } else { + dot.className = 'status-dot disconnected'; + dot.title = 'Disconnected'; + setBackendStatus('offline', 'Backend: Offline'); + } +} + +function setUiMode(mode, persist=true) { + if (mode === 'nail2') activeNailId = 'nail1'; + if (mode === 'nail1') activeNailId = 'nail1'; + uiMode = (mode === 'simple' || mode === 'nail1') ? mode : 'nail1'; + document.body.classList.remove('ui-simple', 'ui-nail1', 'ui-nail2'); + document.body.classList.add('ui-' + uiMode); + + ['simple', 'nail1', 'nail2'].forEach(function(key) { + const btn = document.getElementById('mode-' + key + '-btn'); + if (btn) btn.classList.toggle('active', key === uiMode); + }); + + const label = document.getElementById('advanced-nail-label'); + if (label) label.textContent = 'Nail ' + nailNumFromId(activeNailId); + + if (persist) { + try { + localStorage.setItem('pinail_ui_mode', uiMode); + } catch (e) { + console.warn('Could not persist ui mode:', e); + } + } + + resetChartForNail(); +} + +function updateWallClock() { + const el = document.getElementById('wall-clock'); + if (!el) return; + const d = new Date(); + const hh = ('0' + d.getHours()).slice(-2); + const mm = ('0' + d.getMinutes()).slice(-2); + const ss = ('0' + d.getSeconds()).slice(-2); + el.textContent = hh + ':' + mm + ':' + ss; +} + +function normalizeTimeString(t) { + const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec((t || '').trim()); + if (!m) return null; + return ('0' + m[1]).slice(-2) + ':' + ('0' + m[2]).slice(-2); +} + +function renderSchedulerTimes() { + const id = currentNailId(); + const times = schedulerTimesByNail[id] || []; + const container = document.getElementById('sched-time-list'); + if (!container) return; + if (!times.length) { + container.innerHTML = 'No descent times configured.'; + return; + } + container.innerHTML = times.map(function(t) { + return '' + t + ''; + }).join(''); +} + +function addSchedulerTime() { + const input = document.getElementById('sched-time-input'); + if (!input) return; + const norm = normalizeTimeString(input.value); + if (!norm) { + showAction('Invalid time format. Use HH:MM.', 'error', 3000); + return; + } + const id = currentNailId(); + if (!schedulerTimesByNail[id]) schedulerTimesByNail[id] = []; + if (schedulerTimesByNail[id].indexOf(norm) === -1) { + schedulerTimesByNail[id].push(norm); + schedulerTimesByNail[id].sort(); + renderSchedulerTimes(); + saveSchedulerSettings(); + } +} + +function removeSchedulerTime(t) { + const id = currentNailId(); + schedulerTimesByNail[id] = (schedulerTimesByNail[id] || []).filter(function(x) { return x !== t; }); + renderSchedulerTimes(); + saveSchedulerSettings(); +} + +function schedulerEnabledChanged() { + saveSchedulerSettings(); +} + +function formatSeconds(sec) { + if (typeof sec !== 'number' || !isFinite(sec)) return '--'; + const s = Math.max(0, Math.round(sec)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const r = s % 60; + if (h > 0) return h + 'h ' + m + 'm ' + r + 's'; + return m + 'm ' + r + 's'; +} + +function formatUptime(sec) { + if (typeof sec !== 'number' || !isFinite(sec)) return '--'; + const s = Math.max(0, Math.round(sec)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const r = s % 60; + if (h > 0) return h + 'h ' + m + 'm'; + return m + 'm ' + r + 's'; +} + +function updateSimpleCards() { + ['nail1', 'nail2'].forEach(function(id) { + const s = states[id]; + if (!s) return; + const suffix = id === 'nail2' ? 'nail2' : 'nail1'; + const tEl = document.getElementById('simple-temp-' + suffix); + const mEl = document.getElementById('simple-mode-' + suffix); + const spEl = document.getElementById('simple-target-' + suffix); + const pEl = document.getElementById('simple-power-' + suffix); + const inEl = document.getElementById('simple-setpoint-' + suffix); + const relayEl = document.getElementById('simple-relay-' + suffix); + const outEl = document.getElementById('simple-output-' + suffix); + const tcEl = document.getElementById('simple-tc-' + suffix); + const upEl = document.getElementById('simple-uptime-' + suffix); + const etaEl = document.getElementById('simple-eta-' + suffix); + const cutoffEl = document.getElementById('simple-cutoff-' + suffix); + const alertEl = document.getElementById('simple-alert-' + suffix); + if (tEl) tEl.textContent = s.temp.toFixed(1); + if (mEl) mEl.textContent = s.mode || 'grounded'; + if (spEl) spEl.textContent = s.effective_setpoint.toFixed(0); + if (inEl && document.activeElement !== inEl) inEl.value = s.setpoint; + if (relayEl) relayEl.textContent = s.relay_on ? 'ON' : 'OFF'; + if (outEl) outEl.textContent = (typeof s.output === 'number' ? s.output.toFixed(0) : '--'); + if (tcEl) tcEl.textContent = s.thermocouple_connected ? 'OK' : 'DISC'; + if (upEl) upEl.textContent = formatUptime(s.uptime_seconds); + if (etaEl) etaEl.textContent = formatSeconds(s.mode_eta_seconds); + if (cutoffEl) cutoffEl.textContent = s.scheduler && s.scheduler.enabled ? formatSeconds(s.next_cutoff_seconds) : 'Scheduler off'; + if (alertEl) { + if (s.safety_tripped) { + alertEl.className = 'simple-alert bad'; + alertEl.textContent = 'Safety trip: ' + (s.safety_reason || 'Unknown'); + } else if (!s.thermocouple_connected) { + alertEl.className = 'simple-alert warn'; + alertEl.textContent = 'Thermocouple disconnected.'; + } else { + alertEl.className = 'simple-alert'; + alertEl.textContent = 'No active safety alerts.'; + } + } + if (pEl) { + pEl.textContent = s.enabled ? 'ON' : 'OFF'; + pEl.className = 'power-mini ' + (s.enabled ? 'on' : 'off'); + } + }); +} + +function updateErrorStats() { + const el = document.getElementById('error-stats'); + if (!el) return; + if (!historyBuffer.length) { + el.textContent = 'Err(3m): --'; + return; + } + const nowTs = historyBuffer[historyBuffer.length - 1].timestamp; + const window = historyBuffer.filter(function(p) { return p.timestamp >= (nowTs - 180); }); + if (!window.length) { + el.textContent = 'Err(3m): --'; + return; + } + let sumAbs = 0; + let sumSq = 0; + let maxAbs = 0; + let sumSigned = 0; + window.forEach(function(p) { + const target = (typeof p.flight_setpoint === 'number') ? p.flight_setpoint : p.setpoint; + const err = p.temp - target; + const abs = Math.abs(err); + sumAbs += abs; + sumSq += err * err; + sumSigned += err; + if (abs > maxAbs) maxAbs = abs; + }); + const n = window.length; + const mae = sumAbs / n; + const rmse = Math.sqrt(sumSq / n); + const bias = sumSigned / n; + el.textContent = 'Err(3m) MAE ' + mae.toFixed(1) + 'F | RMSE ' + rmse.toFixed(1) + 'F | Max ' + maxAbs.toFixed(1) + 'F | Bias ' + bias.toFixed(1) + 'F'; +} + +function setAutotuneUi(tune) { + const statusEl = document.getElementById('autotune-status'); + const startBtn = document.getElementById('autotune-start-btn'); + const stopBtn = document.getElementById('autotune-stop-btn'); + const pill = document.getElementById('autotune-pill'); + if (!statusEl || !startBtn || !stopBtn) return; + if (tune && tune.active) { + statusEl.className = 'autotune-status running'; + const phase = tune.phase ? (' ' + tune.phase) : ''; + statusEl.textContent = 'Running' + phase + ' (' + tune.high_peaks + '/' + tune.cycles + ' peaks)'; + if (pill) { + pill.className = 'autotune-pill running'; + pill.textContent = 'Autotune: Running' + phase; + } + startBtn.disabled = true; + stopBtn.disabled = false; + return; + } + startBtn.disabled = false; + stopBtn.disabled = true; + if (tune && tune.last_result) { + statusEl.className = 'autotune-status done'; + statusEl.textContent = 'Complete: kP ' + tune.last_result.kP + ', kI ' + tune.last_result.kI + ', kD ' + tune.last_result.kD; + if (pill) { + pill.className = 'autotune-pill done'; + pill.textContent = 'Autotune: Complete'; + } + return; + } + statusEl.className = 'autotune-status idle'; + statusEl.textContent = (tune && tune.message) ? tune.message : 'Idle'; + if (pill) { + pill.className = 'autotune-pill idle'; + pill.textContent = 'Autotune: Idle'; + } +} + +function initChart() { + const ctx = document.getElementById('temp-chart').getContext('2d'); + chart = new Chart(ctx, { + type: 'line', + data: { datasets: [ + { label: 'Temperature (F)', borderColor: '#ff6b35', backgroundColor: 'rgba(255, 107, 53, 0.1)', borderWidth: 2, pointRadius: 0, fill: true, data: [], yAxisID: 'y' }, + { label: 'Target Setpoint (F)', borderColor: '#4ecdc4', borderWidth: 1.5, borderDash: [5, 5], pointRadius: 0, fill: false, data: [], yAxisID: 'y' }, + { label: 'Flight Trajectory (F)', borderColor: '#f39c12', borderWidth: 2, pointRadius: 0, fill: false, data: [], yAxisID: 'y' }, + { label: 'Output', borderColor: '#45b7d1', backgroundColor: 'rgba(69, 183, 209, 0.1)', borderWidth: 1, pointRadius: 0, fill: true, data: [], yAxisID: 'y1' }, + ]}, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: 'index', intersect: false }, + scales: { + x: { type: 'linear', ticks: { color: '#888', callback: function(value) { return Math.round(value) + 's'; }, maxTicksLimit: 10 }, grid: { color: 'rgba(255,255,255,0.05)' } }, + y: { type: 'linear', position: 'left', title: { display: true, text: 'Temperature (F)', color: '#aaa' }, ticks: { color: '#ff6b35' }, grid: { color: 'rgba(255,255,255,0.05)' }, suggestedMin: 0, suggestedMax: 700 }, + y1: { type: 'linear', position: 'right', title: { display: true, text: 'PID Output', color: '#aaa' }, ticks: { color: '#45b7d1' }, grid: { drawOnChartArea: false }, suggestedMin: 0 }, + }, + plugins: { legend: { labels: { color: '#ccc', boxWidth: 12 } } }, + }, + }); +} + +function initSimpleChart() { + const canvas = document.getElementById('simple-temp-chart'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + simpleChart = new Chart(ctx, { + type: 'line', + data: { datasets: [ + { label: 'Nail 1 Temp', borderColor: '#ff6b35', borderWidth: 2, pointRadius: 0, fill: false, data: [] }, + { label: 'Nail 2 Temp', borderColor: '#4ecdc4', borderWidth: 2, pointRadius: 0, fill: false, data: [] }, + { label: 'Nail 1 Target', borderColor: '#ff9f6b', borderWidth: 1, borderDash: [5, 4], pointRadius: 0, fill: false, data: [] }, + { label: 'Nail 2 Target', borderColor: '#7df7ef', borderWidth: 1, borderDash: [5, 4], pointRadius: 0, fill: false, data: [] }, + ]}, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + interaction: { mode: 'index', intersect: false }, + scales: { + x: { + type: 'linear', + ticks: { + color: '#888', + callback: function(value) { return Math.round(value) + 's'; }, + maxTicksLimit: 10, + }, + grid: { color: 'rgba(255,255,255,0.05)' }, + }, + y: { + type: 'linear', + ticks: { color: '#ccc' }, + grid: { color: 'rgba(255,255,255,0.05)' }, + suggestedMin: 0, + suggestedMax: 700, + title: { display: true, text: 'Temperature (F)', color: '#aaa' }, + }, + }, + plugins: { legend: { labels: { color: '#ccc', boxWidth: 12 } } }, + }, + }); +} + +function resetChartForNail() { + firstTimestamp = null; + lastTimestampByNail[currentNailId()] = 0; + historyBuffer = []; + if (chart) { + chart.data.datasets.forEach(function(ds) { ds.data = []; }); + chart.update('none'); + } + updateErrorStats(); +} + +function addSimpleChartData(nailId, points) { + if (!simpleChart || !points || !points.length) return; + if (simpleFirstTimestamp === null) simpleFirstTimestamp = points[0].timestamp; + const tempDatasetIndex = (nailId === 'nail2') ? 1 : 0; + const targetDatasetIndex = (nailId === 'nail2') ? 3 : 2; + + points.forEach(function(p) { + const x = p.timestamp - simpleFirstTimestamp; + simpleChart.data.datasets[tempDatasetIndex].data.push({ x: x, y: p.temp }); + simpleChart.data.datasets[targetDatasetIndex].data.push({ x: x, y: p.setpoint }); + simpleHistoryByNail[nailId].push(p); + }); + + const newest = simpleHistoryByNail[nailId].length ? simpleHistoryByNail[nailId][simpleHistoryByNail[nailId].length - 1].timestamp : 0; + simpleHistoryByNail[nailId] = simpleHistoryByNail[nailId].filter(function(p) { return p.timestamp >= (newest - 600); }); + + simpleChart.data.datasets.forEach(function(ds) { + if (ds.data.length > chartMaxPoints) ds.data = ds.data.slice(ds.data.length - chartMaxPoints); + }); + simpleChart.update('none'); +} + +function addChartData(points) { + if (!chart || !points.length) return; + if (firstTimestamp === null) firstTimestamp = points[0].timestamp; + points.forEach(function(p) { + const x = p.timestamp - firstTimestamp; + const inRamp = p.mode === 'takeoff' || p.mode === 'descent'; + const flightSp = inRamp && (typeof p.flight_setpoint === 'number') ? p.flight_setpoint : null; + chart.data.datasets[0].data.push({ x: x, y: p.temp }); + chart.data.datasets[1].data.push({ x: x, y: p.setpoint }); + chart.data.datasets[2].data.push({ x: x, y: flightSp }); + chart.data.datasets[3].data.push({ x: x, y: p.output }); + historyBuffer.push(p); + }); + const newestTs = historyBuffer.length ? historyBuffer[historyBuffer.length - 1].timestamp : 0; + historyBuffer = historyBuffer.filter(function(p) { return p.timestamp >= (newestTs - 600); }); + chart.data.datasets.forEach(function(ds) { + if (ds.data.length > chartMaxPoints) ds.data = ds.data.slice(ds.data.length - chartMaxPoints); + }); + chart.update('none'); + updateErrorStats(); +} + +async function fetchJSON(url, options) { + try { + const resp = await fetch(url, options); + const payload = await resp.json(); + if (!resp.ok) throw new Error(payload.error || ('HTTP ' + resp.status)); + lastApiError = ''; + return payload; + } catch (e) { + console.error('API error:', url, e); + lastApiError = String(e); + if (String(e).indexOf('HTTP') < 0) setConnectionStatus(false); + return null; + } +} + +async function apiPost(path, payload, nailId) { + const body = Object.assign({}, payload || {}, { nail: nailId || currentNailId() }); + return fetchJSON(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function renderPresets(presets) { + const container = document.getElementById('presets-container'); + if (!container) return; + const buttons = Object.entries(presets || {}).map(function(entry) { + const name = entry[0]; + const temp = entry[1]; + return ''; + }).join(''); + container.innerHTML = buttons; +} + +function renderAdvanced(status) { + if (!status) return; + const tempEl = document.getElementById('current-temp'); + tempEl.textContent = status.temp.toFixed(1); + const delta = Math.abs(status.temp - status.setpoint); + if (!status.enabled) tempEl.className = 'temp-value'; + else if (delta < 15) tempEl.className = 'temp-value temp-good'; + else if (delta < 50) tempEl.className = 'temp-value temp-warming'; + else tempEl.className = 'temp-value temp-cold'; + + const effective = (typeof status.effective_setpoint === 'number') ? status.effective_setpoint : status.setpoint; + document.getElementById('current-setpoint').textContent = effective.toFixed(0); + + const mode = status.mode || 'grounded'; + const modePill = document.getElementById('mode-pill'); + modePill.className = 'mode-pill ' + mode; + modePill.textContent = 'Mode: ' + mode; + + const modeEta = document.getElementById('mode-eta'); + if ((mode === 'takeoff' || mode === 'descent') && typeof status.mode_eta_seconds === 'number') { + const sec = Math.max(0, Math.round(status.mode_eta_seconds)); + const mm = Math.floor(sec / 60); + const ss = sec % 60; + modeEta.textContent = (mode === 'takeoff' ? 'Time to cruise: ' : 'Time to grounded: ') + mm + 'm ' + ss + 's'; + } else { + modeEta.textContent = 'ETA: --'; + } + + const schedEta = document.getElementById('sched-eta'); + const schedEnabled = !!(status.scheduler && status.scheduler.enabled); + if (schedEnabled && typeof status.next_cutoff_seconds === 'number') { + const sec = Math.max(0, Math.round(status.next_cutoff_seconds)); + const hh = Math.floor(sec / 3600); + const mm = Math.floor((sec % 3600) / 60); + const ss = sec % 60; + schedEta.textContent = 'Time to descent: ' + hh + 'h ' + mm + 'm ' + ss + 's'; + } else if (schedEnabled) schedEta.textContent = 'Time to descent: --'; + else schedEta.textContent = 'Scheduler: off'; + + const powerBtn = document.getElementById('power-btn'); + powerBtn.textContent = status.enabled ? 'ON' : 'OFF'; + powerBtn.className = 'power-btn ' + (status.enabled ? 'on' : 'off'); + + const banner = document.getElementById('safety-banner'); + if (status.safety_tripped) { + banner.classList.remove('hidden'); + document.getElementById('safety-message').textContent = 'SAFETY TRIP: ' + status.safety_reason; + } else banner.classList.add('hidden'); + + document.getElementById('status-output').textContent = status.output.toFixed(1) + ' / ' + (status.config.loop_size_ms || '?'); + const relayEl = document.getElementById('status-relay'); + relayEl.textContent = status.relay_on ? 'ON' : 'OFF'; + relayEl.className = 'value ' + (status.relay_on ? 'relay-on' : 'relay-off'); + document.getElementById('status-uptime').textContent = status.uptime_seconds !== null ? (Math.floor(status.uptime_seconds / 60) + 'm ' + Math.floor(status.uptime_seconds % 60) + 's') : '--'; + document.getElementById('status-loops').textContent = status.loop_count; + const tcEl = document.getElementById('status-tc'); + tcEl.textContent = status.thermocouple_connected ? 'OK' : 'DISCONNECTED'; + tcEl.className = 'value ' + (status.thermocouple_connected ? 'tc-ok' : 'tc-err'); + + if (document.activeElement.id !== 'setpoint-input') document.getElementById('setpoint-input').value = status.setpoint; + if (document.activeElement.id !== 'pid-kp') document.getElementById('pid-kp').value = status.pid.kP; + if (document.activeElement.id !== 'pid-ki') document.getElementById('pid-ki').value = status.pid.kI; + if (document.activeElement.id !== 'pid-kd') document.getElementById('pid-kd').value = status.pid.kD; + if (document.activeElement.id !== 'pid-pmode') document.getElementById('pid-pmode').value = status.pid.proportional_mode || (status.pid.proportional_on_measurement ? 'measurement' : 'error'); + if (document.activeElement.id !== 'control-loop-size') document.getElementById('control-loop-size').value = status.config.loop_size_ms; + if (document.activeElement.id !== 'control-sleep-time') document.getElementById('control-sleep-time').value = status.config.sleep_time; + + const flight = status.flight || {}; + if (document.activeElement.id !== 'flight-takeoff-seconds') document.getElementById('flight-takeoff-seconds').value = flight.takeoff_seconds || 90; + if (document.activeElement.id !== 'flight-descent-seconds') document.getElementById('flight-descent-seconds').value = flight.descent_seconds || 90; + if (document.activeElement.id !== 'flight-descent-target') document.getElementById('flight-descent-target').value = flight.descent_target_f || 120; + const fm = document.getElementById('flight-mode-status'); + if (fm) fm.textContent = ''; + + if (document.activeElement.id !== 'sched-enabled') document.getElementById('sched-enabled').value = status.scheduler.enabled ? 'true' : 'false'; + const incoming = (status.scheduler.cutoff_times || []).slice().sort(); + if (JSON.stringify(incoming) !== JSON.stringify(schedulerTimesByNail[currentNailId()] || [])) { + schedulerTimesByNail[currentNailId()] = incoming; + renderSchedulerTimes(); + } + + setAutotuneUi(status.autotune || {}); +} + +async function pollStatus() { + const payload = await fetchJSON('/api/status/all'); + if (!payload || !payload.nails) return; + setConnectionStatus(true); + states.nail1 = payload.nails.nail1 || states.nail1; + states.nail2 = payload.nails.nail2 || states.nail2; + if (payload.presets) renderPresets(payload.presets); + updateSimpleCards(); + renderAdvanced(states[currentNailId()]); +} + +async function pollHistoryForNail(nailId) { + const since = lastTimestampByNail[nailId] || 0; + const nailNum = nailNumFromId(nailId); + const data = await fetchJSON('/api/history?since=' + since + '&nail=' + nailNum); + if (!data || !data.length) return; + lastTimestampByNail[nailId] = data[data.length - 1].timestamp; + addSimpleChartData(nailId, data); + if (nailId === currentNailId()) { + addChartData(data); + } +} + +async function pollHistory() { + await Promise.all([ + pollHistoryForNail('nail1'), + pollHistoryForNail('nail2'), + ]); +} + +async function pollHeartbeat() { + const hb = await fetchJSON('/api/heartbeat?ts=' + Date.now()); + if (!hb || !hb.ok) { + heartbeatMisses += 1; + if (heartbeatMisses >= 2) { + setBackendStatus('reconnecting', 'Backend: Reconnecting...'); + setControlsEnabled(false); + } + return; + } + if (heartbeatMisses >= 2) showAction('Backend reconnected.', 'success', 2500); + heartbeatMisses = 0; + setConnectionStatus(true); + setControlsEnabled(true); + if (heartbeatInstanceId === null) heartbeatInstanceId = hb.instance_id; + else if (heartbeatInstanceId !== hb.instance_id) { + showAction('Backend restarted. Reloading UI...', 'info', 1500); + setTimeout(function() { window.location.reload(); }, 1200); + } +} + +async function togglePower() { + const id = currentNailId(); + const s = states[id]; + const target = !(s && s.enabled); + const result = await apiPost('/api/power', { enabled: target }, id); + if (!result) return setLastAck('power failed', false); + setLastAck('power ' + (result.enabled ? 'ON' : 'OFF') + ' (' + id + ')', true); +} + +async function simpleTogglePower(num) { + const id = nailIdFromNum(num); + const s = states[id]; + const target = !(s && s.enabled); + const result = await apiPost('/api/power', { enabled: target }, id); + if (!result) return setLastAck('power failed', false); + setLastAck('power ' + (result.enabled ? 'ON' : 'OFF') + ' (' + id + ')', true); +} + +async function simpleSetFlightMode(num, mode) { + const id = nailIdFromNum(num); + const result = await apiPost('/api/flight', { mode: mode }, id); + if (!result) { + setLastAck('mode ' + mode + ' failed', false); + return; + } + setLastAck('mode ' + mode + ' (' + id + ')', true); +} + +async function applySetpoint() { + const value = parseFloat(document.getElementById('setpoint-input').value); + if (isNaN(value)) return; + const result = await apiPost('/api/setpoint', { setpoint: value }, currentNailId()); + if (!result) return setLastAck('setpoint failed', false); + setLastAck('setpoint ' + value + 'F (' + currentNailId() + ')', true); +} + +async function simpleApplySetpoint(num) { + const id = nailIdFromNum(num); + const input = document.getElementById('simple-setpoint-' + id); + const value = parseFloat(input.value); + if (isNaN(value)) return; + const result = await apiPost('/api/setpoint', { setpoint: value }, id); + if (!result) return setLastAck('setpoint failed', false); + setLastAck('setpoint ' + value + 'F (' + id + ')', true); +} + +function adjustSetpoint(delta) { + const input = document.getElementById('setpoint-input'); + input.value = parseFloat(input.value) + delta; + applySetpoint(); +} + +async function applyPID() { + const kp = parseFloat(document.getElementById('pid-kp').value); + const ki = parseFloat(document.getElementById('pid-ki').value); + const kd = parseFloat(document.getElementById('pid-kd').value); + const pMode = document.getElementById('pid-pmode').value; + if (isNaN(kp) || isNaN(ki) || isNaN(kd)) return; + const result = await apiPost('/api/pid', { kP: kp, kI: ki, kD: kd, proportional_mode: pMode }, currentNailId()); + if (!result) return setLastAck('PID apply failed', false); + setLastAck('PID applied (' + currentNailId() + ')', true); +} + +async function applyControlTiming() { + const loopSize = parseInt(document.getElementById('control-loop-size').value, 10); + const sleepTime = parseFloat(document.getElementById('control-sleep-time').value); + if (isNaN(loopSize) || isNaN(sleepTime)) return; + let payload = { loop_size_ms: loopSize, sleep_time: sleepTime }; + let result = await apiPost('/api/control', payload, currentNailId()); + if (!result && (lastApiError || '').indexOf('confirm_extreme') >= 0) { + if (window.confirm('This timing is EXTREME and can overheat quickly. Continue anyway?')) { + payload.confirm_extreme = true; + result = await apiPost('/api/control', payload, currentNailId()); + } + } + if (!result) { + setLastAck('timing apply failed', false); + showAction(lastApiError || 'Timing update failed', 'error', 4000); + return; + } + setLastAck('timing updated (' + currentNailId() + ')', true); +} + +function applyTimingProfile(profile) { + if (profile === 'conservative') { + document.getElementById('control-loop-size').value = 3000; + document.getElementById('control-sleep-time').value = 0.4; + } else if (profile === 'balanced') { + document.getElementById('control-loop-size').value = 2500; + document.getElementById('control-sleep-time').value = 0.25; + } else if (profile === 'responsive') { + document.getElementById('control-loop-size').value = 1800; + document.getElementById('control-sleep-time').value = 0.2; + } + applyControlTiming(); +} + +async function setFlightMode(mode) { + const takeoff = parseFloat(document.getElementById('flight-takeoff-seconds').value); + const descent = parseFloat(document.getElementById('flight-descent-seconds').value); + const descentTarget = parseFloat(document.getElementById('flight-descent-target').value); + const result = await apiPost('/api/flight', { + mode: mode, + takeoff_seconds: takeoff, + descent_seconds: descent, + descent_target_f: descentTarget, + }, currentNailId()); + if (!result) { + setLastAck('mode ' + mode + ' failed', false); + return; + } + setLastAck('mode ' + mode + ' (' + currentNailId() + ')', true); +} + +async function saveSchedulerSettings() { + const id = currentNailId(); + const enabled = document.getElementById('sched-enabled').value === 'true'; + const times = (schedulerTimesByNail[id] || []).slice(); + const result = await apiPost('/api/scheduler', { enabled: enabled, cutoff_times: times }, id); + if (!result) { + setLastAck('scheduler update failed', false); + return; + } + schedulerTimesByNail[id] = (result.scheduler && result.scheduler.cutoff_times) ? result.scheduler.cutoff_times.slice() : times; + renderSchedulerTimes(); + setLastAck('scheduler updated (' + id + ')', true); +} + +async function resetPID() { + const result = await apiPost('/api/pid/reset', {}, currentNailId()); + if (!result) return setLastAck('PID reset failed', false); + setLastAck('PID reset (' + currentNailId() + ')', true); +} + +async function startAutotune() { + const target = parseFloat(document.getElementById('setpoint-input').value); + const result = await apiPost('/api/autotune/start', { setpoint: target }, currentNailId()); + if (!result) return setLastAck('autotune start failed', false); + setLastAck('autotune started (' + currentNailId() + ')', true); +} + +async function stopAutotune() { + const result = await apiPost('/api/autotune/stop', {}, currentNailId()); + if (!result) return setLastAck('autotune stop failed', false); + setLastAck('autotune stopped (' + currentNailId() + ')', true); +} + +async function applyPreset(name) { + const result = await apiPost('/api/preset/' + encodeURIComponent(name), {}, currentNailId()); + if (!result) return setLastAck('preset failed', false); + setLastAck('preset ' + name + ' (' + currentNailId() + ')', true); +} + +async function resetSafety() { + const result = await apiPost('/api/safety/reset', {}, currentNailId()); + if (!result) return setLastAck('safety reset failed', false); + setLastAck('safety reset (' + currentNailId() + ')', true); +} + +function isStandalone() { + return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; +} + +function showInstallButton(show) { + const btn = document.getElementById('install-btn'); + if (!btn) return; + btn.hidden = !show; +} + +async function installApp() { + if (!deferredInstallPrompt) { + showAction('Install prompt unavailable. Use your browser menu to install.', 'info', 5000); + return; + } + deferredInstallPrompt.prompt(); + deferredInstallPrompt = null; + showInstallButton(false); +} + +document.addEventListener('DOMContentLoaded', function() { + const setpointInput = document.getElementById('setpoint-input'); + if (setpointInput) { + setpointInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') applySetpoint(); + }); + } + + let savedMode = null; + try { + savedMode = localStorage.getItem('pinail_ui_mode'); + } catch (e) { + savedMode = null; + } + if (savedMode === 'advanced') savedMode = 'nail1'; + setUiMode(savedMode || 'nail1', false); + + const schedInput = document.getElementById('sched-time-input'); + if (schedInput) { + schedInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + addSchedulerTime(); + } + }); + schedInput.addEventListener('blur', function() { + const n = normalizeTimeString(schedInput.value); + if (n) schedInput.value = n; + }); + } + + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/static/sw.js').catch(function(err) { + console.error('Service worker register failed:', err); + }); + } + + if (isStandalone()) showInstallButton(false); +}); + +window.addEventListener('beforeinstallprompt', function(e) { + e.preventDefault(); + deferredInstallPrompt = e; + if (!isStandalone()) showInstallButton(true); +}); + +window.addEventListener('appinstalled', function() { + deferredInstallPrompt = null; + showInstallButton(false); + showAction('piNail2 installed.', 'success', 4000); +}); + +initChart(); +initSimpleChart(); +setInterval(pollStatus, pollInterval); +setInterval(pollHistory, pollInterval); +setInterval(pollHeartbeat, 2000); +setInterval(updateWallClock, 1000); +pollStatus(); +pollHistory(); +pollHeartbeat(); +updateWallClock(); + +)PINAILRAW"; + +static const char MANIFEST_JSON[] PROGMEM = R"PINAILRAW( +{ + "name": "piNail2 ESP32", + "short_name": "piNail2", + "start_url": "/", + "display": "standalone", + "background_color": "#000000", + "theme_color": "#000000" +} + +)PINAILRAW"; + +static const char SW_JS[] PROGMEM = R"PINAILRAW( +self.addEventListener('install', (e) => { self.skipWaiting(); }); +self.addEventListener('activate', (e) => { e.waitUntil(self.clients.claim()); }); + +)PINAILRAW"; + +static const char LOGO_SVG[] PROGMEM = R"PINAILRAW( +piNail2 ESP32 + +)PINAILRAW"; +