From ef06b18bdde73c7cfbb22ca06297ed379cdc74a4 Mon Sep 17 00:00:00 2001 From: Seth Freiberg Date: Fri, 13 Mar 2026 22:38:46 +0000 Subject: [PATCH] Initial ESP32-C3 single-nail firmware with Wi-Fi onboarding --- .gitignore | 2 + HARDWARE.md | 24 ++++ README.md | 42 ++++++ platformio.ini | 13 ++ src/main.cpp | 353 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 434 insertions(+) create mode 100644 .gitignore create mode 100644 HARDWARE.md create mode 100644 README.md create mode 100644 platformio.ini create mode 100644 src/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..968a41b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.pio/ +.vscode/ diff --git a/HARDWARE.md b/HARDWARE.md new file mode 100644 index 0000000..0881bd4 --- /dev/null +++ b/HARDWARE.md @@ -0,0 +1,24 @@ +# Hardware Spec (ESP32-C3 Single Nail) + +## Controller +- MCU: ESP32-C3, 160MHz, 4MB embedded flash. +- USB: native USB Serial/JTAG. + +## Sensors / Actuation +- Thermocouple frontend: MAX6675 (K-type). +- Relay: SSR/mechanical relay, active HIGH. + +## GPIO +- `GPIO2`: relay output +- `GPIO4`: MAX6675 SCK +- `GPIO5`: MAX6675 CS +- `GPIO6`: 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` diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ede273 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# piNail ESP32-C3 (Single-Nail) + +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. +- Wi-Fi onboarding uses captive portal (`piNail-Setup` / `pinailsetup`). + +## Hardware target +- Board: ESP32-C3 DevKitM-1 class board. +- Thermocouple ADC: MAX6675 (SPI-like bitbang). +- Relay output: active HIGH relay module. + +## GPIO mapping +- Relay: `GPIO2` +- MAX6675 SCK: `GPIO4` +- MAX6675 CS: `GPIO5` +- MAX6675 SO: `GPIO6` + +## API compatibility +Implemented endpoints: +- `GET /api/status` +- `GET /api/history` +- `POST /api/power` +- `POST /api/setpoint` +- `POST /api/pid` +- `POST /api/pid/reset` +- `POST /api/safety/reset` +- `GET /api/heartbeat` + +Not implemented in this firmware: +- Scheduler and dual-nail routing. +- Autotune endpoints. +- Loop timing reconfiguration endpoint. + +## Build and flash +```bash +pio run -t upload --upload-port /dev/ttyACM0 +pio device monitor -b 115200 --port /dev/ttyACM0 +``` diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..737f489 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,13 @@ +[env:esp32-c3-devkitm-1] +platform = espressif32 +board = esp32-c3-devkitm-1 +framework = arduino +monitor_speed = 115200 +upload_speed = 460800 +build_flags = + -DARDUINO_USB_MODE=1 + -DARDUINO_USB_CDC_ON_BOOT=1 +lib_deps = + bblanchon/ArduinoJson @ ^7.0.4 + br3ttb/PID @ ^1.2.1 + tzapu/WiFiManager @ ^2.0.17 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..1b62c20 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,353 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +struct HistoryPoint { + unsigned long ts; + float temp; + float setpoint; + float output; + bool relay; +}; + +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; + +static constexpr float MAX_TEMP_F = 800.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; + +WebServer server(80); +Preferences prefs; + +double inputF = 70.0; +double outputMs = 0.0; +double setpointF = 530.0; +double kp = 113.1768; +double ki = 3.5335; +double kd = 500.0; +PID pid(&inputF, &outputMs, &setpointF, kp, ki, kd, DIRECT); + +bool enabled = false; +bool relayOn = false; +bool safetyTripped = false; +bool thermocoupleConnected = true; +String safetyReason = ""; +String mode = "grounded"; +unsigned long cycleStartMs = 0; +unsigned long lastControlTickMs = 0; +unsigned long idleSinceMs = 0; +unsigned long startMs = 0; +std::vector history; + +float readMax6675F() { + uint16_t v = 0; + digitalWrite(PIN_MAX6675_CS, LOW); + delayMicroseconds(2); + for (int i = 15; i >= 0; --i) { + digitalWrite(PIN_MAX6675_SCK, LOW); + delayMicroseconds(1); + if (digitalRead(PIN_MAX6675_SO)) { + v |= (1U << i); + } + digitalWrite(PIN_MAX6675_SCK, HIGH); + delayMicroseconds(1); + } + digitalWrite(PIN_MAX6675_CS, HIGH); + + if (v & 0x4) { + thermocoupleConnected = false; + return NAN; + } + thermocoupleConnected = true; + v >>= 3; + double c = v * 0.25; + return static_cast(c * 9.0 / 5.0 + 32.0); +} + +void tripSafety(const String& reason) { + safetyTripped = true; + safetyReason = reason; + enabled = false; + relayOn = false; + mode = "grounded"; + digitalWrite(PIN_RELAY, LOW); +} + +void updateControl() { + unsigned long now = millis(); + if (now - lastControlTickMs < CONTROL_TICK_MS) { + return; + } + lastControlTickMs = now; + + float t = readMax6675F(); + if (isnan(t)) { + tripSafety("Thermocouple disconnected"); + return; + } + inputF = t; + + if (inputF > MAX_TEMP_F) { + tripSafety("Temperature exceeds max 800F"); + return; + } + + if (!enabled || safetyTripped) { + relayOn = false; + digitalWrite(PIN_RELAY, LOW); + return; + } + + if ((now - cycleStartMs) >= LOOP_SIZE_MS) { + cycleStartMs = now; + } + + if (IDLE_SHUTOFF_MINUTES > 0) { + bool idleEligible = (!IDLE_ONLY_IN_CRUISE) || (mode == "cruise"); + bool nearSetpoint = fabs(inputF - setpointF) <= IDLE_NEAR_SETPOINT_F; + if (idleEligible && nearSetpoint) { + if (idleSinceMs == 0) { + idleSinceMs = now; + } else if ((now - idleSinceMs) > static_cast(IDLE_SHUTOFF_MINUTES) * 60000UL) { + tripSafety("Idle shutoff: within +/-8F for 30 minutes"); + return; + } + } else { + idleSinceMs = 0; + } + } + + 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)); + } +} + +void sendJson(DynamicJsonDocument& 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"); + pidObj["kP"] = kp; + pidObj["kI"] = ki; + pidObj["kD"] = kd; + pidObj["proportional_on_measurement"] = false; + pidObj["proportional_mode"] = "error"; + JsonObject flightObj = doc.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; + 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; + sendJson(doc); +} + +void handleHistory() { + DynamicJsonDocument doc(8192); + JsonArray arr = doc.to(); + unsigned long since = 0; + if (server.hasArg("since")) { + since = static_cast(server.arg("since").toInt()); + } + for (const auto& p : history) { + if (p.ts <= since) continue; + JsonObject o = arr.createNestedObject(); + o["timestamp"] = p.ts; + o["temp"] = p.temp; + o["setpoint"] = p.setpoint; + o["flight_setpoint"] = p.setpoint; + o["mode"] = mode; + o["output"] = p.output; + o["relay"] = p.relay; + } + String body; + serializeJson(arr, body); + server.send(200, "application/json", body); +} + +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 out(256); + out["enabled"] = enabled; + 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; + } + setpointF = v; + idleSinceMs = 0; + prefs.putFloat("setpoint", static_cast(setpointF)); + DynamicJsonDocument out(256); + out["setpoint"] = setpointF; + 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; + 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); + out["kP"] = kp; + out["kI"] = ki; + out["kD"] = kd; + sendJson(out); +} + +void handleSafetyReset() { + safetyTripped = false; + safetyReason = ""; + idleSinceMs = 0; + DynamicJsonDocument out(128); + out["ok"] = true; + sendJson(out); +} + +void setupRoutes() { + server.on("/", HTTP_GET, []() { + server.send(200, "text/plain", "piNail ESP32-C3 controller online"); + }); + server.on("/api/status", HTTP_GET, handleStatus); + server.on("/api/history", HTTP_GET, handleHistory); + server.on("/api/heartbeat", HTTP_GET, handleHeartbeat); + server.on("/api/power", HTTP_POST, handlePower); + server.on("/api/setpoint", HTTP_POST, handleSetpoint); + server.on("/api/pid", HTTP_POST, handlePid); + server.on("/api/pid/reset", HTTP_POST, handleSafetyReset); + server.on("/api/safety/reset", HTTP_POST, handleSafetyReset); +} + +void setup() { + pinMode(PIN_RELAY, OUTPUT); + digitalWrite(PIN_RELAY, LOW); + pinMode(PIN_MAX6675_SCK, OUTPUT); + pinMode(PIN_MAX6675_CS, OUTPUT); + pinMode(PIN_MAX6675_SO, INPUT); + digitalWrite(PIN_MAX6675_SCK, HIGH); + digitalWrite(PIN_MAX6675_CS, HIGH); + + Serial.begin(115200); + delay(300); + + 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); + + pid.SetTunings(kp, ki, kd); + pid.SetMode(AUTOMATIC); + pid.SetOutputLimits(0, LOOP_SIZE_MS); + pid.SetSampleTime(CONTROL_TICK_MS); + + WiFiManager wm; + wm.setConfigPortalTimeout(300); + bool wifiOk = wm.autoConnect("piNail-Setup", "pinailsetup"); + if (!wifiOk) { + ESP.restart(); + } + + setupRoutes(); + server.begin(); + cycleStartMs = millis(); + lastControlTickMs = 0; +} + +void loop() { + server.handleClient(); + updateControl(); +}