This commit is contained in:
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal 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"
|
||||
]
|
||||
}
|
||||
BIN
AquaMaster-Update-Anleitung.pdf
Normal file
BIN
AquaMaster-Update-Anleitung.pdf
Normal file
Binary file not shown.
66
CLAUDE.md
Normal file
66
CLAUDE.md
Normal 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.
|
||||
111
data/index.css
111
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;
|
||||
|
||||
242
data/index.html
242
data/index.html
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
2
tools/button-simulator/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
44
tools/button-simulator/README.md
Normal file
44
tools/button-simulator/README.md
Normal 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
|
||||
14
tools/button-simulator/package.json
Normal file
14
tools/button-simulator/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
96
tools/button-simulator/public/app.js
Normal file
96
tools/button-simulator/public/app.js
Normal 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);
|
||||
95
tools/button-simulator/public/index.html
Normal file
95
tools/button-simulator/public/index.html
Normal 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>
|
||||
189
tools/button-simulator/public/style.css
Normal file
189
tools/button-simulator/public/style.css
Normal 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;
|
||||
}
|
||||
209
tools/button-simulator/server.js
Normal file
209
tools/button-simulator/server.js
Normal 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
142
tools/update-anleitung.html
Normal 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://<IP-des-Masters>/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-&-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. 5–10 s).</p>
|
||||
|
||||
<h3>4.2 Filesystem (Weboberfläche) aktualisieren</h3>
|
||||
<p class="step"><span class="step-num">1</span>Erneut <code>http://<IP>/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-&-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://<IP>/</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>
|
||||
Reference in New Issue
Block a user