Reset idleSinceMs on mode transitions to prevent stale timer carryover
This commit is contained in:
+429
-36
@@ -1,10 +1,12 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
|
#include <ESPmDNS.h>
|
||||||
#include <WebServer.h>
|
#include <WebServer.h>
|
||||||
#include <WiFiManager.h>
|
#include <WiFiManager.h>
|
||||||
#include <Preferences.h>
|
#include <Preferences.h>
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
#include <PID_v1.h>
|
#include <PID_v1.h>
|
||||||
|
#include <esp_system.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
@@ -14,7 +16,7 @@
|
|||||||
#include "ui_assets.h"
|
#include "ui_assets.h"
|
||||||
|
|
||||||
struct HistoryPoint {
|
struct HistoryPoint {
|
||||||
unsigned long timestamp;
|
double timestamp;
|
||||||
float temp;
|
float temp;
|
||||||
float setpoint;
|
float setpoint;
|
||||||
float flightSetpoint;
|
float flightSetpoint;
|
||||||
@@ -30,7 +32,7 @@ struct Preset {
|
|||||||
|
|
||||||
struct FlightConfig {
|
struct FlightConfig {
|
||||||
float takeoffSeconds = 300.0f;
|
float takeoffSeconds = 300.0f;
|
||||||
float descentSeconds = 300.0f;
|
float descentSeconds = 600.0f;
|
||||||
bool turbo = false;
|
bool turbo = false;
|
||||||
float descentTargetF = 120.0f;
|
float descentTargetF = 120.0f;
|
||||||
};
|
};
|
||||||
@@ -61,16 +63,25 @@ struct AutotuneState {
|
|||||||
std::vector<unsigned long> highTimes;
|
std::vector<unsigned long> highTimes;
|
||||||
};
|
};
|
||||||
|
|
||||||
static constexpr int PIN_RELAY = D10;
|
struct EventEntry {
|
||||||
static constexpr int PIN_MAX6675_SCK = D9;
|
unsigned long id;
|
||||||
static constexpr int PIN_MAX6675_CS = D8;
|
double ts;
|
||||||
static constexpr int PIN_MAX6675_SO = D7;
|
String type;
|
||||||
|
String detail;
|
||||||
|
};
|
||||||
|
|
||||||
|
static constexpr int PIN_RELAY = 10; // XIAO D10 (GPIO10)
|
||||||
|
static constexpr int PIN_MAX6675_SCK = 9; // XIAO D9 (GPIO9)
|
||||||
|
static constexpr int PIN_MAX6675_CS = 8; // XIAO D8 (GPIO8)
|
||||||
|
static constexpr int PIN_MAX6675_SO = 20; // XIAO D7 (GPIO20)
|
||||||
|
static constexpr int PIN_BOOT_BUTTON = 9;
|
||||||
|
static constexpr unsigned long WIFI_RESET_HOLD_MS = 5000;
|
||||||
|
|
||||||
static constexpr float MAX_TEMP_F = 800.0f;
|
static constexpr float MAX_TEMP_F = 800.0f;
|
||||||
static constexpr float MIN_TEMP_F = 0.0f;
|
static constexpr float MIN_TEMP_F = 0.0f;
|
||||||
static constexpr float SPIKE_THRESHOLD_F = 50.0f;
|
static constexpr float SPIKE_THRESHOLD_F = 50.0f;
|
||||||
static constexpr int IDLE_SHUTOFF_MINUTES = 30;
|
static constexpr int IDLE_SHUTOFF_MINUTES = 30;
|
||||||
static constexpr float IDLE_NEAR_SETPOINT_F = 8.0f;
|
static constexpr float IDLE_NEAR_SETPOINT_F = 1.0f;
|
||||||
static constexpr bool IDLE_ONLY_IN_CRUISE = true;
|
static constexpr bool IDLE_ONLY_IN_CRUISE = true;
|
||||||
|
|
||||||
static constexpr int LOOP_MIN_MS = 1500;
|
static constexpr int LOOP_MIN_MS = 1500;
|
||||||
@@ -89,6 +100,22 @@ double ki = 3.5335;
|
|||||||
double kd = 500.0;
|
double kd = 500.0;
|
||||||
PID pid(&inputF, &outputMs, &pidSetpointF, kp, ki, kd, DIRECT);
|
PID pid(&inputF, &outputMs, &pidSetpointF, kp, ki, kd, DIRECT);
|
||||||
|
|
||||||
|
bool hasSensorBaseline = false;
|
||||||
|
int spikeRejectStreak = 0;
|
||||||
|
double lastHistoryTs = 0.0;
|
||||||
|
|
||||||
|
bool parseRequestJson(DynamicJsonDocument& req);
|
||||||
|
void setupMdns();
|
||||||
|
double nowTimestampSec();
|
||||||
|
|
||||||
|
void factoryResetWifiAndRestart() {
|
||||||
|
WiFiManager wm;
|
||||||
|
wm.resetSettings();
|
||||||
|
WiFi.disconnect(true, true);
|
||||||
|
delay(300);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
|
||||||
bool enabled = false;
|
bool enabled = false;
|
||||||
bool relayOn = false;
|
bool relayOn = false;
|
||||||
bool safetyTripped = false;
|
bool safetyTripped = false;
|
||||||
@@ -119,8 +146,147 @@ AutotuneState autotune;
|
|||||||
|
|
||||||
std::vector<Preset> presets;
|
std::vector<Preset> presets;
|
||||||
std::vector<HistoryPoint> history;
|
std::vector<HistoryPoint> history;
|
||||||
|
std::vector<EventEntry> events;
|
||||||
|
unsigned long nextEventId = 1;
|
||||||
|
|
||||||
String instanceId;
|
String instanceId;
|
||||||
|
String mdnsHost = "pinail";
|
||||||
|
bool mdnsReady = false;
|
||||||
|
uint32_t bootCount = 0;
|
||||||
|
String resetReasonText = "unknown";
|
||||||
|
String lastPowerOffReason = "";
|
||||||
|
double lastPowerOffTs = 0.0;
|
||||||
|
|
||||||
|
bool onboardingApActive = false;
|
||||||
|
unsigned long onboardingApEndsMs = 0;
|
||||||
|
static constexpr unsigned long ONBOARDING_AP_GRACE_MS = 60000;
|
||||||
|
static constexpr const char* ONBOARDING_AP_SSID = "piNail-Setup";
|
||||||
|
static constexpr const char* ONBOARDING_AP_PASS = "pinailsetup";
|
||||||
|
|
||||||
|
String localIpString() {
|
||||||
|
IPAddress ip = WiFi.localIP();
|
||||||
|
if (ip == INADDR_NONE || ip.toString() == "0.0.0.0") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return ip.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void pushEvent(const String& type, const String& detail) {
|
||||||
|
EventEntry e;
|
||||||
|
e.id = nextEventId++;
|
||||||
|
e.ts = nowTimestampSec();
|
||||||
|
e.type = type;
|
||||||
|
e.detail = detail;
|
||||||
|
events.push_back(e);
|
||||||
|
if (events.size() > 250) {
|
||||||
|
events.erase(events.begin(), events.begin() + (events.size() - 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String resetReasonToString(esp_reset_reason_t r) {
|
||||||
|
switch (r) {
|
||||||
|
case ESP_RST_UNKNOWN: return "unknown";
|
||||||
|
case ESP_RST_POWERON: return "poweron";
|
||||||
|
case ESP_RST_EXT: return "external";
|
||||||
|
case ESP_RST_SW: return "software";
|
||||||
|
case ESP_RST_PANIC: return "panic";
|
||||||
|
case ESP_RST_INT_WDT: return "int_wdt";
|
||||||
|
case ESP_RST_TASK_WDT: return "task_wdt";
|
||||||
|
case ESP_RST_WDT: return "wdt";
|
||||||
|
case ESP_RST_DEEPSLEEP: return "deepsleep";
|
||||||
|
case ESP_RST_BROWNOUT: return "brownout";
|
||||||
|
case ESP_RST_SDIO: return "sdio";
|
||||||
|
default: return "other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordPowerOffReason(const String& reason) {
|
||||||
|
lastPowerOffReason = reason;
|
||||||
|
lastPowerOffTs = nowTimestampSec();
|
||||||
|
prefs.putString("last_off_reason", reason);
|
||||||
|
prefs.putString("last_off_ts", String(lastPowerOffTs, 3));
|
||||||
|
pushEvent("power_off", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
String onboardingHtml() {
|
||||||
|
String ip = localIpString();
|
||||||
|
if (ip.isEmpty()) ip = "(waiting for DHCP)";
|
||||||
|
String body;
|
||||||
|
body.reserve(1200);
|
||||||
|
body += "<!doctype html><html><head><meta charset='utf-8'>";
|
||||||
|
body += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
|
||||||
|
body += "<title>piNail Connected</title>";
|
||||||
|
body += "<style>body{font-family:system-ui;background:#111;color:#eee;padding:16px}";
|
||||||
|
body += ".card{max-width:720px;margin:auto;background:#1b1b1b;border:1px solid #333;border-radius:12px;padding:16px}";
|
||||||
|
body += ".k{color:#aaa;font-size:13px}.v{font-size:22px;font-weight:700;margin-bottom:10px}";
|
||||||
|
body += "a{color:#ff8c3a}.small{font-size:13px;color:#bbb}</style></head><body><div class='card'>";
|
||||||
|
body += "<h2 style='margin-top:0'>Wi-Fi Connected</h2>";
|
||||||
|
body += "<div class='k'>Device IP</div><div class='v'>" + ip + "</div>";
|
||||||
|
body += "<div class='k'>Local alias</div><div class='v'>http://" + mdnsHost + ".local/</div>";
|
||||||
|
body += "<p class='small'>This setup AP stays online briefly so you can note the address, then turns off automatically.</p>";
|
||||||
|
body += "<p><a href='/wifi/reset'>Factory reset Wi-Fi credentials</a></p>";
|
||||||
|
body += "<p><a href='http://" + mdnsHost + ".local/'>Open via alias</a><br>";
|
||||||
|
if (ip != "(waiting for DHCP)") {
|
||||||
|
body += "<a href='http://" + ip + "/'>Open via IP</a>";
|
||||||
|
}
|
||||||
|
body += "</p></div></body></html>";
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
void maybeStopOnboardingAp() {
|
||||||
|
if (!onboardingApActive) return;
|
||||||
|
if (millis() < onboardingApEndsMs) return;
|
||||||
|
WiFi.softAPdisconnect(true);
|
||||||
|
onboardingApActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void startOnboardingGraceAp() {
|
||||||
|
WiFi.mode(WIFI_AP_STA);
|
||||||
|
if (WiFi.softAP(ONBOARDING_AP_SSID, ONBOARDING_AP_PASS)) {
|
||||||
|
onboardingApActive = true;
|
||||||
|
onboardingApEndsMs = millis() + ONBOARDING_AP_GRACE_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setupMdns() {
|
||||||
|
MDNS.end();
|
||||||
|
String mac = WiFi.macAddress();
|
||||||
|
mac.replace(":", "");
|
||||||
|
String fallback = "pinail-" + mac.substring(std::max(0, static_cast<int>(mac.length()) - 4));
|
||||||
|
|
||||||
|
if (MDNS.begin(mdnsHost.c_str())) {
|
||||||
|
mdnsReady = true;
|
||||||
|
} else if (MDNS.begin(fallback.c_str())) {
|
||||||
|
mdnsHost = fallback;
|
||||||
|
mdnsReady = true;
|
||||||
|
} else {
|
||||||
|
mdnsReady = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MDNS.addService("http", "tcp", 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool handleBootWifiResetHold() {
|
||||||
|
pinMode(PIN_BOOT_BUTTON, INPUT_PULLUP);
|
||||||
|
if (digitalRead(PIN_BOOT_BUTTON) != LOW) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long start = millis();
|
||||||
|
while ((millis() - start) < WIFI_RESET_HOLD_MS) {
|
||||||
|
if (digitalRead(PIN_BOOT_BUTTON) != LOW) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
delay(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
WiFiManager wm;
|
||||||
|
wm.resetSettings();
|
||||||
|
delay(200);
|
||||||
|
ESP.restart();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
unsigned long nowEpochSec() {
|
unsigned long nowEpochSec() {
|
||||||
time_t now = time(nullptr);
|
time_t now = time(nullptr);
|
||||||
@@ -130,6 +296,10 @@ unsigned long nowEpochSec() {
|
|||||||
return millis() / 1000;
|
return millis() / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double nowTimestampSec() {
|
||||||
|
return static_cast<double>(millis()) / 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
int controlTickMs() {
|
int controlTickMs() {
|
||||||
int tick = static_cast<int>(sleepTimeS * 1000.0f);
|
int tick = static_cast<int>(sleepTimeS * 1000.0f);
|
||||||
if (tick < 80) tick = 80;
|
if (tick < 80) tick = 80;
|
||||||
@@ -272,6 +442,8 @@ float readMax6675F() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void tripSafety(const String& reason) {
|
void tripSafety(const String& reason) {
|
||||||
|
recordPowerOffReason(String("safety: ") + reason);
|
||||||
|
pushEvent("safety_trip", reason);
|
||||||
safetyTripped = true;
|
safetyTripped = true;
|
||||||
safetyReason = reason;
|
safetyReason = reason;
|
||||||
enabled = false;
|
enabled = false;
|
||||||
@@ -285,6 +457,8 @@ void enterMode(const String& newMode) {
|
|||||||
modeSinceMs = millis();
|
modeSinceMs = millis();
|
||||||
modeStartTemp = static_cast<float>(inputF);
|
modeStartTemp = static_cast<float>(inputF);
|
||||||
modeStartEpochSec = nowEpochSec();
|
modeStartEpochSec = nowEpochSec();
|
||||||
|
// Reset idle timer on any mode transition so prior accumulation doesn't carry over
|
||||||
|
idleSinceMs = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
void startTakeoff() {
|
void startTakeoff() {
|
||||||
@@ -304,14 +478,17 @@ void startDescent() {
|
|||||||
enterMode("descent");
|
enterMode("descent");
|
||||||
}
|
}
|
||||||
|
|
||||||
void setPower(bool on) {
|
void setPower(bool on, const String& offReason = "manual/api power off") {
|
||||||
if (!on) {
|
if (!on) {
|
||||||
|
recordPowerOffReason(offReason);
|
||||||
|
pushEvent("power", "set false");
|
||||||
enabled = false;
|
enabled = false;
|
||||||
relayOn = false;
|
relayOn = false;
|
||||||
digitalWrite(PIN_RELAY, LOW);
|
digitalWrite(PIN_RELAY, LOW);
|
||||||
enterMode("grounded");
|
enterMode("grounded");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
pushEvent("power", "set true");
|
||||||
safetyTripped = false;
|
safetyTripped = false;
|
||||||
safetyReason = "";
|
safetyReason = "";
|
||||||
if (mode == "grounded") {
|
if (mode == "grounded") {
|
||||||
@@ -344,7 +521,8 @@ void updateFlightSetpoint(unsigned long nowMs) {
|
|||||||
float p = (nowMs - modeSinceMs) / (duration * 1000.0f);
|
float p = (nowMs - modeSinceMs) / (duration * 1000.0f);
|
||||||
if (p >= 1.0f) {
|
if (p >= 1.0f) {
|
||||||
effectiveSetpointF = flight.descentTargetF;
|
effectiveSetpointF = flight.descentTargetF;
|
||||||
setPower(false);
|
pushEvent("flight", "descent complete -> grounded");
|
||||||
|
setPower(false, "flight descent complete");
|
||||||
} else {
|
} else {
|
||||||
effectiveSetpointF = modeStartTemp + (flight.descentTargetF - modeStartTemp) * p;
|
effectiveSetpointF = modeStartTemp + (flight.descentTargetF - modeStartTemp) * p;
|
||||||
}
|
}
|
||||||
@@ -415,6 +593,7 @@ void updateScheduler() {
|
|||||||
if (!parseHm(t, h, m)) continue;
|
if (!parseHm(t, h, m)) continue;
|
||||||
if (h == nowH && m == nowM) {
|
if (h == nowH && m == nowM) {
|
||||||
schedulerLastTriggerMinute = minuteKey;
|
schedulerLastTriggerMinute = minuteKey;
|
||||||
|
pushEvent("scheduler", String("cutoff triggered at ") + t);
|
||||||
startDescent();
|
startDescent();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -429,6 +608,7 @@ String classifyTimingProfile(int loopMs, float sleepS) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void stopAutotune(const String& message) {
|
void stopAutotune(const String& message) {
|
||||||
|
pushEvent("autotune", message);
|
||||||
autotune.active = false;
|
autotune.active = false;
|
||||||
autotune.message = message;
|
autotune.message = message;
|
||||||
autotune.phase = "";
|
autotune.phase = "";
|
||||||
@@ -480,6 +660,7 @@ void completeAutotune() {
|
|||||||
autotune.resKD = kd;
|
autotune.resKD = kd;
|
||||||
autotune.avgHigh = avgHigh;
|
autotune.avgHigh = avgHigh;
|
||||||
autotune.avgLow = avgLow;
|
autotune.avgLow = avgLow;
|
||||||
|
pushEvent("autotune", "complete");
|
||||||
stopAutotune("Autotune complete");
|
stopAutotune("Autotune complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +700,12 @@ void updateAutotune(unsigned long nowMs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void pushHistory(unsigned long ts) {
|
void pushHistory(double ts) {
|
||||||
|
if (ts <= lastHistoryTs) {
|
||||||
|
ts = lastHistoryTs + 0.001;
|
||||||
|
}
|
||||||
|
lastHistoryTs = ts;
|
||||||
|
|
||||||
HistoryPoint p;
|
HistoryPoint p;
|
||||||
p.timestamp = ts;
|
p.timestamp = ts;
|
||||||
p.temp = static_cast<float>(inputF);
|
p.temp = static_cast<float>(inputF);
|
||||||
@@ -548,10 +734,20 @@ void updateControl() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fabs(t - inputF) > SPIKE_THRESHOLD_F && inputF > 0) {
|
if (!hasSensorBaseline) {
|
||||||
t = static_cast<float>(inputF);
|
|
||||||
}
|
|
||||||
inputF = t;
|
inputF = t;
|
||||||
|
hasSensorBaseline = true;
|
||||||
|
spikeRejectStreak = 0;
|
||||||
|
} else if (fabs(t - inputF) > SPIKE_THRESHOLD_F) {
|
||||||
|
spikeRejectStreak++;
|
||||||
|
if (spikeRejectStreak >= 3) {
|
||||||
|
inputF = t;
|
||||||
|
spikeRejectStreak = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
inputF = t;
|
||||||
|
spikeRejectStreak = 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (inputF > MAX_TEMP_F) {
|
if (inputF > MAX_TEMP_F) {
|
||||||
tripSafety("Temperature exceeds max 800F");
|
tripSafety("Temperature exceeds max 800F");
|
||||||
@@ -562,7 +758,7 @@ void updateControl() {
|
|||||||
relayOn = false;
|
relayOn = false;
|
||||||
outputMs = 0;
|
outputMs = 0;
|
||||||
digitalWrite(PIN_RELAY, LOW);
|
digitalWrite(PIN_RELAY, LOW);
|
||||||
pushHistory(nowEpochSec());
|
pushHistory(nowTimestampSec());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +772,7 @@ void updateControl() {
|
|||||||
if (idleSinceMs == 0) {
|
if (idleSinceMs == 0) {
|
||||||
idleSinceMs = nowMs;
|
idleSinceMs = nowMs;
|
||||||
} else if ((nowMs - idleSinceMs) > static_cast<unsigned long>(IDLE_SHUTOFF_MINUTES) * 60000UL) {
|
} else if ((nowMs - idleSinceMs) > static_cast<unsigned long>(IDLE_SHUTOFF_MINUTES) * 60000UL) {
|
||||||
tripSafety("Idle shutoff: within +/-8F for 30 minutes");
|
tripSafety("Idle shutoff: within +/-1F for 30 minutes");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -600,7 +796,7 @@ void updateControl() {
|
|||||||
digitalWrite(PIN_RELAY, relayOn ? HIGH : LOW);
|
digitalWrite(PIN_RELAY, relayOn ? HIGH : LOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
pushHistory(nowEpochSec());
|
pushHistory(nowTimestampSec());
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendError(int code, const String& msg) {
|
void sendError(int code, const String& msg) {
|
||||||
@@ -608,12 +804,18 @@ void sendError(int code, const String& msg) {
|
|||||||
doc["error"] = msg;
|
doc["error"] = msg;
|
||||||
String out;
|
String out;
|
||||||
serializeJson(doc, out);
|
serializeJson(doc, out);
|
||||||
|
server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
server.sendHeader("Pragma", "no-cache");
|
||||||
|
server.sendHeader("Expires", "0");
|
||||||
server.send(code, "application/json", out);
|
server.send(code, "application/json", out);
|
||||||
}
|
}
|
||||||
|
|
||||||
void sendJson(const JsonDocument& doc) {
|
void sendJson(const JsonDocument& doc) {
|
||||||
String body;
|
String body;
|
||||||
serializeJson(doc, body);
|
serializeJson(doc, body);
|
||||||
|
server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
server.sendHeader("Pragma", "no-cache");
|
||||||
|
server.sendHeader("Expires", "0");
|
||||||
server.send(200, "application/json", body);
|
server.send(200, "application/json", body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,6 +835,13 @@ void appendStatus(JsonObject obj, const String& nailName) {
|
|||||||
obj["safety_reason"] = safetyReason;
|
obj["safety_reason"] = safetyReason;
|
||||||
obj["thermocouple_connected"] = thermocoupleConnected;
|
obj["thermocouple_connected"] = thermocoupleConnected;
|
||||||
obj["instance_id"] = instanceId;
|
obj["instance_id"] = instanceId;
|
||||||
|
obj["ip"] = localIpString();
|
||||||
|
obj["mdns_host"] = mdnsHost;
|
||||||
|
obj["mdns_url"] = String("http://") + mdnsHost + ".local/";
|
||||||
|
obj["boot_count"] = bootCount;
|
||||||
|
obj["reset_reason"] = resetReasonText;
|
||||||
|
obj["last_power_off_reason"] = lastPowerOffReason;
|
||||||
|
obj["last_power_off_ts"] = lastPowerOffTs;
|
||||||
|
|
||||||
float modeEta = secondsToModeCompletion(nowMs);
|
float modeEta = secondsToModeCompletion(nowMs);
|
||||||
if (modeEta >= 0) obj["mode_eta_seconds"] = modeEta;
|
if (modeEta >= 0) obj["mode_eta_seconds"] = modeEta;
|
||||||
@@ -719,6 +928,11 @@ void handleHeartbeat() {
|
|||||||
doc["ok"] = true;
|
doc["ok"] = true;
|
||||||
doc["instance_id"] = instanceId;
|
doc["instance_id"] = instanceId;
|
||||||
doc["ts"] = nowEpochSec();
|
doc["ts"] = nowEpochSec();
|
||||||
|
doc["ip"] = localIpString();
|
||||||
|
doc["mdns_host"] = mdnsHost;
|
||||||
|
doc["mdns_url"] = String("http://") + mdnsHost + ".local/";
|
||||||
|
doc["boot_count"] = bootCount;
|
||||||
|
doc["reset_reason"] = resetReasonText;
|
||||||
JsonObject ca = doc.createNestedObject("controller_alive");
|
JsonObject ca = doc.createNestedObject("controller_alive");
|
||||||
ca["nail1"] = true;
|
ca["nail1"] = true;
|
||||||
ca["nail2"] = true;
|
ca["nail2"] = true;
|
||||||
@@ -728,23 +942,166 @@ void handleHeartbeat() {
|
|||||||
sendJson(doc);
|
sendJson(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void handleOnboardingStatus() {
|
||||||
|
DynamicJsonDocument out(512);
|
||||||
|
out["connected"] = WiFi.status() == WL_CONNECTED;
|
||||||
|
out["ip"] = localIpString();
|
||||||
|
out["mdns_host"] = mdnsHost;
|
||||||
|
out["mdns_url"] = String("http://") + mdnsHost + ".local/";
|
||||||
|
out["ap_active"] = onboardingApActive;
|
||||||
|
out["ap_ssid"] = ONBOARDING_AP_SSID;
|
||||||
|
out["ap_ip"] = WiFi.softAPIP().toString();
|
||||||
|
long secondsLeft = onboardingApActive ? static_cast<long>((onboardingApEndsMs - millis()) / 1000UL) : 0;
|
||||||
|
if (secondsLeft < 0) secondsLeft = 0;
|
||||||
|
out["ap_grace_seconds_remaining"] = secondsLeft;
|
||||||
|
sendJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleWifiResetPage() {
|
||||||
|
String body;
|
||||||
|
body.reserve(1200);
|
||||||
|
body += "<!doctype html><html><head><meta charset='utf-8'>";
|
||||||
|
body += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
|
||||||
|
body += "<title>Factory Reset Wi-Fi</title>";
|
||||||
|
body += "<style>body{font-family:system-ui;background:#111;color:#eee;padding:16px}";
|
||||||
|
body += ".card{max-width:640px;margin:auto;background:#1b1b1b;border:1px solid #333;border-radius:12px;padding:16px}";
|
||||||
|
body += "button{background:#8e2a2a;color:#fff;border:0;border-radius:8px;padding:10px 14px;cursor:pointer}";
|
||||||
|
body += "a{color:#ff8c3a}.small{font-size:13px;color:#bbb}</style></head><body><div class='card'>";
|
||||||
|
body += "<h2 style='margin-top:0'>Factory Reset Wi-Fi</h2>";
|
||||||
|
body += "<p>This clears saved Wi-Fi credentials and restarts the controller into onboarding mode.</p>";
|
||||||
|
body += "<p class='small'>Control settings and presets are preserved.</p>";
|
||||||
|
body += "<button onclick=\"if(confirm('Reset Wi-Fi credentials and reboot?')){fetch('/api/wifi/reset',{method:'POST'}).then(()=>{document.body.innerHTML='Rebooting... Reconnect to piNail-Setup in a few seconds.';});}\">Reset Wi-Fi Now</button>";
|
||||||
|
body += "<p style='margin-top:12px'><a href='/'>Back</a></p>";
|
||||||
|
body += "</div></body></html>";
|
||||||
|
server.send(200, "text/html", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleWifiResetApi() {
|
||||||
|
DynamicJsonDocument out(256);
|
||||||
|
out["ok"] = true;
|
||||||
|
out["message"] = "Resetting Wi-Fi credentials and rebooting";
|
||||||
|
sendJson(out);
|
||||||
|
pushEvent("wifi", "factory reset requested");
|
||||||
|
delay(250);
|
||||||
|
factoryResetWifiAndRestart();
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleEvents() {
|
||||||
|
unsigned long sinceId = 0;
|
||||||
|
if (server.hasArg("since_id")) {
|
||||||
|
sinceId = static_cast<unsigned long>(server.arg("since_id").toInt());
|
||||||
|
}
|
||||||
|
int limit = 100;
|
||||||
|
if (server.hasArg("limit")) {
|
||||||
|
limit = server.arg("limit").toInt();
|
||||||
|
}
|
||||||
|
if (limit < 10) limit = 10;
|
||||||
|
if (limit > 250) limit = 250;
|
||||||
|
|
||||||
|
std::vector<const EventEntry*> outEvents;
|
||||||
|
outEvents.reserve(limit);
|
||||||
|
for (const auto& e : events) {
|
||||||
|
if (e.id <= sinceId) continue;
|
||||||
|
outEvents.push_back(&e);
|
||||||
|
if (static_cast<int>(outEvents.size()) > limit) {
|
||||||
|
outEvents.erase(outEvents.begin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DynamicJsonDocument doc(128 + outEvents.size() * 120);
|
||||||
|
JsonArray arr = doc.to<JsonArray>();
|
||||||
|
for (const auto* e : outEvents) {
|
||||||
|
JsonObject o = arr.createNestedObject();
|
||||||
|
o["id"] = e->id;
|
||||||
|
o["ts"] = e->ts;
|
||||||
|
o["type"] = e->type;
|
||||||
|
o["detail"] = e->detail;
|
||||||
|
}
|
||||||
|
sendJson(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendAppJsWithExtras() {
|
||||||
|
String body = FPSTR(APP_JS);
|
||||||
|
body += R"JS(
|
||||||
|
;
|
||||||
|
(function() {
|
||||||
|
function addWifiResetButton() {
|
||||||
|
if (document.getElementById('wifi-factory-reset-btn')) return;
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.id = 'wifi-factory-reset-btn';
|
||||||
|
btn.textContent = 'Factory Reset Wi-Fi';
|
||||||
|
btn.style.position = 'fixed';
|
||||||
|
btn.style.right = '12px';
|
||||||
|
btn.style.bottom = '12px';
|
||||||
|
btn.style.zIndex = '9999';
|
||||||
|
btn.style.background = '#8e2a2a';
|
||||||
|
btn.style.color = '#fff';
|
||||||
|
btn.style.border = '0';
|
||||||
|
btn.style.borderRadius = '8px';
|
||||||
|
btn.style.padding = '10px 12px';
|
||||||
|
btn.style.fontWeight = '700';
|
||||||
|
btn.style.cursor = 'pointer';
|
||||||
|
btn.onclick = function() {
|
||||||
|
var ok = confirm('Factory reset Wi-Fi credentials and reboot?\n\nYou will need to reconnect to piNail-Setup.');
|
||||||
|
if (!ok) return;
|
||||||
|
fetch('/api/wifi/reset', { method: 'POST' })
|
||||||
|
.then(function() {
|
||||||
|
alert('Rebooting. Reconnect to piNail-Setup in a few seconds.');
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
alert('Reset request failed. Try again from /wifi/reset');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', addWifiResetButton);
|
||||||
|
} else {
|
||||||
|
addWifiResetButton();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
)JS";
|
||||||
|
server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
server.sendHeader("Pragma", "no-cache");
|
||||||
|
server.sendHeader("Expires", "0");
|
||||||
|
server.send(200, "application/javascript", body);
|
||||||
|
}
|
||||||
|
|
||||||
void handleHistory() {
|
void handleHistory() {
|
||||||
float since = 0;
|
double since = 0;
|
||||||
if (server.hasArg("since")) {
|
if (server.hasArg("since")) {
|
||||||
since = server.arg("since").toFloat();
|
since = server.arg("since").toFloat();
|
||||||
}
|
}
|
||||||
DynamicJsonDocument doc(24576);
|
int limit = 240;
|
||||||
JsonArray arr = doc.to<JsonArray>();
|
if (server.hasArg("limit")) {
|
||||||
|
limit = server.arg("limit").toInt();
|
||||||
|
}
|
||||||
|
if (limit < 20) limit = 20;
|
||||||
|
if (limit > 500) limit = 500;
|
||||||
|
|
||||||
|
std::vector<const HistoryPoint*> points;
|
||||||
|
points.reserve(limit);
|
||||||
for (const auto& p : history) {
|
for (const auto& p : history) {
|
||||||
if (p.timestamp <= since) continue;
|
if (p.timestamp <= since) continue;
|
||||||
|
points.push_back(&p);
|
||||||
|
if (static_cast<int>(points.size()) > limit) {
|
||||||
|
points.erase(points.begin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t cap = 512 + points.size() * 128;
|
||||||
|
DynamicJsonDocument doc(cap);
|
||||||
|
JsonArray arr = doc.to<JsonArray>();
|
||||||
|
for (const auto* p : points) {
|
||||||
JsonObject o = arr.createNestedObject();
|
JsonObject o = arr.createNestedObject();
|
||||||
o["timestamp"] = p.timestamp;
|
o["timestamp"] = p->timestamp;
|
||||||
o["temp"] = p.temp;
|
o["temp"] = p->temp;
|
||||||
o["setpoint"] = p.setpoint;
|
o["setpoint"] = p->setpoint;
|
||||||
o["flight_setpoint"] = p.flightSetpoint;
|
o["flight_setpoint"] = p->flightSetpoint;
|
||||||
o["mode"] = p.mode;
|
o["mode"] = p->mode;
|
||||||
o["output"] = p.output;
|
o["output"] = p->output;
|
||||||
o["relay"] = p.relay;
|
o["relay"] = p->relay;
|
||||||
}
|
}
|
||||||
sendJson(doc);
|
sendJson(doc);
|
||||||
}
|
}
|
||||||
@@ -762,8 +1119,12 @@ bool parseRequestJson(DynamicJsonDocument& req) {
|
|||||||
void handlePower() {
|
void handlePower() {
|
||||||
DynamicJsonDocument req(512);
|
DynamicJsonDocument req(512);
|
||||||
if (!parseRequestJson(req)) return sendError(400, "invalid json");
|
if (!parseRequestJson(req)) return sendError(400, "invalid json");
|
||||||
bool target = req["enabled"].is<bool>() ? req["enabled"].as<bool>() : !enabled;
|
if (!req["enabled"].is<bool>()) {
|
||||||
setPower(target);
|
return sendError(400, "Missing boolean 'enabled' field");
|
||||||
|
}
|
||||||
|
bool target = req["enabled"].as<bool>();
|
||||||
|
pushEvent("api_power", String("request enabled=") + (target ? "true" : "false"));
|
||||||
|
setPower(target, "api power off");
|
||||||
DynamicJsonDocument out(256);
|
DynamicJsonDocument out(256);
|
||||||
out["enabled"] = enabled;
|
out["enabled"] = enabled;
|
||||||
out["ok"] = true;
|
out["ok"] = true;
|
||||||
@@ -929,6 +1290,7 @@ void handleFlight() {
|
|||||||
|
|
||||||
if (req["mode"].is<const char*>()) {
|
if (req["mode"].is<const char*>()) {
|
||||||
String newMode = req["mode"].as<const char*>();
|
String newMode = req["mode"].as<const char*>();
|
||||||
|
pushEvent("api_flight", String("mode=") + newMode);
|
||||||
if (newMode == "takeoff") startTakeoff();
|
if (newMode == "takeoff") startTakeoff();
|
||||||
else if (newMode == "cruise") startCruise();
|
else if (newMode == "cruise") startCruise();
|
||||||
else if (newMode == "descent") startDescent();
|
else if (newMode == "descent") startDescent();
|
||||||
@@ -966,6 +1328,7 @@ void handleScheduler() {
|
|||||||
scheduler.cutoffTimes = times;
|
scheduler.cutoffTimes = times;
|
||||||
}
|
}
|
||||||
saveSchedulerTimes();
|
saveSchedulerTimes();
|
||||||
|
pushEvent("scheduler", String("updated enabled=") + (scheduler.enabled ? "true" : "false"));
|
||||||
|
|
||||||
DynamicJsonDocument out(1024);
|
DynamicJsonDocument out(1024);
|
||||||
out["ok"] = true;
|
out["ok"] = true;
|
||||||
@@ -1164,11 +1527,26 @@ void handleNotFound() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setupRoutes() {
|
void setupRoutes() {
|
||||||
server.on("/", HTTP_GET, []() { server.send_P(200, "text/html", INDEX_HTML); });
|
server.on("/", HTTP_GET, []() {
|
||||||
|
if (onboardingApActive && server.hostHeader().indexOf("192.168.4.1") >= 0) {
|
||||||
|
String page = onboardingHtml();
|
||||||
|
server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
server.sendHeader("Pragma", "no-cache");
|
||||||
|
server.sendHeader("Expires", "0");
|
||||||
|
server.send(200, "text/html", page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
|
||||||
|
server.sendHeader("Pragma", "no-cache");
|
||||||
|
server.sendHeader("Expires", "0");
|
||||||
|
server.send_P(200, "text/html", INDEX_HTML);
|
||||||
|
});
|
||||||
|
server.on("/wifi/reset", HTTP_GET, handleWifiResetPage);
|
||||||
server.on("/api/status", HTTP_GET, handleStatus);
|
server.on("/api/status", HTTP_GET, handleStatus);
|
||||||
server.on("/api/status/all", HTTP_GET, handleStatusAll);
|
server.on("/api/status/all", HTTP_GET, handleStatusAll);
|
||||||
server.on("/api/heartbeat", HTTP_GET, handleHeartbeat);
|
server.on("/api/heartbeat", HTTP_GET, handleHeartbeat);
|
||||||
server.on("/api/history", HTTP_GET, handleHistory);
|
server.on("/api/history", HTTP_GET, handleHistory);
|
||||||
|
server.on("/api/events", HTTP_GET, handleEvents);
|
||||||
server.on("/api/power", HTTP_POST, handlePower);
|
server.on("/api/power", HTTP_POST, handlePower);
|
||||||
server.on("/api/setpoint", HTTP_POST, handleSetpoint);
|
server.on("/api/setpoint", HTTP_POST, handleSetpoint);
|
||||||
server.on("/api/control", HTTP_POST, handleControl);
|
server.on("/api/control", HTTP_POST, handleControl);
|
||||||
@@ -1180,12 +1558,14 @@ void setupRoutes() {
|
|||||||
server.on("/api/presets", HTTP_GET, handlePresetsGet);
|
server.on("/api/presets", HTTP_GET, handlePresetsGet);
|
||||||
server.on("/api/presets", HTTP_POST, handlePresetsPost);
|
server.on("/api/presets", HTTP_POST, handlePresetsPost);
|
||||||
server.on("/api/config", HTTP_GET, handleConfigGet);
|
server.on("/api/config", HTTP_GET, handleConfigGet);
|
||||||
|
server.on("/api/onboarding", HTTP_GET, handleOnboardingStatus);
|
||||||
|
server.on("/api/wifi/reset", HTTP_POST, handleWifiResetApi);
|
||||||
server.on("/api/autotune", HTTP_GET, handleAutotuneStatus);
|
server.on("/api/autotune", HTTP_GET, handleAutotuneStatus);
|
||||||
server.on("/api/autotune/start", HTTP_POST, handleAutotuneStart);
|
server.on("/api/autotune/start", HTTP_POST, handleAutotuneStart);
|
||||||
server.on("/api/autotune/stop", HTTP_POST, handleAutotuneStop);
|
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/style.css", HTTP_GET, []() { server.send_P(200, "text/css", STYLE_CSS); });
|
||||||
server.on("/static/app.js", HTTP_GET, []() { server.send_P(200, "application/javascript", APP_JS); });
|
server.on("/static/app.js", HTTP_GET, sendAppJsWithExtras);
|
||||||
server.on("/static/manifest.webmanifest", HTTP_GET, []() { server.send_P(200, "application/manifest+json", MANIFEST_JSON); });
|
server.on("/static/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/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/pinail_logo.svg", HTTP_GET, []() { server.send_P(200, "image/svg+xml", LOGO_SVG); });
|
||||||
@@ -1195,6 +1575,15 @@ void setupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setup() {
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(250);
|
||||||
|
bootMs = millis();
|
||||||
|
instanceId = String(bootMs);
|
||||||
|
|
||||||
|
if (handleBootWifiResetHold()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
pinMode(PIN_RELAY, OUTPUT);
|
pinMode(PIN_RELAY, OUTPUT);
|
||||||
digitalWrite(PIN_RELAY, LOW);
|
digitalWrite(PIN_RELAY, LOW);
|
||||||
pinMode(PIN_MAX6675_SCK, OUTPUT);
|
pinMode(PIN_MAX6675_SCK, OUTPUT);
|
||||||
@@ -1203,12 +1592,12 @@ void setup() {
|
|||||||
digitalWrite(PIN_MAX6675_SCK, HIGH);
|
digitalWrite(PIN_MAX6675_SCK, HIGH);
|
||||||
digitalWrite(PIN_MAX6675_CS, HIGH);
|
digitalWrite(PIN_MAX6675_CS, HIGH);
|
||||||
|
|
||||||
Serial.begin(115200);
|
|
||||||
delay(250);
|
|
||||||
bootMs = millis();
|
|
||||||
instanceId = String(bootMs);
|
|
||||||
|
|
||||||
prefs.begin("pinail", false);
|
prefs.begin("pinail", false);
|
||||||
|
resetReasonText = resetReasonToString(esp_reset_reason());
|
||||||
|
bootCount = prefs.getUInt("boot_count", 0) + 1;
|
||||||
|
prefs.putUInt("boot_count", bootCount);
|
||||||
|
lastPowerOffReason = prefs.getString("last_off_reason", "");
|
||||||
|
lastPowerOffTs = prefs.getString("last_off_ts", "0").toFloat();
|
||||||
setpointF = prefs.getFloat("setpoint", 530.0f);
|
setpointF = prefs.getFloat("setpoint", 530.0f);
|
||||||
kp = prefs.getFloat("kP", 113.1768f);
|
kp = prefs.getFloat("kP", 113.1768f);
|
||||||
ki = prefs.getFloat("kI", 3.5335f);
|
ki = prefs.getFloat("kI", 3.5335f);
|
||||||
@@ -1219,7 +1608,7 @@ void setup() {
|
|||||||
if (sleepTimeS < SLEEP_MIN_S || sleepTimeS > SLEEP_MAX_S) sleepTimeS = 0.2f;
|
if (sleepTimeS < SLEEP_MIN_S || sleepTimeS > SLEEP_MAX_S) sleepTimeS = 0.2f;
|
||||||
|
|
||||||
flight.takeoffSeconds = prefs.getFloat("takeoff_s", 300.0f);
|
flight.takeoffSeconds = prefs.getFloat("takeoff_s", 300.0f);
|
||||||
flight.descentSeconds = prefs.getFloat("descent_s", 300.0f);
|
flight.descentSeconds = prefs.getFloat("descent_s", 600.0f);
|
||||||
flight.descentTargetF = prefs.getFloat("descent_t", 120.0f);
|
flight.descentTargetF = prefs.getFloat("descent_t", 120.0f);
|
||||||
flight.turbo = prefs.getBool("turbo", false);
|
flight.turbo = prefs.getBool("turbo", false);
|
||||||
|
|
||||||
@@ -1237,6 +1626,9 @@ void setup() {
|
|||||||
if (!wifiOk) {
|
if (!wifiOk) {
|
||||||
ESP.restart();
|
ESP.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupMdns();
|
||||||
|
startOnboardingGraceAp();
|
||||||
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
|
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
|
||||||
|
|
||||||
setupRoutes();
|
setupRoutes();
|
||||||
@@ -1247,6 +1639,7 @@ void setup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
|
maybeStopOnboardingAp();
|
||||||
server.handleClient();
|
server.handleClient();
|
||||||
updateControl();
|
updateControl();
|
||||||
delay(2);
|
delay(2);
|
||||||
|
|||||||
Reference in New Issue
Block a user