MVP
This commit is contained in:
@@ -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
|
||||||
|
|||||||
307
src/main.cpp
307
src/main.cpp
@@ -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 & 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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user