Initial ESP32-C3 single-nail firmware with Wi-Fi onboarding
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
.pio/
|
||||||
|
.vscode/
|
||||||
+24
@@ -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`
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user