#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" #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()) 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; 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["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: /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(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 += "&"; break; case '<': out += "<"; break; 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 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 += "
"; for (int i = 0; i < meterCount; i++) { String label = strlen(meters[i].name) > 0 ? escHtml(meters[i].name) : "Meter " + String(i); meterRows += ""; } 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); if (useTabs) meterRows += "
"; meterRows += "
"; if (!useTabs) meterRows += "Meter " + String(i) + ""; meterRows += "
"; meterRows += "
"; meterRows += "
"; meterRows += "
"; meterRows += "
"; meterRows += "
"; meterRows += "
"; meterRows += ""; meterRows += ""; meterRows += "" + String(curStr) + "%"; meterRows += "
"; if (useTabs) meterRows += "
"; } if (useTabs) meterRows += "
"; String countOpts; for (int i = 1; i <= MAX_METERS; i++) { countOpts += "