Compare commits

...

13 Commits

Author SHA1 Message Date
adebaumann 034a900c20 Merge pull request 'Main' (#3) from main into feature/esp32-arduino-3x
Reviewed-on: #3
2026-07-06 08:53:21 +00:00
adebaumann 75acb4a6b2 Documentation cards added 2026-07-06 10:51:52 +02:00
adebaumann 8e054ca1ec Arduino 3 later commit 2026-07-06 10:51:38 +02:00
adebaumann 0a2afbaa24 Arduino 3 later commit 2026-07-06 01:26:09 +02:00
adebaumann 558a12340a Merge commit 2026-07-05 23:25:47 +02:00
adebaumann d4745997dc Update platform version and enhance WiFi handling
- Set platform version to espressif32@^6.10.0 for all environments
- Add applyWifiTxPower function to adjust TX power for ESP32-C3
- Implement handleNotFound function for improved HTTP 404 handling
2026-07-05 23:18:17 +02:00
adebaumann aa977e7c89 Migrate all ESP32 envs to Arduino-ESP32 3.x (pioarduino)
The registry espressif32 platform still ships Arduino core 2.0.17, which
lacks ESP32-C6 support. Switch every ESP32 env to the pioarduino fork
(Arduino-ESP32 3.x) via a shared ${common.platform_esp32} variable and
consolidate shared settings into [env].

Rewrite the PWM code for the 3.x LEDC API:
- ledcSetup + ledcAttachPin -> ledcAttach(pin, freq, res)
- ledcDetachPin -> ledcDetach
- ledcWrite now addresses PWM by pin instead of channel index

Adds the esp32-c6-devkitc-1 env; all four ESP32 envs build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 00:24:11 +02:00
adebaumann 82621398b0 More debugging 2026-07-04 22:23:42 +02:00
adebaumann 547f9a42b4 Display multiple meters in tabbed interface 2026-07-04 20:35:28 +02:00
adebaumann c55eea0e14 Label for username added 2026-07-04 20:30:25 +02:00
adebaumann bec0d8d31a Add logo header to web UI, matching Device-Framework layout 2026-07-04 13:00:52 +02:00
adebaumann e64f1c1b85 Framework updated 2026-07-03 20:43:46 +02:00
adebaumann ee73a0f9ba Merge pull request 'Fix meter lifecycle and MQTT/HA integration bugs' (#1) from fix-attach-meters-detach into main
Reviewed-on: #1
2026-06-26 23:53:15 +00:00
7 changed files with 457 additions and 57 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [ "recommendations": [
"Jason2866.esp-decoder",
"pioarduino.pioarduino-ide",
"platformio.platformio-ide" "platformio.platformio-ide"
], ],
"unwantedRecommendations": [ "unwantedRecommendations": [
+77
View File
@@ -0,0 +1,77 @@
========================================================
M1730 ANALOG PANEL METER CONTROLLER — INSTRUCTION CARD
========================================================
(Meters and current-limiting resistors are already installed.)
FIRST-TIME WI-FI SETUP
- Power on the device (USB or 5V supply).
- On your phone/laptop, join the Wi-Fi network named "M1730".
- A setup page opens automatically (captive portal).
- Pick your Wi-Fi network, enter its password.
- Optionally set a Device hostname (default: m1730).
- Tap Save. The device joins your network and restarts.
OPENING THE CONFIG PAGE
- Browse to http://m1730.local (or your chosen hostname).
- If .local doesn't resolve, use the IP address shown on the
serial monitor at boot: http://<IP>
- The Info panel at the top shows the current hostname and IP.
CONFIGURING A METER
- Use the "Meters" dropdown to set how many meters (1-10).
- For each meter (shown as tabs when more than one):
* Pin - GPIO pin the meter is wired to (already set)
* Name - label shown in the UI and Home Assistant
* Unit - optional (e.g. W, A, °C)
* Min/Max - the meter's real-world range (e.g. 0-50 A)
* Max Duty - calibration for full-scale deflection (%)
* Output - live slider, moves the needle right away
- Tap Save to store all settings (survives reboots).
CALIBRATING FULL SCALE (Max Duty)
- Drag the Output slider to 100%.
- Slowly raise "Max Duty" until the needle just reaches the
end of the scale. Don't overshoot. Tap Save.
MOVING THE NEEDLE
- Web UI: drag a meter's Output slider (0-100%).
- Values sent over MQTT/Home Assistant use the real Min-Max
range (e.g. send 25 to a 0-50 meter = half scale).
HTTP CONTROL (DIRECT URL)
- Move a needle from a browser or script with a GET request:
http://m1730.local/set?i=<meter>&v=<value>
* i = meter index, starting at 0 (first meter = 0)
* v = value 0-100 (percent of full scale)
- Example: http://m1730.local/set?i=0&v=75 -> meter 0 to 75%.
- Applies instantly, saves it, and publishes to MQTT.
- Returns 200 OK on success, 400 on bad input.
HOME ASSISTANT (MQTT)
- Open the MQTT section on the config page.
- Enable, then enter Broker host, Port (1883), User, Pass,
and Prefix (default: m1730). Tap Save.
- Each meter shows up automatically in Home Assistant as a
Number entity, grouped under one device (the hostname).
SECURITY (OPTIONAL PASSWORD)
- In the Security section, turn on "Require password" and set
a password. Username is always: admin
- Leave the password field blank to keep the existing one.
RESTART
- "Reset Device" button = simple reboot (keeps all settings).
FACTORY RESET (if locked out or misconfigured)
- Power-cycle (or press EN) 5 times quickly, within a few
seconds each, before boot completes.
* 5x = clears settings BUT keeps meter pins + calibration
* 10x = clears everything, including saved Wi-Fi
- All meters sweep 0 -> full -> 0 to confirm the reset.
- After a 10x reset, redo "First-time Wi-Fi setup" above.
TIPS
- Settings are saved on the device and survive power loss.
- If the meter reads zero unexpectedly, check the Pin number
and that Max Duty is greater than 0.
========================================================
+84
View File
@@ -0,0 +1,84 @@
========================================================
M1730 ANALOG-ZEIGERINSTRUMENT-STEUERUNG — KURZANLEITUNG
========================================================
(Messwerke und Vorwiderstände sind bereits eingebaut.)
ERSTE WLAN-EINRICHTUNG
- Gerät einschalten (USB oder 5-V-Netzteil).
- Am Handy/Laptop das WLAN mit dem Namen "M1730" auswählen.
- Die Einrichtungsseite öffnet sich automatisch (Captive Portal).
- Eigenes WLAN auswählen und dessen Passwort eingeben.
- Optional einen Gerätenamen (Hostname) festlegen (Standard: m1730).
- Auf Speichern tippen. Das Gerät verbindet sich und startet neu.
KONFIGURATIONSSEITE ÖFFNEN
- Im Browser aufrufen: http://m1730.local (oder den gewählten
Hostnamen).
- Falls .local nicht auflöst, die beim Start im seriellen Monitor
angezeigte IP-Adresse verwenden: http://<IP>
- Das Info-Feld oben zeigt aktuellen Hostnamen und IP-Adresse.
MESSWERK KONFIGURIEREN
- Über das Auswahlfeld "Meters" die Anzahl der Messwerke
festlegen (1-10).
- Für jedes Messwerk (als Reiter dargestellt, wenn mehr als eins):
* Pin - GPIO-Pin, an dem das Messwerk hängt (bereits gesetzt)
* Name - Bezeichnung in der Oberfläche und in Home Assistant
* Unit - optional (z. B. W, A, °C)
* Min/Max - realer Messbereich (z. B. 0-50 A)
* Max Duty - Kalibrierung für den Vollausschlag (%)
* Output - Live-Schieberegler, bewegt den Zeiger sofort
- Auf Speichern tippen, um alle Einstellungen zu sichern
(bleiben nach Neustart erhalten).
VOLLAUSSCHLAG KALIBRIEREN (Max Duty)
- Den Output-Schieberegler auf 100 % ziehen.
- "Max Duty" langsam erhöhen, bis der Zeiger gerade das Ende
der Skala erreicht. Nicht überschreiten. Speichern.
ZEIGER BEWEGEN
- Weboberfläche: den Output-Schieberegler ziehen (0-100 %).
- Über MQTT/Home Assistant gesendete Werte verwenden den realen
Min-Max-Bereich (z. B. 25 an ein 0-50-Messwerk = halber
Ausschlag).
HTTP-STEUERUNG (DIREKTE URL)
- Einen Zeiger per GET-Anfrage aus Browser oder Skript bewegen:
http://m1730.local/set?i=<messwerk>&v=<wert>
* i = Index des Messwerks, beginnend bei 0 (erstes = 0)
* v = Wert 0-100 (Prozent des Vollausschlags)
- Beispiel: http://m1730.local/set?i=0&v=75 -> Messwerk 0 auf 75 %.
- Wirkt sofort, wird gespeichert und an MQTT veröffentlicht.
- Antwort: 200 OK bei Erfolg, 400 bei ungültiger Eingabe.
HOME ASSISTANT (MQTT)
- Auf der Konfigurationsseite den Abschnitt MQTT öffnen.
- Aktivieren, dann Broker-Host, Port (1883), Benutzer, Passwort
und Präfix (Standard: m1730) eingeben. Speichern.
- Jedes Messwerk erscheint automatisch in Home Assistant als
Number-Entität, gruppiert unter einem Gerät (dem Hostnamen).
SICHERHEIT (OPTIONALES PASSWORT)
- Im Abschnitt Security "Require password" aktivieren und ein
Passwort setzen. Der Benutzername ist immer: admin
- Das Passwortfeld leer lassen, um das bestehende beizubehalten.
NEUSTART
- Schaltfläche "Reset Device" = einfacher Neustart (alle
Einstellungen bleiben erhalten).
WERKSRESET (bei Aussperrung oder Fehlkonfiguration)
- Gerät 5-mal schnell aus- und einschalten (oder EN drücken),
jeweils innerhalb weniger Sekunden, bevor der Start abgeschlossen ist.
* 5x = löscht Einstellungen, BEHÄLT aber Pins + Kalibrierung
* 10x = löscht alles, einschließlich gespeichertem WLAN
- Alle Messwerke fahren 0 -> Voll -> 0 zur Bestätigung des Resets.
- Nach einem 10x-Reset die "ERSTE WLAN-EINRICHTUNG" oben
erneut durchführen.
HINWEISE
- Einstellungen werden im Gerät gespeichert und überstehen
einen Stromausfall.
- Zeigt ein Messwerk unerwartet null an, den Pin prüfen und
sicherstellen, dass Max Duty größer als 0 ist.
========================================================
+90
View File
@@ -0,0 +1,90 @@
========================================================
CONTRÔLEUR DE GALVANOMÈTRE ANALOGIQUE M1730 — GUIDE RAPIDE
========================================================
(Les galvanomètres et résistances série sont déjà installés.)
PREMIÈRE CONFIGURATION WI-FI
- Mettre l'appareil sous tension (USB ou alimentation 5 V).
- Sur votre téléphone/ordinateur, rejoindre le réseau Wi-Fi
nommé « M1730 ».
- La page de configuration s'ouvre automatiquement (portail captif).
- Choisir votre réseau Wi-Fi et saisir son mot de passe.
- Éventuellement définir un nom d'hôte (Hostname) (défaut : m1730).
- Appuyer sur Save. L'appareil rejoint le réseau et redémarre.
OUVRIR LA PAGE DE CONFIGURATION
- Dans le navigateur, aller à : http://m1730.local
(ou le nom d'hôte choisi).
- Si .local ne fonctionne pas, utiliser l'adresse IP affichée
au démarrage dans le moniteur série : http://<IP>
- Le panneau Info en haut indique le nom d'hôte et l'adresse IP.
CONFIGURER UN GALVANOMÈTRE
- Utiliser le menu déroulant « Meters » pour définir le nombre
de galvanomètres (1-10).
- Pour chaque galvanomètre (affiché en onglets s'il y en a
plusieurs) :
* Pin - broche GPIO câblée au galvanomètre (déjà définie)
* Name - libellé affiché dans l'interface et Home Assistant
* Unit - facultatif (p. ex. W, A, °C)
* Min/Max - plage réelle de mesure (p. ex. 0-50 A)
* Max Duty - calibrage pour la déviation pleine échelle (%)
* Output - curseur en direct, déplace l'aiguille aussitôt
- Appuyer sur Save pour enregistrer tous les réglages
(conservés après redémarrage).
CALIBRER LA PLEINE ÉCHELLE (Max Duty)
- Amener le curseur Output à 100 %.
- Augmenter lentement « Max Duty » jusqu'à ce que l'aiguille
atteigne juste le bout de l'échelle. Ne pas dépasser. Save.
DÉPLACER L'AIGUILLE
- Interface web : faire glisser le curseur Output (0-100 %).
- Les valeurs envoyées via MQTT/Home Assistant utilisent la
plage réelle Min-Max (p. ex. envoyer 25 à un galvanomètre
0-50 = mi-échelle).
CONTRÔLE HTTP (URL DIRECTE)
- Déplacer une aiguille via une requête GET depuis un
navigateur ou un script :
http://m1730.local/set?i=<galvanometre>&v=<valeur>
* i = index du galvanomètre, à partir de 0 (le premier = 0)
* v = valeur 0-100 (pourcentage de la pleine échelle)
- Exemple : http://m1730.local/set?i=0&v=75 -> galvanomètre 0 à 75 %.
- Effet immédiat, enregistré et publié sur MQTT.
- Réponse : 200 OK en cas de succès, 400 si l'entrée est invalide.
HOME ASSISTANT (MQTT)
- Ouvrir la section MQTT sur la page de configuration.
- Activer, puis saisir l'hôte du Broker, le Port (1883),
l'utilisateur, le mot de passe et le préfixe (défaut : m1730).
Save.
- Chaque galvanomètre apparaît automatiquement dans Home
Assistant comme entité Number, regroupée sous un seul
appareil (le nom d'hôte).
SÉCURITÉ (MOT DE PASSE FACULTATIF)
- Dans la section Security, activer « Require password » et
définir un mot de passe. Le nom d'utilisateur est toujours : admin
- Laisser le champ mot de passe vide pour conserver l'actuel.
REDÉMARRAGE
- Bouton « Reset Device » = simple redémarrage (tous les
réglages sont conservés).
RÉINITIALISATION D'USINE (si verrouillé ou mal configuré)
- Éteindre/rallumer (ou appuyer sur EN) 5 fois rapidement,
à quelques secondes d'intervalle, avant la fin du démarrage.
* 5x = efface les réglages MAIS conserve broches + calibrage
* 10x = efface tout, y compris le Wi-Fi enregistré
- Tous les galvanomètres balaient 0 -> plein -> 0 pour
confirmer la réinitialisation.
- Après une réinitialisation 10x, refaire la « PREMIÈRE
CONFIGURATION WI-FI » ci-dessus.
CONSEILS
- Les réglages sont enregistrés dans l'appareil et survivent
à une coupure de courant.
- Si un galvanomètre indique zéro de façon inattendue, vérifier
le numéro de Pin et que Max Duty est supérieur à 0.
========================================================
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+46 -4
View File
@@ -8,12 +8,54 @@
; Please visit documentation for the other options and examples ; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html ; https://docs.platformio.org/page/projectconf.html
[env:esp32-s3-devkitc-1] [common]
platform = espressif32 ; All ESP32 targets use the pioarduino fork, which ships Arduino-ESP32 3.x
board = esp32-s3-devkitc-1 ; (the registry espressif32 platform is still on 2.0.17 and lacks C6 support).
platform_esp32 = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip
[env]
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
lib_deps = lib_deps =
tzapu/WiFiManager@^2.0.17 tzapu/WiFiManager@^2.0.17
bblanchon/ArduinoJson@^7.0.0 bblanchon/ArduinoJson@^7.4.3
knolleary/PubSubClient@^2.8 knolleary/PubSubClient@^2.8
[env:esp32-s3-devkitc-1]
board = esp32-s3-devkitc-1
board_build.filesystem = littlefs
; Arduino-ESP32 3.x images overflow the default dual-OTA app slot; this
; firmware has no OTA, so use a single large app partition.
board_build.partitions = huge_app.csv
[env:esp32dev]
platform = ${common.platform_esp32}
board = esp32dev
board_build.filesystem = littlefs
; Arduino-ESP32 3.x images overflow the default dual-OTA app slot; this
; firmware has no OTA, so use a single large app partition.
board_build.partitions = huge_app.csv
[env:esp32-C3-devkitm-1]
platform = ${common.platform_esp32}
board = esp32-c3-devkitm-1
board_build.filesystem = littlefs
; Arduino-ESP32 3.x images overflow the default dual-OTA app slot; this
; firmware has no OTA, so use a single large app partition.
board_build.partitions = huge_app.csv
[env:esp32-c6-devkitc-1]
platform = ${common.platform_esp32}
board = esp32-c6-devkitc-1
board_build.filesystem = littlefs
; Arduino-ESP32 3.x images overflow the default dual-OTA app slot; this
; firmware has no OTA, so use a single large app partition.
board_build.partitions = huge_app.csv
[env:rpipico2w]
; RP2350 support requires the maxgerhardt fork of the raspberrypi platform
; and the earlephilhower arduino-pico core.
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
board = rpipico2w
board_build.core = earlephilhower
board_build.filesystem_size = 0.5m
+154 -47
View File
@@ -41,12 +41,12 @@ struct AuthConfig {
char pass[32] = ""; char pass[32] = "";
}; };
static char hostname[32] = HOSTNAME_DEFAULT; static char hostname[64] = HOSTNAME_DEFAULT;
static int meterCount = 0; static int meterCount = 0;
static MeterConfig meters[MAX_METERS] = {}; static MeterConfig meters[MAX_METERS] = {};
static MqttConfig mqttCfg; static MqttConfig mqttCfg;
static AuthConfig authCfg; static AuthConfig authCfg;
static WiFiManagerParameter hostnameParam("hostname", "Device hostname", hostname, 32); static WiFiManagerParameter hostnameParam("hostname", "Device hostname", hostname, 63);
static WebServer server(80); static WebServer server(80);
static WiFiClient wifiClient; static WiFiClient wifiClient;
static PubSubClient mqttClient(wifiClient); static PubSubClient mqttClient(wifiClient);
@@ -152,7 +152,7 @@ static void attachMeters() {
// Release the channel if it's no longer used or its pin changed. // Release the channel if it's no longer used or its pin changed.
if (attachedPin[i] != -1 && attachedPin[i] != pin) { if (attachedPin[i] != -1 && attachedPin[i] != pin) {
ledcDetachPin(attachedPin[i]); ledcDetach(attachedPin[i]);
pinMode(attachedPin[i], OUTPUT); pinMode(attachedPin[i], OUTPUT);
digitalWrite(attachedPin[i], LOW); // drive freed pin low so the meter reads zero 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]); Serial.printf("[PWM] detach ch%d pin%d\n", i, attachedPin[i]);
@@ -160,8 +160,7 @@ static void attachMeters() {
} }
if (pin > 0 && attachedPin[i] != pin) { if (pin > 0 && attachedPin[i] != pin) {
ledcSetup(i, PWM_FREQ, PWM_RES); ledcAttach(pin, PWM_FREQ, PWM_RES);
ledcAttachPin(pin, i);
attachedPin[i] = pin; attachedPin[i] = pin;
Serial.printf("[PWM] attach ch%d pin%d\n", i, pin); Serial.printf("[PWM] attach ch%d pin%d\n", i, pin);
} }
@@ -173,7 +172,7 @@ static void applyMeters() {
if (meters[i].pin <= 0 || meters[i].maxDuty <= 0) continue; if (meters[i].pin <= 0 || meters[i].maxDuty <= 0) continue;
float pct = meters[i].currentValue / 100.0f * meters[i].maxDuty / 100.0f; float pct = meters[i].currentValue / 100.0f * meters[i].maxDuty / 100.0f;
int duty = constrain((int)(pct * 1023), 0, 1023); int duty = constrain((int)(pct * 1023), 0, 1023);
ledcWrite(i, duty); 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); Serial.printf("[PWM] ch%d duty=%d (cur=%.1f maxD=%.1f)\n", i, duty, meters[i].currentValue, meters[i].maxDuty);
} }
} }
@@ -222,7 +221,7 @@ static void mqttCallback(char* topic, byte* payload, unsigned int len) {
meters[idx].currentValue = physicalToPct(val, idx); meters[idx].currentValue = physicalToPct(val, idx);
if (meters[idx].pin > 0 && meters[idx].maxDuty > 0) { if (meters[idx].pin > 0 && meters[idx].maxDuty > 0) {
float pct = meters[idx].currentValue / 100.0f * meters[idx].maxDuty / 100.0f; float pct = meters[idx].currentValue / 100.0f * meters[idx].maxDuty / 100.0f;
ledcWrite(idx, constrain((int)(pct * 1023), 0, 1023)); ledcWrite(meters[idx].pin, constrain((int)(pct * 1023), 0, 1023));
} }
mqttPublishCurrent(idx); mqttPublishCurrent(idx);
} }
@@ -382,6 +381,15 @@ static void startMDNS() {
} }
} }
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 // Web helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -413,10 +421,34 @@ static bool requireAuth() {
// Web handlers // 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() { static void handleRoot() {
if (!requireAuth()) { Serial.println("[HTTP] GET / -> 401"); return; } if (!requireAuth()) { Serial.println("[HTTP] GET / -> 401"); return; }
Serial.println("[HTTP] GET /"); Serial.println("[HTTP] GET /");
String meterRows; String meterRows;
bool useTabs = meterCount > 1;
if (useTabs) {
meterRows += "<div class=tabs><div class=tab-nav>";
for (int i = 0; i < meterCount; i++) {
String label = strlen(meters[i].name) > 0 ? escHtml(meters[i].name) : "Meter " + String(i);
meterRows += "<button type=button class=tab-btn" + String(i == 0 ? " active" : "") + " data-tab=" + String(i) + ">" + label + "</button>";
}
meterRows += "</div>";
}
for (int i = 0; i < meterCount; i++) { for (int i = 0; i < meterCount; i++) {
char maxStr[8], curStr[8]; char maxStr[8], curStr[8];
dtostrf(meters[i].maxDuty, 1, 1, maxStr); dtostrf(meters[i].maxDuty, 1, 1, maxStr);
@@ -428,7 +460,9 @@ static void handleRoot() {
dtostrf(meters[i].rangeMin, 1, 1, rMinStr); dtostrf(meters[i].rangeMin, 1, 1, rMinStr);
dtostrf(meters[i].rangeMax, 1, 1, rMaxStr); dtostrf(meters[i].rangeMax, 1, 1, rMaxStr);
meterRows += "<fieldset><legend>Meter " + String(i) + "</legend>"; if (useTabs) meterRows += "<div class=tab-panel" + String(i == 0 ? " active" : "") + " data-tab=" + String(i) + ">";
meterRows += "<fieldset>";
if (!useTabs) meterRows += "<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>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>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>Unit</label><input name=m" + String(i) + "_unit value='" + unitVal + "'></div>";
@@ -440,7 +474,9 @@ static void handleRoot() {
meterRows += "<input name=m" + String(i) + "_cur type=range min=0 max=100 step=0.1 value=" + String(curStr) + ">"; 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 += "<span class=val id=m" + String(i) + ">" + String(curStr) + "%</span>";
meterRows += "</div></fieldset>"; meterRows += "</div></fieldset>";
if (useTabs) meterRows += "</div>";
} }
if (useTabs) meterRows += "</div>";
String countOpts; String countOpts;
for (int i = 1; i <= MAX_METERS; i++) { for (int i = 1; i <= MAX_METERS; i++) {
@@ -458,6 +494,7 @@ static void handleRoot() {
"<fieldset><legend>Security</legend>" "<fieldset><legend>Security</legend>"
"<div class=row>" "<div class=row>"
"<label class=tgl-lbl>Require password</label>" "<label class=tgl-lbl>Require password</label>"
"<span>(username: admin)</span>"
"<label class=switch><input type=checkbox name=auth_en" + checked + "><span class=slid></span></label>" "<label class=switch><input type=checkbox name=auth_en" + checked + "><span class=slid></span></label>"
"</div>" "</div>"
"<div class=row><label>Password</label><input name=auth_pass type=password placeholder='" + String(passHint) + "'></div>"; "<div class=row><label>Password</label><input name=auth_pass type=password placeholder='" + String(passHint) + "'></div>";
@@ -498,7 +535,8 @@ static void handleRoot() {
html += "*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}"; 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 += "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 += ".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 += ".hdr{display:flex;align-items:center;gap:16px;margin-bottom:24px}.hdr img{width:72px;height:72px;flex-shrink:0}";
html += "h1{font-size:24px;margin-bottom:4px}.sub{color:#666;font-size:14px}";
html += ".info{background:#f8f9fc;border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:14px}"; 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 += ".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 += "label{display:block;font-size:14px;font-weight:600;margin-bottom:4px}";
@@ -515,13 +553,17 @@ static void handleRoot() {
html += ".count-row{display:flex;gap:12px;align-items:flex-end;margin-bottom:20px}.count-row > div{flex:1}"; 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 += ".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{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 += ".btn:hover{background:#3651d4}.btn-danger{background:#dc2626}.btn-danger:hover{background:#b91c1c}.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 += ".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}.slid{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:#ccc;border-radius:18px;transition:.3s}"; 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 += ".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 += ".switch input:checked+.slid{background:#4361ee}.switch input:checked+.slid::before{transform:translateX(14px)}";
html += ".tab-nav{display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap}";
html += ".tab-btn{padding:8px 16px;border:1px solid #d1d5db;background:#f8f9fc;border-radius:8px 8px 0 0;cursor:pointer;font-size:13px;font-weight:600;color:#666;transition:all .2s;font-family:inherit}";
html += ".tab-btn:hover{background:#e8eaf0}.tab-btn.active{background:#fff;border-color:#4361ee;color:#4361ee;border-bottom-color:#fff}";
html += ".tab-panel{display:none}.tab-panel.active{display:block}";
html += "</style></head><body><div class=card>"; html += "</style></head><body><div class=card>";
html += "<h1>M1730</h1><div class=sub>Configuration</div><div class=info>"; html += "<div class=hdr><img src=/logo.png alt=logo><div><h1>M1730</h1><div class=sub>Configuration</div></div></div><div class=info>";
html += "<div><span>Hostname</span><span>" + String(hostname) + "</span></div>"; html += "<div><span>Hostname</span><span>" + String(hostname) + "</span></div>";
html += "<div><span>IP</span><span>" + WiFi.localIP().toString() + "</span></div></div>"; html += "<div><span>IP</span><span>" + WiFi.localIP().toString() + "</span></div></div>";
html += "<form action=/config method=post><label>Hostname</label>"; html += "<form action=/config method=post><label>Hostname</label>";
@@ -532,6 +574,8 @@ html += ".switch input:checked+.slid{background:#4361ee}.switch input:checked+.s
html += authSection; html += authSection;
html += mqttSection; html += mqttSection;
html += "<button class=btn type=submit>Save</button></form>"; html += "<button class=btn type=submit>Save</button></form>";
html += "<form action=/reset method=post style=margin-top:8px>";
html += "<button class='btn btn-danger' type=submit>Reset Device</button></form>";
html += "<div class=mac>" + WiFi.macAddress() + "</div></div>"; html += "<div class=mac>" + WiFi.macAddress() + "</div></div>";
html += "<script>"; html += "<script>";
html += "Array.from(document.querySelectorAll('input[type=range]')).forEach(function(s){"; html += "Array.from(document.querySelectorAll('input[type=range]')).forEach(function(s){";
@@ -542,14 +586,35 @@ html += ".switch input:checked+.slid{background:#4361ee}.switch input:checked+.s
html += "x.open('GET','/set?i='+this.name.match(/\\d+/)[0]+'&v='+this.value,true);x.send();"; html += "x.open('GET','/set?i='+this.name.match(/\\d+/)[0]+'&v='+this.value,true);x.send();";
html += "});}});"; html += "});}});";
html += "document.querySelector('select[name=meter_count]').addEventListener('change',function(){this.form.submit();});"; html += "document.querySelector('select[name=meter_count]').addEventListener('change',function(){this.form.submit();});";
if (meterCount > 1) {
html += "document.querySelectorAll('.tab-btn').forEach(function(b){b.addEventListener('click',function(){var t=this.dataset.tab;document.querySelectorAll('.tab-btn').forEach(function(x){x.classList.remove('active')});document.querySelectorAll('.tab-panel').forEach(function(x){x.classList.remove('active')});this.classList.add('active');document.querySelector('.tab-panel[data-tab=\"'+t+'\"]').classList.add('active')})});";
}
html += "</script></body></html>"; html += "</script></body></html>";
server.send(200, "text/html", html); server.send(200, "text/html", html);
Serial.printf("[HTTP] GET / -> 200 (%u bytes)\n", html.length()); Serial.printf("[HTTP] GET / -> 200 (%u bytes)\n", html.length());
} }
static void handleNotFound() {
String uri = server.uri();
if (uri == "/favicon.ico" || uri == "/favicon.png" || uri == "/robots.txt" ||
uri == "/generate_204" || uri == "/hotspot-detect.html" ||
uri == "/connecttest.txt" || uri == "/redirect") {
server.sendHeader("Location", "/");
server.send(302);
return;
}
Serial.printf("[HTTP] 404 %s\n", uri.c_str());
server.send(404, "text/plain", "Not found");
}
static void handleConfig() { static void handleConfig() {
if (!requireAuth()) { Serial.println("[HTTP] POST /config -> 401"); return; } if (!requireAuth()) { Serial.println("[HTTP] POST /config -> 401"); return; }
Serial.println("[HTTP] POST /config"); Serial.println("[HTTP] POST /config");
char oldHostname[64];
strlcpy(oldHostname, hostname, sizeof(oldHostname));
if (server.hasArg("hostname")) { if (server.hasArg("hostname")) {
String val = server.arg("hostname"); String val = server.arg("hostname");
val.trim(); val.trim();
@@ -557,6 +622,8 @@ static void handleConfig() {
val.toCharArray(hostname, sizeof(hostname)); val.toCharArray(hostname, sizeof(hostname));
} }
bool hostnameChanged = strcmp(hostname, oldHostname) != 0;
// Persist the connection fields regardless of the enable checkbox so edits // Persist the connection fields regardless of the enable checkbox so edits
// made while disabling MQTT aren't discarded. // made while disabling MQTT aren't discarded.
if (server.hasArg("mqtt_host")) if (server.hasArg("mqtt_host"))
@@ -623,6 +690,12 @@ static void handleConfig() {
server.sendHeader("Location", "/", true); server.sendHeader("Location", "/", true);
server.send(303); server.send(303);
if (hostnameChanged) {
Serial.printf("[HTTP] hostname changed %s -> %s, restarting\n", oldHostname, hostname);
delay(200);
ESP.restart();
}
} }
static void handleSet() { static void handleSet() {
@@ -635,7 +708,7 @@ static void handleSet() {
if (meters[idx].pin > 0 && meters[idx].maxDuty > 0) { if (meters[idx].pin > 0 && meters[idx].maxDuty > 0) {
float pct = val / 100.0f * meters[idx].maxDuty / 100.0f; float pct = val / 100.0f * meters[idx].maxDuty / 100.0f;
int duty = constrain((int)(pct * 1023), 0, 1023); int duty = constrain((int)(pct * 1023), 0, 1023);
ledcWrite(idx, duty); ledcWrite(meters[idx].pin, duty);
} }
mqttPublishCurrent(idx); mqttPublishCurrent(idx);
server.send(200); server.send(200);
@@ -643,19 +716,24 @@ static void handleSet() {
static void startServer() { static void startServer() {
server.on("/", handleRoot); server.on("/", handleRoot);
server.on("/logo.png", handleLogo);
server.on("/config", HTTP_POST, handleConfig); server.on("/config", HTTP_POST, handleConfig);
server.on("/reset", HTTP_POST, handleReset);
server.on("/set", handleSet); server.on("/set", handleSet);
server.onNotFound(handleNotFound);
server.begin(); server.begin();
Serial.println("HTTP server started"); Serial.println("HTTP server started");
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Factory reset (rapid reset 5× within a few seconds) // Factory reset (5x = config only, 10x = config + WiFi credentials)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static void factoryReset() { static void factoryReset(bool clearWifi) {
Serial.println("[BOOT] factory reset triggered!"); Serial.printf("[BOOT] factory reset triggered (clearWifi=%d)!\n", clearWifi);
// Preserve pin assignments and calibration across a config-only reset so
// the physical wiring doesn't need to be re-entered after every reset.
int count = meterCount; int count = meterCount;
int pins[MAX_METERS]; int pins[MAX_METERS];
float maxD[MAX_METERS]; float maxD[MAX_METERS];
@@ -664,9 +742,9 @@ static void factoryReset() {
maxD[i] = meters[i].maxDuty; maxD[i] = meters[i].maxDuty;
} }
// Reset hostname, auth, MQTT, meter metadata // Reset hostname, auth, MQTT, and meter metadata
strlcpy(hostname, HOSTNAME_DEFAULT, sizeof(hostname)); strlcpy(hostname, HOSTNAME_DEFAULT, sizeof(hostname));
hostnameParam.setValue(hostname, strlen(hostname)); hostnameParam.setValue(hostname, sizeof(hostname) - 1);
authCfg.enabled = false; authCfg.enabled = false;
authCfg.pass[0] = '\0'; authCfg.pass[0] = '\0';
@@ -688,23 +766,31 @@ static void factoryReset() {
meters[i].rangeMax = 100.0; meters[i].rangeMax = 100.0;
} }
// Restore pins and calibration // Restore pins and calibration (unless WiFi reset, where a full clean slate
// makes more sense since the device is being re-provisioned anyway).
if (!clearWifi) {
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
meters[i].pin = pins[i]; meters[i].pin = pins[i];
meters[i].maxDuty = maxD[i]; meters[i].maxDuty = maxD[i];
} }
}
saveConfig(); saveConfig();
if (clearWifi) {
WiFiManager wm;
wm.resetSettings();
Serial.println("[BOOT] WiFi credentials erased");
}
// Attach meters temporarily for the sweep acknowledgement // Attach meters temporarily for the sweep acknowledgement
for (int i = 0; i < count && i < 8; i++) { for (int i = 0; i < count && i < 8; i++) {
if (meters[i].pin > 0) { if (meters[i].pin > 0) {
ledcSetup(i, PWM_FREQ, PWM_RES); ledcAttach(meters[i].pin, PWM_FREQ, PWM_RES);
ledcAttachPin(meters[i].pin, i);
} }
} }
// Sweep 0→100→0 over ~2 seconds // Sweep 0→100→0 over ~2 seconds as a visual confirmation
const int steps = 20; const int steps = 20;
for (int phase = 0; phase < 2; phase++) { for (int phase = 0; phase < 2; phase++) {
for (int s = 0; s <= steps; s++) { for (int s = 0; s <= steps; s++) {
@@ -712,7 +798,7 @@ static void factoryReset() {
for (int i = 0; i < count && i < 8; i++) { for (int i = 0; i < count && i < 8; i++) {
if (meters[i].pin > 0 && meters[i].maxDuty > 0) { if (meters[i].pin > 0 && meters[i].maxDuty > 0) {
float duty = pct / 100.0f * meters[i].maxDuty / 100.0f; float duty = pct / 100.0f * meters[i].maxDuty / 100.0f;
ledcWrite(i, constrain((int)(duty * 1023), 0, 1023)); ledcWrite(meters[i].pin, constrain((int)(duty * 1023), 0, 1023));
} }
} }
delay(50); delay(50);
@@ -724,26 +810,24 @@ static void factoryReset() {
ESP.restart(); ESP.restart();
} }
static void checkFactoryReset() { static int incrementResetCount() {
const int THRESHOLD = 5;
int count = 0; int count = 0;
File f = LittleFS.open("/reset_count", "r"); File f = LittleFS.open("/reset_count", "r");
if (f) { if (f) { count = f.parseInt(); f.close(); }
count = f.parseInt();
f.close();
}
count++; 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"); f = LittleFS.open("/reset_count", "w");
if (f) { if (f) { f.print(count); f.close(); }
f.print(count); Serial.printf("[BOOT] reset count %d\n", count);
f.close(); return count;
Serial.printf("[BOOT] reset count %d/%d\n", count, THRESHOLD); }
static void applyFactoryResetIfNeeded(int count) {
const int THRESHOLD_CONFIG = 5;
const int THRESHOLD_WIFI = 10;
LittleFS.remove("/reset_count");
if (count >= THRESHOLD_CONFIG) {
Serial.printf("[BOOT] reset count %d, triggering factory reset\n", count);
factoryReset(count >= THRESHOLD_WIFI);
} }
} }
@@ -754,23 +838,44 @@ static void checkFactoryReset() {
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
Serial.println("\n\n=== M1730 boot ==="); Serial.println("\n\n=== M1730 boot ===");
Serial.printf("[BOOT] chip=%s rev=%d cores=%d cpu=%dMHz flash=%uMB\n",
ESP.getChipModel(), ESP.getChipRevision(), ESP.getChipCores(),
ESP.getCpuFreqMHz(), ESP.getFlashChipSize() / (1024 * 1024));
Serial.printf("[BOOT] free heap=%u sketch=%u / %u\n",
ESP.getFreeHeap(), ESP.getSketchSize(), ESP.getFreeSketchSpace());
if (!LittleFS.begin(false)) {
Serial.println("[FS] mount failed, formatting...");
LittleFS.begin(true); LittleFS.begin(true);
} else {
Serial.println("[FS] mounted OK");
File root = LittleFS.open("/");
File f = root.openNextFile();
while (f) { Serial.printf("[FS] %s (%u bytes)\n", f.name(), f.size()); f = root.openNextFile(); }
}
loadConfig(); loadConfig();
hostnameParam.setValue(hostname, strlen(hostname)); hostnameParam.setValue(hostname, sizeof(hostname) - 1);
checkFactoryReset(); for (int i = 0; i < meterCount; i++)
Serial.printf("[BOOT] meter[%d] pin=%d name=\"%s\" unit=\"%s\" maxD=%.1f range=[%.1f,%.1f] cur=%.1f\n",
i, meters[i].pin, meters[i].name, meters[i].unit,
meters[i].maxDuty, meters[i].rangeMin, meters[i].rangeMax, meters[i].currentValue);
Serial.printf("[BOOT] mqtt=%d broker=%s:%d prefix=%s\n",
mqttCfg.enabled, mqttCfg.host, mqttCfg.port, mqttCfg.prefix);
Serial.printf("[BOOT] auth=%d\n", authCfg.enabled);
int resetCount = incrementResetCount();
attachMeters(); attachMeters();
applyMeters(); applyMeters();
// Reset-counter settle window: if the device stays powered past this point it // Settle window: if the device stays powered past this point it wasn't a
// wasn't a rapid power-cycle, so clear the counter here — before the (possibly // rapid power-cycle. After the delay, act on the accumulated count (5x =
// indefinitely blocking) WiFi portal — so a stuck/offline boot can't slowly // config reset, 10x = config + WiFi reset) then clear the counter.
// accumulate into a false factory reset.
delay(3000); delay(3000);
LittleFS.remove("/reset_count"); applyFactoryResetIfNeeded(resetCount);
applyWifiTxPower();
Serial.println("[WIFI] starting WiFiManager"); Serial.println("[WIFI] starting WiFiManager");
WiFiManager wm; WiFiManager wm;
wm.setTitle("M1730"); wm.setTitle("M1730");
@@ -785,7 +890,8 @@ void setup() {
Serial.println("[WIFI] failed, restarting"); Serial.println("[WIFI] failed, restarting");
ESP.restart(); ESP.restart();
} }
Serial.printf("[WIFI] connected, IP: %s\n", WiFi.localIP().toString().c_str()); Serial.printf("[WIFI] connected, IP: %s MAC: %s RSSI: %d\n",
WiFi.localIP().toString().c_str(), WiFi.macAddress().c_str(), WiFi.RSSI());
strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname)); strlcpy(hostname, hostnameParam.getValue(), sizeof(hostname));
saveConfig(); saveConfig();
@@ -795,6 +901,7 @@ void setup() {
Serial.printf("[BOOT] ready at http://%s.local or http://%s\n", Serial.printf("[BOOT] ready at http://%s.local or http://%s\n",
hostname, WiFi.localIP().toString().c_str()); hostname, WiFi.localIP().toString().c_str());
Serial.printf("[BOOT] free heap=%u\n", ESP.getFreeHeap());
} }
void loop() { void loop() {