#include #include #include #include #include #include #include #include #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()) 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(); 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(); 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(); auth["en"] = authCfg.enabled; auth["pass"] = authCfg.pass; JsonArray arr = doc["meters"].to(); for (int i = 0; i < meterCount; i++) { JsonObject m = arr.add(); 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: /meter//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 += "
Meter " + String(i) + ""; meterRows += "
"; meterRows += "
"; meterRows += "
"; meterRows += "
"; meterRows += "
"; meterRows += "
"; meterRows += "
"; meterRows += ""; meterRows += ""; meterRows += "" + String(curStr) + "%"; meterRows += "
"; } String countOpts; for (int i = 1; i <= MAX_METERS; i++) { countOpts += "