Button Simmulator, Frontend änderungen
Some checks failed
/ build (push) Has been cancelled

This commit is contained in:
Carsten Graf
2026-04-11 20:24:39 +02:00
parent 05166b443b
commit 0223cceef8
19 changed files with 1200 additions and 152 deletions

2
tools/button-simulator/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
package-lock.json

View File

@@ -0,0 +1,44 @@
# AquaMaster Button-Simulator
Kleine Node.js-/Express-App zum Simulieren der vier Funktaster
(`start1`, `stop1`, `start2`, `stop2`) gegen den MQTT-Broker des AquaMasters.
## Installation
```bash
cd tools/button-simulator
npm install
npm start
```
UI öffnen: <http://localhost:3000>
## Bedienung
1. Mit dem WLAN des AquaMasters verbinden (Default-AP-IP: `192.168.10.1`).
2. In der UI die Broker-URL prüfen (`mqtt://192.168.10.1:1883`) und auf **Verbinden** klicken.
3. MAC-Adressen der vier virtuellen Buttons ggf. anpassen — die Default-MACs
`AA:BB:CC:DD:EE:01..04` funktionieren für einen frischen Anlernlauf:
- Im Web-UI des Masters **Anlernmodus starten**
- Im Simulator nacheinander **Start 1 → Stop 1 → Start 2 → Stop 2** drücken
- Der Master speichert die MACs und ordnet sie den Rollen zu
4. Danach lassen sich mit denselben Buttons Timerläufe auslösen.
## Was die App sendet
| Topic | Auslöser | Payload |
|---------------------------|---------------------------|--------------------------------|
| `aquacross/button/<MAC>` | PRESS-Button | `{"type":1|2,"timestamp":ms}` |
| `aquacross/battery/<MAC>` | Slider / Auto-Heartbeat | `{"voltage":mV}` |
| `heartbeat/alive/<MAC>` | Auto-Heartbeat | `{"timestamp":ms}` |
`type=2` wird für Start-Rollen (start1/start2) gesendet, `type=1` für
Stop-Rollen (stop1/stop2) — genau wie es `src/communication.h` erwartet.
Heartbeat und Battery werden standardmäßig alle 3 s automatisch publiziert,
sobald eine Verbindung besteht. Intervall/Abschaltung per Formular unten.
## Environment-Variablen
- `PORT` — Webserver-Port (Default: `3000`)
- `BROKER_URL` — vorbelegte Broker-URL

View File

@@ -0,0 +1,14 @@
{
"name": "aquamaster-button-simulator",
"version": "1.0.0",
"description": "Simuliert die vier Funktaster (start1/stop1/start2/stop2) für den AquaMaster MQTT über einen Web-UI-Frontend.",
"private": true,
"type": "commonjs",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.19.2",
"mqtt": "^5.10.1"
}
}

View File

