This commit is contained in:
2026-06-14 18:50:09 +02:00
parent dc39b3dc53
commit 168d740595
2 changed files with 225 additions and 82 deletions

View File

@@ -13,4 +13,6 @@ platform = espressif32
board = esp32-s3-devkitc-1 board = esp32-s3-devkitc-1
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
lib_deps = tzapu/WiFiManager@^2.0.17 lib_deps =
tzapu/WiFiManager@^2.0.17
bblanchon/ArduinoJson@^7.0.0

View File

@@ -3,37 +3,97 @@
#include <ESPmDNS.h> #include <ESPmDNS.h>
#include <LittleFS.h> #include <LittleFS.h>
#include <WebServer.h> #include <WebServer.h>
#include <ArduinoJson.h>
#define HOSTNAME_DEFAULT "m1730" #define HOSTNAME_DEFAULT "m1730"
#define MAX_METERS 10
#define PWM_FREQ 5000
#define PWM_RES 10
struct MeterConfig {
int pin;
float maxDuty;
float currentValue;
};
static char hostname[32] = HOSTNAME_DEFAULT; static char hostname[32] = HOSTNAME_DEFAULT;
static int meterCount = 0;
static MeterConfig meters[MAX_METERS] = {};
static WiFiManagerParameter hostnameParam("hostname", "Device hostname", hostname, 32); static WiFiManagerParameter hostnameParam("hostname", "Device hostname", hostname, 32);
static WebServer server(80); static WebServer server(80);
static void saveParams() { // ---------------------------------------------------------------------------
strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname)); // Config persistence (JSON via LittleFS)
// ---------------------------------------------------------------------------
File f = LittleFS.open("/hostname.txt", "w"); static void loadConfig() {
if (f) { if (!LittleFS.exists("/config.json")) return;
f.print(hostname); File f = LittleFS.open("/config.json", "r");
f.close();
}
}
static void loadParams() {
if (!LittleFS.exists("/hostname.txt")) return;
File f = LittleFS.open("/hostname.txt", "r");
if (!f) return; if (!f) return;
String val = f.readString(); JsonDocument doc;
val.trim(); DeserializationError err = deserializeJson(doc, f);
if (val.length() > 0) {
val.toCharArray(hostname, sizeof(hostname));
}
f.close(); f.close();
if (err) return;
if (doc["hostname"].is<const char*>())
strlcpy(hostname, doc["hostname"], sizeof(hostname));
JsonArray arr = doc["meters"].as<JsonArray>();
meterCount = min((int)arr.size(), MAX_METERS);
for (int i = 0; i < meterCount; i++) {
JsonObject m = arr[i];
meters[i].pin = m["pin"] | 0;
meters[i].maxDuty = m["maxD"] | 0.0f;
meters[i].currentValue = m["current"] | 0.0f;
}
} }
static void saveConfig() {
JsonDocument doc;
doc["hostname"] = hostname;
JsonArray arr = doc["meters"].to<JsonArray>();
for (int i = 0; i < meterCount; i++) {
JsonObject m = arr.add<JsonObject>();
m["pin"] = meters[i].pin;
m["maxD"] = meters[i].maxDuty;
m["current"] = meters[i].currentValue;
}
File f = LittleFS.open("/config.json", "w");
if (f) {
serializeJson(doc, f);
f.close();
}
}
// ---------------------------------------------------------------------------
// PWM helpers
// ---------------------------------------------------------------------------
static void attachMeters() {
for (int i = 0; i < meterCount && i < 8; i++) {
if (meters[i].pin > 0) {
ledcSetup(i, PWM_FREQ, PWM_RES);
ledcAttachPin(meters[i].pin, i);
}
}
}
static void applyMeters() {
for (int i = 0; i < meterCount && i < 8; i++) {
if (meters[i].pin <= 0 || meters[i].maxDuty <= 0) continue;
float pct = meters[i].currentValue / 100.0f * meters[i].maxDuty / 100.0f;
int duty = constrain((int)(pct * 1023), 0, 1023);
ledcWrite(i, duty);
}
}
// ---------------------------------------------------------------------------
// mDNS
// ---------------------------------------------------------------------------
static void startMDNS() { static void startMDNS() {
if (MDNS.begin(hostname)) { if (MDNS.begin(hostname)) {
Serial.printf("mDNS: %s.local\n", hostname); Serial.printf("mDNS: %s.local\n", hostname);
@@ -43,7 +103,34 @@ static void startMDNS() {
} }
} }
// ---------------------------------------------------------------------------
// Web handlers
// ---------------------------------------------------------------------------
static void handleRoot() { static void handleRoot() {
String meterRows;
for (int i = 0; i < meterCount; i++) {
char maxStr[8], curStr[8];
dtostrf(meters[i].maxDuty, 1, 1, maxStr);
dtostrf(meters[i].currentValue, 1, 1, curStr);
meterRows += "<fieldset><legend>Meter " + String(i) + "</legend>";
meterRows += "<div class=row><label>Pin</label><input name=m" + String(i) + "_pin type=number min=0 max=99 value=" + String(meters[i].pin) + "></div>";
meterRows += "<div class=row><label>Max Duty %</label><input name=m" + String(i) + "_maxD type=number step=1 min=0 max=100 value=" + String(maxStr) + "></div>";
meterRows += "<div class=slider-row>";
meterRows += "<label>Output %</label>";
meterRows += "<input name=m" + String(i) + "_cur type=range min=0 max=100 step=1 value=" + String(curStr) + ">";
meterRows += "<span class=val id=m" + String(i) + ">" + String(curStr) + "%</span>";
meterRows += "</div></fieldset>";
}
String countOpts;
for (int i = 1; i <= MAX_METERS; i++) {
countOpts += "<option value=" + String(i);
if (i == meterCount) countOpts += " selected";
countOpts += ">" + String(i) + "</option>";
}
String html = R"( String html = R"(
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@@ -52,120 +139,174 @@ static void handleRoot() {
<meta name=viewport content='width=device-width,initial-scale=1'> <meta name=viewport content='width=device-width,initial-scale=1'>
<title>M1730</title> <title>M1730</title>
<style> <style>
*, *::before, *::after { box-sizing:border-box; margin:0; padding:0 } *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body { body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,sans-serif;background:#f2f4f8;color:#1a1a2e;display:flex;justify-content:center;align-items:flex-start;min-height:100vh;padding:24px 16px}
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,sans-serif; .card{background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.08);padding:32px;width:100%;max-width:520px}
background:#f2f4f8; color:#1a1a2e; display:flex; justify-content:center; h1{font-size:24px;margin-bottom:4px}
align-items:center; min-height:100vh; padding:16px; .sub{color:#666;font-size:14px;margin-bottom:24px}
} .info{background:#f8f9fc;border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:14px}
.card { .info div{display:flex;justify-content:space-between;padding:4px 0}
background:#fff; border-radius:12px; box-shadow:0 4px 24px rgba(0,0,0,.08); .info div span:first-child{color:#666}
padding:32px; width:100%; max-width:400px; label{display:block;font-size:14px;font-weight:600;margin-bottom:4px}
} input,select{width:100%;padding:9px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;outline:none;transition:border-color .2s}
h1 { font-size:24px; margin-bottom:4px } input:focus,select:focus{border-color:#4361ee;box-shadow:0 0 0 3px rgba(67,97,238,.15)}
.sub { color:#666; font-size:14px; margin-bottom:24px } fieldset{border:1px solid #e5e7eb;border-radius:10px;padding:16px;margin-bottom:16px}
.info { background:#f8f9fc; border-radius:8px; padding:12px 16px; margin-bottom:20px; font-size:14px } legend{font-weight:600;font-size:14px;padding:0 6px;color:#4361ee}
.info div { display:flex; justify-content:space-between; padding:4px 0 } fieldset .row{display:flex;gap:8px;align-items:center;margin-bottom:10px}
.info div span:first-child { color:#666 } fieldset .row label{width:60px;margin-bottom:0;flex-shrink:0}
label { display:block; font-size:14px; font-weight:600; margin-bottom:6px } .slider-row{display:flex;gap:8px;align-items:center}
input { .slider-row label{width:60px;margin-bottom:0;flex-shrink:0}
width:100%; padding:10px 14px; border:1px solid #d1d5db; border-radius:8px; .slider-row input[type=range]{flex:1;padding:0;border:none;box-shadow:none;accent-color:#4361ee}
font-size:15px; outline:none; transition:border-color .2s; .slider-row input[type=range]:focus{box-shadow:none}
} .val{font-size:14px;font-weight:600;min-width:48px;text-align:right;color:#4361ee}
input:focus { border-color:#4361ee; box-shadow:0 0 0 3px rgba(67,97,238,.15) } .count-row{display:flex;gap:12px;align-items:flex-end;margin-bottom:20px}
button { .count-row > div{flex:1}
width:100%; margin-top:16px; padding:12px; background:#4361ee; color:#fff; .count-row label{margin-bottom:4px}
border:none; border-radius:8px; font-size:15px; font-weight:600; cursor:pointer; .btn{width:100%;margin-top:8px;padding:12px;background:#4361ee;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:background .2s}
transition:background .2s; .btn:hover{background:#3651d4}
} .mac{font-size:12px;color:#999;text-align:center;margin-top:20px}
button:hover { background:#3651d4 }
.mac { font-size:12px; color:#999; text-align:center; margin-top:20px }
</style> </style>
</head> </head>
<body> <body>
<div class=card> <div class=card>
<h1>M1730</h1> <h1>M1730</h1>
<div class=sub>Device Configuration</div> <div class=sub>Configuration</div>
<div class=info> <div class=info>
<div><span>Hostname</span><span>)" + String(hostname) + R"(</span></div> <div><span>Hostname</span><span>)" + String(hostname) + R"(</span></div>
<div><span>IP Address</span><span>)" + WiFi.localIP().toString() + R"(</span></div> <div><span>IP</span><span>)" + WiFi.localIP().toString() + R"(</span></div>
</div> </div>
<form action=/update method=post>
<label for=h>Change hostname</label> <form action=/config method=post>
<input id=h name=hostname value=')" + String(hostname) + R"('> <label>Hostname</label>
<button type=submit>Save &amp; Reboot</button> <input name=hostname value=')" + String(hostname) + R"('>
<div class=count-row>
<div>
<label>Meters</label>
<select name=meter_count onchange='this.form.submit()'>)" + countOpts + R"(</select>
</div>
</div>
)" + meterRows + R"(
<button class=btn type=submit>Save</button>
</form> </form>
<div class=mac>)" + WiFi.macAddress() + R"(</div> <div class=mac>)" + WiFi.macAddress() + R"(</div>
</div> </div>
<script>
Array.from(document.querySelectorAll('input[type=range]')).forEach(function(s){
var span = document.getElementById(s.name.replace('_cur',''));
if(span){
s.addEventListener('input',function(){
span.textContent=this.value+'%';
var x=new XMLHttpRequest();
x.open('GET','/set?i='+this.name.match(/\d+/)[0]+'&v='+this.value,true);
x.send();
});
}
});
document.querySelector('select[name=meter_count]').addEventListener('change',function(){
this.form.submit();
});
</script>
</body> </body>
</html> </html>
)"; )";
server.send(200, "text/html", html); server.send(200, "text/html", html);
} }
static void handleUpdate() { static void handleConfig() {
if (server.hasArg("hostname")) { if (server.hasArg("hostname")) {
String val = server.arg("hostname"); String val = server.arg("hostname");
val.trim(); val.trim();
if (val.length() > 0 && val.length() < sizeof(hostname)) { if (val.length() > 0)
val.toCharArray(hostname, sizeof(hostname)); val.toCharArray(hostname, sizeof(hostname));
saveParams();
String html = R"(
<!DOCTYPE html>
<html><head><meta charset=utf-8>
<meta name=viewport content='width=device-width,initial-scale=1'>
<title>M1730</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,sans-serif;background:#f2f4f8;color:#1a1a2e;display:flex;justify-content:center;align-items:center;min-height:100vh;padding:16px}
.card{background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.08);padding:32px;width:100%;max-width:400px;text-align:center}
h1{font-size:24px;margin-bottom:8px}
p{color:#666;font-size:14px}
</style></head><body>
<div class=card>
<h1>Saved!</h1>
<p>Rebooting with new hostname: <strong>)" + String(hostname) + R"(</strong></p>
</div>
</body></html>
)";
server.send(200, "text/html", html);
delay(1000);
ESP.restart();
return;
} }
int newCount = meterCount;
if (server.hasArg("meter_count"))
newCount = constrain(server.arg("meter_count").toInt(), 1, MAX_METERS);
MeterConfig newMeters[MAX_METERS] = {};
for (int i = 0; i < newCount; i++) {
String pf = "m" + String(i) + "_";
if (server.hasArg(pf + "pin"))
newMeters[i].pin = server.arg(pf + "pin").toInt();
if (server.hasArg(pf + "maxD"))
newMeters[i].maxDuty = server.arg(pf + "maxD").toFloat();
if (server.hasArg(pf + "cur"))
newMeters[i].currentValue = server.arg(pf + "cur").toFloat();
} }
server.send(400, "text/html", "<html><body><h1>Bad Request</h1></body></html>");
meterCount = newCount;
memcpy(meters, newMeters, sizeof(meters));
saveConfig();
attachMeters();
applyMeters();
server.sendHeader("Location", "/");
server.send(303);
}
static void handleSet() {
if (!server.hasArg("i") || !server.hasArg("v")) { server.send(400); return; }
int idx = server.arg("i").toInt();
float val = server.arg("v").toFloat();
if (idx < 0 || idx >= meterCount) { server.send(400); return; }
meters[idx].currentValue = val;
if (meters[idx].pin > 0 && meters[idx].maxDuty > 0) {
float pct = val / 100.0f * meters[idx].maxDuty / 100.0f;
int duty = constrain((int)(pct * 1023), 0, 1023);
ledcWrite(idx, duty);
}
saveConfig();
server.send(200);
} }
static void startServer() { static void startServer() {
server.on("/", handleRoot); server.on("/", handleRoot);
server.on("/update", HTTP_POST, handleUpdate); server.on("/config", HTTP_POST, handleConfig);
server.on("/set", handleSet);
server.begin(); server.begin();
Serial.println("HTTP server started"); Serial.println("HTTP server started");
} }
// ---------------------------------------------------------------------------
// Setup & loop
// ---------------------------------------------------------------------------
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
LittleFS.begin(true); LittleFS.begin(true);
loadParams(); loadConfig();
attachMeters();
applyMeters();
WiFiManager wm; WiFiManager wm;
wm.setTitle("M1730"); wm.setTitle("M1730");
wm.addParameter(&hostnameParam); wm.addParameter(&hostnameParam);
wm.setSaveParamsCallback(saveParams); wm.setSaveParamsCallback([]() {
strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname));
saveConfig();
});
if (!wm.autoConnect("M1730")) { if (!wm.autoConnect("M1730")) {
Serial.println("WiFi failed, restarting"); Serial.println("WiFi failed, restarting");
ESP.restart(); ESP.restart();
} }
saveParams(); strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname));
saveConfig();
startServer(); startServer();
startMDNS(); startMDNS();
Serial.printf("Board address: http://%s.local or http://%s\n", hostname, WiFi.localIP().toString().c_str()); Serial.printf("Board address: http://%s.local or http://%s\n",
hostname, WiFi.localIP().toString().c_str());
} }
void loop() { void loop() {