Files
M1730-ESP32/src/main.cpp
T

772 lines
29 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <Arduino.h>
#include <WiFiManager.h>
#include <ESPmDNS.h>
#include <LittleFS.h>
#include <WebServer.h>
#include <ArduinoJson.h>
#include <WiFiClient.h>
#include <PubSubClient.h>
#define HOSTNAME_DEFAULT "m1730"
#define MAX_METERS 10
#define PWM_FREQ 5000
#define PWM_RES 10
#define MQTT_MANUFACTURER "Baumann Enkataleiptics"
#define AUTH_USERNAME "admin"
struct MeterConfig {
int pin;
float maxDuty;
float currentValue;
char name[32] = "";
char unit[16] = "";
float rangeMin = 0.0;
float rangeMax = 100.0;
};
struct MqttConfig {
bool enabled = false;
char host[64] = "";
uint16_t port = 1883;
char user[32] = "";
char pass[32] = "";
char prefix[32] = "m1730";
};
struct AuthConfig {
bool enabled = false;
char pass[32] = "";
};
static char hostname[32] = HOSTNAME_DEFAULT;
static int meterCount = 0;
static MeterConfig meters[MAX_METERS] = {};
static MqttConfig mqttCfg;
static AuthConfig authCfg;
static WiFiManagerParameter hostnameParam("hostname", "Device hostname", hostname, 32);
static WebServer server(80);
static WiFiClient wifiClient;
static PubSubClient mqttClient(wifiClient);
static unsigned long mqttReconnectAt = 0;
// ---------------------------------------------------------------------------
// Config persistence (JSON via LittleFS)
// ---------------------------------------------------------------------------
static void loadConfig() {
Serial.println("[CFG] loading config");
if (!LittleFS.exists("/config.json")) { Serial.println("[CFG] no config file"); return; }
File f = LittleFS.open("/config.json", "r");
if (!f) { Serial.println("[CFG] failed to open"); return; }
JsonDocument doc;
DeserializationError err = deserializeJson(doc, f);
f.close();
if (err) { Serial.printf("[CFG] parse error: %s\n", err.c_str()); return; }
if (doc["hostname"].is<const char*>())
strlcpy(hostname, doc["hostname"], sizeof(hostname));
JsonObject mq = doc["mqtt"];
if (!mq.isNull()) {
mqttCfg.enabled = mq["en"] | false;
strlcpy(mqttCfg.host, mq["host"] | "", sizeof(mqttCfg.host));
mqttCfg.port = mq["port"] | 1883;
strlcpy(mqttCfg.user, mq["user"] | "", sizeof(mqttCfg.user));
strlcpy(mqttCfg.pass, mq["pass"] | "", sizeof(mqttCfg.pass));
strlcpy(mqttCfg.prefix, mq["prefix"] | "m1730", sizeof(mqttCfg.prefix));
}
JsonObject auth = doc["auth"];
if (!auth.isNull()) {
authCfg.enabled = auth["en"] | false;
strlcpy(authCfg.pass, auth["pass"] | "", sizeof(authCfg.pass));
}
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;
strlcpy(meters[i].name, m["name"] | "", sizeof(meters[i].name));
strlcpy(meters[i].unit, m["unit"] | "", sizeof(meters[i].unit));
meters[i].rangeMin = m["rangeMin"] | m["range"] | 0.0f;
meters[i].rangeMax = m["rangeMax"] | m["range"] | 100.0f;
}
Serial.printf("[CFG] loaded hostname=%s meters=%d mqtt_en=%d\n", hostname, meterCount, mqttCfg.enabled);
}
static void saveConfig() {
JsonDocument doc;
doc["hostname"] = hostname;
JsonObject mq = doc["mqtt"].to<JsonObject>();
mq["en"] = mqttCfg.enabled;
mq["host"] = mqttCfg.host;
mq["port"] = mqttCfg.port;
mq["user"] = mqttCfg.user;
mq["pass"] = mqttCfg.pass;
mq["prefix"] = mqttCfg.prefix;
JsonObject auth = doc["auth"].to<JsonObject>();
auth["en"] = authCfg.enabled;
auth["pass"] = authCfg.pass;
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;
m["name"] = meters[i].name;
m["unit"] = meters[i].unit;
m["rangeMin"] = meters[i].rangeMin;
m["rangeMax"] = meters[i].rangeMax;
}
File f = LittleFS.open("/config.json", "w");
if (f) {
serializeJson(doc, f);
f.close();
Serial.printf("[CFG] saved hostname=%s meters=%d mqtt_en=%d\n", hostname, meterCount, mqttCfg.enabled);
} else {
Serial.println("[CFG] save failed");
}
}
// ---------------------------------------------------------------------------
// 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);
Serial.printf("[PWM] attach ch%d pin%d\n", i, meters[i].pin);
}
}
}
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);
Serial.printf("[PWM] ch%d duty=%d (cur=%.1f maxD=%.1f)\n", i, duty, meters[i].currentValue, meters[i].maxDuty);
}
}
// ---------------------------------------------------------------------------
// Range mapping
// ---------------------------------------------------------------------------
static float pctToPhysical(float pct, int idx) {
return pct / 100.0f * (meters[idx].rangeMax - meters[idx].rangeMin) + meters[idx].rangeMin;
}
static float physicalToPct(float physical, int idx) {
float range = meters[idx].rangeMax - meters[idx].rangeMin;
if (range == 0) return 0;
return constrain((physical - meters[idx].rangeMin) / range * 100.0f, 0, 100);
}
// ---------------------------------------------------------------------------
// MQTT
// ---------------------------------------------------------------------------
static void mqttPublishCurrent(int idx);
static void mqttCallback(char* topic, byte* payload, unsigned int len) {
String valStr;
for (unsigned i = 0; i < len; i++) valStr += (char)payload[i];
float val = valStr.toFloat();
Serial.printf("[MQTT] rcvd topic=%s payload=%s\n", topic, valStr.c_str());
// topic format: <prefix>/meter/<idx>/current/set or .../maxduty/set
String t = String(topic);
String pref = String(mqttCfg.prefix) + "/meter/";
if (!t.startsWith(pref)) return;
t = t.substring(pref.length());
int slash = t.indexOf('/');
if (slash < 0) return;
int idx = t.substring(0, slash).toInt();
if (idx < 0 || idx >= meterCount) return;
String suffix = t.substring(slash);
if (suffix == "/current/set") {
Serial.printf("[MQTT] set meter%d physical=%.1f\n", idx, val);
meters[idx].currentValue = physicalToPct(val, idx);
if (meters[idx].pin > 0 && meters[idx].maxDuty > 0) {
float pct = meters[idx].currentValue / 100.0f * meters[idx].maxDuty / 100.0f;
ledcWrite(idx, constrain((int)(pct * 1023), 0, 1023));
}
mqttPublishCurrent(idx);
}
}
static void mqttPublishCurrent(int idx) {
if (!mqttCfg.enabled || !mqttClient.connected()) return;
char topic[128], val[16];
snprintf(topic, sizeof(topic), "%s/meter/%d/current", mqttCfg.prefix, idx);
float physical = pctToPhysical(meters[idx].currentValue, idx);
snprintf(val, sizeof(val), "%.1f", physical);
bool ok = mqttClient.publish(topic, val, true);
Serial.printf("[MQTT] publish topic=%s val=%s ok=%d\n", topic, val, ok);
}
static void mqttPublishDiscovery() {
if (!mqttCfg.enabled || !mqttClient.connected()) return;
uint8_t mac[6];
WiFi.macAddress(mac);
char devId[32];
snprintf(devId, sizeof(devId), "m1730_%02x%02x%02x", mac[3], mac[4], mac[5]);
for (int i = 0; i < meterCount; i++) {
String objId = String(devId) + "_meter_" + String(i) + "_current";
String stat = String(mqttCfg.prefix) + "/meter/" + String(i) + "/current";
String name = strlen(meters[i].name) > 0
? String(meters[i].name)
: "Meter " + String(i);
JsonDocument doc;
doc["unique_id"] = objId;
doc["name"] = name;
doc["state_topic"] = stat;
doc["command_topic"] = stat + "/set";
doc["min"] = meters[i].rangeMin;
doc["max"] = meters[i].rangeMax;
doc["step"] = 0.1;
if (strlen(meters[i].unit) > 0)
doc["unit_of_measurement"] = meters[i].unit;
doc["device"]["identifiers"][0] = devId;
doc["device"]["name"] = hostname;
doc["device"]["manufacturer"] = MQTT_MANUFACTURER;
doc["device"]["model"] = "ESP32";
char topic[128];
snprintf(topic, sizeof(topic), "homeassistant/number/%s/config", objId.c_str());
char payload[512];
serializeJson(doc, payload, sizeof(payload));
bool pubOk = mqttClient.publish(topic, payload, true);
Serial.printf("[MQTT] discovery %s -> %s (ok=%d)\n", topic, payload, pubOk);
}
}
static void mqttSubscribe() {
if (!mqttCfg.enabled) return;
for (int i = 0; i < meterCount; i++) {
char t[128];
snprintf(t, sizeof(t), "%s/meter/%d/current/set", mqttCfg.prefix, i);
mqttClient.subscribe(t);
Serial.printf("[MQTT] subscribed %s\n", t);
}
}
static bool mqttConnect() {
if (!mqttCfg.enabled || strlen(mqttCfg.host) == 0 || strlen(mqttCfg.user) == 0 || strlen(mqttCfg.pass) == 0) {
Serial.printf("[MQTT] connect skipped en=%d host=%d user=%d pass=%d\n",
mqttCfg.enabled, strlen(mqttCfg.host) > 0, strlen(mqttCfg.user) > 0, strlen(mqttCfg.pass) > 0);
return false;
}
char clientId[32];
{
uint8_t mac[6];
WiFi.macAddress(mac);
snprintf(clientId, sizeof(clientId), "m1730-%02x%02x%02x", mac[3], mac[4], mac[5]);
}
char statusTopic[128];
snprintf(statusTopic, sizeof(statusTopic), "%s/status", mqttCfg.prefix);
mqttClient.setServer(mqttCfg.host, mqttCfg.port);
mqttClient.setCallback(mqttCallback);
mqttClient.setBufferSize(1024);
Serial.printf("[MQTT] connecting to %s:%d as %s\n", mqttCfg.host, mqttCfg.port, clientId);
bool ok = mqttClient.connect(clientId, mqttCfg.user, mqttCfg.pass,
statusTopic, 0, true, "online: false");
if (ok) {
Serial.printf("[MQTT] connected to %s:%d\n", mqttCfg.host, mqttCfg.port);
mqttClient.publish(statusTopic, "online: true", true);
mqttSubscribe();
mqttPublishDiscovery();
for (int i = 0; i < meterCount; i++)
mqttPublishCurrent(i);
} else {
Serial.printf("[MQTT] failed rc=%d\n", mqttClient.state());
}
return ok;
}
static void mqttLoop() {
if (!mqttCfg.enabled || strlen(mqttCfg.host) == 0 || strlen(mqttCfg.user) == 0 || strlen(mqttCfg.pass) == 0) return;
if (!mqttClient.connected()) {
unsigned long now = millis();
if (now > mqttReconnectAt) {
Serial.printf("[MQTT] probing %s:%d\n", mqttCfg.host, mqttCfg.port);
WiFiClient probe;
bool reachable = probe.connect(mqttCfg.host, mqttCfg.port, 1500);
probe.stop();
if (reachable) {
Serial.println("[MQTT] probe OK, connecting");
if (mqttConnect()) mqttReconnectAt = 0;
else mqttReconnectAt = now + 30000;
} else {
Serial.println("[MQTT] probe failed, retry in 30s");
mqttReconnectAt = now + 30000;
}
}
} else {
mqttClient.loop();
}
}
// ---------------------------------------------------------------------------
// mDNS
// ---------------------------------------------------------------------------
static void startMDNS() {
if (MDNS.begin(hostname)) {
Serial.printf("mDNS: %s.local\n", hostname);
MDNS.addService("http", "tcp", 80);
} else {
Serial.println("mDNS start failed");
}
}
// ---------------------------------------------------------------------------
// Web helpers
// ---------------------------------------------------------------------------
static String escHtml(const String& s) {
String out;
for (unsigned i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '&': out += "&amp;"; break;
case '\'': out += "&#39;"; break;
case '"': out += "&quot;"; break;
default: out += c;
}
}
return out;
}
static bool requireAuth() {
if (!authCfg.enabled) return true;
if (server.authenticate(AUTH_USERNAME, authCfg.pass)) return true;
server.requestAuthentication();
return false;
}
// ---------------------------------------------------------------------------
// Web handlers
// ---------------------------------------------------------------------------
static void handleRoot() {
if (!requireAuth()) { Serial.println("[HTTP] GET / -> 401"); return; }
Serial.println("[HTTP] GET /");
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);
String nameVal = escHtml(meters[i].name);
String unitVal = escHtml(meters[i].unit);
char rMinStr[8], rMaxStr[8];
dtostrf(meters[i].rangeMin, 1, 1, rMinStr);
dtostrf(meters[i].rangeMax, 1, 1, rMaxStr);
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>Name</label><input name=m" + String(i) + "_name value='" + nameVal + "'></div>";
meterRows += "<div class=row><label>Unit</label><input name=m" + String(i) + "_unit value='" + unitVal + "'></div>";
meterRows += "<div class=row><label>Min</label><input name=m" + String(i) + "_rangeMin type=number step=any value=" + String(rMinStr) + "></div>";
meterRows += "<div class=row><label>Max</label><input name=m" + String(i) + "_rangeMax type=number step=any value=" + String(rMaxStr) + "></div>";
meterRows += "<div class=row><label>Max Duty</label><input name=m" + String(i) + "_maxD type=number step=any 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=0.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 authSection;
{
String checked = authCfg.enabled ? " checked" : "";
const char* passHint = strlen(authCfg.pass) > 0 ? "set &#8212; leave blank to keep" : "enter password";
authSection +=
"<fieldset><legend>Security</legend>"
"<div class=row>"
"<label class=tgl-lbl>Require password</label>"
"<label class=switch><input type=checkbox name=auth_en" + checked + "><span class=slid></span></label>"
"</div>"
"<div class=row><label>Password</label><input name=auth_pass type=password placeholder='" + String(passHint) + "'></div>";
authSection += "</fieldset>";
}
String mqttSection;
{
char portStr[8];
snprintf(portStr, sizeof(portStr), "%u", mqttCfg.port);
String checked = mqttCfg.enabled ? " checked" : "";
mqttSection +=
"<fieldset><legend>MQTT</legend>"
"<div class=row>"
"<label class=tgl-lbl>Enable</label>"
"<label class=switch><input type=checkbox name=mqtt_en" + checked + "><span class=slid></span></label>"
"</div>";
String hostVal = escHtml(mqttCfg.host);
String userVal = escHtml(mqttCfg.user);
String prefVal = escHtml(mqttCfg.prefix);
const char* passHint = strlen(mqttCfg.pass) > 0 ? "set &#8212; leave blank to keep" : "enter password";
mqttSection +=
"<div class=row><label>Broker</label><input name=mqtt_host value='" + hostVal + "'></div>"
"<div class=row><label>Port</label><input name=mqtt_port type=number value=" + String(portStr) + "></div>"
"<div class=row><label>User</label><input name=mqtt_user value='" + userVal + "'></div>"
"<div class=row><label>Pass</label><input name=mqtt_pass type=password placeholder='" + String(passHint) + "'></div>"
"<div class=row><label>Prefix</label><input name=mqtt_prefix value='" + prefVal + "'></div>";
mqttSection += "</fieldset>";
}
String html;
html.reserve(4096);
html += "<!DOCTYPE html><html><head><meta charset=utf-8>";
html += "<meta name=viewport content='width=device-width,initial-scale=1'><title>M1730</title><style>";
html += "*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}";
html += "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}";
html += ".card{background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.08);padding:32px;width:100%;max-width:560px}";
html += "h1{font-size:24px;margin-bottom:4px}.sub{color:#666;font-size:14px;margin-bottom:24px}";
html += ".info{background:#f8f9fc;border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:14px}";
html += ".info div{display:flex;justify-content:space-between;padding:4px 0}.info div span:first-child{color:#666}";
html += "label{display:block;font-size:14px;font-weight:600;margin-bottom:4px}";
html += "input,select{width:100%;padding:9px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;outline:none;transition:border-color .2s}";
html += "input:focus,select:focus{border-color:#4361ee;box-shadow:0 0 0 3px rgba(67,97,238,.15)}";
html += "fieldset{border:1px solid #e5e7eb;border-radius:10px;padding:16px;margin-bottom:16px}";
html += "legend{font-weight:600;font-size:14px;padding:0 6px;color:#4361ee}";
html += "fieldset .row{display:flex;gap:8px;align-items:center;margin-bottom:8px}";
html += "fieldset .row label{width:70px;margin-bottom:0;flex-shrink:0}.slider-row{display:flex;gap:8px;align-items:center}";
html += ".slider-row label{width:70px;margin-bottom:0;flex-shrink:0}";
html += ".slider-row input[type=range]{flex:1;padding:0;border:none;box-shadow:none;accent-color:#4361ee}";
html += ".slider-row input[type=range]:focus{box-shadow:none}";
html += ".val{font-size:14px;font-weight:600;min-width:48px;text-align:right;color:#4361ee}";
html += ".count-row{display:flex;gap:12px;align-items:flex-end;margin-bottom:20px}.count-row > div{flex:1}";
html += ".count-row label{margin-bottom:4px}";
html += ".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}";
html += ".btn:hover{background:#3651d4}.mac{font-size:12px;color:#999;text-align:center;margin-top:20px}";
html += ".tgl-lbl{margin-bottom:0!important;line-height:18px}.switch{position:relative;display:inline-block;width:32px;height:18px;margin-left:auto;flex-shrink:0}fieldset .row label.switch{width:32px}";
html += ".switch input{opacity:0;width:0;height:0}.slid{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#ccc;border-radius:18px;transition:.3s}";
html += ".slid::before{content:\"\";position:absolute;height:14px;width:14px;left:2px;top:2px;background:#fff;border-radius:50%;transition:.3s;box-shadow:0 1px 3px rgba(0,0,0,.2)}";
html += ".switch input:checked+.slid{background:#4361ee}.switch input:checked+.slid::before{transform:translateX(14px)}";
html += "</style></head><body><div class=card>";
html += "<h1>M1730</h1><div class=sub>Configuration</div><div class=info>";
html += "<div><span>Hostname</span><span>" + String(hostname) + "</span></div>";
html += "<div><span>IP</span><span>" + WiFi.localIP().toString() + "</span></div></div>";
html += "<form action=/config method=post><label>Hostname</label>";
html += "<input name=hostname value='" + String(hostname) + "'>";
html += "<div class=count-row><div><label>Meters</label>";
html += "<select name=meter_count onchange='this.form.submit()'>" + countOpts + "</select></div></div>";
html += meterRows;
html += authSection;
html += mqttSection;
html += "<button class=btn type=submit>Save</button></form>";
html += "<div class=mac>" + WiFi.macAddress() + "</div></div>";
html += "<script>";
html += "Array.from(document.querySelectorAll('input[type=range]')).forEach(function(s){";
html += "var span=document.getElementById(s.name.replace('_cur',''));";
html += "if(span){s.addEventListener('input',function(){";
html += "span.textContent=this.value+'%';";
html += "var x=new XMLHttpRequest();";
html += "x.open('GET','/set?i='+this.name.match(/\\d+/)[0]+'&v='+this.value,true);x.send();";
html += "});}});";
html += "document.querySelector('select[name=meter_count]').addEventListener('change',function(){this.form.submit();});";
html += "</script></body></html>";
server.send(200, "text/html", html);
Serial.printf("[HTTP] GET / -> 200 (%u bytes)\n", html.length());
}
static void handleConfig() {
if (!requireAuth()) { Serial.println("[HTTP] POST /config -> 401"); return; }
Serial.println("[HTTP] POST /config");
if (server.hasArg("hostname")) {
String val = server.arg("hostname");
val.trim();
if (val.length() > 0)
val.toCharArray(hostname, sizeof(hostname));
}
if (server.hasArg("mqtt_en")) {
mqttCfg.enabled = true;
if (server.hasArg("mqtt_host"))
strlcpy(mqttCfg.host, server.arg("mqtt_host").c_str(), sizeof(mqttCfg.host));
if (server.hasArg("mqtt_port"))
mqttCfg.port = server.arg("mqtt_port").toInt();
if (server.hasArg("mqtt_user"))
strlcpy(mqttCfg.user, server.arg("mqtt_user").c_str(), sizeof(mqttCfg.user));
if (server.hasArg("mqtt_pass") && server.arg("mqtt_pass").length() > 0)
strlcpy(mqttCfg.pass, server.arg("mqtt_pass").c_str(), sizeof(mqttCfg.pass));
if (server.hasArg("mqtt_prefix"))
strlcpy(mqttCfg.prefix, server.arg("mqtt_prefix").c_str(), sizeof(mqttCfg.prefix));
Serial.printf("[HTTP] mqtt enabled host=%s port=%u user=%s prefix=%s\n",
mqttCfg.host, mqttCfg.port, mqttCfg.user, mqttCfg.prefix);
} else {
mqttCfg.enabled = false;
Serial.println("[HTTP] mqtt disabled");
}
if (server.hasArg("auth_en")) {
if (server.hasArg("auth_pass") && server.arg("auth_pass").length() > 0)
strlcpy(authCfg.pass, server.arg("auth_pass").c_str(), sizeof(authCfg.pass));
authCfg.enabled = strlen(authCfg.pass) > 0;
Serial.printf("[HTTP] auth enabled=%d\n", authCfg.enabled);
} else {
authCfg.enabled = false;
Serial.println("[HTTP] auth disabled");
}
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 + "name"))
strlcpy(newMeters[i].name, server.arg(pf + "name").c_str(), sizeof(newMeters[i].name));
if (server.hasArg(pf + "unit"))
strlcpy(newMeters[i].unit, server.arg(pf + "unit").c_str(), sizeof(newMeters[i].unit));
if (server.hasArg(pf + "rangeMin"))
newMeters[i].rangeMin = server.arg(pf + "rangeMin").toFloat();
if (server.hasArg(pf + "rangeMax"))
newMeters[i].rangeMax = server.arg(pf + "rangeMax").toFloat();
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();
}
meterCount = newCount;
memcpy(meters, newMeters, sizeof(meters));
for (int i = 0; i < meterCount; i++)
Serial.printf("[HTTP] meter%d pin=%d name=%s unit=%s range=[%.1f,%.1f] maxD=%.1f cur=%.1f\n",
i, meters[i].pin, meters[i].name, meters[i].unit, meters[i].rangeMin, meters[i].rangeMax, meters[i].maxDuty, meters[i].currentValue);
saveConfig();
attachMeters();
applyMeters();
// let mqttLoop handle connection to avoid blocking the HTTP handler
if (mqttClient.connected()) mqttClient.disconnect();
mqttReconnectAt = 0;
handleRoot();
}
static void handleSet() {
if (!requireAuth()) { Serial.println("[HTTP] GET /set -> 401"); return; }
if (!server.hasArg("i") || !server.hasArg("v")) { server.send(400); Serial.println("[HTTP] GET /set missing args"); return; }
int idx = server.arg("i").toInt();
float val = server.arg("v").toFloat();
if (idx < 0 || idx >= meterCount) { server.send(400); Serial.printf("[HTTP] GET /set bad idx=%d\n", idx); return; }
meters[idx].currentValue = val;
Serial.printf("[HTTP] GET /set meter%d=%.1f\n", idx, 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);
}
mqttPublishCurrent(idx);
server.send(200);
}
static void startServer() {
server.on("/", handleRoot);
server.on("/config", HTTP_POST, handleConfig);
server.on("/set", handleSet);
server.begin();
Serial.println("HTTP server started");
}
// ---------------------------------------------------------------------------
// Factory reset (rapid reset 5× within a few seconds)
// ---------------------------------------------------------------------------
static void factoryReset() {
Serial.println("[BOOT] factory reset triggered!");
int count = meterCount;
int pins[MAX_METERS];
float maxD[MAX_METERS];
float cur[MAX_METERS];
for (int i = 0; i < count; i++) {
pins[i] = meters[i].pin;
maxD[i] = meters[i].maxDuty;
cur[i] = meters[i].currentValue;
}
// Reset hostname, auth, MQTT, meter metadata
strlcpy(hostname, HOSTNAME_DEFAULT, sizeof(hostname));
hostnameParam.setValue(hostname, strlen(hostname));
authCfg.enabled = false;
authCfg.pass[0] = '\0';
mqttCfg.enabled = false;
mqttCfg.host[0] = '\0';
mqttCfg.port = 1883;
mqttCfg.user[0] = '\0';
mqttCfg.pass[0] = '\0';
strlcpy(mqttCfg.prefix, "m1730", sizeof(mqttCfg.prefix));
for (int i = 0; i < MAX_METERS; i++) {
meters[i].pin = 0;
meters[i].maxDuty = 0;
meters[i].currentValue = 0;
meters[i].name[0] = '\0';
meters[i].unit[0] = '\0';
meters[i].rangeMin = 0.0;
meters[i].rangeMax = 100.0;
}
// Restore pins, calibration, current state
for (int i = 0; i < count; i++) {
meters[i].pin = pins[i];
meters[i].maxDuty = maxD[i];
meters[i].currentValue = cur[i];
}
saveConfig();
// Attach meters temporarily for the sweep acknowledgement
for (int i = 0; i < count && i < 8; i++) {
if (meters[i].pin > 0) {
ledcSetup(i, PWM_FREQ, PWM_RES);
ledcAttachPin(meters[i].pin, i);
}
}
// Sweep 0→100→0 over ~2 seconds
const int steps = 20;
for (int phase = 0; phase < 2; phase++) {
for (int s = 0; s <= steps; s++) {
float pct = (phase == 0) ? (float)s / steps * 100.0f : (float)(steps - s) / steps * 100.0f;
for (int i = 0; i < count && i < 8; i++) {
if (meters[i].pin > 0 && meters[i].maxDuty > 0) {
float duty = pct / 100.0f * meters[i].maxDuty / 100.0f;
ledcWrite(i, constrain((int)(duty * 1023), 0, 1023));
}
}
delay(50);
}
}
// Restore current values
for (int i = 0; i < count && i < 8; i++) {
if (meters[i].pin > 0 && meters[i].maxDuty > 0) {
float pct = cur[i] / 100.0f * meters[i].maxDuty / 100.0f;
ledcWrite(i, constrain((int)(pct * 1023), 0, 1023));
}
}
Serial.println("[BOOT] factory reset complete, restarting...");
delay(200);
ESP.restart();
}
static void checkFactoryReset() {
const int THRESHOLD = 5;
int count = 0;
File f = LittleFS.open("/reset_count", "r");
if (f) {
count = f.parseInt();
f.close();
}
count++;
if (count >= THRESHOLD) {
LittleFS.remove("/reset_count");
Serial.printf("[BOOT] reset count %d, triggering factory reset\n", count);
factoryReset();
return;
}
f = LittleFS.open("/reset_count", "w");
if (f) {
f.print(count);
f.close();
Serial.printf("[BOOT] reset count %d/%d\n", count, THRESHOLD);
}
}
// ---------------------------------------------------------------------------
// Setup & loop
// ---------------------------------------------------------------------------
void setup() {
Serial.begin(115200);
Serial.println("\n\n=== M1730 boot ===");
LittleFS.begin(true);
loadConfig();
hostnameParam.setValue(hostname, strlen(hostname));
checkFactoryReset();
attachMeters();
applyMeters();
Serial.println("[WIFI] starting WiFiManager");
WiFiManager wm;
wm.setTitle("M1730");
wm.addParameter(&hostnameParam);
wm.setSaveParamsCallback([]() {
strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname));
saveConfig();
});
if (!wm.autoConnect("M1730")) {
Serial.println("[WIFI] failed, restarting");
ESP.restart();
}
Serial.printf("[WIFI] connected, IP: %s\n", WiFi.localIP().toString().c_str());
strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname));
saveConfig();
startServer();
startMDNS();
mqttReconnectAt = 0;
LittleFS.remove("/reset_count");
Serial.printf("[BOOT] ready at http://%s.local or http://%s\n",
hostname, WiFi.localIP().toString().c_str());
}
void loop() {
server.handleClient();
mqttLoop();
}