Initial ESP32-C3 single-nail firmware with Wi-Fi onboarding

This commit is contained in:
2026-03-13 22:38:46 +00:00
commit ef06b18bdd
5 changed files with 434 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.pio/
.vscode/
+24
View File
@@ -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`
+42
View File
@@ -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
```
+13
View File
@@ -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
+353
View File
@@ -0,0 +1,353 @@
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <WiFiManager.h>
#include <Preferences.h>
#include <ArduinoJson.h>
#include <PID_v1.h>
#include <vector>
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<HistoryPoint> 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<float>(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<unsigned long>(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<unsigned long>(outputMs));
digitalWrite(PIN_RELAY, relayOn ? HIGH : LOW);
HistoryPoint p;
p.ts = millis() / 1000;
p.temp = static_cast<float>(inputF);
p.setpoint = static_cast<float>(setpointF);
p.output = static_cast<float>(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<JsonArray>();
unsigned long since = 0;
if (server.hasArg("since")) {
since = static_cast<unsigned long>(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<float>(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<float>(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<float>(kp));
prefs.putFloat("kI", static_cast<float>(ki));
prefs.putFloat("kD", static_cast<float>(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();
}