660 lines
26 KiB
C++
660 lines
26 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"
|
|
|
|
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 += "&"; break;
|
|
case '\'': out += "'"; break;
|
|
case '"': out += """; 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 — 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 — 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}";
|
|
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");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup & loop
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
Serial.println("\n\n=== M1730 boot ===");
|
|
LittleFS.begin(true);
|
|
|
|
loadConfig();
|
|
hostnameParam.setValue(hostname, strlen(hostname));
|
|
|
|
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;
|
|
|
|
Serial.printf("[BOOT] ready at http://%s.local or http://%s\n",
|
|
hostname, WiFi.localIP().toString().c_str());
|
|
}
|
|
|
|
void loop() {
|
|
server.handleClient();
|
|
mqttLoop();
|
|
}
|