diff --git a/platformio.ini b/platformio.ini index 62ad05c..7b1ecd1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -16,3 +16,4 @@ monitor_speed = 115200 lib_deps = tzapu/WiFiManager@^2.0.17 bblanchon/ArduinoJson@^7.0.0 + knolleary/PubSubClient@^2.8 diff --git a/src/main.cpp b/src/main.cpp index b9b1c8c..d8f5d90 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #define HOSTNAME_DEFAULT "m1730" #define MAX_METERS 10 @@ -16,11 +18,24 @@ struct MeterConfig { float currentValue; }; +struct MqttConfig { + bool enabled = false; + char host[64] = ""; + uint16_t port = 1883; + char user[32] = ""; + char pass[32] = ""; + char prefix[32] = "m1730"; +}; + static char hostname[32] = HOSTNAME_DEFAULT; static int meterCount = 0; static MeterConfig meters[MAX_METERS] = {}; +static MqttConfig mqttCfg; 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) @@ -39,12 +54,22 @@ static void loadConfig() { 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)); + } + 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].pin = m["pin"] | 0; + meters[i].maxDuty = m["maxD"] | 0.0f; meters[i].currentValue = m["current"] | 0.0f; } } @@ -53,6 +78,14 @@ 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; + JsonArray arr = doc["meters"].to(); for (int i = 0; i < meterCount; i++) { JsonObject m = arr.add(); @@ -90,6 +123,109 @@ static void applyMeters() { } } +// --------------------------------------------------------------------------- +// MQTT +// --------------------------------------------------------------------------- + +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(); + + // 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") { + meters[idx].currentValue = val; + if (meters[idx].pin > 0 && meters[idx].maxDuty > 0) { + float pct = val / 100.0f * meters[idx].maxDuty / 100.0f; + ledcWrite(idx, constrain((int)(pct * 1023), 0, 1023)); + } + saveConfig(); + } else if (suffix == "/maxduty/set") { + meters[idx].maxDuty = constrain(val, 0, 100); + attachMeters(); + applyMeters(); + saveConfig(); + } +} + +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); + snprintf(val, sizeof(val), "%.1f", meters[idx].currentValue); + mqttClient.publish(topic, val, true); +} + +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); + snprintf(t, sizeof(t), "%s/meter/%d/maxduty/set", mqttCfg.prefix, i); + mqttClient.subscribe(t); + } +} + +static bool mqttConnect() { + if (!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]); + } + + mqttClient.setServer(mqttCfg.host, mqttCfg.port); + mqttClient.setCallback(mqttCallback); + + bool ok = mqttClient.connect(clientId, mqttCfg.user, mqttCfg.pass); + + if (ok) { + Serial.printf("MQTT connected to %s:%d\n", mqttCfg.host, mqttCfg.port); + mqttSubscribe(); + } 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) { + // quick TCP probe with short timeout before the blocking MQTT connect + WiFiClient probe; + bool reachable = probe.connect(mqttCfg.host, mqttCfg.port, 1500); + probe.stop(); + + if (reachable) { + if (mqttConnect()) mqttReconnectAt = 0; + else mqttReconnectAt = now + 30000; + } else { + mqttReconnectAt = now + 30000; + } + } + } else { + mqttClient.loop(); + } +} + // --------------------------------------------------------------------------- // mDNS // --------------------------------------------------------------------------- @@ -103,6 +239,24 @@ static void startMDNS() { } } +// --------------------------------------------------------------------------- +// 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; +} + // --------------------------------------------------------------------------- // Web handlers // --------------------------------------------------------------------------- @@ -131,89 +285,84 @@ static void handleRoot() { countOpts += ">" + String(i) + ""; } - String html = R"( - - - - - -M1730 - - - -
-

M1730

-
Configuration
-
-
Hostname)" + String(hostname) + R"(
-
IP)" + WiFi.localIP().toString() + R"(
-
+ String mqttSection; + { + char portStr[8]; + snprintf(portStr, sizeof(portStr), "%u", mqttCfg.port); + String checked = mqttCfg.enabled ? " checked" : ""; -
- - + mqttSection += + "
MQTT" + "
" + "" + "" + "
"; -
-
- - -
-
+ String hostVal = escHtml(mqttCfg.host); + String userVal = escHtml(mqttCfg.user); + String passVal = escHtml(mqttCfg.pass); + String prefVal = escHtml(mqttCfg.prefix); - )" + meterRows + R"( + mqttSection += + "
" + "
" + "
" + "
" + "
"; + mqttSection += "
"; + } - -
- -
)" + WiFi.macAddress() + R"(
-
- - - - -)"; + String html; + html.reserve(4096); + html += ""; + html += "M1730
"; + html += "

M1730

Configuration
"; + html += "
Hostname" + String(hostname) + "
"; + html += "
IP" + WiFi.localIP().toString() + "
"; + html += "
"; + html += ""; + html += "
"; + html += "
"; + html += meterRows; + html += mqttSection; + html += "
"; + html += "
" + WiFi.macAddress() + "
"; + html += ""; server.send(200, "text/html", html); } @@ -225,6 +374,22 @@ static void handleConfig() { 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")) + 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)); + } else { + mqttCfg.enabled = false; + } + int newCount = meterCount; if (server.hasArg("meter_count")) newCount = constrain(server.arg("meter_count").toInt(), 1, MAX_METERS); @@ -247,6 +412,10 @@ static void handleConfig() { attachMeters(); applyMeters(); + // let mqttLoop handle connection to avoid blocking the HTTP handler + if (mqttClient.connected()) mqttClient.disconnect(); + mqttReconnectAt = 0; + server.sendHeader("Location", "/"); server.send(303); } @@ -263,6 +432,7 @@ static void handleSet() { ledcWrite(idx, duty); } saveConfig(); + mqttPublishCurrent(idx); server.send(200); } @@ -283,6 +453,7 @@ void setup() { LittleFS.begin(true); loadConfig(); + attachMeters(); applyMeters(); @@ -304,6 +475,7 @@ void setup() { saveConfig(); startServer(); startMDNS(); + mqttReconnectAt = 0; // handshake deferred to mqttLoop in loop() Serial.printf("Board address: http://%s.local or http://%s\n", hostname, WiFi.localIP().toString().c_str()); @@ -311,4 +483,5 @@ void setup() { void loop() { server.handleClient(); -} \ No newline at end of file + mqttLoop(); +}