Compare commits

...

18 Commits

Author SHA1 Message Date
Carsten Graf
5beced0041 Merge feat/lb-fly-animation: fly-down animation on lap reset
Some checks failed
/ build (push) Has been cancelled
2026-05-03 17:24:05 +02:00
Carsten Graf
fd18d0cd22 feat(leaderboard): fly-down animation on lap reset
All checks were successful
/ build (push) Successful in 4m6s
Beim Übergang finished -> ready (Auto-Reset) fliegt die große
Lauf-Zeit aus #time1/#time2 nach unten in die Leaderboard-Liste.
Die bestehenden Einträge werden dabei nach unten geschoben, um
Platz zu machen.

Auto-Trigger beim Leaderboard-Polling entfernt; Animation läuft
jetzt ausschließlich am Status-Übergang über einen Snapshot, der
vor kickDisplayScheduler() eingefroren wird.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:23:39 +02:00
455633178c .github/workflows/build.yml aktualisiert
All checks were successful
/ build (push) Successful in 4m2s
revert
2026-05-03 16:41:12 +02:00
9361cfdee6 .github/workflows/build.yml aktualisiert
Some checks failed
/ build (push) Failing after 23s
2026-05-03 16:38:13 +02:00
f558c64886 Merge pull request 'feat/rtc-pcf8523' (#3) from feat/rtc-pcf8523 into main
Some checks failed
/ build (push) Failing after 26s
Reviewed-on: #3
2026-05-03 16:28:05 +02:00
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
24 changed files with 2530 additions and 198 deletions

View File

@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(pio run:*)",
"Bash(npm install:*)",
"Bash(where pio:*)",
"Read(//c/Users/repti/.platformio/penv/Scripts/**)",
"Bash(/c/Users/repti/.platformio/penv/Scripts/pio.exe run:*)",
"Bash(python -c \"import reportlab\")",
"Read(//c/Program Files/Google/Chrome/Application/**)",
"Read(//c/Program Files \\(x86\\)/Microsoft/Edge/Application/**)"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"proxmox"
]
}

Binary file not shown.

66
CLAUDE.md Normal file
View File

