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