Files
M1730-ESP32/src/main.cpp
T
2026-07-05 23:25:47 +02:00

911 lines
36 KiB
C++

#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"
#define MQTT_PROBE_TIMEOUT_MS 400 // keep short: blocks loop()/handleClient() per reconnect attempt
#define MQTT_PAYLOAD_AVAILABLE "online"
#define MQTT_PAYLOAD_NOT_AVAILABLE "offline"
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[64] = HOSTNAME_DEFAULT;
static int meterCount = 0;
static MeterConfig meters[MAX_METERS] = {};
static MqttConfig mqttCfg;
static AuthConfig authCfg;
static WiFiManagerParameter hostnameParam("hostname", "Device hostname", hostname, 63);
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;
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["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
// ---------------------------------------------------------------------------
// Tracks the GPIO currently bound to each PWM channel so channels that get
// freed (meter count shrinks, or a pin is changed/cleared) are detached
// instead of being left driving PWM on their old pin.
static int8_t attachedPin[8] = { -1, -1, -1, -1, -1, -1, -1, -1 };
static void attachMeters() {
for (int i = 0; i < 8; i++) {
int pin = (i < meterCount && meters[i].pin > 0) ? meters[i].pin : -1;
// Release the channel if it's no longer used or its pin changed.
if (attachedPin[i] != -1 && attachedPin[i] != pin) {
ledcDetach(attachedPin[i]);
pinMode(attachedPin[i], OUTPUT);
digitalWrite(attachedPin[i], LOW); // drive freed pin low so the meter reads zero
Serial.printf("[PWM] detach ch%d pin%d\n", i, attachedPin[i]);
attachedPin[i] = -1;
}
if (pin > 0 && attachedPin[i] != pin) {
ledcAttach(pin, PWM_FREQ, PWM_RES);
attachedPin[i] = pin;
Serial.printf("[PWM] attach ch%d pin%d\n", 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(meters[i].pin, 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(meters[idx].pin, 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["retain"] = true; // HA publishes commands retained so values survive restarts
doc["availability_topic"] = String(mqttCfg.prefix) + "/status";
doc["payload_available"] = MQTT_PAYLOAD_AVAILABLE;
doc["payload_not_available"] = MQTT_PAYLOAD_NOT_AVAILABLE;
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[768];
serializeJson(doc, payload, sizeof(payload));
bool pubOk = mqttClient.publish(topic, payload, true);
Serial.printf("[MQTT] discovery %s -> %s (ok=%d)\n", topic, payload, pubOk);
}
// Clear retained discovery for slots no longer in use (meters removed) so
// Home Assistant drops the stale entities instead of keeping them forever.
for (int i = meterCount; i < MAX_METERS; i++) {
String objId = String(devId) + "_meter_" + String(i) + "_current";
char topic[128];
snprintf(topic, sizeof(topic), "homeassistant/number/%s/config", objId.c_str());
mqttClient.publish(topic, "", true);
Serial.printf("[MQTT] discovery clear %s\n", topic);
}
}
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) {
Serial.printf("[MQTT] connect skipped en=%d host=%d\n",
mqttCfg.enabled, strlen(mqttCfg.host) > 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);
// Pass NULL for empty credentials so the broker treats it as an anonymous
// connection rather than an empty-string login (which some brokers reject).
const char* user = strlen(mqttCfg.user) > 0 ? mqttCfg.user : nullptr;
const char* pass = strlen(mqttCfg.pass) > 0 ? mqttCfg.pass : nullptr;
Serial.printf("[MQTT] connecting to %s:%d as %s\n", mqttCfg.host, mqttCfg.port, clientId);
bool ok = mqttClient.connect(clientId, user, pass,
statusTopic, 0, true, MQTT_PAYLOAD_NOT_AVAILABLE);
if (ok) {
Serial.printf("[MQTT] connected to %s:%d\n", mqttCfg.host, mqttCfg.port);
mqttClient.publish(statusTopic, MQTT_PAYLOAD_AVAILABLE, 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) 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, MQTT_PROBE_TIMEOUT_MS);
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");
}
}
static void applyWifiTxPower() {
#if defined(CONFIG_IDF_TARGET_ESP32C3)
WiFi.setTxPower(WIFI_POWER_MINUS_1dBm);
Serial.println("[WIFI] ESP32-C3 detected, lowering TX power for stability");
#else
Serial.println("[WIFI] using default TX power");
#endif
}
// ---------------------------------------------------------------------------
// 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 += "&lt;"; break;
case '>': out += "&gt;"; 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 handleLogo() {
File f = LittleFS.open("/logo.png", "r");
if (!f) { server.send(404, "text/plain", "Not found"); return; }
server.streamFile(f, "image/png");
f.close();
}
static void handleReset() {
if (!requireAuth()) { Serial.println("[HTTP] POST /reset -> 401"); return; }
Serial.println("[HTTP] POST /reset -> restarting");
server.send(200, "text/plain", "Restarting...");
delay(200);
ESP.restart();
}
static void handleRoot() {
if (!requireAuth()) { Serial.println("[HTTP] GET / -> 401"); return; }
Serial.println("[HTTP] GET /");
String meterRows;
bool useTabs = meterCount > 1;
if (useTabs) {
meterRows += "<div class=tabs><div class=tab-nav>";
for (int i = 0; i < meterCount; i++) {
String label = strlen(meters[i].name) > 0 ? escHtml(meters[i].name) : "Meter " + String(i);
meterRows += "<button type=button class=tab-btn" + String(i == 0 ? " active" : "") + " data-tab=" + String(i) + ">" + label + "</button>";
}
meterRows += "</div>";
}
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);
if (useTabs) meterRows += "<div class=tab-panel" + String(i == 0 ? " active" : "") + " data-tab=" + String(i) + ">";
meterRows += "<fieldset>";
if (!useTabs) meterRows += "<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>";
if (useTabs) meterRows += "</div>";
}
if (useTabs) meterRows += "</div>";
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>"
"<span>(username: admin)</span>"
"<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 += ".hdr{display:flex;align-items:center;gap:16px;margin-bottom:24px}.hdr img{width:72px;height:72px;flex-shrink:0}";
html += "h1{font-size:24px;margin-bottom:4px}.sub{color:#666;font-size:14px}";
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}.btn-danger{background:#dc2626}.btn-danger:hover{background:#b91c1c}.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 += ".tab-nav{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap}";
html += ".tab-btn{padding:8px 16px;border:1px solid #d1d5db;background:#f8f9fc;border-radius:8px 8px 0 0;cursor:pointer;font-size:13px;font-weight:600;color:#666;transition:all .2s;font-family:inherit}";
html += ".tab-btn:hover{background:#e8eaf0}.tab-btn.active{background:#fff;border-color:#4361ee;color:#4361ee;border-bottom-color:#fff}";
html += ".tab-panel{display:none}.tab-panel.active{display:block}";
html += "</style></head><body><div class=card>";
html += "<div class=hdr><img src=/logo.png alt=logo><div><h1>M1730</h1><div class=sub>Configuration</div></div></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 += "<form action=/reset method=post style=margin-top:8px>";
html += "<button class='btn btn-danger' type=submit>Reset Device</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();});";
if (meterCount > 1) {
html += "document.querySelectorAll('.tab-btn').forEach(function(b){b.addEventListener('click',function(){var t=this.dataset.tab;document.querySelectorAll('.tab-btn').forEach(function(x){x.classList.remove('active')});document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});this.classList.add('active');document.querySelector('.tab-panel[data-tab=\"'+t+'\"]').classList.add('active')})});";
}
html += "</script></body></html>";
server.send(200, "text/html", html);
Serial.printf("[HTTP] GET / -> 200 (%u bytes)\n", html.length());
}
static void handleNotFound() {
String uri = server.uri();
if (uri == "/favicon.ico" || uri == "/favicon.png" || uri == "/robots.txt" ||
uri == "/generate_204" || uri == "/hotspot-detect.html" ||
uri == "/connecttest.txt" || uri == "/redirect") {
server.sendHeader("Location", "/");
server.send(302);
return;
}
Serial.printf("[HTTP] 404 %s\n", uri.c_str());
server.send(404, "text/plain", "Not found");
}
static void handleConfig() {
if (!requireAuth()) { Serial.println("[HTTP] POST /config -> 401"); return; }
Serial.println("[HTTP] POST /config");
char oldHostname[64];
strlcpy(oldHostname, hostname, sizeof(oldHostname));
if (server.hasArg("hostname")) {
String val = server.arg("hostname");
val.trim();
if (val.length() > 0)
val.toCharArray(hostname, sizeof(hostname));
}
bool hostnameChanged = strcmp(hostname, oldHostname) != 0;
// Persist the connection fields regardless of the enable checkbox so edits
// made while disabling MQTT aren't discarded.
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));
mqttCfg.enabled = server.hasArg("mqtt_en");
Serial.printf("[HTTP] mqtt enabled=%d host=%s port=%u user=%s prefix=%s\n",
mqttCfg.enabled, mqttCfg.host, mqttCfg.port, mqttCfg.user, mqttCfg.prefix);
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;
server.sendHeader("Location", "/", true);
server.send(303);
if (hostnameChanged) {
Serial.printf("[HTTP] hostname changed %s -> %s, restarting\n", oldHostname, hostname);
delay(200);
ESP.restart();
}
}
static void handleSet() {
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(meters[idx].pin, duty);
}
mqttPublishCurrent(idx);
server.send(200);
}
static void startServer() {
server.on("/", handleRoot);
server.on("/logo.png", handleLogo);
server.on("/config", HTTP_POST, handleConfig);
server.on("/reset", HTTP_POST, handleReset);
server.on("/set", handleSet);
server.onNotFound(handleNotFound);
server.begin();
Serial.println("HTTP server started");
}
// ---------------------------------------------------------------------------
// Factory reset (5x = config only, 10x = config + WiFi credentials)
// ---------------------------------------------------------------------------
static void factoryReset(bool clearWifi) {
Serial.printf("[BOOT] factory reset triggered (clearWifi=%d)!\n", clearWifi);
// Preserve pin assignments and calibration across a config-only reset so
// the physical wiring doesn't need to be re-entered after every reset.
int count = meterCount;
int pins[MAX_METERS];
float maxD[MAX_METERS];
for (int i = 0; i < count; i++) {
pins[i] = meters[i].pin;
maxD[i] = meters[i].maxDuty;
}
// Reset hostname, auth, MQTT, and meter metadata
strlcpy(hostname, HOSTNAME_DEFAULT, sizeof(hostname));
hostnameParam.setValue(hostname, sizeof(hostname) - 1);
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 and calibration (unless WiFi reset, where a full clean slate
// makes more sense since the device is being re-provisioned anyway).
if (!clearWifi) {
for (int i = 0; i < count; i++) {
meters[i].pin = pins[i];
meters[i].maxDuty = maxD[i];
}
}
saveConfig();
if (clearWifi) {
WiFiManager wm;
wm.resetSettings();
Serial.println("[BOOT] WiFi credentials erased");
}
// Attach meters temporarily for the sweep acknowledgement
for (int i = 0; i < count && i < 8; i++) {
if (meters[i].pin > 0) {
ledcAttach(meters[i].pin, PWM_FREQ, PWM_RES);
}
}
// Sweep 0→100→0 over ~2 seconds as a visual confirmation
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(meters[i].pin, constrain((int)(duty * 1023), 0, 1023));
}
}
delay(50);
}
}
Serial.println("[BOOT] factory reset complete, restarting...");
delay(200);
ESP.restart();
}
static int incrementResetCount() {
int count = 0;
File f = LittleFS.open("/reset_count", "r");
if (f) { count = f.parseInt(); f.close(); }
count++;
f = LittleFS.open("/reset_count", "w");
if (f) { f.print(count); f.close(); }
Serial.printf("[BOOT] reset count %d\n", count);
return count;
}
static void applyFactoryResetIfNeeded(int count) {
const int THRESHOLD_CONFIG = 5;
const int THRESHOLD_WIFI = 10;
LittleFS.remove("/reset_count");
if (count >= THRESHOLD_CONFIG) {
Serial.printf("[BOOT] reset count %d, triggering factory reset\n", count);
factoryReset(count >= THRESHOLD_WIFI);
}
}
// ---------------------------------------------------------------------------
// Setup & loop
// ---------------------------------------------------------------------------
void setup() {
Serial.begin(115200);
Serial.println("\n\n=== M1730 boot ===");
Serial.printf("[BOOT] chip=%s rev=%d cores=%d cpu=%dMHz flash=%uMB\n",
ESP.getChipModel(), ESP.getChipRevision(), ESP.getChipCores(),
ESP.getCpuFreqMHz(), ESP.getFlashChipSize() / (1024 * 1024));
Serial.printf("[BOOT] free heap=%u sketch=%u / %u\n",
ESP.getFreeHeap(), ESP.getSketchSize(), ESP.getFreeSketchSpace());
if (!LittleFS.begin(false)) {
Serial.println("[FS] mount failed, formatting...");
LittleFS.begin(true);
} else {
Serial.println("[FS] mounted OK");
File root = LittleFS.open("/");
File f = root.openNextFile();
while (f) { Serial.printf("[FS] %s (%u bytes)\n", f.name(), f.size()); f = root.openNextFile(); }
}
loadConfig();
hostnameParam.setValue(hostname, sizeof(hostname) - 1);
for (int i = 0; i < meterCount; i++)
Serial.printf("[BOOT] meter[%d] pin=%d name=\"%s\" unit=\"%s\" maxD=%.1f range=[%.1f,%.1f] cur=%.1f\n",
i, meters[i].pin, meters[i].name, meters[i].unit,
meters[i].maxDuty, meters[i].rangeMin, meters[i].rangeMax, meters[i].currentValue);
Serial.printf("[BOOT] mqtt=%d broker=%s:%d prefix=%s\n",
mqttCfg.enabled, mqttCfg.host, mqttCfg.port, mqttCfg.prefix);
Serial.printf("[BOOT] auth=%d\n", authCfg.enabled);
int resetCount = incrementResetCount();
attachMeters();
applyMeters();
// Settle window: if the device stays powered past this point it wasn't a
// rapid power-cycle. After the delay, act on the accumulated count (5x =
// config reset, 10x = config + WiFi reset) then clear the counter.
delay(3000);
applyFactoryResetIfNeeded(resetCount);
applyWifiTxPower();
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 MAC: %s RSSI: %d\n",
WiFi.localIP().toString().c_str(), WiFi.macAddress().c_str(), WiFi.RSSI());
strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname));
saveConfig();
startServer();
startMDNS();
mqttReconnectAt = 0;
Serial.printf("[BOOT] ready at http://%s.local or http://%s\n",
hostname, WiFi.localIP().toString().c_str());
Serial.printf("[BOOT] free heap=%u\n", ESP.getFreeHeap());
}
void loop() {
server.handleClient();
mqttLoop();
}