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

View File

@@ -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"
]
}

Binary file not shown.

66
CLAUDE.md Normal file
View File

@@ -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-<datum>-<sha7>`. 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/<MAC>`; `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.

View File

@@ -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;

View File

@@ -44,7 +44,6 @@
<div class="header">
<h1>🏊‍♀️ NinjaCross Timer</h1>
<p>Dein professioneller Zeitmesser für Ninjacross Wettkämpfe</p>
</div>
<div id="learning-display" class="learning-mode" style="display: none">
@@ -72,9 +71,15 @@
</div>
</div>
<div class="best-times">
<h3>🏆 Lokales Leaderboard</h3>
<div id="leaderboard-container"></div>
<div class="leaderboards-row">
<div class="best-times" id="best-times-1">
<h3 id="lb-title-1">🏊‍♀️ Bahn 1 — Letzte Zeiten</h3>
<div id="leaderboard-container-1" class="leaderboard-list"></div>
</div>
<div class="best-times" id="best-times-2">
<h3 id="lb-title-2">🏊‍♂️ Bahn 2 — Letzte Zeiten</h3>
<div id="leaderboard-container-2" class="leaderboard-list"></div>
</div>
</div>
<script>
@@ -90,7 +95,7 @@
let learningButton = "";
let name1 = "";
let name2 = "";
let leaderboardData = [];
let leaderboardData = null;
// Lane Configuration
let laneConfigType = 0; // 0=Identical, 1=Different
@@ -329,6 +334,109 @@
}
}
// Passt "Bereit" so an, dass es die Status-Box maximal ausfüllt.
// Nutzt echte DOM-Messung via unsichtbarem Span → berechnet den
// Skalierungsfaktor und setzt font-size pixelgenau.
const fitReadyCache = { 1: { w: 0, h: 0, fs: 0 }, 2: { w: 0, h: 0, fs: 0 } };
function fitReadyText(statusEl, laneEl, laneNum) {
// Wir messen die Status-Box selbst — sie wurde vorher bereits
// positioniert (top/bottom/width gesetzt), hat also ihre finale Größe.
const sw = statusEl.clientWidth;
const sh = statusEl.clientHeight;
if (!sw || !sh) return;
const cache = fitReadyCache[laneNum];
if (cache.w === sw && cache.h === sh) {
statusEl.style.fontSize = cache.fs + "px";
return;
}
// Innenraum der Status-Box (nach Padding)
const cs = window.getComputedStyle(statusEl);
const pL = parseFloat(cs.paddingLeft) || 0;
const pR = parseFloat(cs.paddingRight) || 0;
const pT = parseFloat(cs.paddingTop) || 0;
const pB = parseFloat(cs.paddingBottom) || 0;
const availW = sw - pL - pR - 6;
const availH = sh - pT - pB - 6;
if (availW <= 0 || availH <= 0) return;
// Unsichtbarer Messspan im selben Font
let m = fitReadyText.m;
if (!m) {
m = document.createElement("span");
m.style.cssText =
"position:absolute;visibility:hidden;white-space:nowrap;" +
"left:-99999px;top:0;font-family:'Segoe UI',Arial,sans-serif;" +
"font-weight:600;line-height:1;padding:0;margin:0";
m.textContent = "Bereit";
document.body.appendChild(m);
fitReadyText.m = m;
}
const refSize = 200;
m.style.fontSize = refSize + "px";
const textW = m.offsetWidth || 1;
const textH = m.offsetHeight || 1;
// Skalierungsfaktor so wählen, dass Breite UND Höhe passen
const scale = Math.min(availW / textW, availH / textH);
const finalFs = Math.max(20, Math.floor(refSize * scale));
cache.w = sw;
cache.h = sh;
cache.fs = finalFs;
statusEl.style.fontSize = finalFs + "px";
}
// Passt die Timer-Zeit (Courier-Monospace) so an, dass sie den Platz
// zwischen h2 und Status maximal ausnutzt.
const fitTimeCache = {
1: { len: 0, lw: 0, lh: 0, fs: 0 },
2: { len: 0, lw: 0, lh: 0, fs: 0 },
};
function fitTimeText(timeEl, laneEl, laneNum) {
const text = timeEl.textContent;
const len = text.length;
const lw = laneEl.clientWidth;
const lh = laneEl.clientHeight;
if (!lw || !lh) return;
const cache = fitTimeCache[laneNum];
if (cache.len === len && cache.lw === lw && cache.lh === lh) {
timeEl.style.fontSize = cache.fs + "px";
return;
}
let m = fitTimeText.m;
if (!m) {
m = document.createElement("span");
m.style.cssText =
"position:absolute;visibility:hidden;white-space:nowrap;" +
"left:-99999px;top:0;font-family:'Courier New',monospace;" +
"font-weight:bold;line-height:1;padding:0;margin:0";
document.body.appendChild(m);
fitTimeText.m = m;
}
const refSize = 200;
m.style.fontSize = refSize + "px";
m.textContent = text;
const textW = m.offsetWidth || 1;
const textH = m.offsetHeight || 1;
// Aggressiv: 92% Breite, 62% Höhe (h2 oben + Status unten reserviert)
const availW = lw * 0.92;
const availH = lh * 0.62;
const scale = Math.min(availW / textW, availH / textH);
const fs = Math.max(30, Math.floor(refSize * scale));
cache.len = len;
cache.lw = lw;
cache.lh = lh;
cache.fs = fs;
timeEl.style.fontSize = fs + "px";
}
function formatTime(seconds) {
if (seconds === 0) return "00.00";
@@ -353,70 +461,70 @@
async function loadLeaderboard() {
try {
const response = await fetch("/api/leaderboard");
const data = await response.json();
leaderboardData = data.leaderboard || [];
leaderboardData = await response.json();
updateLeaderboardDisplay();
} catch (error) {
console.error("Fehler beim Laden des Leaderboards:", error);
}
}
function updateLeaderboardDisplay() {
const container = document.getElementById("leaderboard-container");
container.innerHTML = "";
function createEntryElement(entry) {
const div = document.createElement("div");
div.className = "leaderboard-entry";
if (leaderboardData.length === 0) {
container.innerHTML =
'<div class="no-times">Noch keine Zeiten erfasst</div>';
const nameSpan = document.createElement("span");
nameSpan.className = "name";
nameSpan.textContent = entry.name || "Unbekannt";
const timeSpan = document.createElement("span");
timeSpan.className = "time";
timeSpan.textContent = entry.timeFormatted;
div.appendChild(nameSpan);
div.appendChild(timeSpan);
return div;
}
function fillLeaderboardContainer(container, entries) {
container.innerHTML = "";
if (!entries || entries.length === 0) {
const empty = document.createElement("div");
empty.className = "no-times";
empty.textContent = "Noch keine Zeiten";
container.appendChild(empty);
return;
}
entries.forEach((e) => container.appendChild(createEntryElement(e)));
}
function updateLeaderboardDisplay() {
const box1 = document.getElementById("best-times-1");
const box2 = document.getElementById("best-times-2");
const container1 = document.getElementById("leaderboard-container-1");
const container2 = document.getElementById("leaderboard-container-2");
const title1 = document.getElementById("lb-title-1");
const title2 = document.getElementById("lb-title-2");
if (!leaderboardData) {
return;
}
// Erstelle zwei Reihen für 2x3 Layout
const row1 = document.createElement("div");
row1.className = "leaderboard-row";
const row2 = document.createElement("div");
row2.className = "leaderboard-row";
// Reset Layout-Klassen
box1.classList.remove("best-times--full");
box2.style.display = "";
leaderboardData.forEach((entry, index) => {
const entryDiv = document.createElement("div");
entryDiv.className = "leaderboard-entry";
// Podium-Plätze hervorheben
if (index === 0) {
entryDiv.classList.add("gold");
} else if (index === 1) {
entryDiv.classList.add("silver");
} else if (index === 2) {
entryDiv.classList.add("bronze");
}
const rankSpan = document.createElement("span");
rankSpan.className = "rank";
rankSpan.textContent = entry.rank + ".";
const nameSpan = document.createElement("span");
nameSpan.className = "name";
nameSpan.textContent = entry.name;
const timeSpan = document.createElement("span");
timeSpan.className = "time";
timeSpan.textContent = entry.timeFormatted;
entryDiv.appendChild(rankSpan);
entryDiv.appendChild(nameSpan);
entryDiv.appendChild(timeSpan);
// Erste 3 Einträge in die erste Reihe, nächste 3 in die zweite Reihe
if (index < 3) {
row1.appendChild(entryDiv);
} else if (index < 6) {
row2.appendChild(entryDiv);
}
});
container.appendChild(row1);
if (leaderboardData.length > 3) {
container.appendChild(row2);
if (leaderboardData.mode === "different") {
// Unterschiedliche Lanes: eigene History pro Bahn unter jeder Lane
title1.textContent = "🏊‍♀️ Bahn 1 — Letzte Zeiten";
title2.textContent = "🏊‍♂️ Bahn 2 — Letzte Zeiten";
fillLeaderboardContainer(container1, leaderboardData.lane1);
fillLeaderboardContainer(container2, leaderboardData.lane2);
} else {
// Identische Lanes: ein gemeinsames Leaderboard über beide Spalten
title1.textContent = "🏆 Letzte Zeiten";
box1.classList.add("best-times--full");
box2.style.display = "none";
fillLeaderboardContainer(container1, leaderboardData.entries);
}
}
@@ -484,7 +592,7 @@
s1.style.display = "flex";
s1.style.alignItems = "center";
s1.style.justifyContent = "center";
s1.style.fontSize = "clamp(2rem, 8vw, 8rem)";
fitReadyText(s1, lane1Element, 1);
} else {
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
time1Element.style.display = "";
@@ -522,12 +630,13 @@
s1.style.fontSize = "";
s1.style.left = "";
s1.style.bottom = "";
fitTimeText(time1Element, lane1Element, 1);
}
}
switch (status1) {
case "ready":
s1.textContent = "Bereit für den Start!";
s1.textContent = "Bereit";
break;
case "running":
s1.textContent = "Läuft - Gib alles!";
@@ -587,7 +696,7 @@
s2.style.display = "flex";
s2.style.alignItems = "center";
s2.style.justifyContent = "center";
s2.style.fontSize = "clamp(2rem, 8vw, 8rem)";
fitReadyText(s2, lane2Element, 2);
} else {
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
time2Element.style.display = "";
@@ -625,12 +734,13 @@
s2.style.fontSize = "";
s2.style.left = "";
s2.style.bottom = "";
fitTimeText(time2Element, lane2Element, 2);
}
}
switch (status2) {
case "ready":
s2.textContent = "Bereit für den Start!";
s2.textContent = "Bereit";
break;
case "running":
s2.textContent = "Läuft - Gib alles!";
@@ -757,6 +867,14 @@
});
}, 1000);
window.addEventListener("resize", () => {
fitReadyCache[1].w = 0;
fitReadyCache[2].w = 0;
fitTimeCache[1].lw = 0;
fitTimeCache[2].lw = 0;
updateDisplay();
});
// Initial load
syncFromBackend();
loadLaneConfig();

View File

@@ -56,7 +56,7 @@ struct UserData {
};
// Forward declarations für Leaderboard-Funktionen
void addLocalTime(String uid, String name, unsigned long timeMs);
void addLocalTime(String uid, String name, unsigned long timeMs, int lane);
// Prüft, ob ein Benutzer mit der angegebenen UID in der Datenbank existiert und
// gibt dessen Daten zurück.
@@ -353,49 +353,67 @@ void setupBackendRoutes(AsyncWebServer &server) {
// Andere Logik wie in getBestLocs
});
// Lokales Leaderboard API (für Hauptseite - 6 Einträge)
// Lokales Leaderboard API (für Hauptseite - neueste Zeiten)
// Liefert:
// - bei identischen Lanes: "entries" = neueste 3 Zeiten (Bahnen gemischt)
// - bei unterschiedlichen Lanes: "lane1" / "lane2" = je neueste 3 Zeiten
// Reihenfolge ist immer neueste zuerst. Einfügereihenfolge in localTimes
// ist chronologisch (push_back) und bleibt das auch nach load.
server.on("/api/leaderboard", HTTP_GET, [](AsyncWebServerRequest *request) {
// Sortiere nach Zeit (beste zuerst)
std::sort(localTimes.begin(), localTimes.end(),
[](const LocalTime &a, const LocalTime &b) {
return a.timeMs < b.timeMs;
});
DynamicJsonDocument doc(2048);
JsonArray leaderboard = doc.createNestedArray("leaderboard");
// Nimm die besten 6
int count = 0;
for (const auto &time : localTimes) {
if (count >= 6)
break;
JsonObject entry = leaderboard.createNestedObject();
entry["rank"] = count + 1;
entry["name"] = time.name;
entry["uid"] = time.uid;
entry["time"] = time.timeMs / 1000.0;
// Format time inline
float seconds = time.timeMs / 1000.0;
auto formatTime = [](unsigned long timeMs) -> String {
float seconds = timeMs / 1000.0;
int totalSeconds = (int)seconds;
int minutes = totalSeconds / 60;
int remainingSeconds = totalSeconds % 60;
int milliseconds = (int)((seconds - totalSeconds) * 100);
String timeFormatted;
if (minutes > 0) {
timeFormatted = String(minutes) + ":" +
(remainingSeconds < 10 ? "0" : "") +
String(remainingSeconds) + "." +
(milliseconds < 10 ? "0" : "") + String(milliseconds);
} else {
timeFormatted = String(remainingSeconds) + "." +
(milliseconds < 10 ? "0" : "") + String(milliseconds);
return String(minutes) + ":" + (remainingSeconds < 10 ? "0" : "") +
String(remainingSeconds) + "." +
(milliseconds < 10 ? "0" : "") + String(milliseconds);
}
entry["timeFormatted"] = timeFormatted;
return String(remainingSeconds) + "." +
(milliseconds < 10 ? "0" : "") + String(milliseconds);
};
count++;
auto addEntry = [&](JsonArray &arr, const LocalTime &t) {
JsonObject entry = arr.createNestedObject();
entry["name"] = t.name;
entry["uid"] = t.uid;
entry["lane"] = t.lane;
entry["time"] = t.timeMs / 1000.0;
entry["timeFormatted"] = formatTime(t.timeMs);
};
DynamicJsonDocument doc(2048);
doc["mode"] = (laneConfigType == 1) ? "different" : "identical";
const int limit = 3;
if (laneConfigType == 1) {
// Unterschiedliche Lanes: eigene History pro Bahn
JsonArray lane1Arr = doc.createNestedArray("lane1");
JsonArray lane2Arr = doc.createNestedArray("lane2");
int c1 = 0, c2 = 0;
for (auto it = localTimes.rbegin();
it != localTimes.rend() && (c1 < limit || c2 < limit); ++it) {
if (it->lane == 1 && c1 < limit) {
addEntry(lane1Arr, *it);
c1++;
} else if (it->lane == 2 && c2 < limit) {
addEntry(lane2Arr, *it);
c2++;
}
}
} else {
// Identische Lanes: gemeinsame History
JsonArray entries = doc.createNestedArray("entries");
int count = 0;
for (auto it = localTimes.rbegin();
it != localTimes.rend() && count < limit; ++it) {
addEntry(entries, *it);
count++;
}
}
String result;
@@ -525,7 +543,7 @@ void sendTimeToOnlineAPI(int lane, String uid, float timeInSeconds) {
}
// Funktionen für lokales Leaderboard
void addLocalTime(String uid, String name, unsigned long timeMs) {
void addLocalTime(String uid, String name, unsigned long timeMs, int lane) {
// Prüfe minimale Zeit für Leaderboard-Eintrag
if (timeMs < minTimeForLeaderboard) {
Serial.printf(
@@ -540,6 +558,7 @@ void addLocalTime(String uid, String name, unsigned long timeMs) {
newTime.name = name;
newTime.timeMs = timeMs;
newTime.timestamp = millis();
newTime.lane = lane;
localTimes.push_back(newTime);

View File

@@ -62,18 +62,18 @@ void IndividualMode(const char *action, int press, int lane,
// Finde den Namen des lokalen Users
UserData userData = checkUser(getStart1UID());
if (userData.exists) {
addLocalTime(getStart1UID(), userData.firstname, currentTime);
addLocalTime(getStart1UID(), userData.firstname, currentTime, 1);
} else {
// User lokal gefunden aber keine Daten - speichere ohne Namen
addLocalTime(getStart1UID(), "Unbekannt", currentTime);
addLocalTime(getStart1UID(), "Unbekannt", currentTime, 1);
}
} else if (!wasStart1FoundLocally() && getStart1UID().length() > 0) {
// Sende Zeit an Online-API wenn User online gefunden wurde
sendTimeToOnlineAPI(1, getStart1UID(), currentTime / 1000.0);
} else {
// Kein User gefunden - speichere Zeit ohne UID und Namen
addLocalTime("", "Spieler " + String((localTimes.size() + 1)),
currentTime);
addLocalTime("", "Lauf " + String((localTimes.size() + 1)),
currentTime, 1);
}
}
}
@@ -109,18 +109,18 @@ void IndividualMode(const char *action, int press, int lane,
// Finde den Namen des lokalen Users
UserData userData = checkUser(getStart2UID());
if (userData.exists) {
addLocalTime(getStart2UID(), userData.firstname, currentTime);
addLocalTime(getStart2UID(), userData.firstname, currentTime, 2);
} else {
// User lokal gefunden aber keine Daten - speichere ohne Namen
addLocalTime(getStart2UID(), "Unbekannt", currentTime);
addLocalTime(getStart2UID(), "Unbekannt", currentTime, 2);
}
} else if (!wasStart2FoundLocally() && getStart2UID().length() > 0) {
// Sende Zeit an Online-API wenn User online gefunden wurde
sendTimeToOnlineAPI(2, getStart2UID(), currentTime / 1000.0);
} else {
// Kein User gefunden - speichere Zeit ohne UID und Namen
addLocalTime("", "Spieler " + String((localTimes.size() + 1)),
currentTime);
addLocalTime("", "Lauf " + String((localTimes.size() + 1)),
currentTime, 2);
}
}
}

View File

@@ -24,7 +24,7 @@
#include <webserverrouter.h>
#include <wificlass.h>
const char *firmwareversion = "1.0.0"; // Version der Firmware
const char *firmwareversion = "1.1.0"; // Version der Firmware
// moved to preferencemanager.h

View File

@@ -31,6 +31,7 @@ struct LocalTime {
String name;
unsigned long timeMs;
unsigned long timestamp;
int lane; // 1 = Bahn 1, 2 = Bahn 2, 0 = unbekannt
};
// Timer Struktur für Bahn 2

View File

@@ -39,6 +39,8 @@ void saveBestTimes() {
localTimes[i].timeMs); // e0t, e1t, etc.
preferences.putULong((key + "s").c_str(),
localTimes[i].timestamp); // e0s, e1s, etc.
preferences.putInt((key + "l").c_str(),
localTimes[i].lane); // e0l, e1l, etc.
}
preferences.end();
@@ -68,6 +70,8 @@ void loadBestTimes() {
preferences.getULong((key + "t").c_str(), 0); // e0t, e1t, etc.
entry.timestamp =
preferences.getULong((key + "s").c_str(), 0); // e0s, e1s, etc.
entry.lane =
preferences.getInt((key + "l").c_str(), 0); // e0l, e1l, etc.
localTimes.push_back(entry);
}

View File

@@ -24,6 +24,13 @@ bool isRFIDReadingActive() { return readingMode; }
// Initialisiert den RFID-Reader
void setupRFID() {
// RFID-Hardware-Initialisierung deaktiviert (Leser aktuell nicht verwendet);
// spart Boot-Zeit durch den PN532-Probe-Timeout. Bei Bedarf Block unten
// wieder aktivieren.
Serial.println("RFID: Hardware-Init übersprungen (deaktiviert)");
rfidInitialized = false;
/*
// I2C starten mit korrekten Pins
Wire.begin(SDA_PIN, SCL_PIN, 100000);
delay(100);
@@ -46,6 +53,7 @@ void setupRFID() {
rfidInitialized = true;
Serial.println("RFID: Setup erfolgreich!");
*/
}
// Prüft ob RFID funktioniert

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>