@@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Projektüberblick
**AquaMaster MQTT** ist die ESP32-Firmware für die Master-Einheit eines "Aquacross / NinjaCross"-Sport-Timers (zwei Bahnen, Start/Stopp-Taster). Der Master stellt einen WiFi-AP und/oder STA bereit, hostet einen MQTT-Broker, einen Async-Webserver mit WebSocket-Live-Updates und kommuniziert mit batteriebetriebenen Funktastern. Die README.md im Repo-Root ist **veraltet/falsch** (Gitea-MCP-Text) — als Quelle für Projektkontext stattdessen `API.md`, `TODO.md`, `Bedienungsanleitung_NinjaCross_Timer.html` und den Code selbst nutzen.
## Build & Flash (PlatformIO)
Default-Environment ist `esp32thing_CI` (siehe `platformio.ini`). Weitere Envs: `wemos_d1_mini32`, `esp32thing`, `esp32thing_OTA` (OTA an `192.168.1.96`), `um_feathers3`, `um_feathers3_debug`.
```bash
pio run # Default-Env bauen
pio run -e esp32thing # Spezifisches Env
pio run -e esp32thing -t upload # Flashen
pio run -e esp32thing -t buildfs # SPIFFS-Image aus data/ bauen
pio run -e esp32thing -t uploadfs # SPIFFS flashen (data/ → ESP)
pio device monitor -b 115200 # Serieller Monitor
pio run -e esp32thing_OTA -t upload # OTA-Upload (Ziel-IP in platformio.ini)
```
Tests gibt es nicht — `test/` enthält nur ein leeres README.
## CI
`.github/workflows/build.yml` baut bei jedem Push `firmware.bin` und `spiffs.bin` mit `pio run -e esp32thing_CI` und erzeugt automatisch ein GitHub-Release mit Tag `esp32thing-<datum>-<sha7>`. Wenn der Build lokal funktioniert, aber CI nicht, ist `esp32thing_CI` (board=esp32dev, platform=espressif32) die maßgebliche Konfiguration.
## Architektur (das Wesentliche)
### Header-only-Pattern (wichtig!)
Es gibt nur **eine** `.cpp`-Datei: `src/master.cpp`. Alle anderen Module unter `src/*.h` enthalten sowohl Deklarationen *als auch* Implementierungen und definieren teilweise **globale Objekte** (z. B. `AsyncWebServer server(80)` in `webserverrouter.h`, `Preferences preferences` in `licenceing.h`, `PicoMQTT::Server mqtt` in `communication.h`). Konsequenzen:
- Jeder dieser Header darf **nur in `master.cpp`** inkludiert werden, sonst gibt es Multiple-Definition-Linkerfehler.
- Header inkludieren sich gegenseitig (`master.h``webserverrouter.h``communication.h`). Beim Hinzufügen neuer Header die bestehende Include-Reihenfolge in `master.cpp` beibehalten.
- Globale Timer-/Button-State-Variablen (`timerData1`, `timerData2`, `buttonConfigs`, `localTimes`, `learningMode`, `gamemode`, …) leben in `src/master.h` und werden überall direkt referenziert.
Wer eine neue Datei anlegt: entweder als weiteren Header dem Pattern folgen und in `master.cpp` einklinken, oder bewusst eine echte `.cpp` mit `extern`-Deklarationen erstellen.
### Laufzeit-Module
- **`master.cpp`** — `setup()`/`loop()`. Reihenfolge in `setup()` ist relevant (SPIFFS → API-Setups → `load*()` aus Preferences → WiFi → OTA → Routes → WebSocket → MQTT → RFID). `loop()` priorisiert MQTT vor WebSocket vor RFID.
- **`communication.h`** — PicoMQTT-Broker. Tasten publishen auf `aquacross/button/<MAC>`; `readButtonJSON()` parst, ordnet die MAC einer der vier Rollen (`start1`/`stop1`/`start2`/`stop2`) zu und triggert die Timerlogik. Hält pro MAC `TimestampData` für Drift-Berechnung.
- **`webserverrouter.h`** — `ESPAsyncWebServer` auf Port 80 + WebSocket `/ws`. Liefert statische Seiten aus SPIFFS (`/`, `/settings`, `/leaderboard`, `/rfid`) und alle `/api/...`-Endpunkte. Vollständige Routenliste in `API.md`.
- **`wificlass.h`** — AP-Modus auf `192.168.10.1` (eindeutiger SSID-Suffix), STA-Fallback wenn gespeicherte Credentials vorhanden. Bindet `PrettyOTA` (lokale Bibliothek unter `lib/PrettyOTA/`) und mDNS ein.
- **`preferencemanager.h`** — Persistierung in NVS (`Preferences`). Namespaces u. a. `buttons`, `leaderboard`, plus WiFi-/Location-/Settings-Slots. Beim Ändern persistierter Strukturen (z. B. `ButtonConfigs`) auf Größenkompatibilität achten — `loadButtonConfig()` lädt nur, wenn `getBytesLength == sizeof(buttonConfigs)`.
- **`licenceing.h`** — HMAC-SHA256 (`mbedtls`) gegen `secret` über die STA-MAC; bestimmt Tier/Online-Funktionen. Lizenz wird zusammen mit jeder Backend-Anfrage als `Authorization: Bearer …` gesendet.
- **`databasebackend.h`** — HTTPS-Client gegen `https://ninja.reptilfpv.de` (Locations, Leaderboard-Upload, Health). Funktioniert nur bei verbundenem STA + gültiger Lizenz.
- **`rfid.h`** — Adafruit PN532 (I²C/SPI). Liest UIDs nur, wenn `isRFIDReadingActive()`; UID landet in `TimerData*::RFIDUID` und wird mit Namen aus `localUsers`/Backend verknüpft.
- **`gamemodes.h`** — Modus `0=individual`, `1=wettkampf`; steuert, wann Timer als „bereit/armiert/laufend" gilt und wie Bestzeiten abgelegt werden (lokales `localTimes`-Vektor + optional Backend).
- **`timesync.h`/`debug.h`/`statusled.h`/`battery.h`/`buttonassigh.h`/`helper.h`** — Hilfsmodule (NTP/Zeitzone, Debug-API, Status-LED, Akku, Lerne-Mode für Tasten-Zuordnung).
### Web-Frontend
`data/` enthält `index.html`, `settings.html`, `leaderboard.html`, `rfid.html` plus zugehörige CSS und ein `pictures/`-Verzeichnis. Diese Dateien werden via `pio run -t uploadfs` ins SPIFFS geschrieben und vom Webserver direkt ausgeliefert. **Frontend-Änderungen erfordern ein erneutes `uploadfs`** — ein normaler Firmware-Upload aktualisiert sie nicht.
`data/firmware.bin` wird unter `/firmware.bin` ausgeliefert (Buttons können sich darüber selbst aktualisieren).
## API
Vollständige HTTP-/WebSocket-API in `API.md` (autoritativ; `apientpoints` ist eine ältere Kurzversion). Alle POST-Routen erwarten **Form-Parameter, kein JSON-Body**. Antworten sind JSON, außer bei statischen Dateien.
## Sprache
Code-Kommentare und einige Variablennamen sind deutsch (`bahn`, `wettkampf`, „Anlernmodus"). Beim Erweitern bei der vorhandenen Sprache bleiben statt halb zu übersetzen.

View File

@@ -27,10 +27,12 @@ body {
.logo { .logo {
position: fixed; position: fixed;
top: 20px; /* Vertikal zentriert im 60px-Header-Bereich (top:20px, height:60px → Mitte 50px) */
top: 50px;
left: 20px; left: 20px;
width: auto; width: auto;
height: auto; height: auto;
transform: translateY(-50%);
z-index: 1000; z-index: 1000;
border-radius: 10px; border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
@@ -43,7 +45,7 @@ body {
} }
.logo:hover { .logo:hover {
transform: scale(1.1); transform: translateY(-50%) scale(1.1);
} }
.logo img { .logo img {
@@ -105,17 +107,43 @@ body {
transform: scale(1.1); 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 { .heartbeat-indicators {
position: fixed; position: fixed;
top: 20px; top: 20px;
right: 160px; right: 160px;
height: 60px;
display: flex; display: flex;
gap: 15px; align-items: flex-end;
gap: 18px;
z-index: 1000; z-index: 1000;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border-radius: 25px; border-radius: 30px;
padding: 10px 20px; padding: 0 24px 10px 24px;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
} }
@@ -123,7 +151,8 @@ body {
.logo { .logo {
width: 40px; width: 40px;
height: 40px; height: 40px;
top: 15px; /* Mobile: Header-Band top:15px height:60px → Mitte 45px */
top: 45px;
left: 15px; left: 15px;
padding: 3px; padding: 3px;
} }
@@ -142,22 +171,34 @@ body {
font-size: 1.2rem; 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 { .heartbeat-indicators {
top: 15px; top: 15px;
right: 90px; right: 90px;
gap: 8px; height: 60px;
padding: 8px 12px; gap: 12px;
padding: 0 16px 10px 16px;
font-size: 0.8rem; font-size: 0.8rem;
border-radius: 30px;
} }
.heartbeat-indicator { .heartbeat-indicator {
width: 12px; width: 18px;
height: 12px; height: 18px;
} }
.heartbeat-indicator::before { .heartbeat-indicator::before {
font-size: 8px; font-size: 8px;
top: -20px; top: -14px;
} }
.header h1 { .header h1 {
@@ -170,8 +211,8 @@ body {
} }
.heartbeat-indicator { .heartbeat-indicator {
width: 20px; width: 26px;
height: 20px; height: 26px;
border-radius: 50%; border-radius: 50%;
background: #f50f0f; background: #f50f0f;
transition: all 0.3s ease; transition: all 0.3s ease;
@@ -181,7 +222,7 @@ body {
.heartbeat-indicator::before { .heartbeat-indicator::before {
content: attr(data-label); content: attr(data-label);
position: absolute; position: absolute;
top: -25px; top: -18px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
font-size: 10px; font-size: 10px;
@@ -472,10 +513,13 @@ body {
} }
.status.large-status.ready { .status.large-status.ready {
font-size: clamp(2rem, 8vw, 8rem) !important;
display: flex !important; display: flex !important;
align-items: center !important; align-items: center !important;
justify-content: center !important; justify-content: center !important;
white-space: nowrap;
line-height: 1;
padding: 8px 12px !important;
overflow: hidden;
} }
.status.finished { .status.finished {
@@ -538,41 +582,55 @@ body {
} }
} }
.best-times { .leaderboards-row {
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;
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr 1fr;
gap: clamp(12px, 2vh, 20px); gap: clamp(15px, 2vw, 30px);
width: 100%; width: 100%;
max-width: 100%;
padding: 0 2vw;
margin-top: 0.5vh;
box-sizing: border-box;
flex-shrink: 0;
} }
@media (min-width: 768px) { @media (max-width: 768px) {
#leaderboard-container { .leaderboards-row {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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 { .best-times h3 {
font-size: clamp(0.9rem, 1.8vw, 1.1rem); font-size: clamp(0.7rem, 1.2vw, 0.85rem);
margin: 0 auto; margin: 0 auto;
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
@@ -592,53 +650,140 @@ body {
} }
/* Leaderboard Styles */ /* Leaderboard Styles */
#leaderboard-container {
text-align: left;
}
.leaderboard-entry { .leaderboard-entry {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin: clamp(8px, 1vh, 12px) 0; margin: 0;
font-size: clamp(1.1rem, 2.2vw, 1.4rem); font-size: clamp(0.7rem, 1.1vw, 0.9rem);
font-weight: 600; font-weight: 600;
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.15);
padding: clamp(12px, 2vh, 16px) clamp(16px, 3vw, 24px); padding: clamp(4px, 0.8vh, 7px) clamp(8px, 1.2vw, 12px);
border-radius: 10px; border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.3);
transition: all 0.3s ease; transition: all 0.3s ease;
min-height: 50px; min-height: 0;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
gap: 8px;
} }
.leaderboard-entry:hover { .leaderboard-entry:hover {
background: rgba(255, 255, 255, 0.25); background: rgba(255, 255, 255, 0.25);
transform: translateY(-2px); transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); 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 { .leaderboard-entry .rank {
color: #ffd700; color: #ffd700;
font-weight: bold; font-weight: bold;
min-width: 30px; min-width: 20px;
font-size: clamp(1.2rem, 2.4vw, 1.5rem); font-size: clamp(0.75rem, 1.2vw, 0.95rem);
flex-shrink: 0;
} }
.leaderboard-entry .name { .leaderboard-entry .name {
flex: 1; flex: 1;
margin: 0 15px; margin: 0;
color: #ffffff; 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 { .leaderboard-entry .time {
color: #00ff88; color: #00ff88;
font-weight: bold; font-weight: bold;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
min-width: 80px; min-width: 70px;
text-align: right; text-align: right;
font-size: clamp(1rem, 1.8vw, 1.3rem);
flex-shrink: 0;
} }
.leaderboard-entry.gold { .leaderboard-entry.gold {
@@ -703,6 +848,7 @@ body {
padding: 20px; padding: 20px;
} }
.learning-mode { .learning-mode {
background: rgba(245, 157, 15, 0.2); background: rgba(245, 157, 15, 0.2);
border: 2px solid #f59d0f; border: 2px solid #f59d0f;

View File

@@ -24,6 +24,7 @@
</div> </div>
<img src="/pictures/erlebniss.png" class="logo" alt="NinjaCross Logo" /> <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="/leaderboard.html" class="leaderboard-btn">🏆</a>
<a href="/settings" class="settings-btn">⚙️</a> <a href="/settings" class="settings-btn">⚙️</a>
@@ -44,7 +45,6 @@
<div class="header"> <div class="header">
<h1>🏊‍♀️ NinjaCross Timer</h1> <h1>🏊‍♀️ NinjaCross Timer</h1>
<p>Dein professioneller Zeitmesser für Ninjacross Wettkämpfe</p>
</div> </div>
<div id="learning-display" class="learning-mode" style="display: none"> <div id="learning-display" class="learning-mode" style="display: none">
@@ -72,9 +72,15 @@
</div> </div>
</div> </div>
<div class="best-times"> <div class="leaderboards-row">
<h3>🏆 Lokales Leaderboard</h3> <div class="best-times" id="best-times-1">
<div id="leaderboard-container"></div> <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> </div>
<script> <script>
@@ -90,7 +96,7 @@
let learningButton = ""; let learningButton = "";
let name1 = ""; let name1 = "";
let name2 = ""; let name2 = "";
let leaderboardData = []; let leaderboardData = null;
// Lane Configuration // Lane Configuration
let laneConfigType = 0; // 0=Identical, 1=Different let laneConfigType = 0; // 0=Identical, 1=Different
@@ -162,6 +168,12 @@
document.getElementById(indicatorId).classList.remove("active"); 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 { 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) { function formatTime(seconds) {
if (seconds === 0) return "00.00"; if (seconds === 0) return "00.00";
@@ -353,70 +468,232 @@
async function loadLeaderboard() { async function loadLeaderboard() {
try { try {
const response = await fetch("/api/leaderboard"); const response = await fetch("/api/leaderboard");
const data = await response.json(); leaderboardData = await response.json();
leaderboardData = data.leaderboard || [];
updateLeaderboardDisplay(); updateLeaderboardDisplay();
} catch (error) { } catch (error) {
console.error("Fehler beim Laden des Leaderboards:", error); console.error("Fehler beim Laden des Leaderboards:", error);
} }
} }
function updateLeaderboardDisplay() { function formatEndTime(epochSeconds) {
const container = document.getElementById("leaderboard-container"); if (!epochSeconds || epochSeconds < 1577836800) return ""; // < 2020 = kein NTP-Sync
container.innerHTML = ""; 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) { function createEntryElement(entry, isLatest) {
container.innerHTML = const div = document.createElement("div");
'<div class="no-times">Noch keine Zeiten erfasst</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))
);
}
// -------- Fly-down Animation --------
// Wird ausgelöst beim Status-Übergang finished -> ready (kurz vor dem
// Auto-Reset des Backends). Damit bleibt die große Zeit oben sichtbar
// bis der Backend resettet, und fliegt dann erst nach unten.
// Snapshot der Quelle einfrieren, BEVOR sie versteckt wird.
function captureSourceSnapshot(el) {
if (!el) return null;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
const cs = window.getComputedStyle(el);
return {
rect,
fontSize: cs.fontSize,
fontFamily: cs.fontFamily,
fontWeight: cs.fontWeight,
color: cs.color,
text: el.textContent,
};
}
function flyDownFromSnapshot(srcSnap, destEl) {
if (!srcSnap || !destEl) return;
const dstRect = destEl.getBoundingClientRect();
if (dstRect.width === 0 || dstRect.height === 0) return;
// ---- Phase 1: bestehende Einträge nach unten "schieben" ----
// Wir verstecken den (bereits gerenderten) neuen Top-Eintrag und
// setzen die Geschwister visuell an die Position, die sie VOR
// dem neuen Eintrag hatten (eine Slot-Höhe nach oben). Dann
// gleiten sie animiert in ihre natürliche Position herunter.
const container = destEl.parentElement;
const siblings = container
? Array.from(
container.querySelectorAll(".leaderboard-entry")
).filter((e) => e !== destEl)
: [];
let shiftPx = dstRect.height;
if (container) {
const cs = window.getComputedStyle(container);
const gap =
parseFloat(cs.rowGap) || parseFloat(cs.gap) || 0;
shiftPx += gap;
}
// Dest sofort verstecken (Layout-Slot bleibt erhalten)
destEl.style.visibility = "hidden";
// Geschwister hochsetzen (instant, ohne Transition)
siblings.forEach((s) => {
s.style.transition = "none";
s.style.transform = `translateY(-${shiftPx}px)`;
});
// Reflow, damit der "instant"-Setup wirkt
if (siblings.length > 0) siblings[0].getBoundingClientRect();
// Slide nach unten zur natürlichen Position
const slideMs = 280;
siblings.forEach((s) => {
s.style.transition = `transform ${slideMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
s.style.transform = "translateY(0)";
});
// ---- Phase 2: nach dem Slide den Ghost einfliegen lassen ----
const flyMs = 800;
setTimeout(() => {
const ghost = document.createElement("div");
ghost.textContent = srcSnap.text;
ghost.style.position = "fixed";
ghost.style.left = srcSnap.rect.left + "px";
ghost.style.top = srcSnap.rect.top + "px";
ghost.style.width = srcSnap.rect.width + "px";
ghost.style.height = srcSnap.rect.height + "px";
ghost.style.margin = "0";
ghost.style.padding = "0";
ghost.style.zIndex = "9999";
ghost.style.pointerEvents = "none";
ghost.style.color = srcSnap.color;
ghost.style.fontFamily = srcSnap.fontFamily;
ghost.style.fontWeight = srcSnap.fontWeight;
ghost.style.fontSize = srcSnap.fontSize;
ghost.style.lineHeight = "1";
ghost.style.display = "flex";
ghost.style.alignItems = "center";
ghost.style.justifyContent = "center";
ghost.style.textShadow = "0 0 12px rgba(0, 255, 136, 0.8)";
ghost.style.borderRadius = "8px";
ghost.style.transition =
`left ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`top ${flyMs}ms cubic-bezier(0.55, 0.05, 0.3, 1.1),` +
`width ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`height ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`font-size ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`color ${flyMs}ms ease-out,` +
`text-shadow ${flyMs}ms ease-out`;
document.body.appendChild(ghost);
// Reflow erzwingen, damit die Anfangsposition wirkt
ghost.getBoundingClientRect();
// Endwerte: aktuelle Position des .time-Spans im Eintrag
const destTimeSpan = destEl.querySelector(".time") || destEl;
const destTimeRect = destTimeSpan.getBoundingClientRect();
const destStyle = window.getComputedStyle(destTimeSpan);
ghost.style.left = destTimeRect.left + "px";
ghost.style.top = destTimeRect.top + "px";
ghost.style.width = destTimeRect.width + "px";
ghost.style.height = destTimeRect.height + "px";
ghost.style.fontSize = destStyle.fontSize;
ghost.style.color = destStyle.color;
ghost.style.textShadow = "0 0 6px rgba(0, 255, 136, 0.55)";
setTimeout(() => {
ghost.remove();
destEl.style.visibility = "";
// Sibling-Transforms aufräumen (sie sind eh schon bei 0)
siblings.forEach((s) => {
s.style.transition = "";
s.style.transform = "";
});
}, flyMs + 20);
}, slideMs);
}
// Bestimmt das Ziel-Element (erster Leaderboard-Eintrag) für eine Lane.
function findFlyDest(lane) {
const containerId =
leaderboardData && leaderboardData.mode === "different"
? "leaderboard-container-" + lane
: "leaderboard-container-1";
const container = document.getElementById(containerId);
if (!container) return null;
return container.querySelector(".leaderboard-entry");
}
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; return;
} }
// Erstelle zwei Reihen für 2x3 Layout // Reset Layout-Klassen
const row1 = document.createElement("div"); box1.classList.remove("best-times--full");
row1.className = "leaderboard-row"; box2.style.display = "";
const row2 = document.createElement("div");
row2.className = "leaderboard-row";
leaderboardData.forEach((entry, index) => { if (leaderboardData.mode === "different") {
const entryDiv = document.createElement("div"); // Unterschiedliche Lanes: eigene History pro Bahn unter jeder Lane
entryDiv.className = "leaderboard-entry"; title1.textContent = "🏊‍♀️ Bahn 1 — Letzte Zeiten";
title2.textContent = "🏊‍♂️ Bahn 2 — Letzte Zeiten";
// Podium-Plätze hervorheben fillLeaderboardContainer(container1, leaderboardData.lane1);
if (index === 0) { fillLeaderboardContainer(container2, leaderboardData.lane2);
entryDiv.classList.add("gold"); } else {
} else if (index === 1) { // Identische Lanes: ein gemeinsames Leaderboard über beide Spalten
entryDiv.classList.add("silver"); title1.textContent = "🏆 Letzte Zeiten";
} else if (index === 2) { box1.classList.add("best-times--full");
entryDiv.classList.add("bronze"); box2.style.display = "none";
} fillLeaderboardContainer(container1, leaderboardData.entries);
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);
} }
} }
@@ -484,7 +761,7 @@
s1.style.display = "flex"; s1.style.display = "flex";
s1.style.alignItems = "center"; s1.style.alignItems = "center";
s1.style.justifyContent = "center"; s1.style.justifyContent = "center";
s1.style.fontSize = "clamp(2rem, 8vw, 8rem)"; fitReadyText(s1, lane1Element, 1);
} else { } else {
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an // Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
time1Element.style.display = ""; time1Element.style.display = "";
@@ -522,12 +799,13 @@
s1.style.fontSize = ""; s1.style.fontSize = "";
s1.style.left = ""; s1.style.left = "";
s1.style.bottom = ""; s1.style.bottom = "";
fitTimeText(time1Element, lane1Element, 1);
} }
} }
switch (status1) { switch (status1) {
case "ready": case "ready":
s1.textContent = "Bereit für den Start!"; s1.textContent = "Bereit";
break; break;
case "running": case "running":
s1.textContent = "Läuft - Gib alles!"; s1.textContent = "Läuft - Gib alles!";
@@ -587,7 +865,7 @@
s2.style.display = "flex"; s2.style.display = "flex";
s2.style.alignItems = "center"; s2.style.alignItems = "center";
s2.style.justifyContent = "center"; s2.style.justifyContent = "center";
s2.style.fontSize = "clamp(2rem, 8vw, 8rem)"; fitReadyText(s2, lane2Element, 2);
} else { } else {
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an // Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
time2Element.style.display = ""; time2Element.style.display = "";
@@ -625,12 +903,13 @@
s2.style.fontSize = ""; s2.style.fontSize = "";
s2.style.left = ""; s2.style.left = "";
s2.style.bottom = ""; s2.style.bottom = "";
fitTimeText(time2Element, lane2Element, 2);
} }
} }
switch (status2) { switch (status2) {
case "ready": case "ready":
s2.textContent = "Bereit für den Start!"; s2.textContent = "Bereit";
break; break;
case "running": case "running":
s2.textContent = "Läuft - Gib alles!"; s2.textContent = "Läuft - Gib alles!";
@@ -677,20 +956,51 @@
} }
} }
const validStatuses = ["ready", "running", "finished", "armed"];
function syncFromBackend() { function syncFromBackend() {
fetch("/api/data") fetch("/api/data")
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .then((data) => {
timer1 = data.time1; timer1 = data.time1;
timer2 = data.time2; timer2 = data.time2;
status1 = data.status1;
status2 = data.status2; // Alte Status-Werte sichern, BEVOR sie überschrieben werden
const oldStatus1 = status1;
const oldStatus2 = 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; best1 = data.best1;
best2 = data.best2; best2 = data.best2;
learningMode = data.learningMode; learningMode = data.learningMode;
learningButton = data.learningButton || ""; learningButton = data.learningButton || "";
lastSync = Date.now(); lastSync = Date.now();
updateDisplay();
// Übergang finished -> ready erkennen.
// Snapshot der großen Zeit JETZT einfrieren, bevor
// kickDisplayScheduler/updateDisplay sie versteckt.
const fly1 =
oldStatus1 === "finished" && status1 === "ready"
? captureSourceSnapshot(document.getElementById("time1"))
: null;
const fly2 =
oldStatus2 === "finished" && status2 === "ready"
? captureSourceSnapshot(document.getElementById("time2"))
: null;
kickDisplayScheduler();
// Animation auf nächsten Frame, wenn updateDisplay durch ist
if (fly1 || fly2) {
requestAnimationFrame(() => {
if (fly1) flyDownFromSnapshot(fly1, findFlyDest(1));
if (fly2) flyDownFromSnapshot(fly2, findFlyDest(2));
});
}
}) })
.catch((error) => .catch((error) =>
console.error("Fehler beim Laden deiner Daten:", error) console.error("Fehler beim Laden deiner Daten:", error)
@@ -739,8 +1049,24 @@
// Sync with backend every 1 second // Sync with backend every 1 second
setInterval(syncFromBackend, 1000); setInterval(syncFromBackend, 1000);
// Smooth update every 50ms // Adaptive Update-Rate: 50 ms wenn mindestens eine Bahn läuft,
setInterval(updateDisplay, 50); // 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) // Heartbeat timeout check (every second)
setInterval(() => { setInterval(() => {
@@ -757,6 +1083,14 @@
}); });
}, 1000); }, 1000);
window.addEventListener("resize", () => {
fitReadyCache[1].w = 0;
fitReadyCache[2].w = 0;
fitTimeCache[1].lw = 0;
fitTimeCache[2].lw = 0;
updateDisplay();
});
// Initial load // Initial load
syncFromBackend(); syncFromBackend();
loadLaneConfig(); loadLaneConfig();
@@ -764,6 +1098,18 @@
// Leaderboard alle 5 Sekunden aktualisieren // Leaderboard alle 5 Sekunden aktualisieren
setInterval(loadLeaderboard, 5000); 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> </script>
</body> </body>
</html> </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

@@ -19,7 +19,7 @@ lib_compat_mode = strict
[env:wemos_d1_mini32] [env:wemos_d1_mini32]
board = wemos_d1_mini32 board = wemos_d1_mini32
monitor_speed = 115200 monitor_speed = 115200
build_flags = build_flags =
-DBOARD_HAS_PSRAM -DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue -mfix-esp32-psram-cache-issue
-DBATTERY_PIN=16 -DBATTERY_PIN=16
@@ -27,19 +27,20 @@ board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv board_build.partitions = default_16MB.csv
targets = uploadfs targets = uploadfs
board_build.psram = disabled board_build.psram = disabled
lib_deps = lib_deps =
bblanchon/ArduinoJson@^7.4.1 bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing_OTA] [env:esp32thing_OTA]
board = esp32thing board = esp32thing
monitor_speed = 115200 monitor_speed = 115200
upload_protocol = espota upload_protocol = espota
upload_port = 192.168.1.96 upload_port = 192.168.1.96
build_flags = build_flags =
-DBOARD_HAS_PSRAM -DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue -mfix-esp32-psram-cache-issue
-DBATTERY_PIN=36 -DBATTERY_PIN=36
@@ -47,19 +48,20 @@ board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv board_build.partitions = default_16MB.csv
targets = uploadfs targets = uploadfs
board_build.psram = disabled board_build.psram = disabled
lib_deps = lib_deps =
bblanchon/ArduinoJson@^7.4.1 bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing] [env:esp32thing]
board = esp32thing_plus board = esp32thing_plus
monitor_speed = 115200 monitor_speed = 115200
build_flags = build_flags =
-DBOARD_HAS_PSRAM -DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue -mfix-esp32-psram-cache-issue
-DBATTERY_PIN=36 -DBATTERY_PIN=36
@@ -67,54 +69,57 @@ board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv board_build.partitions = default_16MB.csv
targets = uploadfs targets = uploadfs
board_build.psram = disabled board_build.psram = disabled
lib_deps = lib_deps =
bblanchon/ArduinoJson@^7.4.1 bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing_CI] [env:esp32thing_CI]
platform = espressif32 platform = espressif32
board = esp32dev board = esp32dev
framework = arduino framework = arduino
build_flags = build_flags =
-DBOARD_HAS_PSRAM -DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue -mfix-esp32-psram-cache-issue
-DBATTERY_PIN=36 -DBATTERY_PIN=36
board_upload.flash_size = 16MB board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv board_build.partitions = default_16MB.csv
lib_deps = lib_deps =
bblanchon/ArduinoJson@^7.4.1 bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:um_feathers3] [env:um_feathers3]
board = um_feathers3 board = um_feathers3
monitor_speed = 115200 monitor_speed = 115200
board_upload.flash_size = 16MB board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv board_build.partitions = default_16MB.csv
board_upload.wait_for_upload_port = false board_upload.wait_for_upload_port = false
build_flags = build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1 -D ARDUINO_USB_CDC_ON_BOOT=1
-D BATTERY_PIN=35 -D BATTERY_PIN=35
-D ARDUINO_USB_MODE=1 -D ARDUINO_USB_MODE=1
lib_deps = lib_deps =
bblanchon/ArduinoJson@^7.4.1 bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:um_feathers3_debug] [env:um_feathers3_debug]
board = um_feathers3 board = um_feathers3
board_upload.flash_size = 16MB board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv board_build.partitions = default_16MB.csv
board_upload.wait_for_upload_port = false board_upload.wait_for_upload_port = false
build_flags = build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1 -D ARDUINO_USB_CDC_ON_BOOT=1
-D BATTERY_PIN=35 -D BATTERY_PIN=35
-D ARDUINO_USB_MODE=0 -D ARDUINO_USB_MODE=0
@@ -126,10 +131,11 @@ upload_port = COM5
monitor_speed = 115200 monitor_speed = 115200
monitor_port = COM7 monitor_port = COM7
lib_deps = lib_deps =
bblanchon/ArduinoJson@^7.4.1 bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4 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 // 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 // Prüft, ob ein Benutzer mit der angegebenen UID in der Datenbank existiert und
// gibt dessen Daten zurück. // gibt dessen Daten zurück.
@@ -353,49 +353,68 @@ void setupBackendRoutes(AsyncWebServer &server) {
// Andere Logik wie in getBestLocs // 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) { server.on("/api/leaderboard", HTTP_GET, [](AsyncWebServerRequest *request) {
// Sortiere nach Zeit (beste zuerst) auto formatTime = [](unsigned long timeMs) -> String {
std::sort(localTimes.begin(), localTimes.end(), float seconds = timeMs / 1000.0;
[](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;
int totalSeconds = (int)seconds; int totalSeconds = (int)seconds;
int minutes = totalSeconds / 60; int minutes = totalSeconds / 60;
int remainingSeconds = totalSeconds % 60; int remainingSeconds = totalSeconds % 60;
int milliseconds = (int)((seconds - totalSeconds) * 100); int milliseconds = (int)((seconds - totalSeconds) * 100);
String timeFormatted;
if (minutes > 0) { if (minutes > 0) {
timeFormatted = String(minutes) + ":" + return String(minutes) + ":" + (remainingSeconds < 10 ? "0" : "") +
(remainingSeconds < 10 ? "0" : "") + String(remainingSeconds) + "." +
String(remainingSeconds) + "." + (milliseconds < 10 ? "0" : "") + String(milliseconds);
(milliseconds < 10 ? "0" : "") + String(milliseconds);
} else {
timeFormatted = 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; String result;
@@ -406,8 +425,12 @@ void setupBackendRoutes(AsyncWebServer &server) {
// Erweiterte Leaderboard API (für Leaderboard-Seite - 10 Einträge) // Erweiterte Leaderboard API (für Leaderboard-Seite - 10 Einträge)
server.on( server.on(
"/api/leaderboard-full", HTTP_GET, [](AsyncWebServerRequest *request) { "/api/leaderboard-full", HTTP_GET, [](AsyncWebServerRequest *request) {
// Sortiere nach Zeit (beste zuerst) // Sortiere eine Kopie nach Zeit (beste zuerst). Niemals die globale
std::sort(localTimes.begin(), localTimes.end(), // 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) { [](const LocalTime &a, const LocalTime &b) {
return a.timeMs < b.timeMs; return a.timeMs < b.timeMs;
}); });
@@ -417,7 +440,7 @@ void setupBackendRoutes(AsyncWebServer &server) {
// Nimm die besten 10 // Nimm die besten 10
int count = 0; int count = 0;
for (const auto &time : localTimes) { for (const auto &time : sortedTimes) {
if (count >= 10) if (count >= 10)
break; break;
@@ -525,7 +548,7 @@ void sendTimeToOnlineAPI(int lane, String uid, float timeInSeconds) {
} }
// Funktionen für lokales Leaderboard // 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 // Prüfe minimale Zeit für Leaderboard-Eintrag
if (timeMs < minTimeForLeaderboard) { if (timeMs < minTimeForLeaderboard) {
Serial.printf( Serial.printf(
@@ -539,7 +562,11 @@ void addLocalTime(String uid, String name, unsigned long timeMs) {
newTime.uid = uid; newTime.uid = uid;
newTime.name = name; newTime.name = name;
newTime.timeMs = timeMs; 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); 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 // Finde den Namen des lokalen Users
UserData userData = checkUser(getStart1UID()); UserData userData = checkUser(getStart1UID());
if (userData.exists) { if (userData.exists) {
addLocalTime(getStart1UID(), userData.firstname, currentTime); addLocalTime(getStart1UID(), userData.firstname, currentTime, 1);
} else { } else {
// User lokal gefunden aber keine Daten - speichere ohne Namen // User lokal gefunden aber keine Daten - speichere ohne Namen
addLocalTime(getStart1UID(), "Unbekannt", currentTime); addLocalTime(getStart1UID(), "Unbekannt", currentTime, 1);
} }
} else if (!wasStart1FoundLocally() && getStart1UID().length() > 0) { } else if (!wasStart1FoundLocally() && getStart1UID().length() > 0) {
// Sende Zeit an Online-API wenn User online gefunden wurde // Sende Zeit an Online-API wenn User online gefunden wurde
sendTimeToOnlineAPI(1, getStart1UID(), currentTime / 1000.0); sendTimeToOnlineAPI(1, getStart1UID(), currentTime / 1000.0);
} else { } else {
// Kein User gefunden - speichere Zeit ohne UID und Namen // Kein User gefunden - speichere Zeit ohne UID und Namen
addLocalTime("", "Spieler " + String((localTimes.size() + 1)), addLocalTime("", "Lauf " + String((localTimes.size() + 1)),
currentTime); currentTime, 1);
} }
} }
} }
@@ -109,18 +109,18 @@ void IndividualMode(const char *action, int press, int lane,
// Finde den Namen des lokalen Users // Finde den Namen des lokalen Users
UserData userData = checkUser(getStart2UID()); UserData userData = checkUser(getStart2UID());
if (userData.exists) { if (userData.exists) {
addLocalTime(getStart2UID(), userData.firstname, currentTime); addLocalTime(getStart2UID(), userData.firstname, currentTime, 2);
} else { } else {
// User lokal gefunden aber keine Daten - speichere ohne Namen // User lokal gefunden aber keine Daten - speichere ohne Namen
addLocalTime(getStart2UID(), "Unbekannt", currentTime); addLocalTime(getStart2UID(), "Unbekannt", currentTime, 2);
} }
} else if (!wasStart2FoundLocally() && getStart2UID().length() > 0) { } else if (!wasStart2FoundLocally() && getStart2UID().length() > 0) {
// Sende Zeit an Online-API wenn User online gefunden wurde // Sende Zeit an Online-API wenn User online gefunden wurde
sendTimeToOnlineAPI(2, getStart2UID(), currentTime / 1000.0); sendTimeToOnlineAPI(2, getStart2UID(), currentTime / 1000.0);
} else { } else {
// Kein User gefunden - speichere Zeit ohne UID und Namen // Kein User gefunden - speichere Zeit ohne UID und Namen
addLocalTime("", "Spieler " + String((localTimes.size() + 1)), addLocalTime("", "Lauf " + String((localTimes.size() + 1)),
currentTime); currentTime, 2);
} }
} }
} }

View File

@@ -21,10 +21,11 @@
#include <preferencemanager.h> #include <preferencemanager.h>
#include <rfid.h> #include <rfid.h>
#include <timesync.h> #include <timesync.h>
#include <rtcsync.h>
#include <webserverrouter.h> #include <webserverrouter.h>
#include <wificlass.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 // moved to preferencemanager.h
@@ -50,7 +51,18 @@ void setup() {
loadWifiSettings(); loadWifiSettings();
loadLocationSettings(); loadLocationSettings();
setupRTC(); // RTC zuerst, damit Systemzeit vor WiFi plausibel ist
setupWifi(); // WiFi initialisieren 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); setupOTA(&server);
setupRoutes(); setupRoutes();
@@ -64,6 +76,7 @@ void setup() {
void loop() { void loop() {
checkAutoReset(); checkAutoReset();
loopRTC();
// MQTT hat höchste Priorität (wird zuerst verarbeitet) // MQTT hat höchste Priorität (wird zuerst verarbeitet)
loopMqttServer(); loopMqttServer();

View File

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

View File

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

View File

@@ -24,6 +24,13 @@ bool isRFIDReadingActive() { return readingMode; }
// Initialisiert den RFID-Reader // Initialisiert den RFID-Reader
void setupRFID() { 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 // I2C starten mit korrekten Pins
Wire.begin(SDA_PIN, SCL_PIN, 100000); Wire.begin(SDA_PIN, SCL_PIN, 100000);
delay(100); delay(100);
@@ -46,6 +53,7 @@ void setupRFID() {
rfidInitialized = true; rfidInitialized = true;
Serial.println("RFID: Setup erfolgreich!"); Serial.println("RFID: Setup erfolgreich!");
*/
} }
// Prüft ob RFID funktioniert // 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 <sys/time.h>
#include <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 // Globale Zeitvariablen
struct timeval tv; struct timeval tv;
struct timezone tz; struct timezone tz;
@@ -24,7 +29,7 @@ String getCurrentTimeJSON() {
gettimeofday(&tv, &tz); gettimeofday(&tv, &tz);
now = tv.tv_sec; now = tv.tv_sec;
StaticJsonDocument<200> doc; JsonDocument doc;
doc["timestamp"] = (long)now; doc["timestamp"] = (long)now;
doc["success"] = true; doc["success"] = true;
@@ -33,19 +38,24 @@ String getCurrentTimeJSON() {
char timeStr[64]; char timeStr[64];
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo); strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo);
doc["formatted"] = String(timeStr); doc["formatted"] = String(timeStr);
doc["year"] = timeinfo.tm_year + 1900; doc["year"] = timeinfo.tm_year + 1900;
doc["month"] = timeinfo.tm_mon + 1; doc["month"] = timeinfo.tm_mon + 1;
doc["day"] = timeinfo.tm_mday; doc["day"] = timeinfo.tm_mday;
doc["hour"] = timeinfo.tm_hour; doc["hour"] = timeinfo.tm_hour;
doc["minute"] = timeinfo.tm_min; doc["minute"] = timeinfo.tm_min;
doc["second"] = timeinfo.tm_sec; doc["second"] = timeinfo.tm_sec;
// Optionale RTC/Sync-Status-Felder, falls rtcsync.h gelinkt ist
if (appendTimeStatus) {
appendTimeStatus(doc);
}
String response; String response;
serializeJson(doc, response); serializeJson(doc, response);
return 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) { long gmtOffset_sec = 3600, int daylightOffset_sec = 0) {
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer); configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
Serial.println("Warte auf NTP-Zeit (max 5s)..."); Serial.println("Warte auf NTP-Zeit (max 5s)...");
@@ -66,6 +76,7 @@ void syncTimeWithNTP(const char *ntpServer = "pool.ntp.org",
} else { } else {
Serial.println("\nNTP-Sync fehlgeschlagen (Timeout nach 5s)"); Serial.println("\nNTP-Sync fehlgeschlagen (Timeout nach 5s)");
} }
return synced;
} }
// Hilfsfunktion: Setzt die Systemzeit auf den angegebenen Zeitstempel. // Hilfsfunktion: Setzt die Systemzeit auf den angegebenen Zeitstempel.
@@ -76,6 +87,9 @@ bool setSystemTime(long timestamp) {
if (settimeofday(&tv, NULL) == 0) { if (settimeofday(&tv, NULL) == 0) {
Serial.println("Zeit erfolgreich gesetzt: " + String(timestamp)); Serial.println("Zeit erfolgreich gesetzt: " + String(timestamp));
if (onSystemTimeSet) {
onSystemTimeSet((time_t)timestamp);
}
return true; return true;
} else { } else {
Serial.println("Fehler beim Setzen der Zeit"); 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>