Remove M1730 ammeter code; generalize to ESP32 device framework

This commit is contained in:
2026-06-28 23:37:51 +02:00
parent 757f5f4c06
commit 3c2f99ffad
2 changed files with 645 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
# ESP32 Device Framework
A reusable ESP32 firmware base with Wi-Fi, web configuration UI, authentication, MQTT / Home Assistant integration, and factory reset. Add device-specific logic on top without re-implementing the boilerplate.
## Table of contents
- [Features](#features)
- [Building and flashing](#building-and-flashing)
- [First-time Wi-Fi setup](#first-time-wi-fi-setup)
- [Web UI](#web-ui)
- [MQTT / Home Assistant](#mqtt--home-assistant)
- [HTTP API](#http-api)
- [Configuration reference](#configuration-reference)
- [Extending the framework](#extending-the-framework)
---
## Features
- **Wi-Fi** via WiFiManager — captive-portal setup on first boot, credentials stored in flash
- **mDNS** — device reachable as `<hostname>.local`
- **Web UI** — responsive configuration page served from the device
- **Authentication** — optional HTTP Basic Auth to protect the web UI
- **MQTT** — connects to any broker, publishes an availability topic, reconnects automatically without blocking the web server
- **Home Assistant discovery** — stub ready for your entities
- **Factory reset** — rapid 5× power-cycle clears all settings and restarts
- **Config persistence** — all settings stored as JSON in LittleFS (`/config.json`)
---
## Building and flashing
The project uses [PlatformIO](https://platformio.org/).
```bash
# Build
pio run
# Flash
pio run --target upload
# Open serial monitor (115200 baud)
pio device monitor
```
Board target: `esp32-s3-devkitc-1`
---
## First-time Wi-Fi setup
On first boot (or when stored Wi-Fi credentials are missing), the device starts an access point named **ESP32-Device**. Connect to it with any phone or laptop — a captive portal will appear automatically.
1. Enter your Wi-Fi SSID and password.
2. Optionally change the **Device hostname** (default: `esp32-device`).
3. Click **Save**. The device connects to your network and restarts.
After connecting, the device is reachable at:
- `http://<hostname>.local` (default `http://esp32-device.local`) — mDNS, works on most local networks
- `http://<IP address>` — shown in the serial monitor on boot
### Factory reset
If you lose the web UI password or need to clear all settings, **reset the device 5 times in quick succession** (press EN or cycle power 5× within a few seconds, before boot completes). All settings are wiped and the device restarts into the Wi-Fi captive portal.
Serial output shows the progress:
```
[BOOT] reset count 1/5
[BOOT] reset count 2/5
...
[BOOT] reset count 5/5, triggering factory reset
[BOOT] factory reset triggered!
[BOOT] factory reset complete, restarting...
```
---
## Web UI
Browse to the device address to open the configuration page.
### Info panel
Shows the current hostname and IP address.
### Hostname
Sets the mDNS name (`<hostname>.local`) and the MQTT client ID. Saved across reboots.
### Security
| Field | Description |
|-------|-------------|
| Require password | Enables HTTP Basic Auth on the web UI |
| Password | Password for the `admin` account. Leave blank to keep the existing password |
### MQTT
| Field | Description |
|-------|-------------|
| Enable | Toggle MQTT on/off |
| Broker | Hostname or IP of your MQTT broker |
| Port | Default `1883` |
| User / Pass | Broker credentials (leave Pass blank to keep the existing value) |
| Prefix | Topic prefix (default `device`) |
Click **Save** to persist all settings.
---
## MQTT / Home Assistant
### Connection behaviour
On connect the device publishes `online` to `<prefix>/status` (retained) and sets `offline` as the LWT. If the broker is unreachable, the firmware probes the TCP port before attempting a full MQTT connect and retries every 30 seconds without blocking the web server.
### Extending with discovery and topics
Three stubs in `main.cpp` are the intended extension points:
| Function | Purpose |
|----------|---------|
| `mqttSubscribe()` | Subscribe to command topics after connecting |
| `mqttCallback()` | Handle incoming messages |
| `mqttPublishDiscovery()` | Publish Home Assistant discovery payloads |
---
## HTTP API
### `GET /`
Returns the HTML configuration page.
### `POST /config`
Saves all configuration submitted by the HTML form. Redirects back to `/`.
---
## Configuration reference
Config is stored as JSON in LittleFS at `/config.json`.
```json
{
"hostname": "esp32-device",
"mqtt": {
"en": true,
"host": "192.168.1.10",
"port": 1883,
"user": "ha",
"pass": "secret",
"prefix": "device"
},
"auth": {
"en": true,
"pass": "secret"
}
}
```
The file is written by the web UI. To erase everything including flash, use `pio run --target erase`.
---
## Extending the framework
1. Add your hardware setup in `setup()` after the framework initialises.
2. Add your per-loop logic in `loop()` alongside `server.handleClient()` and `mqttLoop()`.
3. Fill in the three MQTT stubs to subscribe, receive, and publish discovery payloads.
4. Add extra web routes with `server.on(...)` in `startServer()` if you need device-specific endpoints.
5. Persist extra config fields by adding keys to `loadConfig()` and `saveConfig()`.
+470
View File
@@ -0,0 +1,470 @@
#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 "esp32-device"
#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 MqttConfig {
bool enabled = false;
char host[64] = "";
uint16_t port = 1883;
char user[32] = "";
char pass[32] = "";
char prefix[32] = "device";
};
struct AuthConfig {
bool enabled = false;
char pass[32] = "";
};
static char hostname[32] = HOSTNAME_DEFAULT;
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"] | "device", sizeof(mqttCfg.prefix));
}
JsonObject auth = doc["auth"];
if (!auth.isNull()) {
authCfg.enabled = auth["en"] | false;
strlcpy(authCfg.pass, auth["pass"] | "", sizeof(authCfg.pass));
}
Serial.printf("[CFG] loaded hostname=%s mqtt_en=%d\n", hostname, 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;
File f = LittleFS.open("/config.json", "w");
if (f) {
serializeJson(doc, f);
f.close();
Serial.printf("[CFG] saved hostname=%s mqtt_en=%d\n", hostname, mqttCfg.enabled);
} else {
Serial.println("[CFG] save failed");
}
}
// ---------------------------------------------------------------------------
// MQTT
// ---------------------------------------------------------------------------
static void mqttCallback(char* topic, byte* payload, unsigned int len) {
String valStr;
for (unsigned i = 0; i < len; i++) valStr += (char)payload[i];
Serial.printf("[MQTT] rcvd topic=%s payload=%s\n", topic, valStr.c_str());
// TODO: handle incoming messages
}
static void mqttPublishDiscovery() {
if (!mqttCfg.enabled || !mqttClient.connected()) return;
// TODO: publish Home Assistant discovery payloads
}
static void mqttSubscribe() {
if (!mqttCfg.enabled) return;
// TODO: subscribe to device-specific topics
}
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), "esp32-%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();
} 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");
}
}
// ---------------------------------------------------------------------------
// 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 handleRoot() {
if (!requireAuth()) { Serial.println("[HTTP] GET / -> 401"); return; }
Serial.println("[HTTP] GET /");
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>"
"<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>"
"</fieldset>";
}
String mqttSection;
{
char portStr[8];
snprintf(portStr, sizeof(portStr), "%u", mqttCfg.port);
String checked = mqttCfg.enabled ? " checked" : "";
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 +=
"<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>"
"<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>"
"</fieldset>";
}
String html;
html.reserve(3072);
html += "<!DOCTYPE html><html><head><meta charset=utf-8>";
html += "<meta name=viewport content='width=device-width,initial-scale=1'><title>ESP32 Device</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{width:100%;padding:9px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;outline:none;transition:border-color .2s;margin-bottom:12px}";
html += "input: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}";
html += "fieldset .row input{margin-bottom:0}";
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}fieldset .row label.switch{width:32px}";
html += ".switch input{opacity:0;width:0;height:0;margin-bottom: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>ESP32 Device</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>";
html += "<label>Hostname</label><input name=hostname value='" + String(hostname) + "'>";
html += authSection;
html += mqttSection;
html += "<button class=btn type=submit>Save</button></form>";
html += "<div class=mac>" + WiFi.macAddress() + "</div></div>";
html += "</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));
}
// Persist connection fields regardless of 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");
}
saveConfig();
// let mqttLoop handle reconnection to avoid blocking the HTTP handler
if (mqttClient.connected()) mqttClient.disconnect();
mqttReconnectAt = 0;
server.sendHeader("Location", "/", true);
server.send(303);
}
static void startServer() {
server.on("/", handleRoot);
server.on("/config", HTTP_POST, handleConfig);
server.begin();
Serial.println("HTTP server started");
}
// ---------------------------------------------------------------------------
// Factory reset (rapid reset 5x within a few seconds)
// ---------------------------------------------------------------------------
static void factoryReset() {
Serial.println("[BOOT] factory reset triggered!");
strlcpy(hostname, HOSTNAME_DEFAULT, sizeof(hostname));
hostnameParam.setValue(hostname, strlen(hostname));
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, "device", sizeof(mqttCfg.prefix));
saveConfig();
Serial.println("[BOOT] factory reset complete, restarting...");
delay(200);
ESP.restart();
}
static void checkFactoryReset() {
const int THRESHOLD = 5;
int count = 0;
File f = LittleFS.open("/reset_count", "r");
if (f) {
count = f.parseInt();
f.close();
}
count++;
if (count >= THRESHOLD) {
LittleFS.remove("/reset_count");
Serial.printf("[BOOT] reset count %d, triggering factory reset\n", count);
factoryReset();
return;
}
f = LittleFS.open("/reset_count", "w");
if (f) {
f.print(count);
f.close();
Serial.printf("[BOOT] reset count %d/%d\n", count, THRESHOLD);
}
}
// ---------------------------------------------------------------------------
// Setup & loop
// ---------------------------------------------------------------------------
void setup() {
Serial.begin(115200);
Serial.println("\n\n=== ESP32 Device boot ===");
LittleFS.begin(true);
loadConfig();
hostnameParam.setValue(hostname, strlen(hostname));
checkFactoryReset();
// Reset-counter settle window: if the device stays powered past this point it
// wasn't a rapid power-cycle, so clear the counter before the (possibly
// indefinitely blocking) WiFi portal.
delay(3000);
LittleFS.remove("/reset_count");
Serial.println("[WIFI] starting WiFiManager");
WiFiManager wm;
wm.setTitle("ESP32 Device");
wm.addParameter(&hostnameParam);
wm.setSaveParamsCallback([]() {
strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname));
saveConfig();
});
if (!wm.autoConnect("ESP32-Device")) {
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();
}