Compare commits

...

15 Commits

Author SHA1 Message Date
Carsten Graf
3400b9cc6a glow für neue Zeit
Some checks failed
/ build (push) Failing after 4m11s
2026-05-03 16:27:27 +02:00
Carsten Graf
fa87fd0222 fix(rtc): address code-review findings (5 fixes)
- Drop extern "C" from weak hooks (UB with C++ reference param)
- syncTimeWithNTP returns bool; syncFromNTP uses it (robust success check)
- Avoid duplicate NTP sync at boot (wificlass already syncs)
- Clamp negative time deltas in 24h timer and JSON status
- Cache rtc.now() in loopRTC to avoid I2C race with PN532
2026-05-03 15:17:19 +02:00
Carsten Graf
a6c885ee33 feat(rtc): wire rtcsync into setup and loop 2026-05-03 15:06:07 +02:00
Carsten Graf
8acb611b9b feat(rtc): add loopRTC and appendTimeStatus hook 2026-05-03 15:01:31 +02:00
Carsten Graf
68483c8127 feat(rtc): add syncFromNTP wrapper that persists to RTC 2026-05-03 15:00:08 +02:00
Carsten Graf
781ad18c6a feat(rtc): wire timesync hooks and add persistSystemTimeToRTC 2026-05-03 14:58:48 +02:00
Carsten Graf
a875b20ba2 feat(rtc): add rtcsync.h with PCF8523 detection and time fallback 2026-05-03 14:55:05 +02:00
Carsten Graf
f6b2dceedc build: add RTClib dependency for PCF8523 RTC support 2026-05-03 14:51:51 +02:00
Carsten Graf
df95a37ca7 docs: add implementation plan for PCF8523 RTC fallback
Ten tasks covering dependency setup, header creation, weak-hook
decoupling from timesync.h, master.cpp wiring, and hardware
verification on real device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:47:37 +02:00
Carsten Graf
96fcb74c80 docs: add design spec for PCF8523 RTC fallback
New header rtcsync.h providing persistent time storage and offline
fallback when NTP is unavailable. Soft-fails when hardware is absent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:44:40 +02:00
Carsten Graf
48ae556949 BugFix: Status Unknown
Some checks failed
/ build (push) Has been cancelled
2026-04-12 14:50:33 +02:00
Carsten Graf
9d958c94f1 UHrzeit und Starttick
Some checks failed
/ build (push) Has been cancelled
2026-04-11 23:28:37 +02:00
Carsten Graf
0223cceef8 Button Simmulator, Frontend änderungen
Some checks failed
/ build (push) Has been cancelled
2026-04-11 20:24:39 +02:00
05166b443b Merge pull request 'Kleine änderungen' (#2) from v1 into main
Some checks failed
/ build (push) Failing after 26s
Reviewed-on: #2
2026-03-06 15:00:32 +01:00
Carsten Graf
76b492606e Kleine änderungen
All checks were successful
/ build (push) Successful in 6m43s
2026-02-21 15:37:54 +01:00
35 changed files with 2483 additions and 4058 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.

BIN
data/firmware.bin Normal file

Binary file not shown.

View File

@@ -27,10 +27,12 @@ body {
.logo {
position: fixed;
top: 20px;
/* Vertikal zentriert im 60px-Header-Bereich (top:20px, height:60px → Mitte 50px) */
top: 50px;
left: 20px;
width: auto;
height: auto;
transform: translateY(-50%);
z-index: 1000;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
@@ -43,7 +45,7 @@ body {
}
.logo:hover {
transform: scale(1.1);
transform: translateY(-50%) scale(1.1);
}
.logo img {
@@ -105,17 +107,43 @@ body {
transform: scale(1.1);
}
.live-clock {
position: fixed;
top: 20px;
left: 25%;
transform: translateX(-50%);
height: 60px;
min-width: 150px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 24px;
z-index: 1000;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 30px;
font-family: "Consolas", "Menlo", "Courier New", monospace;
font-size: 1.6rem;
font-weight: 600;
letter-spacing: 2px;
color: rgba(255, 255, 255, 0.95);
font-variant-numeric: tabular-nums;
}
.heartbeat-indicators {
position: fixed;
top: 20px;
right: 160px;
height: 60px;
display: flex;
gap: 15px;
align-items: flex-end;
gap: 18px;
z-index: 1000;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 25px;
padding: 10px 20px;
border-radius: 30px;
padding: 0 24px 10px 24px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
@@ -123,7 +151,8 @@ body {
.logo {
width: 40px;
height: 40px;
top: 15px;
/* Mobile: Header-Band top:15px height:60px → Mitte 45px */
top: 45px;
left: 15px;
padding: 3px;
}
@@ -142,22 +171,34 @@ body {
font-size: 1.2rem;
}
.live-clock {
top: 15px;
height: 40px;
min-width: 100px;
padding: 0 14px;
font-size: 1rem;
letter-spacing: 1px;
border-radius: 20px;
}
.heartbeat-indicators {
top: 15px;
right: 90px;
gap: 8px;
padding: 8px 12px;
height: 60px;
gap: 12px;
padding: 0 16px 10px 16px;
font-size: 0.8rem;
border-radius: 30px;
}
.heartbeat-indicator {
width: 12px;
height: 12px;
width: 18px;
height: 18px;
}
.heartbeat-indicator::before {
font-size: 8px;
top: -20px;
top: -14px;
}
.header h1 {
@@ -170,8 +211,8 @@ body {
}
.heartbeat-indicator {
width: 20px;
height: 20px;
width: 26px;
height: 26px;
border-radius: 50%;
background: #f50f0f;
transition: all 0.3s ease;
@@ -181,7 +222,7 @@ body {
.heartbeat-indicator::before {
content: attr(data-label);
position: absolute;
top: -25px;
top: -18px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
@@ -471,6 +512,16 @@ body {
overflow: visible;
}
.status.large-status.ready {
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 {
background-color: rgba(73, 186, 228, 0.3);
border: 2px solid #49bae4;
@@ -479,7 +530,6 @@ body {
.status.ready {
background-color: rgb(0 165 3 / 54%);
border: 2px solid #06ff00;
animation: pulse 1s infinite;
}
.status.armed {
@@ -532,41 +582,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;
@@ -586,53 +650,140 @@ 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.latest {
border: 2px solid #00ff88;
animation: latest-pulse 1.6s ease-in-out infinite;
position: relative;
z-index: 1;
}
.leaderboard-entry.latest .name {
color: #ffffff;
font-weight: 800;
text-shadow: 0 0 8px rgba(0, 255, 136, 0.7);
}
.leaderboard-entry.latest .time {
color: #ffffff;
animation: latest-time-flash 1.6s ease-in-out infinite;
}
.latest-badge {
display: inline-block;
background: #00ff88;
color: #0d1733;
font-weight: 900;
font-size: clamp(0.6rem, 1vw, 0.85rem);
letter-spacing: 1px;
padding: 3px 8px;
border-radius: 5px;
flex-shrink: 0;
text-transform: uppercase;
animation: latest-badge-pulse 1.6s ease-in-out infinite;
}
@keyframes latest-pulse {
0%,
100% {
background: linear-gradient(
135deg,
rgba(0, 255, 136, 0.28) 0%,
rgba(0, 200, 110, 0.18) 100%
);
box-shadow: 0 0 8px rgba(0, 255, 136, 0.35),
inset 0 0 6px rgba(0, 255, 136, 0.18);
border-color: #00ff88;
}
50% {
background: linear-gradient(
135deg,
rgba(0, 255, 136, 0.5) 0%,
rgba(0, 230, 120, 0.32) 100%
);
box-shadow: 0 0 16px rgba(0, 255, 136, 0.6),
0 0 32px rgba(0, 255, 136, 0.3),
inset 0 0 10px rgba(255, 255, 255, 0.25);
border-color: #ffffff;
}
}
@keyframes latest-badge-pulse {
0%,
100% {
background: #00ff88;
color: #0d1733;
box-shadow: 0 0 5px rgba(0, 255, 136, 0.5);
}
50% {
background: #ffffff;
color: #006a3a;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.7),
0 0 16px rgba(0, 255, 136, 0.55);
}
}
@keyframes latest-time-flash {
0%,
100% {
text-shadow: 0 0 6px rgba(0, 255, 136, 0.55);
}
50% {
text-shadow: 0 0 8px #ffffff, 0 0 14px rgba(0, 255, 136, 0.7);
}
}
.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 {
@@ -697,6 +848,7 @@ body {
padding: 20px;
}
.learning-mode {
background: rgba(245, 157, 15, 0.2);
border: 2px solid #f59d0f;

View File

@@ -24,6 +24,7 @@
</div>
<img src="/pictures/erlebniss.png" class="logo" alt="NinjaCross Logo" />
<div id="live-clock" class="live-clock">--:--:--</div>
<a href="/leaderboard.html" class="leaderboard-btn">🏆</a>
<a href="/settings" class="settings-btn">⚙️</a>
@@ -44,7 +45,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 +72,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 +96,7 @@
let learningButton = "";
let name1 = "";
let name2 = "";
let leaderboardData = [];
let leaderboardData = null;
// Lane Configuration
let laneConfigType = 0; // 0=Identical, 1=Different
@@ -162,6 +168,12 @@
document.getElementById(indicatorId).classList.remove("active");
}
}
// Hinweis: Heartbeats und echte Tastendrücke kommen im WebSocket
// identisch als {button, mac, active: true} an. Eine optimistische
// Status-Übernahme (z. B. running→finished bei stop1) führte daher
// zu kurzem „Geschafft!"-Aufblitzen während des Laufs, sobald der
// Stop-Button einen periodischen Heartbeat sendete. Der Status
// kommt jetzt ausschließlich über syncFromBackend (1 s-Polling).
}
try {
@@ -329,6 +341,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 +468,95 @@
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 formatEndTime(epochSeconds) {
if (!epochSeconds || epochSeconds < 1577836800) return ""; // < 2020 = kein NTP-Sync
const d = new Date(epochSeconds * 1000);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
if (leaderboardData.length === 0) {
container.innerHTML =
'<div class="no-times">Noch keine Zeiten erfasst</div>';
function createEntryElement(entry, isLatest) {
const div = document.createElement("div");
div.className = "leaderboard-entry";
if (isLatest) div.classList.add("latest");
if (isLatest) {
const badge = document.createElement("span");
badge.className = "latest-badge";
badge.textContent = "NEU";
div.appendChild(badge);
}
const nameSpan = document.createElement("span");
nameSpan.className = "name";
let label = entry.name || "Unbekannt";
// Bei "Lauf N"-Einträgen die Endzeit in Klammern anhängen
if (/^Lauf\s+\d+$/.test(label)) {
const endTime = formatEndTime(entry.endEpoch);
if (endTime) label += ` (${endTime})`;
}
nameSpan.textContent = label;
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, i) =>
container.appendChild(createEntryElement(e, i === 0))
);
}
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);
}
}
@@ -449,62 +589,86 @@
if (!lane1Connected) {
s1.className = "status standby large-status";
s1.textContent = "Standby: Drücke beide Buttons einmal";
time1Element.style.display = "none";
// Position über time-display, aber innerhalb des Containers
if (s1.classList.contains("large-status")) {
const time1Rect = time1Element.getBoundingClientRect();
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const time1Center = time1Rect.top - lane1Rect.top + time1Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane1Rect.top;
// Stelle sicher, dass die obere Kante der Status-Box unter h2 beginnt
// Beginne unter h2 (ohne translate(-50%, -50%) beginnt die Box von oben)
const startTop = h2Bottom + 10;
// Positioniere so, dass die Box über time-display zentriert ist, aber nicht über h2 hinausragt
// Berechne die benötigte Höhe, um über time-display zentriert zu sein
const statusHeight = s1.offsetHeight || 200; // Verwende tatsächliche Höhe oder Schätzwert
const targetTop = Math.max(startTop, time1Center - statusHeight / 2);
s1.style.top = targetTop + "px";
s1.style.top = startTop + "px";
s1.style.left = "50%";
s1.style.transform = "translateX(-50%)";
// Stelle sicher, dass die Box innerhalb des Containers bleibt
const maxHeight = lane1Rect.height - targetTop - 30;
s1.style.maxHeight = maxHeight + "px";
s1.style.overflow = "auto";
s1.style.bottom = "20px";
s1.style.width = "calc(100% - 40px)";
s1.style.display = "flex";
s1.style.alignItems = "center";
s1.style.justifyContent = "center";
}
} else {
s1.className = `status ${status1}`;
// Add large-status class if not running and not finished
if (status1 !== "running" && status1 !== "finished") {
// Wenn status "ready" ist, verstecke Zeit und mache Status groß
if (status1 === "ready") {
s1.classList.add("large-status");
// Position über time-display, aber innerhalb des Containers
const time1Rect = time1Element.getBoundingClientRect();
time1Element.style.display = "none";
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const time1Center = time1Rect.top - lane1Rect.top + time1Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane1Rect.top;
// Stelle sicher, dass die obere Kante der Status-Box unter h2 beginnt
// Beginne unter h2 (ohne translate(-50%, -50%) beginnt die Box von oben)
const startTop = h2Bottom + 10;
// Positioniere so, dass die Box über time-display zentriert ist, aber nicht über h2 hinausragt
// Berechne die benötigte Höhe, um über time-display zentriert zu sein
const statusHeight = s1.offsetHeight || 200; // Verwende tatsächliche Höhe oder Schätzwert
const targetTop = Math.max(startTop, time1Center - statusHeight / 2);
s1.style.top = targetTop + "px";
s1.style.top = startTop + "px";
s1.style.left = "50%";
s1.style.transform = "translateX(-50%)";
// Stelle sicher, dass die Box innerhalb des Containers bleibt
const maxHeight = lane1Rect.height - targetTop - 30;
s1.style.maxHeight = maxHeight + "px";
s1.style.overflow = "auto";
s1.style.bottom = "20px";
s1.style.width = "calc(100% - 40px)";
s1.style.display = "flex";
s1.style.alignItems = "center";
s1.style.justifyContent = "center";
fitReadyText(s1, lane1Element, 1);
} else {
s1.classList.remove("large-status");
s1.style.top = "";
s1.style.transform = "";
s1.style.maxHeight = "";
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
time1Element.style.display = "";
if (status1 !== "running" && status1 !== "finished") {
s1.classList.add("large-status");
const time1Rect = time1Element.getBoundingClientRect();
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const time1Center = time1Rect.top - lane1Rect.top + time1Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane1Rect.top;
const startTop = h2Bottom + 10;
const statusHeight = s1.offsetHeight || 200;
const targetTop = Math.max(startTop, time1Center - statusHeight / 2);
s1.style.top = targetTop + "px";
s1.style.transform = "translateX(-50%)";
s1.style.height = "";
s1.style.width = "";
s1.style.display = "";
s1.style.alignItems = "";
s1.style.justifyContent = "";
s1.style.fontSize = "";
const maxHeight = lane1Rect.height - targetTop - 30;
s1.style.maxHeight = maxHeight + "px";
s1.style.overflow = "auto";
} else {
s1.classList.remove("large-status");
s1.style.top = "";
s1.style.transform = "";
s1.style.maxHeight = "";
s1.style.height = "";
s1.style.width = "";
s1.style.display = "";
s1.style.alignItems = "";
s1.style.justifyContent = "";
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!";
@@ -529,62 +693,86 @@
if (!lane2Connected) {
s2.className = "status standby large-status";
s2.textContent = "Standby: Drücke beide Buttons einmal";
time2Element.style.display = "none";
// Position über time-display, aber innerhalb des Containers
if (s2.classList.contains("large-status")) {
const time2Rect = time2Element.getBoundingClientRect();
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
const time2Center = time2Rect.top - lane2Rect.top + time2Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane2Rect.top;
// Stelle sicher, dass die obere Kante der Status-Box unter h2 beginnt
// Beginne unter h2 (ohne translate(-50%, -50%) beginnt die Box von oben)
const startTop = h2Bottom + 10;
// Positioniere so, dass die Box über time-display zentriert ist, aber nicht über h2 hinausragt
// Berechne die benötigte Höhe, um über time-display zentriert zu sein
const statusHeight = s2.offsetHeight || 200; // Verwende tatsächliche Höhe oder Schätzwert
const targetTop = Math.max(startTop, time2Center - statusHeight / 2);
s2.style.top = targetTop + "px";
s2.style.top = startTop + "px";
s2.style.left = "50%";
s2.style.transform = "translateX(-50%)";
// Stelle sicher, dass die Box innerhalb des Containers bleibt
const maxHeight = lane2Rect.height - targetTop - 30;
s2.style.maxHeight = maxHeight + "px";
s2.style.overflow = "auto";
s2.style.bottom = "20px";
s2.style.width = "calc(100% - 40px)";
s2.style.display = "flex";
s2.style.alignItems = "center";
s2.style.justifyContent = "center";
}
} else {
s2.className = `status ${status2}`;
// Add large-status class if not running and not finished
if (status2 !== "running" && status2 !== "finished") {
// Wenn status "ready" ist, verstecke Zeit und mache Status groß
if (status2 === "ready") {
s2.classList.add("large-status");
// Position über time-display, aber innerhalb des Containers
const time2Rect = time2Element.getBoundingClientRect();
time2Element.style.display = "none";
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
// Stelle sicher, dass die obere Kante der Status-Box unter h2 beginnt
const h2Bottom = h2Rect.bottom - lane2Rect.top;
const time2Center = time2Rect.top - lane2Rect.top + time2Rect.height / 2;
// Beginne unter h2 (ohne translate(-50%, -50%) beginnt die Box von oben)
const startTop = h2Bottom + 10;
// Positioniere so, dass die Box über time-display zentriert ist, aber nicht über h2 hinausragt
// Berechne die benötigte Höhe, um über time-display zentriert zu sein
const statusHeight = s2.offsetHeight || 200; // Verwende tatsächliche Höhe oder Schätzwert
const targetTop = Math.max(startTop, time2Center - statusHeight / 2);
s2.style.top = targetTop + "px";
s2.style.top = startTop + "px";
s2.style.left = "50%";
s2.style.transform = "translateX(-50%)";
// Stelle sicher, dass die Box innerhalb des Containers bleibt
const maxHeight = lane2Rect.height - targetTop - 30;
s2.style.maxHeight = maxHeight + "px";
s2.style.overflow = "auto";
s2.style.bottom = "20px";
s2.style.width = "calc(100% - 40px)";
s2.style.display = "flex";
s2.style.alignItems = "center";
s2.style.justifyContent = "center";
fitReadyText(s2, lane2Element, 2);
} else {
s2.classList.remove("large-status");
s2.style.top = "";
s2.style.transform = "";
s2.style.maxHeight = "";
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
time2Element.style.display = "";
if (status2 !== "running" && status2 !== "finished") {
s2.classList.add("large-status");
const time2Rect = time2Element.getBoundingClientRect();
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
const time2Center = time2Rect.top - lane2Rect.top + time2Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane2Rect.top;
const startTop = h2Bottom + 10;
const statusHeight = s2.offsetHeight || 200;
const targetTop = Math.max(startTop, time2Center - statusHeight / 2);
s2.style.top = targetTop + "px";
s2.style.transform = "translateX(-50%)";
s2.style.height = "";
s2.style.width = "";
s2.style.display = "";
s2.style.alignItems = "";
s2.style.justifyContent = "";
s2.style.fontSize = "";
const maxHeight = lane2Rect.height - targetTop - 30;
s2.style.maxHeight = maxHeight + "px";
s2.style.overflow = "auto";
} else {
s2.classList.remove("large-status");
s2.style.top = "";
s2.style.transform = "";
s2.style.maxHeight = "";
s2.style.height = "";
s2.style.width = "";
s2.style.display = "";
s2.style.alignItems = "";
s2.style.justifyContent = "";
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!";
@@ -631,20 +819,25 @@
}
}
const validStatuses = ["ready", "running", "finished", "armed"];
function syncFromBackend() {
fetch("/api/data")
.then((response) => response.json())
.then((data) => {
timer1 = data.time1;
timer2 = data.time2;
status1 = data.status1;
status2 = data.status2;
// Status nur übernehmen, wenn der Wert gültig ist.
// Bei unvollständiger ESP-Response (Last) bleibt der
// bisherige Status erhalten statt "Status unbekannt".
if (validStatuses.includes(data.status1)) status1 = data.status1;
if (validStatuses.includes(data.status2)) status2 = data.status2;
best1 = data.best1;
best2 = data.best2;
learningMode = data.learningMode;
learningButton = data.learningButton || "";
lastSync = Date.now();
updateDisplay();
kickDisplayScheduler();
})
.catch((error) =>
console.error("Fehler beim Laden deiner Daten:", error)
@@ -693,8 +886,24 @@
// Sync with backend every 1 second
setInterval(syncFromBackend, 1000);
// Smooth update every 50ms
setInterval(updateDisplay, 50);
// Adaptive Update-Rate: 50 ms wenn mindestens eine Bahn läuft,
// sonst 500 ms. Über kickDisplayScheduler() kann der Zyklus sofort
// neu gestartet werden (WebSocket-Start-Event, frische Sync-Daten),
// damit beim Übergang Stand→Lauf nichts springt.
let displayTimer = null;
function scheduleDisplayUpdate() {
updateDisplay();
const anyRunning = status1 === "running" || status2 === "running";
displayTimer = setTimeout(scheduleDisplayUpdate, anyRunning ? 50 : 500);
}
function kickDisplayScheduler() {
if (displayTimer !== null) {
clearTimeout(displayTimer);
displayTimer = null;
}
scheduleDisplayUpdate();
}
scheduleDisplayUpdate();
// Heartbeat timeout check (every second)
setInterval(() => {
@@ -711,6 +920,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();
@@ -718,6 +935,18 @@
// Leaderboard alle 5 Sekunden aktualisieren
setInterval(loadLeaderboard, 5000);
// Live-Uhr im Header (HH:mm:ss, Browser-Lokalzeit)
function updateLiveClock() {
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const ss = String(now.getSeconds()).padStart(2, "0");
const el = document.getElementById("live-clock");
if (el) el.textContent = `${hh}:${mm}:${ss}`;
}
updateLiveClock();
setInterval(updateLiveClock, 1000);
</script>
</body>
</html>

View File

@@ -0,0 +1,607 @@
# RTC PCF8523 Fallback Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a new header `src/rtcsync.h` that uses an Adafruit Adalogger FeatherWing (PCF8523) as persistent time storage and as a fallback time source when NTP is unavailable, while remaining fully optional (soft-fail without hardware).
**Architecture:** Header-only module following the existing AquaMaster pattern. Decoupled from `timesync.h` via GCC weak symbols, so devices without RTC compile and run unchanged. Time is stored in the RTC as UTC (POSIX epoch). NTP remains the authoritative source; RTC is consulted only at boot before WiFi comes up. Manual time sets (e.g. browser-time button) are persisted to RTC with a plausibility check.
**Tech Stack:** ESP32 / Arduino-Framework / PlatformIO, `adafruit/RTClib`, `ArduinoJson@^7.4.1`, existing `timesync.h` API.
**Spec:** [docs/superpowers/specs/2026-05-03-rtc-pcf8523-fallback-design.md](../specs/2026-05-03-rtc-pcf8523-fallback-design.md)
**Verification model:** This project has no unit-test suite (`test/` is empty). "Tests" in this plan mean: (1) build success across all PlatformIO envs, (2) serial-log behavior on real hardware, (3) HTTP-API checks via `curl`/browser. Each task ends with a verification step appropriate for the change.
---
## File Structure
| File | Role |
|------|------|
| `src/rtcsync.h` | **NEW** — header-only RTC module (globals + `setupRTC`, `loopRTC`, `syncFromNTP`, `persistSystemTimeToRTC`, weak-hook overrides) |
| `src/timesync.h` | Add two weak-symbol declarations + invocations (no behavioral change without `rtcsync.h`) |
| `src/master.cpp` | Include `rtcsync.h`; call `setupRTC()` before `setupWifi()`; call `loopRTC()` at start of `loop()` |
| `platformio.ini` | Add `adafruit/RTClib` to `lib_deps` of every env |
---
## Task 1: Add RTClib dependency to all PlatformIO envs
**Files:**
- Modify: `platformio.ini` (lines 30, 50, 70, 87, 105, 129 — `lib_deps` blocks of all six envs)
- [ ] **Step 1: Add `adafruit/RTClib@^2.1.4` to every `lib_deps` block**
In `platformio.ini`, append the line ` adafruit/RTClib@^2.1.4` (tab-indented like the other entries) to each of the six `lib_deps` sections. Concretely, each block should become:
```ini
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
```
Apply this edit to envs: `wemos_d1_mini32`, `esp32thing_OTA`, `esp32thing`, `esp32thing_CI`, `um_feathers3`, `um_feathers3_debug`.
- [ ] **Step 2: Verify CI env builds with new dependency**
Run: `pio run -e esp32thing_CI`
Expected: Library is downloaded (look for `Library Manager: Installing adafruit/RTClib`), build succeeds with `SUCCESS` line. No code change yet, so binary size should be essentially unchanged.
- [ ] **Step 3: Commit**
```bash
git add platformio.ini
git commit -m "build: add RTClib dependency for PCF8523 RTC support"
```
---
## Task 2: Create `src/rtcsync.h` skeleton with RTC detection (soft-fail)
**Files:**
- Create: `src/rtcsync.h`
- [ ] **Step 1: Create the skeleton file**
Create `src/rtcsync.h` with the following content. This is the smallest possible vertical slice — only detection, no time-handling yet:
```cpp
// PCF8523-RTC-Modul mit NTP-Sync und Fallback.
// Header-only nach Projekt-Pattern: Globale Objekte werden hier definiert.
// Dieser Header darf NUR in master.cpp inkludiert werden.
#pragma once
#include <Arduino.h>
#include <RTClib.h>
#include <Wire.h>
// Globale RTC-Instanz und Status-Flags
RTC_PCF8523 rtc;
bool rtcAvailable = false;
bool ntpEverSynced = false;
time_t lastNtpSyncEpoch = 0;
bool lastStaConnected = false;
// I2C-Init und PCF8523-Detektion. Soft-Fail: setzt nur rtcAvailable.
void setupRTC() {
Wire.begin(); // idempotent — falls bereits durch andere Module aufgerufen
if (!rtc.begin()) {
Serial.println("[RTC] PCF8523 nicht gefunden — RTC-Funktionen deaktiviert");
rtcAvailable = false;
return;
}
if (!rtc.initialized() || rtc.lostPower()) {
Serial.println("[RTC] PCF8523 erkannt, aber Akku-Backup ungültig (lostPower)");
// Wir betrachten den Chip trotzdem als verfügbar — NTP wird ihn gleich neu setzen.
}
rtc.start(); // STOP-Bit löschen, falls gesetzt
rtcAvailable = true;
Serial.println("[RTC] PCF8523 initialisiert");
}
```
- [ ] **Step 2: Build CI env to verify the header compiles standalone**
The header is not yet included anywhere, so this only checks that the includes resolve. Run: `pio run -e esp32thing_CI`
Expected: `SUCCESS`, no warnings about `rtcsync.h`.
- [ ] **Step 3: Commit**
```bash
git add src/rtcsync.h
git commit -m "feat(rtc): add rtcsync.h skeleton with PCF8523 detection"
```
---
## Task 3: Add RTC → system-time fallback inside `setupRTC()`
**Files:**
- Modify: `src/rtcsync.h` (the `setupRTC()` function from Task 2)
- [ ] **Step 1: Extend `setupRTC()` to seed the system clock from RTC if available**
Replace the body of `setupRTC()` with:
```cpp
void setupRTC() {
Wire.begin();
if (!rtc.begin()) {
Serial.println("[RTC] PCF8523 nicht gefunden — RTC-Funktionen deaktiviert");
rtcAvailable = false;
return;
}
if (!rtc.initialized() || rtc.lostPower()) {
Serial.println("[RTC] PCF8523 erkannt, aber Akku-Backup ungültig (lostPower)");
}
rtc.start();
rtcAvailable = true;
Serial.println("[RTC] PCF8523 initialisiert");
// Systemzeit aus RTC vorbelegen — wird ggf. später durch NTP übersteuert.
// RTC speichert UTC, settimeofday erwartet UTC. Kein Offset nötig.
DateTime nowRtc = rtc.now();
uint32_t rtcEpoch = nowRtc.unixtime();
// Plausibilität: RTC sollte mindestens 2025 zeigen, sonst ist sie ungestellt.
if (rtcEpoch >= 1735689600UL) {
struct timeval tv;
tv.tv_sec = (time_t)rtcEpoch;
tv.tv_usec = 0;
settimeofday(&tv, NULL);
Serial.printf("[RTC] Systemzeit aus RTC gesetzt: %lu (UTC)\n",
(unsigned long)rtcEpoch);
} else {
Serial.printf("[RTC] RTC-Zeit unplausibel (%lu) — nicht übernommen\n",
(unsigned long)rtcEpoch);
}
}
```
- [ ] **Step 2: Build CI env**
Run: `pio run -e esp32thing_CI`
Expected: `SUCCESS`. The header still isn't wired into `master.cpp`, so no runtime effect yet.
- [ ] **Step 3: Commit**
```bash
git add src/rtcsync.h
git commit -m "feat(rtc): seed system time from RTC at boot"
```
---
## Task 4: Add weak-symbol hooks to `timesync.h`
These hooks let `rtcsync.h` extend `timesync.h` without `timesync.h` knowing about it. Without `rtcsync.h` linked in, the weak default is a null pointer and the call sites short-circuit.
**Files:**
- Modify: `src/timesync.h:23-46` (`getCurrentTimeJSON`)
- Modify: `src/timesync.h:72-84` (`setSystemTime`)
- [ ] **Step 1: Add weak hook declarations near the top of `timesync.h`**
Insert these declarations directly after the existing `bool isValidDateTime(...)` line is declared (i.e. after the prototypes block, before `getCurrentTimeJSON`'s implementation around line 22). If no clean location exists, place them right after the `#include <time.h>` line:
```cpp
// Weak hooks — falls rtcsync.h kompiliert/gelinkt wird, überschreibt es diese.
// Ohne rtcsync.h sind beide Symbole nullptr und werden nicht aufgerufen.
extern "C" void onSystemTimeSet(time_t t) __attribute__((weak));
extern "C" void appendTimeStatus(JsonDocument &doc) __attribute__((weak));
```
- [ ] **Step 2: Convert `getCurrentTimeJSON()` doc type and invoke `appendTimeStatus`**
The existing function uses the deprecated `StaticJsonDocument<200>`. ArduinoJson v7 prefers plain `JsonDocument`. Replace lines 2346 of `src/timesync.h` (the entire `getCurrentTimeJSON()` body) with:
```cpp
String getCurrentTimeJSON() {
gettimeofday(&tv, &tz);
now = tv.tv_sec;
JsonDocument doc;
doc["timestamp"] = (long)now;
doc["success"] = true;
// Zusätzliche Zeitinformationen
gmtime_r(&now, &timeinfo);
char timeStr[64];
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo);
doc["formatted"] = String(timeStr);
doc["year"] = timeinfo.tm_year + 1900;
doc["month"] = timeinfo.tm_mon + 1;
doc["day"] = timeinfo.tm_mday;
doc["hour"] = timeinfo.tm_hour;
doc["minute"] = timeinfo.tm_min;
doc["second"] = timeinfo.tm_sec;
// Optionale RTC/Sync-Status-Felder, falls rtcsync.h gelinkt ist
if (appendTimeStatus) {
appendTimeStatus(doc);
}
String response;
serializeJson(doc, response);
return response;
}
```
- [ ] **Step 3: Invoke `onSystemTimeSet` from `setSystemTime()`**
Replace lines 7284 of `src/timesync.h` (the `setSystemTime` function) with:
```cpp
bool setSystemTime(long timestamp) {
struct timeval tv;
tv.tv_sec = timestamp;
tv.tv_usec = 0;
if (settimeofday(&tv, NULL) == 0) {
Serial.println("Zeit erfolgreich gesetzt: " + String(timestamp));
if (onSystemTimeSet) {
onSystemTimeSet((time_t)timestamp);
}
return true;
} else {
Serial.println("Fehler beim Setzen der Zeit");
return false;
}
}
```
- [ ] **Step 4: Build CI env to verify backwards compatibility**
`rtcsync.h` is still not included in `master.cpp`, so both weak symbols must resolve to nullptr and the `if (...)` guards must skip them.
Run: `pio run -e esp32thing_CI`
Expected: `SUCCESS`. No new warnings. Behavior is identical to before this commit (the hooks are no-ops).
- [ ] **Step 5: Commit**
```bash
git add src/timesync.h
git commit -m "feat(timesync): add weak hooks for RTC integration"
```
---
## Task 5: Implement `persistSystemTimeToRTC()` and the `onSystemTimeSet` override
**Files:**
- Modify: `src/rtcsync.h` (append below `setupRTC`)
- [ ] **Step 1: Append the persistence function and the weak-hook override**
Add the following at the end of `src/rtcsync.h`:
```cpp
// Plausibilitäts-Grenzen: 2025-01-01 .. 2100-01-01 (UTC).
// Verhindert, dass kaputte Timestamps (0, negativ, 1970) die RTC korrumpieren.
static constexpr uint32_t RTC_MIN_EPOCH = 1735689600UL; // 2025-01-01 00:00:00 UTC
static constexpr uint32_t RTC_MAX_EPOCH = 4102444800UL; // 2100-01-01 00:00:00 UTC
void persistSystemTimeToRTC(time_t t) {
if (!rtcAvailable) return;
if ((uint32_t)t < RTC_MIN_EPOCH || (uint32_t)t >= RTC_MAX_EPOCH) {
Serial.printf("[RTC] persist abgelehnt — Timestamp unplausibel: %ld\n",
(long)t);
return;
}
rtc.adjust(DateTime((uint32_t)t));
Serial.printf("[RTC] in RTC geschrieben: %ld (UTC)\n", (long)t);
}
// Weak-Hook-Override aus timesync.h — wird automatisch aufgerufen,
// sobald irgendwo setSystemTime() Erfolg meldet.
extern "C" void onSystemTimeSet(time_t t) {
persistSystemTimeToRTC(t);
}
```
- [ ] **Step 2: Build CI env**
Run: `pio run -e esp32thing_CI`
Expected: `SUCCESS`. Still no behavioral change because `rtcsync.h` is not yet included by `master.cpp`.
- [ ] **Step 3: Commit**
```bash
git add src/rtcsync.h
git commit -m "feat(rtc): persist system time to RTC with plausibility check"
```
---
## Task 6: Implement `syncFromNTP()` wrapper
**Files:**
- Modify: `src/rtcsync.h` (append at end)
- [ ] **Step 1: Add a thin wrapper around `syncTimeWithNTP()` from `timesync.h`**
`syncTimeWithNTP()` in `timesync.h:48` already does the NTP heavy lifting (configTime + 5 s polling loop) and prints success/failure. We just need to detect success after the fact (system clock is now > 2025) and persist + bookkeep.
Append to `src/rtcsync.h`:
```cpp
// Versucht NTP-Sync via timesync.h. Bei Erfolg: schreibt UTC in RTC,
// setzt ntpEverSynced=true, aktualisiert lastNtpSyncEpoch.
// Returns true bei Erfolg.
bool syncFromNTP() {
// Snapshot vor dem Sync — wenn die Zeit hinterher >= 2025 und neuer als vorher,
// werten wir den Sync als erfolgreich.
time_t before = time(NULL);
syncTimeWithNTP(); // benutzt Defaults aus timesync.h: pool.ntp.org, +1h, kein DST
time_t after = time(NULL);
bool ok = ((uint32_t)after >= RTC_MIN_EPOCH) && (after >= before);
if (!ok) {
Serial.println("[RTC] NTP-Sync fehlgeschlagen — RTC unverändert");
return false;
}
// setSystemTime() wird intern von syncTimeWithNTP NICHT aufgerufen,
// also den Weak-Hook-Pfad hier umgehen und direkt schreiben.
persistSystemTimeToRTC(after);
ntpEverSynced = true;
lastNtpSyncEpoch = after;
return true;
}
```
- [ ] **Step 2: Build CI env**
Run: `pio run -e esp32thing_CI`
Expected: `SUCCESS`.
- [ ] **Step 3: Commit**
```bash
git add src/rtcsync.h
git commit -m "feat(rtc): add syncFromNTP wrapper that persists to RTC"
```
---
## Task 7: Implement `loopRTC()` (STA reconnect edge + 24 h periodic)
**Files:**
- Modify: `src/rtcsync.h` (append at end)
- [ ] **Step 1: Add the loop-tick function**
Append to `src/rtcsync.h`:
```cpp
// Aus loop() aufgerufen. Triggert syncFromNTP():
// - bei steigender Flanke von WiFi.isConnected() (STA-Reconnect)
// - alle 24h, sobald STA verbunden ist
// Reine Vergleichsoperationen, NTP-Roundtrip selbst ist blockierend (~ms..5s).
void loopRTC() {
bool sta = (WiFi.status() == WL_CONNECTED);
// Edge: false -> true (frischer STA-Connect)
if (sta && !lastStaConnected) {
Serial.println("[RTC] STA-Reconnect erkannt — NTP-Sync");
syncFromNTP();
}
lastStaConnected = sta;
// 24h-Periodik (nur wenn STA online)
if (sta && ntpEverSynced) {
time_t nowEpoch = time(NULL);
if (nowEpoch - lastNtpSyncEpoch >= 86400) {
Serial.println("[RTC] 24h-Periodik — NTP-Sync");
syncFromNTP();
}
}
}
```
`WiFi.h` ist bereits indirekt durch andere Header (`wificlass.h``WiFi.h`) verfügbar; falls der Build hier ein Symbol nicht findet, oben im Header `#include <WiFi.h>` ergänzen.
- [ ] **Step 2: Build CI env**
Run: `pio run -e esp32thing_CI`
Expected: `SUCCESS`. Bei `'WiFi' was not declared`: `#include <WiFi.h>` ganz oben in `rtcsync.h` ergänzen und neu bauen.
- [ ] **Step 3: Commit**
```bash
git add src/rtcsync.h
git commit -m "feat(rtc): add loopRTC with STA-reconnect and 24h re-sync"
```
---
## Task 8: Implement `appendTimeStatus()` override
**Files:**
- Modify: `src/rtcsync.h` (append at end)
- [ ] **Step 1: Add the JSON-extension override**
Append to `src/rtcsync.h`:
```cpp
// Weak-Hook-Override aus timesync.h — erweitert /api/time um RTC-Status.
extern "C" void appendTimeStatus(JsonDocument &doc) {
doc["rtc_available"] = rtcAvailable;
doc["rtc_synced_from_ntp"] = ntpEverSynced;
doc["last_ntp_sync_ago_s"] =
ntpEverSynced ? (long)(time(NULL) - lastNtpSyncEpoch) : (long)-1;
if (rtcAvailable) {
doc["rtc_time_utc"] = (long)rtc.now().unixtime();
} else {
doc["rtc_time_utc"] = (long)0;
}
}
```
- [ ] **Step 2: Build CI env**
Run: `pio run -e esp32thing_CI`
Expected: `SUCCESS`.
- [ ] **Step 3: Commit**
```bash
git add src/rtcsync.h
git commit -m "feat(rtc): expose RTC status fields via /api/time"
```
---
## Task 9: Wire `rtcsync.h` into `master.cpp`
This is the activation step. Until now, `rtcsync.h` exists but is unused. After this commit, devices with the FeatherWing get RTC behavior; devices without it print one warning line and run normally.
**Files:**
- Modify: `src/master.cpp:3-25` (include block) and `src/master.cpp:31-81` (setup/loop)
- [ ] **Step 1: Add the include**
In `src/master.cpp`, add `#include <rtcsync.h>` directly after the existing `#include <timesync.h>` line (currently line 23). The new include block tail should look like:
```cpp
#include <rfid.h>
#include <timesync.h>
#include <rtcsync.h>
#include <webserverrouter.h>
#include <wificlass.h>
```
**Reihenfolge:** `rtcsync.h` MUSS nach `timesync.h` kommen — die Weak-Hook-Overrides in `rtcsync.h` brauchen die Deklarationen aus `timesync.h`.
- [ ] **Step 2: Call `setupRTC()` before `setupWifi()`**
In `setup()`, insert `setupRTC();` directly before the existing `setupWifi();` call (currently line 53). The relevant block becomes:
```cpp
loadWifiSettings();
loadLocationSettings();
setupRTC(); // RTC zuerst, damit Systemzeit vor WiFi plausibel ist
setupWifi(); // WiFi initialisieren
setupOTA(&server);
```
- [ ] **Step 3: Add an explicit NTP-sync attempt after WiFi is up**
After `setupWifi()`, the STA may already be connected. The first `loopRTC()` call would catch the edge eventually, but doing one explicit boot-time sync makes the boot log cleaner. Insert directly after `setupWifi();`:
```cpp
setupWifi(); // WiFi initialisieren
if (WiFi.status() == WL_CONNECTED) {
syncFromNTP();
lastStaConnected = true; // Edge bereits "konsumiert"
}
setupOTA(&server);
```
- [ ] **Step 4: Call `loopRTC()` at the start of `loop()`**
In `loop()` (currently lines 6581), make `loopRTC()` the first call after `checkAutoReset()`:
```cpp
void loop() {
checkAutoReset();
loopRTC();
// MQTT hat höchste Priorität (wird zuerst verarbeitet)
loopMqttServer();
...
```
- [ ] **Step 5: Build all PlatformIO envs**
Run them sequentially (or in one go if your machine handles it):
```
pio run -e esp32thing_CI
pio run -e esp32thing
pio run -e wemos_d1_mini32
pio run -e um_feathers3
```
Expected: every env reports `SUCCESS`. Note the increase in flash usage (RTClib is small, ~5 KB).
- [ ] **Step 6: Commit**
```bash
git add src/master.cpp
git commit -m "feat(rtc): wire rtcsync into setup/loop"
```
---
## Task 10: Hardware verification on real device
This task is mandatory — automated tests cannot validate the I²C interaction or boot ordering. If no FeatherWing is currently attached, do at least Sub-step A (no-hardware soft-fail check).
**Files:** none (manual)
- [ ] **Step A — Without RTC attached: confirm soft-fail path**
1. Ensure no FeatherWing is plugged in.
2. `pio run -e esp32thing -t upload`
3. `pio device monitor -b 115200`
4. Expected serial output during boot:
```
[RTC] PCF8523 nicht gefunden — RTC-Funktionen deaktiviert
```
followed by the rest of the boot sequence reaching the WiFi/MQTT init **without crash or reboot loop**.
5. Open `http://<device-ip>/api/time` in the browser. Expected JSON contains:
```json
"rtc_available": false,
"rtc_synced_from_ntp": <true if STA is online, else false>,
"rtc_time_utc": 0
```
- [ ] **Step B — With RTC attached, no NTP (AP-only mode)**
1. Plug in the Adalogger FeatherWing.
2. Disconnect the device from any STA network (factory-reset WiFi or boot in AP-only).
3. Boot, observe serial:
```
[RTC] PCF8523 initialisiert
[RTC] Systemzeit aus RTC gesetzt: <epoch> (UTC)
```
(If the RTC was never set, the line says "unplausibel" instead — acceptable for a fresh chip.)
4. Connect to the AP and load `http://192.168.10.1/api/time`. Expected: `rtc_available: true`, formatted time matches the RTC's last known value.
- [ ] **Step C — With RTC attached, with NTP**
1. Configure WiFi-STA credentials so the device joins the home network.
2. Boot, observe serial:
```
[RTC] PCF8523 initialisiert
[RTC] Systemzeit aus RTC gesetzt: ...
... (WiFi connect)
Warte auf NTP-Zeit (max 5s)...
NTP-Zeit synchronisiert!
[RTC] in RTC geschrieben: <epoch> (UTC)
```
3. `curl http://<device-ip>/api/time` — expected `rtc_synced_from_ntp: true`, `last_ntp_sync_ago_s` close to 0, `rtc_time_utc` matches `timestamp`.
- [ ] **Step D — Browser-time persistence**
1. With device booted (Step C), use the existing "set browser time" button in the settings UI.
2. Power-cycle the device with WiFi disabled (pull antenna or block STA).
3. Boot. Expected: serial shows the time from the browser-set, not 1970.
- [ ] **Step E — Document outcome**
If all steps pass: the task is done.
If any step fails: do **not** mark this task complete. Open a follow-up note describing what failed and which task's assumptions were wrong.
---
## Self-Review (already performed)
- **Spec coverage:** Each spec section maps to a task — Architecture/library → Task 1; new header + soft-fail → Tasks 23; weak-hook decoupling → Tasks 45; NTP-sync wrapper → Task 6; loop integration → Task 7; API extension → Task 8; setup/loop wiring → Task 9; hardware verification → Task 10.
- **Placeholder scan:** No TBD/TODO; all code blocks are concrete and copy-pasteable.
- **Type/name consistency:** `appendTimeStatus`, `onSystemTimeSet`, `persistSystemTimeToRTC`, `syncFromNTP`, `setupRTC`, `loopRTC`, `rtcAvailable`, `ntpEverSynced`, `lastNtpSyncEpoch`, `lastStaConnected`, `RTC_MIN_EPOCH`, `RTC_MAX_EPOCH` all used consistently across tasks.
- **Bounds check:** `RTC_MIN_EPOCH = 1735689600` (2025-01-01) and `RTC_MAX_EPOCH = 4102444800` (2100-01-01) — match the spec's "Year 20252099" range.

View File

@@ -0,0 +1,152 @@
# RTC-Fallback (PCF8523) — Design
**Datum:** 2026-05-03
**Status:** Entwurf
**Scope:** Neuer Header `src/rtcsync.h` für Adafruit Adalogger FeatherWing (PCF8523) als persistente Zeitquelle mit NTP-Sync und Fallback bei fehlendem Internet.
## Ziel
Der AquaMaster soll nach jedem Boot eine plausible Uhrzeit haben, auch ohne WiFi-STA-Verbindung. NTP bleibt die primäre Quelle; die RTC dient als persistenter Speicher (überlebt Power-Off) und als Fallback im AP-only-Betrieb.
Hardware ist optional — Geräte ohne FeatherWing müssen unverändert funktionieren.
## Nicht-Ziele
- Keine Drift-Kompensation in Software (PCF8523-Trim-Register werden nicht angefasst)
- Kein RTC-Alarm-/Interrupt-Handling
- Keine Persistenz anderer Daten als der Uhrzeit (SD-Slot des FeatherWing wird ignoriert)
- Kein Migrations-Pfad für andere RTC-Chips (DS3231 etc.) — explizit auf PCF8523 zugeschnitten
## Architektur
### Neuer Header: `src/rtcsync.h`
Header-only nach bestehendem Pattern, wird **nur** in `master.cpp` inkludiert. Definiert eigene globale Objekte:
- `RTC_PCF8523 rtc`
- `bool rtcAvailable` — wurde Chip beim Boot via I²C gefunden
- `bool ntpEverSynced` — gab es seit Boot mind. einen erfolgreichen NTP-Sync
- `time_t lastNtpSyncEpoch` — Zeitpunkt des letzten NTP-Erfolgs (UTC, für 24h-Timer und Status-Anzeige)
- `bool lastStaConnected` — Edge-Detection für STA-Reconnect
### Library
`adafruit/RTClib` (zieht `adafruit/Adafruit BusIO` transitiv mit) — ergänzt in `platformio.ini` unter `lib_deps` aller relevanten Envs.
### Schnittstelle (Header-Funktionen)
| Funktion | Zweck |
|----------|-------|
| `void setupRTC()` | I²C-Init, PCF8523 detektieren (`rtc.begin()` + `rtc.initialized()`-Check), bei Erfolg RTC-Zeit als initialen Fallback in Systemzeit übernehmen |
| `bool syncFromNTP()` | Ruft existierendes `syncTimeWithNTP()` aus `timesync.h` auf; bei Erfolg → schreibt UTC in RTC, setzt `ntpEverSynced=true`, aktualisiert `lastNtpSyncEpoch`. Return: ob NTP erfolgreich war. |
| `void loopRTC()` | Aus `loop()` aufgerufen: erkennt STA-Reconnect-Edge (Übergang `lastStaConnected: false → true`) und 24h-Periodik → ruft `syncFromNTP()`. Reine Vergleichsoperationen, nicht-blockierend bis NTP tatsächlich getriggert wird. |
| `void persistSystemTimeToRTC(time_t t)` | Schreibt Systemzeit in RTC, **nur wenn** `rtcAvailable && t >= 1735689600 && t < 4102444800` (Jahr 20252099). Wird via Weak-Hook von `setSystemTime()` aufgerufen. |
| `void appendTimeStatus(JsonDocument &doc)` | Hängt Felder `rtc_available`, `rtc_synced_from_ntp`, `last_ntp_sync_ago_s`, `rtc_time_utc` an die Antwort von `/api/time` an (Implementierung des Weak-Hooks aus `timesync.h`). |
### Kopplung mit `timesync.h` via Weak-Symbol
`timesync.h` darf `rtcsync.h` **nicht** kennen (Geräte ohne RTC sollen ohne Code-Änderung funktionieren). Lösung:
```cpp
// in timesync.h, am Ende von setSystemTime() vor return true:
extern "C" void onSystemTimeSet(time_t t) __attribute__((weak));
if (onSystemTimeSet) onSystemTimeSet(timestamp);
// in rtcsync.h:
extern "C" void onSystemTimeSet(time_t t) {
persistSystemTimeToRTC(t);
}
```
Wenn `rtcsync.h` aus `master.cpp` weggelassen wird, ist `onSystemTimeSet` unaufgelöst → Weak-Default ist Nullpointer → if-Check verhindert Aufruf. Geräte ohne RTC kompilieren und laufen unverändert.
Analog für `getCurrentTimeJSON()`: Weak-Hook `void appendTimeStatus(JsonDocument&)` der von `rtcsync.h` überschrieben wird.
## Boot-Flow (`master.cpp::setup()`)
Reihenfolge — `setupRTC()` kommt **vor** WiFi, damit Systemzeit so früh wie möglich plausibel ist:
```
SPIFFS
→ API-Setups
→ load*() aus Preferences
→ setupRTC() ← NEU
└─ wenn rtcAvailable: settimeofday(rtc.now().unixtime())
→ WiFi (AP/STA)
→ wenn STA online: syncFromNTP() ← überschreibt RTC mit NTP-Wert
→ ...rest unverändert
```
Im AP-only-Betrieb bleibt es bei der RTC-Zeit. Im STA-Modus wird sie sofort durch NTP übersteuert.
## Loop-Flow (`master.cpp::loop()`)
`loopRTC()` als erste Zeile in `loop()` — billig (zwei Vergleiche), nicht-blockierend bis tatsächlich ein Sync ausgelöst wird. Der NTP-Sync selbst ist blockierend (max. 5 s wie heute), passiert aber selten:
- bei STA-Reconnect (Übergang false→true von `WiFi.isConnected()`)
- alle 24 h (`time(NULL) - lastNtpSyncEpoch >= 86400`)
## Zeitzone
UTC in der RTC. Schreiben mit `rtc.adjust(DateTime((uint32_t)t))`, lesen mit `rtc.now().unixtime()`. Systemzeit ist POSIX-intern auch UTC; der TZ-Offset (`configTime(3600, 0, ...)`) wirkt nur auf `localtime()`-Konversion. Daher kein +/-3600 nötig.
## Plausibilitäts-Check
`persistSystemTimeToRTC()` schreibt nur, wenn der Timestamp im Bereich [2025-01-01, 2099-01-01) liegt. Verhindert Bugs mit Timestamp=0 oder negativen Werten, ist aber lose genug, dass jede vom Browser gesetzte Zeit durchkommt (Browser-Sync-Workflow soll erhalten bleiben).
Konkret: untere Grenze `1735689600` (= 2025-01-01 UTC), obere Grenze `4102444800` (= 2100-01-01 UTC).
## Soft-Fail-Verhalten
Wenn `rtc.begin()` fehlschlägt:
- `rtcAvailable = false`, Warnung in Serial
- Alle RTC-Schreib-/Lese-Operationen werden no-op (geprüft via `rtcAvailable`)
- NTP-Sync funktioniert weiter wie bisher
- `/api/time` zeigt `rtc_available: false`
Es gibt **keine** automatische Re-Detection. Wenn die RTC nachträglich angesteckt wird, ist ein Reboot nötig.
## API-Erweiterung
`/api/time` (GET) bekommt zusätzliche Felder:
```json
{
"timestamp": 1746288000,
"success": true,
"formatted": "2026-05-03 14:00:00",
...,
"rtc_available": true,
"rtc_synced_from_ntp": true,
"last_ntp_sync_ago_s": 1234,
"rtc_time_utc": 1746288001
}
```
`rtc_time_utc` ist der direkt aus dem Chip gelesene Wert (zur Diagnose, falls Systemzeit und RTC divergieren).
Keine neuen Endpunkte. Bewusste Entscheidung gegen `/api/rtc/set`, weil der bestehende `set browser time`-Workflow über `setSystemTime()` jetzt automatisch in die RTC durchschlägt.
## Geänderte Dateien
| Datei | Änderung |
|-------|----------|
| `src/rtcsync.h` | **Neu** — komplette RTC-Logik |
| `src/master.cpp` | Include `rtcsync.h`; `setupRTC()` in `setup()` (vor WiFi); `loopRTC()` in `loop()` (erste Zeile) |
| `src/timesync.h` | Weak-Hook `onSystemTimeSet()` am Ende von `setSystemTime()`; Weak-Hook `appendTimeStatus()` in `getCurrentTimeJSON()` |
| `platformio.ini` | `adafruit/RTClib` in `lib_deps` aller Envs ergänzen |
## Risiken & Trade-offs
- **PCF8523-Drift (~±20 ppm, ≈1.7 s/Tag):** Akzeptabel, weil bei jedem STA-Reconnect und mind. alle 24 h nachsyncronisiert wird.
- **I²C-Bus geteilt mit PN532:** Keine Adresskollision (PN532=`0x24`, PCF8523=`0x68`). Bus-Init muss vor *beiden* Devices passieren — `Wire.begin()` wird in `setupRTC()` einmal aufgerufen und ist idempotent.
- **`rtc.begin()`-Verhalten:** Returnt auf manchen Library-Versionen `true` auch ohne Hardware. Zusätzlich `rtc.initialized()` und ein Test-Read prüfen.
- **Weak-Symbol-Pattern:** Funktioniert mit GCC (ESP32-Toolchain ist GCC) — keine Portabilitätsbedenken in diesem Projekt.
## Was explizit *nicht* getestet wird
Es gibt im Projekt keine Test-Suite (`test/` ist leer). Verifikation erfolgt durch:
- Build über alle Envs (`pio run`)
- Manueller Test auf Hardware mit/ohne FeatherWing
- Serial-Log-Checks für Boot-Flow
- `/api/time` im Frontend prüfen

View File

@@ -1,4 +0,0 @@
node_modules/
*.log
.DS_Store
.env

View File

@@ -1,179 +0,0 @@
# AquaMaster Mock Server
Mock ESP32 Server und MQTT-Broker für lokales Testing ohne Hardware.
## Übersicht
Dieses Projekt simuliert:
- **MQTT-Broker** (Port 1883 TCP, Port 9001 WebSocket) - Lokaler MQTT-Broker für Kommunikation
- **Mock ESP32 Server** (Port 80) - Simuliert alle ESP32 API-Endpunkte und WebSocket
- **Web Debug UI** - Browser-basierte Oberfläche zum Testen von API und MQTT
## Voraussetzungen
- Node.js 16+ (LTS empfohlen)
- npm
## Installation
```bash
cd mock-server
npm install
```
## Verwendung
### Option 1: Beide Server zusammen starten
```bash
npm start
# oder
node start_all.js
```
### Option 2: Server einzeln starten
**Terminal 1 - MQTT Broker:**
```bash
npm run mqtt
# oder
node mqtt_broker.js
```
**Terminal 2 - Mock ESP32 Server:**
```bash
npm run server
# oder
node mock_esp32_server.js
```
### Web Debug UI öffnen
Nach dem Start der Server:
1. Öffne einen Browser
2. Navigiere zu: `http://localhost:80`
3. Die Debug-Oberfläche sollte sichtbar sein
## Features
### MQTT Broker
- Läuft auf Port 1883 (TCP) und Port 9001 (WebSocket)
- Unterstützt alle relevanten Topics:
- `aquacross/button/#`
- `aquacross/button/rfid/#`
- `aquacross/battery/#`
- `heartbeat/alive/#`
- `aquacross/competition/#`
- `sync/time`
- `aquacross/lanes/#`
- Loggt alle Nachrichten für Debugging
### Mock ESP32 Server
- Simuliert alle API-Endpunkte aus der ESP32-Firmware
- WebSocket-Support für Live-Updates
- MQTT-Client, der sich mit dem Broker verbindet
- Timer-Logik (Individual/Wettkampf-Modi)
- Button-Konfigurationen und Learning-Mode
### Web Debug UI
- **API Testing Tab**: Teste alle API-Endpunkte
- **MQTT Testing Tab**: Publish/Subscribe MQTT-Nachrichten
- **Debug Endpoints Tab**: Direkte Timer-Kontrolle
## API-Endpunkte
Alle Endpunkte sind unter `http://localhost:80/api/...` verfügbar:
- `GET /api/data` - Timer-Daten abrufen
- `POST /api/reset-best` - Beste Zeiten zurücksetzen
- `POST /api/unlearn-button` - Button-Zuordnungen löschen
- `GET /api/debug/start1` - Lane 1 starten (Debug)
- `GET /api/debug/stop1` - Lane 1 stoppen (Debug)
- `GET /api/debug/start2` - Lane 2 starten (Debug)
- `GET /api/debug/stop2` - Lane 2 stoppen (Debug)
- ... und viele mehr (siehe `../API.md`)
## MQTT Topics
### Button Topics
- `aquacross/button/{MAC}` - Button-Press Nachrichten
```json
{"type": 2, "timestamp": 1234567890}
```
- `type: 2` = Start-Button
- `type: 1` = Stop-Button
### RFID Topics
- `aquacross/button/rfid/{MAC}` - RFID-Read Nachrichten
```json
{"uid": "TEST123456"}
```
### Battery Topics
- `aquacross/battery/{MAC}` - Batteriestand
```json
{"voltage": 3600}
```
### Heartbeat Topics
- `heartbeat/alive/{MAC}` - Heartbeat-Nachrichten
```json
{"timestamp": 1234567890}
```
### Competition Topics
- `aquacross/competition/toMaster` - Wettkampf-Start
```
"start"
```
### Time Sync
- `sync/time` - Zeit-Synchronisation (vom Server alle 5 Sekunden)
## Troubleshooting
### Port bereits belegt
Falls Port 80 oder 1883 bereits belegt sind:
- Windows: Port 80 benötigt Admin-Rechte, verwende einen anderen Port
- Linux/Mac: Port 80 benötigt sudo, verwende einen anderen Port
Um Ports zu ändern, editiere:
- `mqtt_broker.js` - Zeile mit `const port = 1883;`
- `mock_esp32_server.js` - Zeile mit `const PORT = 80;`
### MQTT-Verbindung fehlgeschlagen
- Stelle sicher, dass der MQTT-Broker läuft
- Prüfe, ob Port 1883 (TCP) oder 9001 (WebSocket) erreichbar ist
- Browser benötigen WebSocket-Verbindung (Port 9001)
### WebSocket-Verbindung fehlgeschlagen
- Stelle sicher, dass der Mock ESP32 Server läuft
- Prüfe Browser-Konsole auf Fehler
- Socket.io sollte automatisch geladen werden
## Projektstruktur
```
mock-server/
├── package.json # Node.js Dependencies
├── README.md # Diese Datei
├── .gitignore # Git ignore
├── mqtt_broker.js # MQTT-Broker
├── mock_esp32_server.js # Mock ESP32 Server
├── start_all.js # Startet beide Server
└── debug_server/
├── index.html # Web Debug UI
├── debug.js # JavaScript-Logik
└── debug.css # Styling
```
## Hinweise
- Der Mock-Server speichert keinen persistenten State (alles im Speicher)
- Nach Neustart sind alle Einstellungen zurückgesetzt
- Für Produktionstests sollte der echte ESP32 verwendet werden
- Dieser Mock-Server ist nur für Entwicklung und Testing gedacht
## Lizenz
MIT

View File

@@ -1,273 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background: #2c3e50;
color: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
header h1 {
margin-bottom: 15px;
}
.status-bar {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.status-indicator {
padding: 5px 10px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
font-size: 0.9em;
}
.status-indicator.connected {
background: #27ae60;
}
.status-indicator.disconnected {
background: #e74c3c;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #ddd;
}
.tab-button {
padding: 12px 24px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 16px;
color: #666;
transition: all 0.3s;
}
.tab-button:hover {
color: #2c3e50;
background: #f0f0f0;
}
.tab-button.active {
color: #2c3e50;
border-bottom-color: #3498db;
font-weight: bold;
}
.tab-content {
display: none;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.tab-content.active {
display: block;
}
.section {
margin-bottom: 30px;
}
.section h2 {
margin-bottom: 15px;
color: #2c3e50;
border-bottom: 2px solid #ecf0f1;
padding-bottom: 10px;
}
.section h3 {
margin-top: 20px;
margin-bottom: 10px;
color: #34495e;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #555;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
.form-group textarea {
resize: vertical;
font-family: 'Courier New', monospace;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s;
margin-right: 10px;
margin-bottom: 10px;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover {
background: #2980b9;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-secondary:hover {
background: #7f8c8d;
}
.btn-small {
padding: 5px 10px;
font-size: 12px;
}
.button-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 15px;
}
.response-section {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
}
.response-section pre {
background: #2c3e50;
color: #ecf0f1;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
font-size: 13px;
max-height: 400px;
overflow-y: auto;
}
.quick-actions {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ddd;
}
.messages-section {
margin-top: 30px;
}
.messages-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.messages-container {
background: #2c3e50;
color: #ecf0f1;
padding: 15px;
border-radius: 4px;
max-height: 500px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 12px;
}
.message-item {
padding: 8px;
margin-bottom: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
border-left: 3px solid #3498db;
}
.message-item .timestamp {
color: #95a5a6;
font-size: 11px;
margin-bottom: 4px;
}
.message-item .topic {
color: #3498db;
font-weight: bold;
margin-bottom: 4px;
}
.message-item .payload {
color: #ecf0f1;
word-break: break-all;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.tabs {
flex-wrap: wrap;
}
.tab-button {
flex: 1;
min-width: 100px;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
margin-right: 0;
}
}

View File

@@ -1,378 +0,0 @@
// Configuration
const API_BASE_URL = 'http://localhost:80';
const MQTT_BROKER_URL = 'ws://localhost:9001/mqtt'; // WebSocket port for MQTT
const WS_URL = 'http://localhost:80';
// State
let mqttClient = null;
let wsClient = null;
let subscribedTopics = new Set();
// Initialize
document.addEventListener('DOMContentLoaded', init);
function init() {
setupTabs();
setupAPI();
setupMQTT();
setupWebSocket();
setupDebug();
setupQuickActions();
}
// Tab Management
function setupTabs() {
const tabButtons = document.querySelectorAll('.tab-button');
const tabContents = document.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabName = button.dataset.tab;
// Remove active class from all
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to selected
button.classList.add('active');
document.getElementById(`${tabName}-tab`).classList.add('active');
});
});
}
// API Setup
function setupAPI() {
const endpointSelect = document.getElementById('api-endpoint');
const paramsTextarea = document.getElementById('api-params');
const sendBtn = document.getElementById('api-send-btn');
const responsePre = document.getElementById('api-response');
sendBtn.addEventListener('click', async () => {
const endpoint = endpointSelect.value;
const [method, path] = endpoint.split(' ');
const params = paramsTextarea.value.trim();
try {
let options = {
method: method,
headers: {}
};
if (method === 'POST' && params) {
// Try to parse as JSON, otherwise use as form data
try {
const jsonData = JSON.parse(params);
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(jsonData);
} catch {
// Not JSON, use form data
const formData = new URLSearchParams();
const pairs = params.split('&');
pairs.forEach(pair => {
const [key, value] = pair.split('=');
if (key && value) {
formData.append(key, decodeURIComponent(value));
}
});
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
options.body = formData.toString();
}
}
const response = await fetch(`${API_BASE_URL}${path}`, options);
const text = await response.text();
let formatted;
try {
formatted = JSON.stringify(JSON.parse(text), null, 2);
} catch {
formatted = text;
}
responsePre.textContent = formatted;
} catch (error) {
responsePre.textContent = `Error: ${error.message}`;
}
});
}
// MQTT Setup
function setupMQTT() {
const topicInput = document.getElementById('mqtt-topic');
const payloadTextarea = document.getElementById('mqtt-payload');
const publishBtn = document.getElementById('mqtt-publish-btn');
const subscribeBtn = document.getElementById('mqtt-subscribe-btn');
const unsubscribeBtn = document.getElementById('mqtt-unsubscribe-btn');
const subscribeTopicInput = document.getElementById('mqtt-subscribe-topic');
const messagesContainer = document.getElementById('mqtt-messages');
const clearMessagesBtn = document.getElementById('clear-messages-btn');
// Connect to MQTT broker
try {
mqttClient = mqtt.connect(MQTT_BROKER_URL, {
clientId: 'debug-ui-' + Math.random().toString(16).substr(2, 8)
});
mqttClient.on('connect', () => {
console.log('MQTT connected');
updateStatus('mqtt-status', 'MQTT: Connected', true);
});
mqttClient.on('error', (error) => {
console.error('MQTT error:', error);
updateStatus('mqtt-status', 'MQTT: Error', false);
});
mqttClient.on('close', () => {
console.log('MQTT disconnected');
updateStatus('mqtt-status', 'MQTT: Disconnected', false);
});
mqttClient.on('message', (topic, message) => {
addMessage(topic, message.toString());
});
} catch (error) {
console.error('Failed to connect to MQTT:', error);
updateStatus('mqtt-status', 'MQTT: Connection Failed', false);
}
publishBtn.addEventListener('click', () => {
const topic = topicInput.value.trim();
let payload = payloadTextarea.value.trim();
if (!topic) {
alert('Please enter a topic');
return;
}
// Try to parse as JSON, otherwise use as-is
try {
const jsonData = JSON.parse(payload);
payload = JSON.stringify(jsonData);
} catch {
// Not JSON, use as-is
}
if (mqttClient && mqttClient.connected) {
mqttClient.publish(topic, payload, (err) => {
if (err) {
console.error('Publish error:', err);
alert('Failed to publish: ' + err.message);
} else {
console.log('Published to', topic);
}
});
} else {
alert('MQTT not connected');
}
});
subscribeBtn.addEventListener('click', () => {
const topic = subscribeTopicInput.value.trim();
if (!topic) {
alert('Please enter a topic pattern');
return;
}
if (mqttClient && mqttClient.connected) {
mqttClient.subscribe(topic, (err) => {
if (err) {
console.error('Subscribe error:', err);
alert('Failed to subscribe: ' + err.message);
} else {
subscribedTopics.add(topic);
console.log('Subscribed to', topic);
}
});
} else {
alert('MQTT not connected');
}
});
unsubscribeBtn.addEventListener('click', () => {
if (mqttClient && mqttClient.connected) {
subscribedTopics.forEach(topic => {
mqttClient.unsubscribe(topic);
});
subscribedTopics.clear();
console.log('Unsubscribed from all topics');
}
});
clearMessagesBtn.addEventListener('click', () => {
messagesContainer.innerHTML = '';
});
}
function addMessage(topic, payload) {
const messagesContainer = document.getElementById('mqtt-messages');
const messageDiv = document.createElement('div');
messageDiv.className = 'message-item';
const timestamp = new Date().toLocaleTimeString();
let formattedPayload = payload;
try {
formattedPayload = JSON.stringify(JSON.parse(payload), null, 2);
} catch {}
messageDiv.innerHTML = `
<div class="timestamp">${timestamp}</div>
<div class="topic">${topic}</div>
<div class="payload">${formattedPayload}</div>
`;
messagesContainer.appendChild(messageDiv);
// Auto-scroll
if (document.getElementById('auto-scroll').checked) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
// WebSocket Setup
function setupWebSocket() {
if (typeof io !== 'undefined') {
try {
wsClient = io(SOCKET_IO_URL);
wsClient.on('connect', () => {
console.log('WebSocket connected');
updateStatus('ws-status', 'WebSocket: Connected', true);
});
wsClient.on('disconnect', () => {
console.log('WebSocket disconnected');
updateStatus('ws-status', 'WebSocket: Disconnected', false);
});
wsClient.on('update', (data) => {
console.log('WebSocket update:', data);
// Could display in a separate section
});
} catch (error) {
console.error('Failed to connect WebSocket:', error);
updateStatus('ws-status', 'WebSocket: Error', false);
}
} else {
console.warn('Socket.io not loaded');
updateStatus('ws-status', 'WebSocket: Library Not Loaded', false);
}
}
// Debug Endpoints Setup
function setupDebug() {
const debugButtons = document.querySelectorAll('[data-debug]');
const responsePre = document.getElementById('debug-response');
debugButtons.forEach(button => {
button.addEventListener('click', async () => {
const action = button.dataset.debug;
const endpoint = `/api/debug/${action}`;
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`);
const text = await response.text();
responsePre.textContent = text;
} catch (error) {
responsePre.textContent = `Error: ${error.message}`;
}
});
});
}
// Quick Actions Setup
function setupQuickActions() {
const quickActionButtons = document.querySelectorAll('[data-action]');
quickActionButtons.forEach(button => {
button.addEventListener('click', () => {
const action = button.dataset.action;
const topicInput = document.getElementById('mqtt-topic');
const payloadTextarea = document.getElementById('mqtt-payload');
switch (action) {
case 'button-start1':
topicInput.value = 'aquacross/button/00:00:00:00:00:01';
payloadTextarea.value = JSON.stringify({
type: 2,
timestamp: Date.now()
}, null, 2);
break;
case 'button-stop1':
topicInput.value = 'aquacross/button/00:00:00:00:00:03';
payloadTextarea.value = JSON.stringify({
type: 1,
timestamp: Date.now()
}, null, 2);
break;
case 'button-start2':
topicInput.value = 'aquacross/button/00:00:00:00:00:02';
payloadTextarea.value = JSON.stringify({
type: 2,
timestamp: Date.now()
}, null, 2);
break;
case 'button-stop2':
topicInput.value = 'aquacross/button/00:00:00:00:00:04';
payloadTextarea.value = JSON.stringify({
type: 1,
timestamp: Date.now()
}, null, 2);
break;
case 'rfid-read':
topicInput.value = 'aquacross/button/rfid/00:00:00:00:00:01';
payloadTextarea.value = JSON.stringify({
uid: 'TEST123456'
}, null, 2);
break;
case 'battery-update':
topicInput.value = 'aquacross/battery/00:00:00:00:00:01';
payloadTextarea.value = JSON.stringify({
voltage: 3600
}, null, 2);
break;
case 'heartbeat':
topicInput.value = 'heartbeat/alive/00:00:00:00:00:01';
payloadTextarea.value = JSON.stringify({
timestamp: Date.now()
}, null, 2);
break;
case 'button-available':
topicInput.value = 'aquacross/button/status/00:00:00:00:00:01';
payloadTextarea.value = JSON.stringify({
available: true,
sleep: false,
timestamp: Date.now()
}, null, 2);
break;
case 'button-sleep':
topicInput.value = 'aquacross/button/status/00:00:00:00:00:01';
payloadTextarea.value = JSON.stringify({
available: false,
sleep: true,
timestamp: Date.now()
}, null, 2);
break;
}
// Auto-publish
document.getElementById('mqtt-publish-btn').click();
});
});
}
// Helper Functions
function updateStatus(elementId, text, connected) {
const element = document.getElementById(elementId);
element.textContent = text;
element.classList.remove('connected', 'disconnected');
element.classList.add(connected ? 'connected' : 'disconnected');
}
// Initialize on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}

View File

@@ -1,139 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AquaMaster Debug Server</title>
<link rel="stylesheet" href="debug.css">
</head>
<body>
<div class="container">
<header>
<h1>AquaMaster Debug Server</h1>
<div class="status-bar">
<span id="mqtt-status" class="status-indicator">MQTT: Disconnected</span>
<span id="ws-status" class="status-indicator">WebSocket: Disconnected</span>
<span id="api-status" class="status-indicator">API: Ready</span>
</div>
</header>
<nav class="tabs">
<button class="tab-button active" data-tab="api">API Testing</button>
<button class="tab-button" data-tab="mqtt">MQTT Testing</button>
<button class="tab-button" data-tab="debug">Debug Endpoints</button>
</nav>
<!-- API Testing Tab -->
<div id="api-tab" class="tab-content active">
<div class="section">
<h2>API Endpoint Testing</h2>
<div class="form-group">
<label for="api-endpoint">Endpoint:</label>
<select id="api-endpoint">
<option value="GET /api/data">GET /api/data</option>
<option value="POST /api/reset-best">POST /api/reset-best</option>
<option value="POST /api/unlearn-button">POST /api/unlearn-button</option>
<option value="POST /api/set-max-time">POST /api/set-max-time</option>
<option value="GET /api/get-settings">GET /api/get-settings</option>
<option value="POST /api/start-learning">POST /api/start-learning</option>
<option value="POST /api/stop-learning">POST /api/stop-learning</option>
<option value="GET /api/learn/status">GET /api/learn/status</option>
<option value="GET /api/buttons/status">GET /api/buttons/status</option>
<option value="GET /api/info">GET /api/info</option>
<option value="POST /api/set-wifi">POST /api/set-wifi</option>
<option value="GET /api/get-wifi">GET /api/get-wifi</option>
<option value="POST /api/set-location">POST /api/set-location</option>
<option value="GET /api/get-location">GET /api/get-location</option>
<option value="GET /api/updateButtons">GET /api/updateButtons</option>
<option value="POST /api/set-mode">POST /api/set-mode</option>
<option value="GET /api/get-mode">GET /api/get-mode</option>
<option value="POST /api/set-lane-config">POST /api/set-lane-config</option>
<option value="GET /api/get-lane-config">GET /api/get-lane-config</option>
</select>
</div>
<div class="form-group">
<label for="api-params">Parameters (JSON or form data):</label>
<textarea id="api-params" rows="4" placeholder='{"maxTime": 300, "maxTimeDisplay": 20}'></textarea>
</div>
<button id="api-send-btn" class="btn btn-primary">Send Request</button>
<div class="response-section">
<h3>Response:</h3>
<pre id="api-response"></pre>
</div>
</div>
</div>
<!-- MQTT Testing Tab -->
<div id="mqtt-tab" class="tab-content">
<div class="section">
<h2>MQTT Publish</h2>
<div class="form-group">
<label for="mqtt-topic">Topic:</label>
<input type="text" id="mqtt-topic" placeholder="aquacross/button/00:00:00:00:00:01" value="aquacross/button/00:00:00:00:00:01">
</div>
<div class="form-group">
<label for="mqtt-payload">Payload (JSON or text):</label>
<textarea id="mqtt-payload" rows="4" placeholder='{"type": 2, "timestamp": 1234567890}'></textarea>
</div>
<button id="mqtt-publish-btn" class="btn btn-primary">Publish</button>
<div class="quick-actions">
<h3>Quick Actions:</h3>
<div class="button-group">
<button class="btn btn-secondary" data-action="button-start1">Simulate Start1 Button</button>
<button class="btn btn-secondary" data-action="button-stop1">Simulate Stop1 Button</button>
<button class="btn btn-secondary" data-action="button-start2">Simulate Start2 Button</button>
<button class="btn btn-secondary" data-action="button-stop2">Simulate Stop2 Button</button>
<button class="btn btn-secondary" data-action="rfid-read">Simulate RFID Read</button>
<button class="btn btn-secondary" data-action="battery-update">Simulate Battery Update</button>
<button class="btn btn-secondary" data-action="heartbeat">Simulate Heartbeat</button>
<button class="btn btn-secondary" data-action="button-available">Button Available (Wake)</button>
<button class="btn btn-secondary" data-action="button-sleep">Button Sleep Mode</button>
</div>
</div>
</div>
<div class="section">
<h2>MQTT Subscribe</h2>
<div class="form-group">
<label for="mqtt-subscribe-topic">Topic Pattern:</label>
<input type="text" id="mqtt-subscribe-topic" placeholder="# or aquacross/button/#" value="#">
</div>
<button id="mqtt-subscribe-btn" class="btn btn-primary">Subscribe</button>
<button id="mqtt-unsubscribe-btn" class="btn btn-secondary">Unsubscribe All</button>
<div class="messages-section">
<h3>Received Messages:</h3>
<div class="messages-controls">
<button id="clear-messages-btn" class="btn btn-small">Clear</button>
<label><input type="checkbox" id="auto-scroll" checked> Auto-scroll</label>
</div>
<div id="mqtt-messages" class="messages-container"></div>
</div>
</div>
</div>
<!-- Debug Endpoints Tab -->
<div id="debug-tab" class="tab-content">
<div class="section">
<h2>Debug Endpoints</h2>
<p>Direct access to debug endpoints for timer control:</p>
<div class="button-group">
<button class="btn btn-primary" data-debug="start1">Start Lane 1</button>
<button class="btn btn-primary" data-debug="stop1">Stop Lane 1</button>
<button class="btn btn-primary" data-debug="start2">Start Lane 2</button>
<button class="btn btn-primary" data-debug="stop2">Stop Lane 2</button>
</div>
<div class="response-section">
<h3>Last Response:</h3>
<pre id="debug-response"></pre>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/mqtt@5/dist/mqtt.min.js"></script>
<script src="https://cdn.socket.io/4.6.1/socket.io.min.js"></script>
<script src="debug.js"></script>
</body>
</html>

View File

@@ -1,718 +0,0 @@
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const mqtt = require('mqtt');
const cors = require('cors');
const bodyParser = require('body-parser');
const path = require('path');
const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
const PORT = 80;
const MQTT_BROKER = 'mqtt://localhost:1883';
// Middleware
app.use(cors());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, 'debug_server')));
// State - simuliert ESP32 Datenstrukturen
const state = {
timerData1: {
startTime: 0,
localStartTime: 0,
finishedSince: 0,
endTime: 0,
bestTime: 0,
isRunning: false,
isReady: true,
isArmed: false,
RFIDUID: ""
},
timerData2: {
startTime: 0,
localStartTime: 0,
finishedSince: 0,
endTime: 0,
bestTime: 0,
isRunning: false,
isReady: true,
isArmed: false,
RFIDUID: ""
},
buttonConfigs: {
start1: { mac: [0, 0, 0, 0, 0, 0], isAssigned: false, voltage: 0, lastHeartbeat: 0, heartbeatActive: false },
stop1: { mac: [0, 0, 0, 0, 0, 0], isAssigned: false, voltage: 0, lastHeartbeat: 0, heartbeatActive: false },
start2: { mac: [0, 0, 0, 0, 0, 0], isAssigned: false, voltage: 0, lastHeartbeat: 0, heartbeatActive: false },
stop2: { mac: [0, 0, 0, 0, 0, 0], isAssigned: false, voltage: 0, lastHeartbeat: 0, heartbeatActive: false }
},
learningMode: false,
learningStep: 0,
maxTimeBeforeReset: 300000,
maxTimeDisplay: 20000,
minTimeForLeaderboard: 5000,
masterlocation: "",
gamemode: 0, // 0=Individual, 1=Wettkampf
startCompetition: false,
laneConfigType: 0,
lane1DifficultyType: 0,
lane2DifficultyType: 0,
localTimes: [],
wifi: {
ssid: "",
password: ""
},
start1FoundLocally: false,
start2FoundLocally: false,
start1UID: "",
start2UID: ""
};
// Helper: millis() - simuliert Arduino millis()
function millis() {
return Date.now();
}
// Helper: getTimerDataJSON() - simuliert getTimerDataJSON()
function getTimerDataJSON() {
const currentTime = millis();
const data = {};
// Lane 1
if (state.timerData1.isRunning) {
data.time1 = (currentTime - state.timerData1.localStartTime) / 1000.0;
data.status1 = "running";
} else if (state.timerData1.endTime > 0) {
data.time1 = (state.timerData1.endTime - state.timerData1.startTime) / 1000.0;
data.status1 = "finished";
} else if (state.timerData1.isArmed) {
data.time1 = 0;
data.status1 = "armed";
} else {
data.time1 = 0;
data.status1 = "ready";
}
// Lane 2
if (state.timerData2.isRunning) {
data.time2 = (currentTime - state.timerData2.localStartTime) / 1000.0;
data.status2 = "running";
} else if (state.timerData2.endTime > 0) {
data.time2 = (state.timerData2.endTime - state.timerData2.startTime) / 1000.0;
data.status2 = "finished";
} else if (state.timerData2.isArmed) {
data.time2 = 0;
data.status2 = "armed";
} else {
data.time2 = 0;
data.status2 = "ready";
}
// Best times
data.best1 = state.timerData1.bestTime / 1000.0;
data.best2 = state.timerData2.bestTime / 1000.0;
// Learning mode
data.learningMode = state.learningMode;
if (state.learningMode) {
const buttons = ["Start Bahn 1", "Stop Bahn 1", "Start Bahn 2", "Stop Bahn 2"];
data.learningButton = buttons[state.learningStep];
}
return JSON.stringify(data);
}
// Timer-Logik: IndividualMode
function individualMode(action, press, lane, timestamp = 0) {
const ts = timestamp > 0 ? timestamp : millis();
if (action === "start" && press === 2 && lane === 1) {
if (!state.timerData1.isRunning && state.timerData1.isReady) {
state.timerData1.isReady = false;
state.timerData1.startTime = ts;
state.timerData1.localStartTime = millis();
state.timerData1.isRunning = true;
state.timerData1.endTime = 0;
state.timerData1.isArmed = false;
publishLaneStatus(1, "running");
console.log("Bahn 1 gestartet");
}
}
if (action === "stop" && press === 1 && lane === 1) {
if (state.timerData1.isRunning) {
state.timerData1.endTime = ts;
state.timerData1.finishedSince = millis();
state.timerData1.isRunning = false;
const currentTime = state.timerData1.endTime - state.timerData1.startTime;
if (state.timerData1.bestTime === 0 || currentTime < state.timerData1.bestTime) {
state.timerData1.bestTime = currentTime;
}
publishLaneStatus(1, "stopped");
console.log(`Bahn 1 gestoppt - Zeit: ${(currentTime / 1000.0).toFixed(2)}s`);
}
}
if (action === "start" && press === 2 && lane === 2) {
if (!state.timerData2.isRunning && state.timerData2.isReady) {
state.timerData2.isReady = false;
state.timerData2.startTime = ts;
state.timerData2.localStartTime = millis();
state.timerData2.isRunning = true;
state.timerData2.endTime = 0;
state.timerData2.isArmed = false;
publishLaneStatus(2, "running");
console.log("Bahn 2 gestartet");
}
}
if (action === "stop" && press === 1 && lane === 2) {
if (state.timerData2.isRunning) {
state.timerData2.endTime = ts;
state.timerData2.finishedSince = millis();
state.timerData2.isRunning = false;
const currentTime = state.timerData2.endTime - state.timerData2.startTime;
if (state.timerData2.bestTime === 0 || currentTime < state.timerData2.bestTime) {
state.timerData2.bestTime = currentTime;
}
publishLaneStatus(2, "stopped");
console.log(`Bahn 2 gestoppt - Zeit: ${(currentTime / 1000.0).toFixed(2)}s`);
}
}
}
// Helper: publishLaneStatus
function publishLaneStatus(lane, status) {
if (mqttClient && mqttClient.connected) {
const topic = `aquacross/lanes/lane${lane}`;
const message = JSON.stringify({ lane, status });
mqttClient.publish(topic, message);
}
}
// Helper: pushUpdateToFrontend
function pushUpdateToFrontend(message) {
io.emit('update', message);
}
// MQTT Client Setup
let mqttClient = null;
let mqttReconnectInterval = null;
function connectMQTT() {
// Don't reconnect if already connected or connecting
if (mqttClient && (mqttClient.connected || mqttClient.connecting)) {
return;
}
// Clear any existing reconnect interval
if (mqttReconnectInterval) {
clearInterval(mqttReconnectInterval);
mqttReconnectInterval = null;
}
// Close existing client if any
if (mqttClient) {
mqttClient.end(true);
}
console.log('[MQTT] Attempting to connect to broker at', MQTT_BROKER);
mqttClient = mqtt.connect(MQTT_BROKER, {
reconnectPeriod: 5000,
connectTimeout: 10000,
clientId: 'mock-esp32-' + Math.random().toString(16).substr(2, 8)
});
mqttClient.on('connect', () => {
console.log('[MQTT] Connected to broker');
// Subscribe to all relevant topics
mqttClient.subscribe('aquacross/button/#', (err) => {
if (!err) console.log('[MQTT] Subscribed to aquacross/button/#');
});
mqttClient.subscribe('aquacross/button/rfid/#', (err) => {
if (!err) console.log('[MQTT] Subscribed to aquacross/button/rfid/#');
});
mqttClient.subscribe('aquacross/battery/#', (err) => {
if (!err) console.log('[MQTT] Subscribed to aquacross/battery/#');
});
mqttClient.subscribe('heartbeat/alive/#', (err) => {
if (!err) console.log('[MQTT] Subscribed to heartbeat/alive/#');
});
mqttClient.subscribe('aquacross/competition/toMaster', (err) => {
if (!err) console.log('[MQTT] Subscribed to aquacross/competition/toMaster');
});
mqttClient.subscribe('aquacross/button/status/#', (err) => {
if (!err) console.log('[MQTT] Subscribed to aquacross/button/status/#');
});
});
mqttClient.on('message', (topic, message) => {
const payload = message.toString();
console.log(`[MQTT] Received on ${topic}: ${payload}`);
// Handle different topic types
if (topic.startsWith('aquacross/button/rfid/')) {
handleRFIDTopic(topic, payload);
} else if (topic.startsWith('aquacross/button/status/')) {
handleButtonStatusTopic(topic, payload);
} else if (topic.startsWith('aquacross/button/')) {
handleButtonTopic(topic, payload);
} else if (topic.startsWith('aquacross/battery/')) {
handleBatteryTopic(topic, payload);
} else if (topic.startsWith('heartbeat/alive/')) {
handleHeartbeatTopic(topic, payload);
} else if (topic === 'aquacross/competition/toMaster') {
if (payload === 'start') {
state.startCompetition = true;
runCompetition();
}
}
});
mqttClient.on('error', (err) => {
console.error('[MQTT] Error:', err.message || err);
if (err.code === 'ECONNREFUSED') {
console.log('[MQTT] Broker not available at', MQTT_BROKER, '- will retry automatically');
}
});
mqttClient.on('close', () => {
console.log('[MQTT] Connection closed');
});
mqttClient.on('offline', () => {
console.log('[MQTT] Client offline, will reconnect automatically...');
});
mqttClient.on('reconnect', () => {
console.log('[MQTT] Reconnecting to broker...');
});
}
// MQTT Topic Handlers
function handleButtonTopic(topic, payload) {
try {
const buttonId = topic.replace('aquacross/button/', '');
const data = JSON.parse(payload);
const pressType = data.type || 0;
const timestamp = data.timestamp || millis();
console.log(`Button Press: ${buttonId}, Type: ${pressType}, Timestamp: ${timestamp}`);
// Simulate button assignment check (simplified)
// In real implementation, would check MAC addresses
if (state.learningMode) {
// Handle learning mode
return;
}
// Trigger action based on button (simplified - would check MAC in real implementation)
if (pressType === 2) {
// Start button
if (buttonId.includes('start1') || buttonId.includes('00:00:00:00:00:01')) {
individualMode("start", 2, 1, timestamp);
} else if (buttonId.includes('start2') || buttonId.includes('00:00:00:00:00:02')) {
individualMode("start", 2, 2, timestamp);
}
} else if (pressType === 1) {
// Stop button
if (buttonId.includes('stop1') || buttonId.includes('00:00:00:00:00:03')) {
individualMode("stop", 1, 1, timestamp);
} else if (buttonId.includes('stop2') || buttonId.includes('00:00:00:00:00:04')) {
individualMode("stop", 1, 2, timestamp);
}
}
} catch (err) {
console.error('Error handling button topic:', err);
}
}
function handleRFIDTopic(topic, payload) {
try {
const buttonId = topic.replace('aquacross/button/rfid/', '');
const data = JSON.parse(payload);
const uid = data.uid || '';
console.log(`RFID Read: ${buttonId}, UID: ${uid}`);
// Send to frontend
const message = JSON.stringify({
name: uid,
lane: buttonId.includes('start1') ? 'start1' : 'start2'
});
pushUpdateToFrontend(message);
} catch (err) {
console.error('Error handling RFID topic:', err);
}
}
function handleBatteryTopic(topic, payload) {
try {
const buttonId = topic.replace('aquacross/battery/', '');
const data = JSON.parse(payload);
const voltage = data.voltage || 0;
console.log(`Battery: ${buttonId}, Voltage: ${voltage}`);
// Update button config if known
// Send to frontend
const message = JSON.stringify({
button: buttonId,
mac: buttonId,
batteryLevel: Math.round((voltage - 3200) / 50) // Simple calculation
});
pushUpdateToFrontend(message);
} catch (err) {
console.error('Error handling battery topic:', err);
}
}
function handleHeartbeatTopic(topic, payload) {
try {
const buttonId = topic.replace('heartbeat/alive/', '');
console.log(`Heartbeat: ${buttonId}`);
// Update button heartbeat
// Send to frontend
const message = JSON.stringify({
button: buttonId,
mac: buttonId,
active: true
});
pushUpdateToFrontend(message);
} catch (err) {
console.error('Error handling heartbeat topic:', err);
}
}
function handleButtonStatusTopic(topic, payload) {
try {
const buttonId = topic.replace('aquacross/button/status/', '');
const data = JSON.parse(payload);
const available = data.available !== false;
const sleep = data.sleep === true;
console.log(`Button Status: ${buttonId}, Available: ${available}, Sleep: ${sleep}`);
// Send to frontend
const message = JSON.stringify({
button: buttonId,
mac: buttonId,
available: available,
sleep: sleep,
timestamp: data.timestamp || Date.now()
});
pushUpdateToFrontend(message);
} catch (err) {
console.error('Error handling button status topic:', err);
}
}
function runCompetition() {
if (state.timerData1.isArmed && state.timerData2.isArmed && state.startCompetition) {
const startNow = millis();
state.timerData1.isReady = false;
state.timerData1.startTime = startNow;
state.timerData1.localStartTime = millis();
state.timerData1.isRunning = true;
state.timerData1.endTime = 0;
state.timerData1.isArmed = false;
publishLaneStatus(1, "running");
state.timerData2.isReady = false;
state.timerData2.startTime = startNow;
state.timerData2.localStartTime = millis();
state.timerData2.isRunning = true;
state.timerData2.endTime = 0;
state.timerData2.isArmed = false;
publishLaneStatus(2, "running");
console.log("Competition started");
}
}
// API Routes
app.get('/api/data', (req, res) => {
res.json(JSON.parse(getTimerDataJSON()));
});
app.post('/api/reset-best', (req, res) => {
state.timerData1.bestTime = 0;
state.timerData2.bestTime = 0;
state.localTimes = [];
res.json({ success: true });
});
app.post('/api/unlearn-button', (req, res) => {
state.buttonConfigs.start1.isAssigned = false;
state.buttonConfigs.stop1.isAssigned = false;
state.buttonConfigs.start2.isAssigned = false;
state.buttonConfigs.stop2.isAssigned = false;
res.json({ success: true });
});
app.post('/api/set-max-time', (req, res) => {
if (req.body.maxTime) {
state.maxTimeBeforeReset = parseInt(req.body.maxTime) * 1000;
}
if (req.body.maxTimeDisplay) {
state.maxTimeDisplay = parseInt(req.body.maxTimeDisplay) * 1000;
}
if (req.body.minTimeForLeaderboard) {
state.minTimeForLeaderboard = parseInt(req.body.minTimeForLeaderboard) * 1000;
}
res.json({ success: true });
});
app.get('/api/get-settings', (req, res) => {
res.json({
maxTime: state.maxTimeBeforeReset / 1000,
maxTimeDisplay: state.maxTimeDisplay / 1000,
minTimeForLeaderboard: state.minTimeForLeaderboard / 1000
});
});
app.post('/api/start-learning', (req, res) => {
state.learningMode = true;
state.learningStep = 0;
res.json({ success: true });
});
app.post('/api/stop-learning', (req, res) => {
state.learningMode = false;
state.learningStep = 0;
res.json({ success: true });
});
app.get('/api/learn/status', (req, res) => {
res.json({
active: state.learningMode,
step: state.learningStep
});
});
app.get('/api/buttons/status', (req, res) => {
res.json({
lane1Start: state.buttonConfigs.start1.isAssigned,
lane1StartVoltage: state.buttonConfigs.start1.voltage,
lane1Stop: state.buttonConfigs.stop1.isAssigned,
lane1StopVoltage: state.buttonConfigs.stop1.voltage,
lane2Start: state.buttonConfigs.start2.isAssigned,
lane2StartVoltage: state.buttonConfigs.start2.voltage,
lane2Stop: state.buttonConfigs.stop2.isAssigned,
lane2StopVoltage: state.buttonConfigs.stop2.voltage
});
});
app.get('/api/info', (req, res) => {
const connected = [
state.buttonConfigs.start1.isAssigned,
state.buttonConfigs.stop1.isAssigned,
state.buttonConfigs.start2.isAssigned,
state.buttonConfigs.stop2.isAssigned
].filter(Boolean).length;
res.json({
ip: "127.0.0.1",
ipSTA: "127.0.0.1",
channel: 1,
mac: "AA:BB:CC:DD:EE:FF",
freeMemory: 1024 * 1024,
connectedButtons: connected,
isOnline: true,
valid: "Ja",
tier: 1
});
});
app.post('/api/set-wifi', (req, res) => {
if (req.body.ssid) {
state.wifi.ssid = req.body.ssid;
state.wifi.password = req.body.password || "";
res.json({ success: true });
} else {
res.status(400).json({ success: false, error: "SSID fehlt" });
}
});
app.get('/api/get-wifi', (req, res) => {
res.json({
ssid: state.wifi.ssid,
password: state.wifi.password
});
});
app.post('/api/set-location', (req, res) => {
if (req.body.name) {
state.masterlocation = req.body.name;
}
res.json({ success: true });
});
app.get('/api/get-location', (req, res) => {
res.json({
locationid: state.masterlocation
});
});
app.get('/api/updateButtons', (req, res) => {
if (mqttClient && mqttClient.connected) {
mqttClient.publish('aquacross/update/flag', '1');
}
res.json({ success: true });
});
app.post('/api/set-mode', (req, res) => {
if (req.body.mode) {
state.gamemode = req.body.mode === "individual" ? 0 : 1;
res.json({ success: true });
} else {
res.status(400).json({ success: false, error: "Modus fehlt" });
}
});
app.get('/api/get-mode', (req, res) => {
res.json({
mode: state.gamemode === 0 ? "individual" : "wettkampf"
});
});
app.post('/api/set-lane-config', (req, res) => {
if (req.body.type) {
state.laneConfigType = req.body.type === "identical" ? 0 : 1;
if (state.laneConfigType === 1) {
if (req.body.lane1Difficulty) {
state.lane1DifficultyType = req.body.lane1Difficulty === "light" ? 0 : 1;
}
if (req.body.lane2Difficulty) {
state.lane2DifficultyType = req.body.lane2Difficulty === "light" ? 0 : 1;
}
}
res.json({ success: true });
} else {
res.status(400).json({ success: false, error: "Lane type missing" });
}
});
app.get('/api/get-lane-config', (req, res) => {
const config = {
type: state.laneConfigType === 0 ? "identical" : "different"
};
if (state.laneConfigType === 1) {
config.lane1Difficulty = state.lane1DifficultyType === 0 ? "light" : "heavy";
config.lane2Difficulty = state.lane2DifficultyType === 0 ? "light" : "heavy";
}
res.json(config);
});
// Debug Endpoints
app.get('/api/debug/start1', (req, res) => {
individualMode("start", 2, 1, millis());
res.send("handleStart1() called");
});
app.get('/api/debug/stop1', (req, res) => {
individualMode("stop", 1, 1, millis());
res.send("handleStop1() called");
});
app.get('/api/debug/start2', (req, res) => {
individualMode("start", 2, 2, millis());
res.send("handleStart2() called");
});
app.get('/api/debug/stop2', (req, res) => {
individualMode("stop", 1, 2, millis());
res.send("handleStop2() called");
});
// WebSocket Setup
io.on('connection', (socket) => {
console.log(`[WebSocket] Client connected: ${socket.id}`);
socket.on('disconnect', () => {
console.log(`[WebSocket] Client disconnected: ${socket.id}`);
});
});
// Time sync - publish every 5 seconds
setInterval(() => {
if (mqttClient && mqttClient.connected) {
mqttClient.publish('sync/time', millis().toString());
}
}, 5000);
// Auto-reset check
setInterval(() => {
const currentTime = millis();
if (state.gamemode === 0) {
// Individual mode
if (!state.timerData1.isRunning && state.timerData1.endTime > 0 &&
state.timerData1.finishedSince > 0) {
if (currentTime - state.timerData1.finishedSince > state.maxTimeDisplay) {
state.timerData1.startTime = 0;
state.timerData1.endTime = 0;
state.timerData1.finishedSince = 0;
state.timerData1.isReady = true;
publishLaneStatus(1, "ready");
}
}
if (!state.timerData2.isRunning && state.timerData2.endTime > 0 &&
state.timerData2.finishedSince > 0) {
if (currentTime - state.timerData2.finishedSince > state.maxTimeDisplay) {
state.timerData2.startTime = 0;
state.timerData2.endTime = 0;
state.timerData2.finishedSince = 0;
state.timerData2.isReady = true;
publishLaneStatus(2, "ready");
}
}
}
}, 1000);
// Start server
server.listen(PORT, () => {
console.log(`[Server] Mock ESP32 Server running on port ${PORT}`);
console.log(`[Server] Web UI available at http://localhost:${PORT}`);
// Wait a moment before trying to connect to MQTT broker
// This gives the broker time to start if both are started together
setTimeout(() => {
console.log('[MQTT] Attempting initial connection to broker...');
connectMQTT();
}, 2000);
// Also set up a periodic check (backup retry mechanism)
// Note: mqtt.js already has auto-reconnect, this is just a backup
mqttReconnectInterval = setInterval(() => {
if (!mqttClient || (!mqttClient.connected && !mqttClient.connecting)) {
console.log('[MQTT] Connection check: Not connected, attempting reconnect...');
connectMQTT();
}
}, 15000); // Check every 15 seconds if not connected
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n[Server] Shutting down...');
if (mqttClient) {
mqttClient.end();
}
server.close(() => {
console.log('[Server] Server closed');
process.exit(0);
});
});

View File

@@ -1,108 +0,0 @@
const aedes = require('aedes')();
const net = require('net');
const ws = require('ws');
const http = require('http');
const port = 1883;
const wsPort = 9001;
// TCP Server for MQTT
const server = net.createServer(aedes.handle);
// Logging für alle Nachrichten
aedes.on('publish', (packet, client) => {
if (client) {
console.log(`[MQTT] Client ${client.id} published to topic: ${packet.topic}`);
console.log(`[MQTT] Payload: ${packet.payload.toString()}`);
} else {
console.log(`[MQTT] Published to topic: ${packet.topic}`);
console.log(`[MQTT] Payload: ${packet.payload.toString()}`);
}
});
// Client-Verbindungen
aedes.on('client', (client) => {
console.log(`[MQTT] Client connected: ${client.id}`);
});
aedes.on('clientDisconnect', (client) => {
console.log(`[MQTT] Client disconnected: ${client.id}`);
});
// Fehlerbehandlung
aedes.on('clientError', (client, err) => {
console.error(`[MQTT] Client error for ${client.id}:`, err);
});
// WebSocket Server for browser connections
const httpServer = http.createServer();
const wsServer = new ws.Server({
server: httpServer,
path: '/mqtt'
});
wsServer.on('connection', (socket, req) => {
// Create a proper stream adapter for Aedes
const { Duplex } = require('stream');
const stream = new Duplex({
write(chunk, encoding, callback) {
if (socket.readyState === ws.OPEN) {
socket.send(chunk);
callback();
} else {
callback(new Error('WebSocket is not open'));
}
},
read() {
// No-op: we push data when we receive it
}
});
// Handle incoming WebSocket messages
socket.on('message', (data) => {
stream.push(data);
});
socket.on('error', (err) => {
console.error('[MQTT] WebSocket error:', err);
stream.destroy(err);
});
socket.on('close', () => {
console.log('[MQTT] WebSocket client disconnected');
stream.push(null); // End the stream
});
// Handle stream errors
stream.on('error', (err) => {
console.error('[MQTT] Stream error:', err);
if (socket.readyState === ws.OPEN) {
socket.close();
}
});
// Pass the stream to Aedes
aedes.handle(stream);
});
server.listen(port, () => {
console.log(`[MQTT] TCP Broker started and listening on port ${port}`);
console.log(`[MQTT] Ready to accept TCP connections`);
});
httpServer.listen(wsPort, () => {
console.log(`[MQTT] WebSocket Broker started and listening on port ${wsPort}`);
console.log(`[MQTT] Ready to accept WebSocket connections at ws://localhost:${wsPort}/mqtt`);
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n[MQTT] Shutting down broker...');
server.close(() => {
console.log('[MQTT] TCP server closed');
});
httpServer.close(() => {
console.log('[MQTT] WebSocket server closed');
process.exit(0);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
{
"name": "aquamaster-mock-server",
"version": "1.0.0",
"description": "Mock ESP32 Server and MQTT Broker for testing AquaMaster without hardware",
"main": "start_all.js",
"scripts": {
"start": "node start_all.js",
"mqtt": "node mqtt_broker.js",
"server": "node mock_esp32_server.js"
},
"keywords": [
"mqtt",
"esp32",
"mock",
"testing"
],
"author": "",
"license": "MIT",
"dependencies": {
"aedes": "^0.50.0",
"express": "^4.18.2",
"socket.io": "^4.6.1",
"mqtt": "^5.3.1",
"cors": "^2.8.5",
"body-parser": "^1.20.2",
"ws": "^8.14.2"
}
}

View File

@@ -1,47 +0,0 @@
const { spawn } = require('child_process');
const path = require('path');
console.log('Starting AquaMaster Mock Server...\n');
// Start MQTT Broker
console.log('[1/2] Starting MQTT Broker...');
const mqttBroker = spawn('node', [path.join(__dirname, 'mqtt_broker.js')], {
stdio: 'inherit',
cwd: __dirname
});
mqttBroker.on('error', (err) => {
console.error('Failed to start MQTT Broker:', err);
process.exit(1);
});
// Wait a bit longer for MQTT broker to fully start
setTimeout(() => {
// Start Mock ESP32 Server
console.log('[2/2] Starting Mock ESP32 Server...');
const mockServer = spawn('node', [path.join(__dirname, 'mock_esp32_server.js')], {
stdio: 'inherit',
cwd: __dirname
});
mockServer.on('error', (err) => {
console.error('Failed to start Mock ESP32 Server:', err);
mqttBroker.kill();
process.exit(1);
});
// Handle shutdown
const shutdown = () => {
console.log('\nShutting down servers...');
if (mqttBroker && !mqttBroker.killed) {
mqttBroker.kill();
}
if (mockServer && !mockServer.killed) {
mockServer.kill();
}
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}, 3000); // Increased wait time to 3 seconds

View File

@@ -19,7 +19,7 @@ lib_compat_mode = strict
[env:wemos_d1_mini32]
board = wemos_d1_mini32
monitor_speed = 115200
build_flags =
build_flags =
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
-DBATTERY_PIN=16
@@ -27,19 +27,20 @@ board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
targets = uploadfs
board_build.psram = disabled
lib_deps =
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing_OTA]
board = esp32thing
monitor_speed = 115200
upload_protocol = espota
upload_port = 192.168.1.96
build_flags =
build_flags =
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
-DBATTERY_PIN=36
@@ -47,19 +48,20 @@ board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
targets = uploadfs
board_build.psram = disabled
lib_deps =
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing]
board = esp32thing_plus
monitor_speed = 115200
build_flags =
build_flags =
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
-DBATTERY_PIN=36
@@ -67,54 +69,57 @@ board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
targets = uploadfs
board_build.psram = disabled
lib_deps =
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing_CI]
platform = espressif32
board = esp32dev
framework = arduino
build_flags =
build_flags =
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
-DBATTERY_PIN=36
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
lib_deps =
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:um_feathers3]
board = um_feathers3
monitor_speed = 115200
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
board_upload.wait_for_upload_port = false
build_flags =
board_upload.wait_for_upload_port = false
build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1
-D BATTERY_PIN=35
-D ARDUINO_USB_MODE=1
lib_deps =
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:um_feathers3_debug]
board = um_feathers3
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
board_upload.wait_for_upload_port = false
build_flags =
board_upload.wait_for_upload_port = false
build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1
-D BATTERY_PIN=35
-D ARDUINO_USB_MODE=0
@@ -126,10 +131,11 @@ upload_port = COM5
monitor_speed = 115200
monitor_port = COM7
lib_deps =
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4

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,68 @@ 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);
entry["endEpoch"] = t.timestamp;
};
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;
@@ -406,8 +425,12 @@ void setupBackendRoutes(AsyncWebServer &server) {
// Erweiterte Leaderboard API (für Leaderboard-Seite - 10 Einträge)
server.on(
"/api/leaderboard-full", HTTP_GET, [](AsyncWebServerRequest *request) {
// Sortiere nach Zeit (beste zuerst)
std::sort(localTimes.begin(), localTimes.end(),
// Sortiere eine Kopie nach Zeit (beste zuerst). Niemals die globale
// localTimes-Liste sortieren - sonst geht die chronologische
// Reihenfolge verloren, die /api/leaderboard für "letzte Zeiten"
// braucht (und die per saveBestTimes auch persistiert wird).
std::vector<LocalTime> sortedTimes(localTimes);
std::sort(sortedTimes.begin(), sortedTimes.end(),
[](const LocalTime &a, const LocalTime &b) {
return a.timeMs < b.timeMs;
});
@@ -417,7 +440,7 @@ void setupBackendRoutes(AsyncWebServer &server) {
// Nimm die besten 10
int count = 0;
for (const auto &time : localTimes) {
for (const auto &time : sortedTimes) {
if (count >= 10)
break;
@@ -525,7 +548,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(
@@ -539,7 +562,11 @@ void addLocalTime(String uid, String name, unsigned long timeMs) {
newTime.uid = uid;
newTime.name = name;
newTime.timeMs = timeMs;
newTime.timestamp = millis();
// Epoch-Sekunden zum Zeitpunkt des Laufendes (via NTP). Fällt auf 0 zurück,
// wenn noch keine Zeit synchronisiert wurde — Frontend blendet die Uhrzeit
// in dem Fall aus.
newTime.timestamp = (unsigned long)time(nullptr);
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

@@ -21,10 +21,11 @@
#include <preferencemanager.h>
#include <rfid.h>
#include <timesync.h>
#include <rtcsync.h>
#include <webserverrouter.h>
#include <wificlass.h>
const char *firmwareversion = "1.0.0"; // Version der Firmware
const char *firmwareversion = "1.1.1"; // Version der Firmware
// moved to preferencemanager.h
@@ -50,7 +51,18 @@ void setup() {
loadWifiSettings();
loadLocationSettings();
setupRTC(); // RTC zuerst, damit Systemzeit vor WiFi plausibel ist
setupWifi(); // WiFi initialisieren
// wificlass.h hat intern bereits syncTimeWithNTP() versucht.
// Falls die Systemzeit jetzt plausibel ist, in RTC persistieren und Bookkeeping setzen —
// ohne einen zweiten NTP-Roundtrip zu provozieren.
if (WiFi.status() == WL_CONNECTED && time(NULL) >= 1735689600L) {
time_t nowEpoch = time(NULL);
persistSystemTimeToRTC(nowEpoch);
ntpEverSynced = true;
lastNtpSyncEpoch = nowEpoch;
lastStaConnected = true; // Edge bereits "konsumiert"
}
setupOTA(&server);
setupRoutes();
@@ -64,6 +76,7 @@ void setup() {
void loop() {
checkAutoReset();
loopRTC();
// MQTT hat höchste Priorität (wird zuerst verarbeitet)
loopMqttServer();

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

133
src/rtcsync.h Normal file
View File

@@ -0,0 +1,133 @@
// PCF8523-RTC-Modul mit NTP-Sync und Fallback.
// Header-only nach Projekt-Pattern: Globale Objekte werden hier definiert.
// Dieser Header darf NUR in master.cpp inkludiert werden.
#pragma once
#include <Arduino.h>
#include <RTClib.h>
#include <Wire.h>
// Globale RTC-Instanz und Status-Flags
RTC_PCF8523 rtc;
bool rtcAvailable = false;
bool ntpEverSynced = false;
time_t lastNtpSyncEpoch = 0;
bool lastStaConnected = false;
time_t cachedRtcEpoch = 0; // letzter rtc.now()-Wert, gelesen aus loopRTC (I2C-Race-Guard)
// I2C-Init, PCF8523-Detektion, und Systemzeit-Fallback aus RTC.
// Soft-Fail: bei nicht gefundener Hardware bleibt rtcAvailable=false.
void setupRTC() {
Wire.begin();
if (!rtc.begin()) {
Serial.println("[RTC] PCF8523 nicht gefunden — RTC-Funktionen deaktiviert");
rtcAvailable = false;
return;
}
if (!rtc.initialized() || rtc.lostPower()) {
Serial.println("[RTC] PCF8523 erkannt, aber Akku-Backup ungültig (lostPower)");
}
rtc.start();
rtcAvailable = true;
Serial.println("[RTC] PCF8523 initialisiert");
// Systemzeit aus RTC vorbelegen — wird ggf. später durch NTP übersteuert.
// RTC speichert UTC, settimeofday erwartet UTC. Kein Offset nötig.
DateTime nowRtc = rtc.now();
uint32_t rtcEpoch = nowRtc.unixtime();
// Plausibilität: RTC sollte mindestens 2025 zeigen, sonst ist sie ungestellt.
if (rtcEpoch >= 1735689600UL) {
struct timeval tv;
tv.tv_sec = (time_t)rtcEpoch;
tv.tv_usec = 0;
settimeofday(&tv, NULL);
cachedRtcEpoch = (time_t)rtcEpoch;
Serial.printf("[RTC] Systemzeit aus RTC gesetzt: %lu (UTC)\n",
(unsigned long)rtcEpoch);
} else {
Serial.printf("[RTC] RTC-Zeit unplausibel (%lu) — nicht übernommen\n",
(unsigned long)rtcEpoch);
}
}
// Plausibilitäts-Grenzen: 2025-01-01 .. 2100-01-01 (UTC).
// Verhindert, dass kaputte Timestamps (0, negativ, 1970) die RTC korrumpieren.
static constexpr uint32_t RTC_MIN_EPOCH = 1735689600UL; // 2025-01-01 00:00:00 UTC
static constexpr uint32_t RTC_MAX_EPOCH = 4102444800UL; // 2100-01-01 00:00:00 UTC
void persistSystemTimeToRTC(time_t t) {
if (!rtcAvailable) return;
if ((uint32_t)t < RTC_MIN_EPOCH || (uint32_t)t >= RTC_MAX_EPOCH) {
Serial.printf("[RTC] persist abgelehnt — Timestamp unplausibel: %ld\n",
(long)t);
return;
}
rtc.adjust(DateTime((uint32_t)t));
Serial.printf("[RTC] in RTC geschrieben: %ld (UTC)\n", (long)t);
}
// Weak-Hook-Override aus timesync.h — wird automatisch aufgerufen,
// sobald irgendwo setSystemTime() Erfolg meldet.
void onSystemTimeSet(time_t t) {
persistSystemTimeToRTC(t);
}
// Versucht NTP-Sync via timesync.h. Bei Erfolg: schreibt UTC in RTC,
// setzt ntpEverSynced=true, aktualisiert lastNtpSyncEpoch.
// Returns true bei Erfolg.
bool syncFromNTP() {
if (!syncTimeWithNTP()) {
Serial.println("[RTC] NTP-Sync fehlgeschlagen — RTC unverändert");
return false;
}
time_t after = time(NULL);
if ((uint32_t)after < RTC_MIN_EPOCH) {
Serial.println("[RTC] NTP-Sync lieferte unplausible Zeit — RTC unverändert");
return false;
}
persistSystemTimeToRTC(after);
ntpEverSynced = true;
lastNtpSyncEpoch = after;
return true;
}
// Aus loop() aufgerufen. Triggert syncFromNTP():
// - bei steigender Flanke von WiFi.isConnected() (STA-Reconnect)
// - alle 24h, sobald STA verbunden ist
// Reine Vergleichsoperationen, NTP-Roundtrip selbst ist blockierend (~ms..5s).
void loopRTC() {
// RTC-Lesen passiert NUR aus der main-loop (verhindert I2C-Race mit PN532).
// appendTimeStatus() liest danach nur den gecachten Wert.
if (rtcAvailable) {
cachedRtcEpoch = (time_t)rtc.now().unixtime();
}
bool sta = (WiFi.status() == WL_CONNECTED);
// Edge: false -> true (frischer STA-Connect)
if (sta && !lastStaConnected) {
Serial.println("[RTC] STA-Reconnect erkannt — NTP-Sync");
syncFromNTP();
}
lastStaConnected = sta;
// 24h-Periodik (nur wenn STA online)
if (sta && ntpEverSynced) {
time_t nowEpoch = time(NULL);
// Defensive: Clock-Jump rückwärts (z.B. via Browser-Time auf altes Datum)
// würde das Delta negativ machen. Auf "jetzt" zurücksetzen.
if (nowEpoch < lastNtpSyncEpoch) lastNtpSyncEpoch = nowEpoch;
if (nowEpoch - lastNtpSyncEpoch >= 86400) {
Serial.println("[RTC] 24h-Periodik — NTP-Sync");
syncFromNTP();
}
}
}
// Weak-Hook-Override aus timesync.h — erweitert /api/time um RTC-Status.
void appendTimeStatus(JsonDocument &doc) {
doc["rtc_available"] = rtcAvailable;
doc["rtc_synced_from_ntp"] = ntpEverSynced;
long ago = ntpEverSynced ? (long)(time(NULL) - lastNtpSyncEpoch) : (long)-1;
if (ago < 0 && ntpEverSynced) ago = 0; // Clock-Jump rückwärts → 0 statt negativ
doc["last_ntp_sync_ago_s"] = ago;
doc["rtc_time_utc"] = rtcAvailable ? (long)cachedRtcEpoch : (long)0;
}

View File

@@ -6,6 +6,11 @@
#include <sys/time.h>
#include <time.h>
// Weak hooks — falls rtcsync.h kompiliert/gelinkt wird, überschreibt es diese.
// Ohne rtcsync.h sind beide Symbole nullptr und werden nicht aufgerufen.
void onSystemTimeSet(time_t t) __attribute__((weak));
void appendTimeStatus(JsonDocument &doc) __attribute__((weak));
// Globale Zeitvariablen
struct timeval tv;
struct timezone tz;
@@ -24,7 +29,7 @@ String getCurrentTimeJSON() {
gettimeofday(&tv, &tz);
now = tv.tv_sec;
StaticJsonDocument<200> doc;
JsonDocument doc;
doc["timestamp"] = (long)now;
doc["success"] = true;
@@ -33,19 +38,24 @@ String getCurrentTimeJSON() {
char timeStr[64];
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo);
doc["formatted"] = String(timeStr);
doc["year"] = timeinfo.tm_year + 1900;
doc["month"] = timeinfo.tm_mon + 1;
doc["day"] = timeinfo.tm_mday;
doc["hour"] = timeinfo.tm_hour;
doc["year"] = timeinfo.tm_year + 1900;
doc["month"] = timeinfo.tm_mon + 1;
doc["day"] = timeinfo.tm_mday;
doc["hour"] = timeinfo.tm_hour;
doc["minute"] = timeinfo.tm_min;
doc["second"] = timeinfo.tm_sec;
// Optionale RTC/Sync-Status-Felder, falls rtcsync.h gelinkt ist
if (appendTimeStatus) {
appendTimeStatus(doc);
}
String response;
serializeJson(doc, response);
return response;
}
void syncTimeWithNTP(const char *ntpServer = "pool.ntp.org",
bool syncTimeWithNTP(const char *ntpServer = "pool.ntp.org",
long gmtOffset_sec = 3600, int daylightOffset_sec = 0) {
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
Serial.println("Warte auf NTP-Zeit (max 5s)...");
@@ -66,6 +76,7 @@ void syncTimeWithNTP(const char *ntpServer = "pool.ntp.org",
} else {
Serial.println("\nNTP-Sync fehlgeschlagen (Timeout nach 5s)");
}
return synced;
}
// Hilfsfunktion: Setzt die Systemzeit auf den angegebenen Zeitstempel.
@@ -76,6 +87,9 @@ bool setSystemTime(long timestamp) {
if (settimeofday(&tv, NULL) == 0) {
Serial.println("Zeit erfolgreich gesetzt: " + String(timestamp));
if (onSystemTimeSet) {
onSystemTimeSet((time_t)timestamp);
}
return true;
} else {
Serial.println("Fehler beim Setzen der Zeit");

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>