MVP
This commit is contained in:
@@ -13,4 +13,6 @@ platform = espressif32
|
||||
board = esp32-s3-devkitc-1
|
||||
framework = arduino
|
||||
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 <LittleFS.h>
|
||||
#include <WebServer.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
#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 int meterCount = 0;
|
||||
static MeterConfig meters[MAX_METERS] = {};
|
||||
static WiFiManagerParameter hostnameParam("hostname", "Device hostname", hostname, 32);
|
||||
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");
|
||||
if (f) {
|
||||
f.print(hostname);
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
|
||||
static void loadParams() {
|
||||
if (!LittleFS.exists("/hostname.txt")) return;
|
||||
|
||||
File f = LittleFS.open("/hostname.txt", "r");
|
||||
static void loadConfig() {
|
||||
if (!LittleFS.exists("/config.json")) return;
|
||||
File f = LittleFS.open("/config.json", "r");
|
||||
if (!f) return;
|
||||
|
||||
String val = f.readString();
|
||||
val.trim();
|
||||
if (val.length() > 0) {
|
||||
val.toCharArray(hostname, sizeof(hostname));
|
||||
}
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, f);
|
||||
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() {
|
||||
if (MDNS.begin(hostname)) {
|
||||
Serial.printf("mDNS: %s.local\n", hostname);
|
||||
@@ -43,7 +103,34 @@ static void startMDNS() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Web handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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"(
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -52,120 +139,174 @@ static void handleRoot() {
|
||||
<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;
|
||||
}
|
||||
h1 { font-size:24px; margin-bottom:4px }
|
||||
.sub { color:#666; font-size:14px; margin-bottom:24px }
|
||||
.info { background:#f8f9fc; border-radius:8px; padding:12px 16px; margin-bottom:20px; font-size:14px }
|
||||
.info div { display:flex; justify-content:space-between; padding:4px 0 }
|
||||
.info div span:first-child { color:#666 }
|
||||
label { display:block; font-size:14px; font-weight:600; margin-bottom:6px }
|
||||
input {
|
||||
width:100%; padding:10px 14px; border:1px solid #d1d5db; border-radius:8px;
|
||||
font-size:15px; outline:none; transition:border-color .2s;
|
||||
}
|
||||
input:focus { border-color:#4361ee; box-shadow:0 0 0 3px rgba(67,97,238,.15) }
|
||||
button {
|
||||
width:100%; margin-top:16px; padding:12px; background:#4361ee; color:#fff;
|
||||
border:none; border-radius:8px; font-size:15px; font-weight:600; cursor:pointer;
|
||||
transition:background .2s;
|
||||
}
|
||||
button:hover { background:#3651d4 }
|
||||
.mac { font-size:12px; color:#999; text-align:center; margin-top:20px }
|
||||
*,*::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:flex-start;min-height:100vh;padding:24px 16px}
|
||||
.card{background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.08);padding:32px;width:100%;max-width:520px}
|
||||
h1{font-size:24px;margin-bottom:4px}
|
||||
.sub{color:#666;font-size:14px;margin-bottom:24px}
|
||||
.info{background:#f8f9fc;border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:14px}
|
||||
.info div{display:flex;justify-content:space-between;padding:4px 0}
|
||||
.info div span:first-child{color:#666}
|
||||
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}
|
||||
input:focus,select:focus{border-color:#4361ee;box-shadow:0 0 0 3px rgba(67,97,238,.15)}
|
||||
fieldset{border:1px solid #e5e7eb;border-radius:10px;padding:16px;margin-bottom:16px}
|
||||
legend{font-weight:600;font-size:14px;padding:0 6px;color:#4361ee}
|
||||
fieldset .row{display:flex;gap:8px;align-items:center;margin-bottom:10px}
|
||||
fieldset .row label{width:60px;margin-bottom:0;flex-shrink:0}
|
||||
.slider-row{display:flex;gap:8px;align-items:center}
|
||||
.slider-row label{width:60px;margin-bottom:0;flex-shrink:0}
|
||||
.slider-row input[type=range]{flex:1;padding:0;border:none;box-shadow:none;accent-color:#4361ee}
|
||||
.slider-row input[type=range]:focus{box-shadow:none}
|
||||
.val{font-size:14px;font-weight:600;min-width:48px;text-align:right;color:#4361ee}
|
||||
.count-row{display:flex;gap:12px;align-items:flex-end;margin-bottom:20px}
|
||||
.count-row > div{flex:1}
|
||||
.count-row label{margin-bottom:4px}
|
||||
.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}
|
||||
.btn:hover{background:#3651d4}
|
||||
.mac{font-size:12px;color:#999;text-align:center;margin-top:20px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class=card>
|
||||
<h1>M1730</h1>
|
||||
<div class=sub>Device Configuration</div>
|
||||
<div class=sub>Configuration</div>
|
||||
<div class=info>
|
||||
<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>
|
||||
<form action=/update method=post>
|
||||
<label for=h>Change hostname</label>
|
||||
<input id=h name=hostname value=')" + String(hostname) + R"('>
|
||||
<button type=submit>Save & Reboot</button>
|
||||
|
||||
<form action=/config method=post>
|
||||
<label>Hostname</label>
|
||||
<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>
|
||||
|
||||
<div class=mac>)" + WiFi.macAddress() + R"(</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>
|
||||
</html>
|
||||
)";
|
||||
server.send(200, "text/html", html);
|
||||
}
|
||||
|
||||
static void handleUpdate() {
|
||||
static void handleConfig() {
|
||||
if (server.hasArg("hostname")) {
|
||||
String val = server.arg("hostname");
|
||||
val.trim();
|
||||
if (val.length() > 0 && val.length() < sizeof(hostname)) {
|
||||
if (val.length() > 0)
|
||||
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() {
|
||||
server.on("/", handleRoot);
|
||||
server.on("/update", HTTP_POST, handleUpdate);
|
||||
server.on("/config", HTTP_POST, handleConfig);
|
||||
server.on("/set", handleSet);
|
||||
server.begin();
|
||||
Serial.println("HTTP server started");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup & loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
LittleFS.begin(true);
|
||||
|
||||
loadParams();
|
||||
loadConfig();
|
||||
attachMeters();
|
||||
applyMeters();
|
||||
|
||||
WiFiManager wm;
|
||||
wm.setTitle("M1730");
|
||||
|
||||
wm.addParameter(&hostnameParam);
|
||||
wm.setSaveParamsCallback(saveParams);
|
||||
wm.setSaveParamsCallback([]() {
|
||||
strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname));
|
||||
saveConfig();
|
||||
});
|
||||
|
||||
if (!wm.autoConnect("M1730")) {
|
||||
Serial.println("WiFi failed, restarting");
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
saveParams();
|
||||
strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname));
|
||||
saveConfig();
|
||||
startServer();
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user