@@ -0,0 +1,96 @@
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
async function api(path, body) {
const opts = { method: body ? "POST" : "GET" };
if (body) {
opts.headers = { "Content-Type": "application/json" };
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
return res.json();
}
function formatLog(entries) {
return entries
.map((e) => {
const ts = new Date(e.t).toLocaleTimeString();
const arrow = e.direction === "out" ? "→" : e.direction === "in" ? "←" : e.direction === "err" ? "✖" : "·";
return `${ts} ${arrow} ${e.text}`;
})
.join("\n");
}
let lastKnownButtons = null;
async function refreshStatus() {
try {
const s = await api("/api/status");
$("#status").textContent = s.connected ? "online" : "offline";
$("#status").className = "status " + (s.connected ? "online" : "offline");
if (!lastKnownButtons) {
$("#brokerUrl").value = s.brokerUrl;
$("#hbEnabled").checked = s.heartbeatEnabled;
$("#hbInterval").value = s.heartbeatIntervalMs;
for (const key of ["start1", "stop1", "start2", "stop2"]) {
const card = $(`.button-card[data-button="${key}"]`);
card.querySelector(".mac").value = s.buttons[key].mac;
card.querySelector(".mv").value = s.buttons[key].voltage;
card.querySelector(".mv-value").textContent = s.buttons[key].voltage;
}
lastKnownButtons = s.buttons;
}
$("#log").textContent = formatLog(s.log);
$("#log").scrollTop = $("#log").scrollHeight;
} catch (err) {
console.error(err);
}
}
$("#btnConnect").addEventListener("click", async () => {
await api("/api/connect", { brokerUrl: $("#brokerUrl").value.trim() });
refreshStatus();
});
$("#btnDisconnect").addEventListener("click", async () => {
await api("/api/disconnect", {});
refreshStatus();
});
$("#btnHbApply").addEventListener("click", async () => {
await api("/api/heartbeat", {
enabled: $("#hbEnabled").checked,
intervalMs: parseInt($("#hbInterval").value, 10),
});
refreshStatus();
});
$$(".button-card").forEach((card) => {
const button = card.dataset.button;
const macInput = card.querySelector(".mac");
const mvInput = card.querySelector(".mv");
const mvValue = card.querySelector(".mv-value");
const pressBtn = card.querySelector(".press");
macInput.addEventListener("change", async () => {
await api("/api/config", { button, mac: macInput.value.trim() });
});
mvInput.addEventListener("input", () => {
mvValue.textContent = mvInput.value;
});
mvInput.addEventListener("change", async () => {
await api("/api/battery", { button, voltage: parseInt(mvInput.value, 10) });
});
pressBtn.addEventListener("click", async () => {
await api("/api/press", { button });
refreshStatus();
});
});
refreshStatus();
setInterval(refreshStatus, 1500);

View File

@@ -0,0 +1,95 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AquaMaster Button-Simulator</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<h1>AquaMaster Button-Simulator</h1>
<div class="connection">
<input id="brokerUrl" type="text" placeholder="mqtt://192.168.10.1:1883" />
<button id="btnConnect" class="primary">Verbinden</button>
<button id="btnDisconnect">Trennen</button>
<span id="status" class="status offline">offline</span>
</div>
</header>
<section class="grid">
<div class="lane lane1">
<h2>Bahn 1</h2>
<div class="button-card" data-button="start1">
<div class="button-header">
<span class="role">Start 1</span>
<input class="mac" type="text" spellcheck="false" />
</div>
<button class="press press-start">PRESS</button>
<div class="battery">
<label>Akku (mV): <span class="mv-value">3700</span></label>
<input class="mv" type="range" min="3000" max="4200" step="10" value="3700" />
</div>
</div>
<div class="button-card" data-button="stop1">
<div class="button-header">
<span class="role">Stop 1</span>
<input class="mac" type="text" spellcheck="false" />
</div>
<button class="press press-stop">PRESS</button>
<div class="battery">
<label>Akku (mV): <span class="mv-value">3700</span></label>
<input class="mv" type="range" min="3000" max="4200" step="10" value="3700" />
</div>
</div>
</div>
<div class="lane lane2">
<h2>Bahn 2</h2>
<div class="button-card" data-button="start2">
<div class="button-header">
<span class="role">Start 2</span>
<input class="mac" type="text" spellcheck="false" />
</div>
<button class="press press-start">PRESS</button>
<div class="battery">
<label>Akku (mV): <span class="mv-value">3700</span></label>
<input class="mv" type="range" min="3000" max="4200" step="10" value="3700" />
</div>
</div>
<div class="button-card" data-button="stop2">
<div class="button-header">
<span class="role">Stop 2</span>
<input class="mac" type="text" spellcheck="false" />
</div>
<button class="press press-stop">PRESS</button>
<div class="battery">
<label>Akku (mV): <span class="mv-value">3700</span></label>
<input class="mv" type="range" min="3000" max="4200" step="10" value="3700" />
</div>
</div>
</div>
</section>
<section class="controls">
<label>
<input id="hbEnabled" type="checkbox" checked />
Heartbeat + Battery automatisch senden
</label>
<label>
Intervall (ms):
<input id="hbInterval" type="number" min="500" max="60000" step="500" value="3000" />
</label>
<button id="btnHbApply">Übernehmen</button>
</section>
<section class="log-wrap">
<h3>Log</h3>
<pre id="log"></pre>
</section>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,189 @@
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 16px;
}
header {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
header h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.connection {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.connection input {
flex: 1;
min-width: 220px;
padding: 8px 10px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
font-family: monospace;
}
button {
padding: 8px 14px;
border: 1px solid #334155;
background: #1e293b;
color: #e2e8f0;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
}
button:hover { background: #334155; }
button.primary { background: #2563eb; border-color: #2563eb; }
button.primary:hover { background: #1d4ed8; }
.status {
padding: 4px 10px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status.online { background: #14532d; color: #86efac; }
.status.offline { background: #7f1d1d; color: #fca5a5; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 720px) {
.grid { grid-template-columns: 1fr; }
}
.lane {
background: #1e293b;
border: 1px solid #334155;
border-radius: 10px;
padding: 12px;
}
.lane h2 {
margin: 0 0 10px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
color: #94a3b8;
}
.button-card {
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
}
.button-card:last-child { margin-bottom: 0; }
.button-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.role {
font-weight: 700;
font-size: 13px;
min-width: 60px;
}
.mac {
flex: 1;
padding: 6px 8px;
font-family: monospace;
font-size: 12px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 4px;
color: #e2e8f0;
}
.press {
width: 100%;
padding: 16px;
font-size: 18px;
font-weight: 700;
letter-spacing: 2px;
margin-bottom: 8px;
}
.press-start { background: #166534; border-color: #166534; }
.press-start:hover { background: #15803d; }
.press-stop { background: #991b1b; border-color: #991b1b; }
.press-stop:hover { background: #b91c1c; }
.press:active { transform: scale(0.98); }
.battery label {
display: block;
font-size: 12px;
color: #94a3b8;
margin-bottom: 4px;
}
.battery input[type="range"] { width: 100%; }
.controls {
background: #1e293b;
border: 1px solid #334155;
border-radius: 10px;
padding: 12px;
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 16px;
}
.controls label { font-size: 13px; display: flex; align-items: center; gap: 6px; }
.controls input[type="number"] {
width: 80px;
padding: 6px 8px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 4px;
color: #e2e8f0;
}
.log-wrap {
background: #1e293b;
border: 1px solid #334155;
border-radius: 10px;
padding: 12px;
}
.log-wrap h3 { margin: 0 0 8px; font-size: 13px; color: #94a3b8; }
#log {
margin: 0;
max-height: 260px;
overflow-y: auto;
font-family: monospace;
font-size: 11px;
white-space: pre-wrap;
color: #cbd5e1;
}

View File

@@ -0,0 +1,209 @@
const express = require("express");
const mqtt = require("mqtt");
const path = require("path");
const PORT = process.env.PORT || 3000;
const BUTTON_KEYS = ["start1", "stop1", "start2", "stop2"];
const state = {
brokerUrl: process.env.BROKER_URL || "mqtt://192.168.1.209:1883",
connected: false,
lastError: null,
heartbeatEnabled: true,
heartbeatIntervalMs: 3000,
buttons: {
start1: { mac: "AA:BB:CC:DD:EE:01", voltage: 3700 },
stop1: { mac: "AA:BB:CC:DD:EE:02", voltage: 3700 },
start2: { mac: "AA:BB:CC:DD:EE:03", voltage: 3700 },
stop2: { mac: "AA:BB:CC:DD:EE:04", voltage: 3700 },
},
log: [],
};
let client = null;
let heartbeatTimer = null;
let bootTime = Date.now();
function logEvent(direction, text) {
const entry = { t: Date.now(), direction, text };
state.log.push(entry);
if (state.log.length > 200) state.log.shift();
const arrow = direction === "out" ? "→" : direction === "in" ? "←" : "·";
console.log(`[${new Date(entry.t).toISOString()}] ${arrow} ${text}`);
}
function publish(topic, payload) {
if (!client || !state.connected) {
logEvent("err", `publish abgelehnt (nicht verbunden): ${topic}`);
return false;
}
const body = typeof payload === "string" ? payload : JSON.stringify(payload);
client.publish(topic, body, { qos: 0 }, (err) => {
if (err) logEvent("err", `publish fehlgeschlagen ${topic}: ${err.message}`);
});
logEvent("out", `${topic} ${body}`);
return true;
}
function startHeartbeats() {
stopHeartbeats();
if (!state.heartbeatEnabled) return;
heartbeatTimer = setInterval(() => {
if (!state.connected) return;
const now = Date.now();
const uptime = now - bootTime;
for (const key of BUTTON_KEYS) {
const mac = state.buttons[key].mac;
publish(`heartbeat/alive/${mac}`, { timestamp: now, uptime });
publish(`aquacross/battery/${mac}`, {
timestamp: now,
voltage: state.buttons[key].voltage,
});
}
}, state.heartbeatIntervalMs);
}
function stopHeartbeats() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
function connectBroker(url) {
disconnectBroker();
state.brokerUrl = url;
state.lastError = null;
logEvent("sys", `Verbinde zu ${url} ...`);
client = mqtt.connect(url, {
clientId: `button-simulator-${Math.random().toString(16).slice(2, 10)}`,
reconnectPeriod: 0,
connectTimeout: 5000,
clean: true,
});
client.on("connect", () => {
state.connected = true;
bootTime = Date.now();
logEvent("sys", `Verbunden mit ${url}`);
startHeartbeats();
});
client.on("error", (err) => {
state.lastError = err.message;
logEvent("err", `MQTT Fehler: ${err.message}`);
});
client.on("close", () => {
if (state.connected) logEvent("sys", "Verbindung geschlossen");
state.connected = false;
stopHeartbeats();
});
client.on("offline", () => {
state.connected = false;
logEvent("sys", "offline");
});
}
function disconnectBroker() {
stopHeartbeats();
if (client) {
try { client.end(true); } catch (_) {}
client = null;
}
state.connected = false;
}
const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
app.get("/api/status", (_req, res) => {
res.json({
brokerUrl: state.brokerUrl,
connected: state.connected,
lastError: state.lastError,
heartbeatEnabled: state.heartbeatEnabled,
heartbeatIntervalMs: state.heartbeatIntervalMs,
buttons: state.buttons,
log: state.log.slice(-80),
});
});
app.post("/api/connect", (req, res) => {
const url = (req.body && req.body.brokerUrl) || state.brokerUrl;
connectBroker(url);
res.json({ ok: true });
});
app.post("/api/disconnect", (_req, res) => {
disconnectBroker();
logEvent("sys", "Getrennt (manuell)");
res.json({ ok: true });
});
app.post("/api/config", (req, res) => {
const { button, mac, voltage } = req.body || {};
if (!BUTTON_KEYS.includes(button)) {
return res.status(400).json({ ok: false, error: "unbekannter Button" });
}
if (typeof mac === "string" && mac.length > 0) {
state.buttons[button].mac = mac.toUpperCase();
}
if (typeof voltage === "number" && voltage >= 2500 && voltage <= 4500) {
state.buttons[button].voltage = Math.round(voltage);
}
res.json({ ok: true, button: state.buttons[button] });
});
app.post("/api/heartbeat", (req, res) => {
const { enabled, intervalMs } = req.body || {};
if (typeof enabled === "boolean") state.heartbeatEnabled = enabled;
if (typeof intervalMs === "number" && intervalMs >= 500 && intervalMs <= 60000) {
state.heartbeatIntervalMs = Math.round(intervalMs);
}
if (state.connected) startHeartbeats();
res.json({
ok: true,
heartbeatEnabled: state.heartbeatEnabled,
heartbeatIntervalMs: state.heartbeatIntervalMs,
});
});
app.post("/api/press", (req, res) => {
const { button } = req.body || {};
if (!BUTTON_KEYS.includes(button)) {
return res.status(400).json({ ok: false, error: "unbekannter Button" });
}
// start* sendet type=2, stop* sendet type=1 (siehe communication.h)
const type = button.startsWith("start") ? 2 : 1;
const mac = state.buttons[button].mac;
const payload = { type, timestamp: Date.now() };
const ok = publish(`aquacross/button/${mac}`, payload);
res.json({ ok, button, mac, payload });
});
app.post("/api/battery", (req, res) => {
const { button, voltage } = req.body || {};
if (!BUTTON_KEYS.includes(button)) {
return res.status(400).json({ ok: false, error: "unbekannter Button" });
}
if (typeof voltage !== "number") {
return res.status(400).json({ ok: false, error: "voltage fehlt" });
}
state.buttons[button].voltage = Math.round(voltage);
const mac = state.buttons[button].mac;
const ok = publish(`aquacross/battery/${mac}`, {
timestamp: Date.now(),
voltage: state.buttons[button].voltage,
});
res.json({ ok, button, mac, voltage: state.buttons[button].voltage });
});
app.listen(PORT, () => {
console.log(`Button-Simulator läuft: http://localhost:${PORT}`);
console.log(`Broker (Default): ${state.brokerUrl}`);
});

142
tools/update-anleitung.html Normal file
View File

@@ -0,0 +1,142 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>AquaMaster OTA Update Anleitung</title>
<style>
@page { size: A4; margin: 18mm 20mm; }
html, body { font-family: "Segoe UI", Arial, sans-serif; color: #1a1a1a; font-size: 11pt; line-height: 1.45; }
h1 { font-size: 22pt; margin: 0 0 4pt 0; color: #0b4a7a; }
h2 { font-size: 14pt; margin: 18pt 0 6pt 0; color: #0b4a7a; border-bottom: 1px solid #c8d8e4; padding-bottom: 3pt; }
h3 { font-size: 12pt; margin: 12pt 0 4pt 0; color: #12466b; }
p { margin: 4pt 0; }
ul, ol { margin: 4pt 0 6pt 20pt; padding: 0; }
li { margin: 2pt 0; }
code, .mono { font-family: "Consolas", "Courier New", monospace; background: #f1f5f9; padding: 1pt 4pt; border-radius: 3pt; font-size: 10pt; }
.lead { color: #475569; font-size: 11pt; margin-bottom: 10pt; }
.box { border-left: 4pt solid #0b4a7a; background: #f1f6fb; padding: 8pt 12pt; margin: 10pt 0; border-radius: 0 4pt 4pt 0; }
.warn { border-left-color: #b45309; background: #fff7ed; }
.ok { border-left-color: #15803d; background: #f0fdf4; }
table { border-collapse: collapse; width: 100%; margin: 6pt 0; font-size: 10pt; }
th, td { border: 1px solid #c8d8e4; padding: 5pt 7pt; text-align: left; vertical-align: top; }
th { background: #eaf2f9; }
.meta { color: #64748b; font-size: 9pt; margin-top: 4pt; }
.step { margin: 8pt 0; }
.step-num { display: inline-block; width: 18pt; height: 18pt; line-height: 18pt; text-align: center; background: #0b4a7a; color: white; border-radius: 50%; font-weight: bold; margin-right: 6pt; font-size: 9pt; }
</style>
</head>
<body>
<h1>AquaMaster OTA Update Anleitung</h1>
<p class="lead">Schritt-für-Schritt-Anleitung zum Einspielen eines neuen Firmware- und Filesystem-Updates auf die AquaMaster-Einheit über die Weboberfläche (PrettyOTA).</p>
<p class="meta">Gilt für: AquaMaster MQTT (ESP32) · OTA-Oberfläche: PrettyOTA · Build: esp32thing_CI</p>
<h2>1. Warum zwei Dateien?</h2>
<p>Der ESP32 speichert die Software des AquaMasters in <b>zwei getrennten Flash-Partitionen</b>. Jede enthält etwas anderes, und beide können (und müssen teilweise) einzeln aktualisiert werden:</p>
<table>
<tr><th style="width:28%">Datei</th><th style="width:22%">Partition</th><th>Inhalt</th></tr>
<tr>
<td><code>firmware.bin</code></td>
<td>App-Partition (OTA)</td>
<td>Die eigentliche ESP32-Software: Timer-Logik, MQTT-Broker, WiFi, Webserver, Spielmodi, RFID-Auswertung, Lizenzprüfung usw. Also alles, was der Master <i>tut</i>.</td>
</tr>
<tr>
<td><code>spiffs.bin</code></td>
<td>SPIFFS-Partition</td>
<td>Das Dateisystem mit der <b>Weboberfläche</b>: <code>index.html</code>, <code>settings.html</code>, <code>leaderboard.html</code>, <code>rfid.html</code>, CSS, Bilder sowie die Button-<code>firmware.bin</code>, über die sich die Funktaster selbst updaten.</td>
</tr>
</table>
<div class="box">
<b>Kurz gesagt:</b> <code>firmware.bin</code> = was der Master <i>macht</i>, <code>spiffs.bin</code> = wie der Master im Browser <i>aussieht</i>. Beide leben in verschiedenen Flash-Bereichen und werden deshalb getrennt hochgeladen.
</div>
<h3>Muss ich immer beide flashen?</h3>
<ul>
<li><b>Nur Code geändert</b> (z. B. neue Timerlogik, Bugfix): es reicht <code>firmware.bin</code>.</li>
<li><b>Nur Weboberfläche geändert</b> (HTML/CSS, neue Button-Firmware): es reicht <code>spiffs.bin</code>.</li>
<li><b>Im Zweifel beide</b> einspielen in der Reihenfolge unten dann kann garantiert nichts „alt gegen neu" kollidieren.</li>
</ul>
<h2>2. Vorbereitung</h2>
<ol>
<li>Die aktuelle Release-ZIP entpacken. Darin liegen <code>firmware.bin</code> und <code>spiffs.bin</code>.</li>
<li>AquaMaster einschalten und mit seinem WLAN verbinden:
<ul>
<li><b>AP-Modus:</b> SSID <code>AquaMaster-xxxx</code>, IP <code>192.168.10.1</code></li>
<li><b>STA-Modus:</b> die im Heimnetz vergebene IP (im Router oder per mDNS-Namen <code>aquamaster.local</code>)</li>
</ul>
</li>
<li>Im Browser die OTA-Seite öffnen: <code>http://&lt;IP-des-Masters&gt;/update</code><br>
Beispiel AP: <code>http://192.168.10.1/update</code></li>
</ol>
<div class="box warn">
<b>Wichtig:</b> Während des Updates den AquaMaster <b>nicht vom Strom trennen</b> und die Verbindung nicht abbrechen. Ein unterbrochener Firmware-Upload führt im schlimmsten Fall zu einem Rollback auf die vorherige Version ein unterbrochenes Filesystem-Update zu einer leeren Weboberfläche.
</div>
<h2>3. Update-Modus wählen <i>das Wichtigste</i></h2>
<p>Auf der PrettyOTA-Seite gibt es oben ein Dropdown <b>„OTA-Mode"</b> (oder <b>„Update Mode"</b>) mit zwei Einträgen:</p>
<table>
<tr><th style="width:30%">Modus</th><th>Wann auswählen?</th></tr>
<tr><td><b>Firmware</b></td><td>Wenn du <code>firmware.bin</code> hochladen willst (Standard, die Option ist beim Öffnen der Seite bereits aktiv).</td></tr>
<tr><td><b>Filesystem</b></td><td>Wenn du <code>spiffs.bin</code> hochladen willst. Muss <b>vor dem Upload</b> manuell umgestellt werden!</td></tr>
</table>
<div class="box warn">
<b>Häufigster Fehler:</b> <code>spiffs.bin</code> wird im Modus „Firmware" hochgeladen. PrettyOTA akzeptiert die Datei dann zwar, schreibt sie aber in die falsche Partition der Master startet nicht mehr sauber bzw. zeigt nach dem Neustart eine kaputte Weboberfläche. <b>Immer zuerst den richtigen Modus wählen, dann die Datei hinzufügen.</b>
</div>
<h2>4. Update durchführen</h2>
<h3>4.1 Firmware aktualisieren</h3>
<p class="step"><span class="step-num">1</span>Im Dropdown <b>„Firmware"</b> auswählen.</p>
<p class="step"><span class="step-num">2</span><code>firmware.bin</code> per Drag-&amp;-Drop auf den Upload-Bereich ziehen (oder über <i>„Datei auswählen"</i> öffnen).</p>
<p class="step"><span class="step-num">3</span>Fortschrittsbalken abwarten, bis „Update successful" erscheint.</p>
<p class="step"><span class="step-num">4</span>Der AquaMaster startet automatisch neu. Warten, bis die Status-LED wieder Normalbetrieb signalisiert (ca. 510 s).</p>
<h3>4.2 Filesystem (Weboberfläche) aktualisieren</h3>
<p class="step"><span class="step-num">1</span>Erneut <code>http://&lt;IP&gt;/update</code> öffnen (nach dem Neustart ist die Verbindung evtl. kurz weg).</p>
<p class="step"><span class="step-num">2</span>Im Dropdown auf <b>„Filesystem"</b> umstellen. <b>Nicht vergessen!</b></p>
<p class="step"><span class="step-num">3</span><code>spiffs.bin</code> per Drag-&amp;-Drop hochladen.</p>
<p class="step"><span class="step-num">4</span>Nach „Update successful" startet der Master erneut.</p>
<div class="box ok">
<b>Tipp Reihenfolge:</b> Erst <code>firmware.bin</code>, dann <code>spiffs.bin</code>. So läuft nach dem ersten Reboot bereits die neue Timer-Software, die dann passend zur neuen Weboberfläche ist.
</div>
<h2>5. Prüfen, ob das Update sitzt</h2>
<ul>
<li>Die Hauptseite <code>http://&lt;IP&gt;/</code> öffnen die Oberfläche sollte fehlerfrei laden.</li>
<li>Unter <b>Einstellungen</b> / <b>Status</b> die Firmware-Version bzw. das Build-Datum kontrollieren.</li>
<li>Einen kurzen Testlauf starten (Start-/Stopp-Taster drücken), um zu prüfen, dass die Timerlogik reagiert.</li>
<li>Falls die Seite nach einem Filesystem-Update weiß/leer bleibt: <code>spiffs.bin</code> nochmal im Modus „Filesystem" hochladen.</li>
</ul>
<h2>6. Fehlerbehebung</h2>
<table>
<tr><th style="width:42%">Symptom</th><th>Ursache / Fix</th></tr>
<tr>
<td>„Wrong partition" oder Upload bricht sofort ab</td>
<td>Falscher Modus gewählt Dropdown umstellen und erneut hochladen.</td>
</tr>
<tr>
<td>Nach dem Update leere / unformatierte Weboberfläche</td>
<td>Es wurde nur <code>firmware.bin</code> geflasht, aber die HTML/CSS-Dateien haben sich geändert. <code>spiffs.bin</code> nachliefern (Modus „Filesystem").</td>
</tr>
<tr>
<td>Master kommt nach Firmware-Update nicht mehr ins WLAN</td>
<td>Kurz stromlos machen; PrettyOTA führt intern bei einem fehlerhaften Boot ein Rollback auf die vorherige App-Partition aus.</td>
</tr>
<tr>
<td>Browser zeigt „Verbindung verloren"</td>
<td>Normal während des Reboots. Seite nach ~10 s neu laden.</td>
</tr>
</table>
<p class="meta">Bei fortbestehenden Problemen: seriellen Monitor mit 115200 Baud anschließen PrettyOTA und der Master schreiben detaillierte Update- und Boot-Logs auf UART.</p>
</body>
</html>