diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..bf308a5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(pio run:*)", + "Bash(npm install:*)", + "Bash(where pio:*)", + "Read(//c/Users/repti/.platformio/penv/Scripts/**)", + "Bash(/c/Users/repti/.platformio/penv/Scripts/pio.exe run:*)", + "Bash(python -c \"import reportlab\")", + "Read(//c/Program Files/Google/Chrome/Application/**)", + "Read(//c/Program Files \\(x86\\)/Microsoft/Edge/Application/**)" + ] + }, + "enableAllProjectMcpServers": true, + "enabledMcpjsonServers": [ + "proxmox" + ] +} diff --git a/AquaMaster-Update-Anleitung.pdf b/AquaMaster-Update-Anleitung.pdf new file mode 100644 index 0000000..295b36c Binary files /dev/null and b/AquaMaster-Update-Anleitung.pdf differ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..44e6871 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Projektüberblick + +**AquaMaster MQTT** ist die ESP32-Firmware für die Master-Einheit eines "Aquacross / NinjaCross"-Sport-Timers (zwei Bahnen, Start/Stopp-Taster). Der Master stellt einen WiFi-AP und/oder STA bereit, hostet einen MQTT-Broker, einen Async-Webserver mit WebSocket-Live-Updates und kommuniziert mit batteriebetriebenen Funktastern. Die README.md im Repo-Root ist **veraltet/falsch** (Gitea-MCP-Text) — als Quelle für Projektkontext stattdessen `API.md`, `TODO.md`, `Bedienungsanleitung_NinjaCross_Timer.html` und den Code selbst nutzen. + +## Build & Flash (PlatformIO) + +Default-Environment ist `esp32thing_CI` (siehe `platformio.ini`). Weitere Envs: `wemos_d1_mini32`, `esp32thing`, `esp32thing_OTA` (OTA an `192.168.1.96`), `um_feathers3`, `um_feathers3_debug`. + +```bash +pio run # Default-Env bauen +pio run -e esp32thing # Spezifisches Env +pio run -e esp32thing -t upload # Flashen +pio run -e esp32thing -t buildfs # SPIFFS-Image aus data/ bauen +pio run -e esp32thing -t uploadfs # SPIFFS flashen (data/ → ESP) +pio device monitor -b 115200 # Serieller Monitor +pio run -e esp32thing_OTA -t upload # OTA-Upload (Ziel-IP in platformio.ini) +``` + +Tests gibt es nicht — `test/` enthält nur ein leeres README. + +## CI + +`.github/workflows/build.yml` baut bei jedem Push `firmware.bin` und `spiffs.bin` mit `pio run -e esp32thing_CI` und erzeugt automatisch ein GitHub-Release mit Tag `esp32thing--`. Wenn der Build lokal funktioniert, aber CI nicht, ist `esp32thing_CI` (board=esp32dev, platform=espressif32) die maßgebliche Konfiguration. + +## Architektur (das Wesentliche) + +### Header-only-Pattern (wichtig!) + +Es gibt nur **eine** `.cpp`-Datei: `src/master.cpp`. Alle anderen Module unter `src/*.h` enthalten sowohl Deklarationen *als auch* Implementierungen und definieren teilweise **globale Objekte** (z. B. `AsyncWebServer server(80)` in `webserverrouter.h`, `Preferences preferences` in `licenceing.h`, `PicoMQTT::Server mqtt` in `communication.h`). Konsequenzen: + +- Jeder dieser Header darf **nur in `master.cpp`** inkludiert werden, sonst gibt es Multiple-Definition-Linkerfehler. +- Header inkludieren sich gegenseitig (`master.h` ↔ `webserverrouter.h` ↔ `communication.h`). Beim Hinzufügen neuer Header die bestehende Include-Reihenfolge in `master.cpp` beibehalten. +- Globale Timer-/Button-State-Variablen (`timerData1`, `timerData2`, `buttonConfigs`, `localTimes`, `learningMode`, `gamemode`, …) leben in `src/master.h` und werden überall direkt referenziert. + +Wer eine neue Datei anlegt: entweder als weiteren Header dem Pattern folgen und in `master.cpp` einklinken, oder bewusst eine echte `.cpp` mit `extern`-Deklarationen erstellen. + +### Laufzeit-Module + +- **`master.cpp`** — `setup()`/`loop()`. Reihenfolge in `setup()` ist relevant (SPIFFS → API-Setups → `load*()` aus Preferences → WiFi → OTA → Routes → WebSocket → MQTT → RFID). `loop()` priorisiert MQTT vor WebSocket vor RFID. +- **`communication.h`** — PicoMQTT-Broker. Tasten publishen auf `aquacross/button/`; `readButtonJSON()` parst, ordnet die MAC einer der vier Rollen (`start1`/`stop1`/`start2`/`stop2`) zu und triggert die Timerlogik. Hält pro MAC `TimestampData` für Drift-Berechnung. +- **`webserverrouter.h`** — `ESPAsyncWebServer` auf Port 80 + WebSocket `/ws`. Liefert statische Seiten aus SPIFFS (`/`, `/settings`, `/leaderboard`, `/rfid`) und alle `/api/...`-Endpunkte. Vollständige Routenliste in `API.md`. +- **`wificlass.h`** — AP-Modus auf `192.168.10.1` (eindeutiger SSID-Suffix), STA-Fallback wenn gespeicherte Credentials vorhanden. Bindet `PrettyOTA` (lokale Bibliothek unter `lib/PrettyOTA/`) und mDNS ein. +- **`preferencemanager.h`** — Persistierung in NVS (`Preferences`). Namespaces u. a. `buttons`, `leaderboard`, plus WiFi-/Location-/Settings-Slots. Beim Ändern persistierter Strukturen (z. B. `ButtonConfigs`) auf Größenkompatibilität achten — `loadButtonConfig()` lädt nur, wenn `getBytesLength == sizeof(buttonConfigs)`. +- **`licenceing.h`** — HMAC-SHA256 (`mbedtls`) gegen `secret` über die STA-MAC; bestimmt Tier/Online-Funktionen. Lizenz wird zusammen mit jeder Backend-Anfrage als `Authorization: Bearer …` gesendet. +- **`databasebackend.h`** — HTTPS-Client gegen `https://ninja.reptilfpv.de` (Locations, Leaderboard-Upload, Health). Funktioniert nur bei verbundenem STA + gültiger Lizenz. +- **`rfid.h`** — Adafruit PN532 (I²C/SPI). Liest UIDs nur, wenn `isRFIDReadingActive()`; UID landet in `TimerData*::RFIDUID` und wird mit Namen aus `localUsers`/Backend verknüpft. +- **`gamemodes.h`** — Modus `0=individual`, `1=wettkampf`; steuert, wann Timer als „bereit/armiert/laufend" gilt und wie Bestzeiten abgelegt werden (lokales `localTimes`-Vektor + optional Backend). +- **`timesync.h`/`debug.h`/`statusled.h`/`battery.h`/`buttonassigh.h`/`helper.h`** — Hilfsmodule (NTP/Zeitzone, Debug-API, Status-LED, Akku, Lerne-Mode für Tasten-Zuordnung). + +### Web-Frontend + +`data/` enthält `index.html`, `settings.html`, `leaderboard.html`, `rfid.html` plus zugehörige CSS und ein `pictures/`-Verzeichnis. Diese Dateien werden via `pio run -t uploadfs` ins SPIFFS geschrieben und vom Webserver direkt ausgeliefert. **Frontend-Änderungen erfordern ein erneutes `uploadfs`** — ein normaler Firmware-Upload aktualisiert sie nicht. + +`data/firmware.bin` wird unter `/firmware.bin` ausgeliefert (Buttons können sich darüber selbst aktualisieren). + +## API + +Vollständige HTTP-/WebSocket-API in `API.md` (autoritativ; `apientpoints` ist eine ältere Kurzversion). Alle POST-Routen erwarten **Form-Parameter, kein JSON-Body**. Antworten sind JSON, außer bei statischen Dateien. + +## Sprache + +Code-Kommentare und einige Variablennamen sind deutsch (`bahn`, `wettkampf`, „Anlernmodus"). Beim Erweitern bei der vorhandenen Sprache bleiben statt halb zu übersetzen. diff --git a/data/index.css b/data/index.css index 0c4e468..b2170e5 100644 --- a/data/index.css +++ b/data/index.css @@ -472,10 +472,13 @@ body { } .status.large-status.ready { - font-size: clamp(2rem, 8vw, 8rem) !important; display: flex !important; align-items: center !important; justify-content: center !important; + white-space: nowrap; + line-height: 1; + padding: 8px 12px !important; + overflow: hidden; } .status.finished { @@ -538,41 +541,55 @@ body { } } -.best-times { - background: rgba(255, 255, 255, 0.15); - backdrop-filter: blur(10px); - border-radius: 15px; - padding: clamp(10px, 1.5vh, 15px); - margin: 1vh 0 0 0; - width: clamp(320px, 80vw, 960px); - max-width: 960px; - text-align: center; - border: 1px solid rgba(255, 255, 255, 0.2); - flex-shrink: 0; - align-self: center; - display: flex; - flex-direction: column; - align-items: stretch; - gap: clamp(12px, 2vh, 20px); - box-sizing: border-box; -} - -#leaderboard-container { - text-align: left; +.leaderboards-row { display: grid; - grid-template-columns: 1fr; - gap: clamp(12px, 2vh, 20px); + grid-template-columns: 1fr 1fr; + gap: clamp(15px, 2vw, 30px); width: 100%; + max-width: 100%; + padding: 0 2vw; + margin-top: 0.5vh; + box-sizing: border-box; + flex-shrink: 0; } -@media (min-width: 768px) { - #leaderboard-container { - grid-template-columns: repeat(2, minmax(0, 1fr)); +@media (max-width: 768px) { + .leaderboards-row { + grid-template-columns: 1fr; + gap: clamp(15px, 3vw, 30px); + padding: 0 15px; } } +.best-times { + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(10px); + border-radius: 12px; + padding: clamp(6px, 1vh, 10px); + text-align: center; + border: 1px solid rgba(255, 255, 255, 0.2); + display: flex; + flex-direction: column; + align-items: stretch; + gap: clamp(4px, 0.8vh, 8px); + box-sizing: border-box; + min-width: 0; +} + +.best-times--full { + grid-column: 1 / -1; +} + +.leaderboard-list { + text-align: left; + display: flex; + flex-direction: column; + gap: clamp(4px, 0.8vh, 8px); + width: 100%; +} + .best-times h3 { - font-size: clamp(0.9rem, 1.8vw, 1.1rem); + font-size: clamp(0.7rem, 1.2vw, 0.85rem); margin: 0 auto; font-weight: bold; text-transform: uppercase; @@ -592,53 +609,58 @@ body { } /* Leaderboard Styles */ -#leaderboard-container { - text-align: left; -} - .leaderboard-entry { display: flex; justify-content: space-between; align-items: center; - margin: clamp(8px, 1vh, 12px) 0; - font-size: clamp(1.1rem, 2.2vw, 1.4rem); + margin: 0; + font-size: clamp(0.7rem, 1.1vw, 0.9rem); font-weight: 600; background: rgba(255, 255, 255, 0.15); - padding: clamp(12px, 2vh, 16px) clamp(16px, 3vw, 24px); - border-radius: 10px; + padding: clamp(4px, 0.8vh, 7px) clamp(8px, 1.2vw, 12px); + border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.3); transition: all 0.3s ease; - min-height: 50px; + min-height: 0; width: 100%; box-sizing: border-box; + gap: 8px; } .leaderboard-entry:hover { background: rgba(255, 255, 255, 0.25); - transform: translateY(-2px); - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + transform: translateY(-1px); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); } .leaderboard-entry .rank { color: #ffd700; font-weight: bold; - min-width: 30px; - font-size: clamp(1.2rem, 2.4vw, 1.5rem); + min-width: 20px; + font-size: clamp(0.75rem, 1.2vw, 0.95rem); + flex-shrink: 0; } .leaderboard-entry .name { flex: 1; - margin: 0 15px; + margin: 0; color: #ffffff; - font-weight: 600; + font-weight: 500; + font-size: clamp(0.7rem, 1.1vw, 0.9rem); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } .leaderboard-entry .time { color: #00ff88; font-weight: bold; font-family: 'Courier New', monospace; - min-width: 80px; + min-width: 70px; text-align: right; + font-size: clamp(1rem, 1.8vw, 1.3rem); + flex-shrink: 0; } .leaderboard-entry.gold { @@ -703,6 +725,7 @@ body { padding: 20px; } + .learning-mode { background: rgba(245, 157, 15, 0.2); border: 2px solid #f59d0f; diff --git a/data/index.html b/data/index.html index c2f98db..61f44e3 100644 --- a/data/index.html +++ b/data/index.html @@ -44,7 +44,6 @@

🏊‍♀️ NinjaCross Timer

-

Dein professioneller Zeitmesser für Ninjacross Wettkämpfe

-
-

🏆 Lokales Leaderboard

-
+
+
+

🏊‍♀️ Bahn 1 — Letzte Zeiten

+
+
+
+

🏊‍♂️ Bahn 2 — Letzte Zeiten

+
+
+ + diff --git a/tools/button-simulator/public/style.css b/tools/button-simulator/public/style.css new file mode 100644 index 0000000..b636b8c --- /dev/null +++ b/tools/button-simulator/public/style.css @@ -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; +} diff --git a/tools/button-simulator/server.js b/tools/button-simulator/server.js new file mode 100644 index 0000000..997ce18 --- /dev/null +++ b/tools/button-simulator/server.js @@ -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}`); +}); diff --git a/tools/update-anleitung.html b/tools/update-anleitung.html new file mode 100644 index 0000000..103ad96 --- /dev/null +++ b/tools/update-anleitung.html @@ -0,0 +1,142 @@ + + + + +AquaMaster – OTA Update Anleitung + + + + +

AquaMaster – OTA Update Anleitung

+

Schritt-für-Schritt-Anleitung zum Einspielen eines neuen Firmware- und Filesystem-Updates auf die AquaMaster-Einheit über die Weboberfläche (PrettyOTA).

+

Gilt für: AquaMaster MQTT (ESP32) · OTA-Oberfläche: PrettyOTA · Build: esp32thing_CI

+ +

1. Warum zwei Dateien?

+

Der ESP32 speichert die Software des AquaMasters in zwei getrennten Flash-Partitionen. Jede enthält etwas anderes, und beide können (und müssen teilweise) einzeln aktualisiert werden:

+ + + + + + + + + + + + + +
DateiPartitionInhalt
firmware.binApp-Partition (OTA)Die eigentliche ESP32-Software: Timer-Logik, MQTT-Broker, WiFi, Webserver, Spielmodi, RFID-Auswertung, Lizenzprüfung usw. Also alles, was der Master tut.
spiffs.binSPIFFS-PartitionDas Dateisystem mit der Weboberfläche: index.html, settings.html, leaderboard.html, rfid.html, CSS, Bilder sowie die Button-firmware.bin, über die sich die Funktaster selbst updaten.
+ +
+ Kurz gesagt: firmware.bin = was der Master macht, spiffs.bin = wie der Master im Browser aussieht. Beide leben in verschiedenen Flash-Bereichen und werden deshalb getrennt hochgeladen. +
+ +

Muss ich immer beide flashen?

+
    +
  • Nur Code geändert (z. B. neue Timerlogik, Bugfix): es reicht firmware.bin.
  • +
  • Nur Weboberfläche geändert (HTML/CSS, neue Button-Firmware): es reicht spiffs.bin.
  • +
  • Im Zweifel beide einspielen – in der Reihenfolge unten – dann kann garantiert nichts „alt gegen neu" kollidieren.
  • +
+ +

2. Vorbereitung

+
    +
  1. Die aktuelle Release-ZIP entpacken. Darin liegen firmware.bin und spiffs.bin.
  2. +
  3. AquaMaster einschalten und mit seinem WLAN verbinden: +
      +
    • AP-Modus: SSID AquaMaster-xxxx, IP 192.168.10.1
    • +
    • STA-Modus: die im Heimnetz vergebene IP (im Router oder per mDNS-Namen aquamaster.local)
    • +
    +
  4. +
  5. Im Browser die OTA-Seite öffnen: http://<IP-des-Masters>/update
    + Beispiel AP: http://192.168.10.1/update
  6. +
+ +
+ Wichtig: Während des Updates den AquaMaster nicht vom Strom trennen 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. +
+ +

3. Update-Modus wählen – das Wichtigste

+

Auf der PrettyOTA-Seite gibt es oben ein Dropdown „OTA-Mode" (oder „Update Mode") mit zwei Einträgen:

+ + + + + +
ModusWann auswählen?
FirmwareWenn du firmware.bin hochladen willst (Standard, die Option ist beim Öffnen der Seite bereits aktiv).
FilesystemWenn du spiffs.bin hochladen willst. Muss vor dem Upload manuell umgestellt werden!
+ +
+ Häufigster Fehler: spiffs.bin 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. Immer zuerst den richtigen Modus wählen, dann die Datei hinzufügen. +
+ +

4. Update durchführen

+ +

4.1 Firmware aktualisieren

+

1Im Dropdown „Firmware" auswählen.

+

2firmware.bin per Drag-&-Drop auf den Upload-Bereich ziehen (oder über „Datei auswählen" öffnen).

+

3Fortschrittsbalken abwarten, bis „Update successful" erscheint.

+

4Der AquaMaster startet automatisch neu. Warten, bis die Status-LED wieder Normalbetrieb signalisiert (ca. 5–10 s).

+ +

4.2 Filesystem (Weboberfläche) aktualisieren

+

1Erneut http://<IP>/update öffnen (nach dem Neustart ist die Verbindung evtl. kurz weg).

+

2Im Dropdown auf „Filesystem" umstellen. Nicht vergessen!

+

3spiffs.bin per Drag-&-Drop hochladen.

+

4Nach „Update successful" startet der Master erneut.

+ +
+ Tipp – Reihenfolge: Erst firmware.bin, dann spiffs.bin. So läuft nach dem ersten Reboot bereits die neue Timer-Software, die dann passend zur neuen Weboberfläche ist. +
+ +

5. Prüfen, ob das Update sitzt

+
    +
  • Die Hauptseite http://<IP>/ öffnen – die Oberfläche sollte fehlerfrei laden.
  • +
  • Unter Einstellungen / Status die Firmware-Version bzw. das Build-Datum kontrollieren.
  • +
  • Einen kurzen Testlauf starten (Start-/Stopp-Taster drücken), um zu prüfen, dass die Timerlogik reagiert.
  • +
  • Falls die Seite nach einem Filesystem-Update weiß/leer bleibt: spiffs.bin nochmal im Modus „Filesystem" hochladen.
  • +
+ +

6. Fehlerbehebung

+ + + + + + + + + + + + + + + + + + +
SymptomUrsache / Fix
„Wrong partition" oder Upload bricht sofort abFalscher Modus gewählt – Dropdown umstellen und erneut hochladen.
Nach dem Update leere / unformatierte WeboberflächeEs wurde nur firmware.bin geflasht, aber die HTML/CSS-Dateien haben sich geändert. spiffs.bin nachliefern (Modus „Filesystem").
Master kommt nach Firmware-Update nicht mehr ins WLANKurz stromlos machen; PrettyOTA führt intern bei einem fehlerhaften Boot ein Rollback auf die vorherige App-Partition aus.
Browser zeigt „Verbindung verloren"Normal während des Reboots. Seite nach ~10 s neu laden.
+ +

Bei fortbestehenden Problemen: seriellen Monitor mit 115200 Baud anschließen – PrettyOTA und der Master schreiben detaillierte Update- und Boot-Logs auf UART.

+ + +