Compare commits

...

44 Commits

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:44:40 +02:00
Carsten Graf
48ae556949 BugFix: Status Unknown
Some checks failed
/ build (push) Has been cancelled
2026-04-12 14:50:33 +02:00
Carsten Graf
9d958c94f1 UHrzeit und Starttick
Some checks failed
/ build (push) Has been cancelled
2026-04-11 23:28:37 +02:00
Carsten Graf
0223cceef8 Button Simmulator, Frontend änderungen
Some checks failed
/ build (push) Has been cancelled
2026-04-11 20:24:39 +02:00
05166b443b Merge pull request 'Kleine änderungen' (#2) from v1 into main
Some checks failed
/ build (push) Failing after 26s
Reviewed-on: #2
2026-03-06 15:00:32 +01:00
Carsten Graf
76b492606e Kleine änderungen
All checks were successful
/ build (push) Successful in 6m43s
2026-02-21 15:37:54 +01:00
d9edd47a31 Merge pull request 'v1' (#1) from v1 into main
Some checks failed
/ build (push) Has been cancelled
Reviewed-on: #1
2026-01-24 15:11:30 +01:00
Carsten Graf
a67e29b9e4 Add DevServer (brokern)
Some checks failed
/ build (push) Has been cancelled
2026-01-24 15:08:14 +01:00
Carsten Graf
5ef5e6d636 Changes to the Statusdisplay 2026-01-24 14:51:33 +01:00
Carsten Graf
77f1ebc1f1 Add Manual 2025-11-05 22:32:16 +01:00
Carsten Graf
2a832257ba Added minTime 2025-10-13 19:17:35 +02:00
Carsten Graf
5ca67d8804 Add Local leaderboard, CSS optimiztion 2025-09-23 20:07:35 +02:00
Carsten Graf
8fac847a75 Change best times to Local leaderboard 2025-09-22 20:51:09 +02:00
Carsten Graf
36c35ba161 leere lokales leaderboard wenn best times zurück gesetzt werden 2025-09-22 20:41:33 +02:00
Carsten Graf
e383e54e41 Add all times to local leaderboard 2025-09-22 20:37:13 +02:00
Carsten Graf
9de327bfb3 Lokal Leaderboard 2025-09-20 19:14:41 +02:00
Carsten Graf
7e9705902e RFID Implementierung 2025-09-20 01:04:00 +02:00
Carsten Graf
1ed3a30340 RFID im master ist back 2025-09-18 23:23:49 +02:00
Carsten Graf
02a60d84cf Update 2025-09-18 23:21:14 +02:00
Carsten Graf
4f0fc68d41 Lane difficulty added 2025-09-11 13:56:07 +02:00
Carsten Graf
3aac843736 RFID erstellung raugeflogen 2025-09-11 11:56:59 +02:00
Carsten Graf
ed9e8994a9 Auch settings aufs neue farbschema 2025-09-11 11:54:42 +02:00
Carsten Graf
86b0407f82 Rename and add logos 2025-09-11 11:50:24 +02:00
Carsten Graf
a400ca00ff NewColors 2025-09-11 10:19:35 +02:00
Carsten Graf
173b13fcfc add settings locations
Some checks failed
/ build (push) Has been cancelled
2025-09-08 22:30:15 +02:00
Carsten Graf
55eb062d2c Move all the preference in seperate h file 2025-08-18 17:57:43 +02:00
Carsten Graf
a768783640 Update API markdown 2025-08-14 10:02:03 +02:00
Carsten Graf
2b9cc7283c Fix: Debugmode. TBD 2025-08-14 09:02:03 +02:00
Carsten Graf
ba1b86a053 Bug fixed, Wettkampfmodus Done. TODO: Zeitstempel der Statusampel im Master verwenden 2025-08-06 22:47:31 +02:00
Carsten Graf
4a04565878 BUG: Comp-mode erstes stoppen zeigt falsche zeit an! Individ mode geht. 2025-08-06 00:46:05 +02:00
Carsten Graf
6793a54103 Refactor for Gamemodes 2025-08-05 21:21:22 +02:00
Carsten Graf
60d4393bd2 Merge branch 'main' of https://git.reptilfpv.de/reptil1990/AquaMasterMQTT 2025-08-02 21:08:25 +02:00
Carsten Graf
a1c68791bf Start competition mode 2025-08-02 20:36:19 +02:00
e6a089fd61 .github/workflows/build.yml aktualisiert
Add Unique ID
2025-08-01 17:04:17 +02:00
46 changed files with 7522 additions and 1465 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"
]
}

View File

@@ -31,11 +31,17 @@ jobs:
cp .pio/build/esp32thing_CI/firmware.bin firmware.bin
cp .pio/build/esp32thing_CI/spiffs.bin spiffs.bin
- name: Generate tag name
id: tag
run: |
TAG="esp32thing-$(date +'%Y%m%d-%H%M%S')-${GITHUB_SHA::7}"
echo "tag_name=$TAG" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "esp32thing Firmware ${{ github.ref_name }}"
tag_name: "${{ github.ref_name }}"
name: "esp32thing Firmware ${{ steps.tag.outputs.tag_name }}"
tag_name: "${{ steps.tag.outputs.tag_name }}"
files: |
firmware.bin
spiffs.bin

106
.gitignore vendored
View File

@@ -1,6 +1,104 @@
.pio
# PlatformIO
.pio/
.pioenvs/
.piolibdeps/
.platformio/
platformio.ini.bak
# Build directories
build/
.vscode/
# IDE files
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Node.js (falls du Node.js Tools verwendest)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
# IDE specific files
.vscode/settings.json
.vscode/launch.json
.vscode/ipch
.vscode/extensions.json
.idea/
*.iml
*.ipr
*.iws
# Temporary files
*.tmp
*.temp
# Compiled files
*.o
*.obj
*.exe
*.dll
*.so
*.dylib
# Firmware files (optional - falls du sie nicht versionieren willst)
# *.bin
# *.hex
# Backup files
*.bak
*.backup
# Archive files
*.zip
*.tar.gz
*.rar
# MCP related files (falls nicht benötigt)
gitea-mcp.exe
gitea-mcp.zip
# Local configuration files
config.local.*

142
API.md
View File

@@ -1,109 +1,93 @@
# API- und Routenbeschreibung für das AquaMaster-Projekt
# API Documentation: AquaMaster Webserver
Diese Datei beschreibt alle HTTP-Routen (API und statische Seiten) für das AquaMaster-Projekt. Sie dient als Referenz für Frontend-Entwickler.
This document describes all available API routes provided by the webserver in `webserverrouter.h`.
All API endpoints return JSON unless otherwise noted.
---
## Statische Seiten
## Static Files
| Route | Methode | Beschreibung | Antwort (Content-Type) |
|-----------------|---------|-------------------------------------|------------------------|
| `/` | GET | Hauptseite (Timer) | HTML |
| `/settings` | GET | Einstellungen-Seite | HTML |
| `/about` | GET | Info-/About-Seite | HTML |
| `/` (static) | GET | Statische Dateien (CSS, Bilder, JS) | entspr. MIME-Type |
| Route | Method | Description | Response Type |
| --------------- | ------ | ---------------------- | ------------- |
| `/` | GET | Main page | HTML |
| `/settings` | GET | Settings page | HTML |
| `/rfid` | GET | RFID page | HTML |
| `/firmware.bin` | GET | Firmware file (SPIFFS) | Binary |
---
## API-Routen
## Timer & Data
### Timer & Daten
| Route | Methode | Beschreibung | Body/Parameter | Antwort (Content-Type) |
|-------------------|---------|-------------------------------------|------------------------|--------------------------------|
| `/api/data` | GET | Aktuelle Timerdaten und Status | | JSON |
**Beispiel-Response:**
```json
{
"time1": 12.34,
"status1": "running",
"time2": 0,
"status2": "ready",
"best1": 10.12,
"best2": 9.87,
"learningMode": false,
"learningButton": "Start Bahn 1"
}
```
| Route | Method | Description | Request Body/Params | Response Example |
| ----------------- | ------ | --------------------------------- | ------------------- | --------------------- |
| `/api/data` | GET | Get current timer and status data | | `{...}` |
| `/api/reset-best` | POST | Reset best times | | `{ "success": true }` |
---
### Bestzeiten
## Button Learning
| Route | Methode | Beschreibung | Body/Parameter | Antwort (Content-Type) |
|----------------------|---------|-------------------------------------|------------------------|--------------------------------|
| `/api/reset-best` | POST | Setzt Bestzeiten zurück | | `{ "success": true }` |
| Route | Method | Description | Request Body/Params | Response Example |
| --------------------- | ------ | --------------------------------- | ------------------- | ------------------------------------------------------- |
| `/api/unlearn-button` | POST | Remove all button assignments | | `{ "success": true }` |
| `/api/start-learning` | POST | Start button learning mode | | `{ "success": true }` |
| `/api/stop-learning` | POST | Stop button learning mode | | `{ "success": true }` |
| `/api/learn/status` | GET | Get learning mode status | | `{ "active": true, "step": 1 }` |
| `/api/buttons/status` | GET | Get button assignment and voltage | | `{ "lane1Start": true, "lane1StartVoltage": 3.3, ... }` |
---
### Button-Lernmodus
## Settings
| Route | Methode | Beschreibung | Body/Parameter | Antwort (Content-Type) |
|--------------------------|---------|-------------------------------------|------------------------|--------------------------------|
| `/api/start-learning` | POST | Startet Lernmodus | | `{ "success": true }` |
| `/api/stop-learning` | POST | Beendet Lernmodus | | `{ "success": true }` |
| `/api/learn/status` | GET | Status des Lernmodus | | `{ "active": true, "step": 1 }`|
| `/api/unlearn-button` | POST | Löscht alle Button-Zuordnungen | | `{ "success": true }` |
| `/api/buttons/status` | GET | Status der Button-Zuordnung | | JSON (siehe unten) |
**Beispiel-Response für `/api/buttons/status`:**
```json
{
"lane1Start": true,
"lane1Stop": false,
"lane2Start": true,
"lane2Stop": false
}
```
| Route | Method | Description | Request Body/Params | Response Example |
| ------------------- | ------ | ------------------------------ | --------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `/api/set-max-time` | POST | Set max timer and display time | `maxTime`, `maxTimeDisplay`, `minTimeForLeaderboard` (form params, seconds) | `{ "success": true }` |
| `/api/get-settings` | GET | Get current timer settings | | `{ "maxTime": 300, "maxTimeDisplay": 20, "minTimeForLeaderboard": 5 }` |
---
### Einstellungen
## WiFi Configuration
| Route | Methode | Beschreibung | Body/Parameter | Antwort (Content-Type) |
|------------------------|---------|-------------------------------------|------------------------|--------------------------------|
| `/api/set-max-time` | POST | Setzt max. Laufzeit & max. Anzeigezeit | `maxTime` (Sekunden, optional), `maxTimeDisplay` (Sekunden, optional) als Form-Parameter | `{ "success": true }` oder `{ "success": false }` |
| `/api/get-settings` | GET | Liefert aktuelle Einstellungen | | `{ "maxTime": 300, "maxTimeDisplay": 20 }` |
| Route | Method | Description | Request Body/Params | Response Example |
| --------------- | ------ | ---------------------------------- | -------------------------------- | -------------------------------------- |
| `/api/set-wifi` | POST | Set WiFi SSID and password | `ssid`, `password` (form params) | `{ "success": true }` |
| `/api/get-wifi` | GET | Get current WiFi SSID and password | | `{ "ssid": "...", "password": "..." }` |
---
### Systeminfo
## Location Configuration
| Route | Methode | Beschreibung | Antwort (Content-Type) |
|-------------------|---------|-------------------------------------|--------------------------------|
| `/api/info` | GET | Systeminfos (IP, MAC, Speicher, Lizenz, verbundene Buttons) | JSON (siehe unten) |
**Beispiel-Response:**
```json
{
"ip": "192.168.4.1",
"channel": 1,
"mac": "AA:BB:CC:DD:EE:FF",
"freeMemory": 123456,
"connectedButtons": 3,
"valid": "Ja",
"tier": 1
}
```
| Route | Method | Description | Request Body/Params | Response Example |
| ------------------- | ------ | ------------------------ | -------------------------- | ------------------------- |
| `/api/set-location` | POST | Set location name and ID | `id`, `name` (form params) | `{ "success": true }` |
| `/api/get-location` | GET | Get current location | | `{ "locationid": "..." }` |
---
## Hinweise
## Button Update & Mode
- **Alle API-Routen liefern JSON zurück.**
- **POST-Requests erwarten ggf. Form-Parameter (kein JSON-Body).**
- **Statische Seiten und Assets werden direkt ausgeliefert.**
- **Kein Authentifizierungsverfahren implementiert.**
| Route | Method | Description | Request Body/Params | Response Example |
| -------------------- | ------ | ------------------------------- | ------------------------------------------------ | -------------------------- |
| `/api/updateButtons` | GET | Trigger MQTT update for buttons | | `{ "success": true }` |
| `/api/set-mode` | POST | Set operational mode | `mode` (form param: "individual" or "wettkampf") | `{ "success": true }` |
| `/api/get-mode` | GET | Get current operational mode | | `{ "mode": "individual" }` |
---
---
## System Info
| Route | Method | Description | Request Body/Params | Response Example |
| ----------- | ------ | ------------------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/api/info` | GET | Get system info (IP, MAC, memory, license, etc.) | | `{ "ip": "...", "ipSTA": "...", "channel": 1, "mac": "...", "freeMemory": 123456, "connectedButtons": 3, "isOnline": true, "valid": "Ja", "tier": 1 }` |
---
## WebSocket
| Route | Description |
| ----- | ----------------------------------- |
| `/ws` | WebSocket endpoint for live updates |
---
**All API endpoints return JSON unless otherwise noted. POST requests expect form parameters (not JSON body).**

Binary file not shown.

View File

@@ -0,0 +1,674 @@
<!DOCTYPE html>
<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>
<head>
<meta charset="utf-8">
<title>NinjaCross Timer - Bedienungsanleitung</title>
<!--[if gte mso 9]>
<xml>
<w:WordDocument>
<w:View>Print</w:View>
<w:Zoom>90</w:Zoom>
<w:DoNotOptimizeForBrowser/>
</w:WordDocument>
</xml>
<![endif]-->
<style>
body { font-family: Arial, sans-serif; }
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
h2 { color: #34495e; margin-top: 30px; }
h3 { color: #555; }
ul { line-height: 1.8; }
ol { line-height: 1.8; }
code { background-color: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
.warning { background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 15px 0; }
.info { background-color: #d1ecf1; border-left: 4px solid #0dcaf0; padding: 15px; margin: 15px 0; }
.success { background-color: #d4edda; border-left: 4px solid #28a745; padding: 15px; margin: 15px 0; }
table { border-collapse: collapse; width: 100%; margin: 15px 0; }
table th, table td { border: 1px solid #ddd; padding: 12px; text-align: left; }
table th { background-color: #3498db; color: white; }
table tr:nth-child(even) { background-color: #f2f2f2; }
</style>
</head>
<body>
<h1>NinjaCross Timer - Bedienungsanleitung</h1>
<div class="info">
<p><strong>Version:</strong> 1.0</p>
<p><strong>Hersteller:</strong> AquaMaster MQTT</p>
<p><strong>Datum:</strong> 2024</p>
</div>
<h2>1. Einleitung</h2>
<p>Der NinjaCross Timer ist ein professionelles Zeitmessgerät für Ninjacross-Wettkämpfe. Das System ermöglicht die präzise Zeitmessung für bis zu zwei Bahnen gleichzeitig und bietet zahlreiche Features wie RFID-Erkennung, lokales Leaderboard und Internet-Konnektivität über WiFi und MQTT.</p>
<h2>2. Systemübersicht</h2>
<h3>2.1 Komponenten</h3>
<ul>
<li><strong>ESP32 Master</strong>: Hauptprozessor mit Web-Interface</li>
<li><strong>4 Wireless-Buttons</strong>: Start/Stop Buttons für 2 Bahnen</li>
<li><strong>RFID-Reader</strong>: Optional - für Nutzeridentifikation</li>
<li><strong>Internet-Verbindung</strong>: Über WiFi für Cloud-Synchronisation</li>
</ul>
<h3>2.2 Anzeigen und Status</h3>
<table>
<tr>
<th>Komponente</th>
<th>Beschreibung</th>
</tr>
<tr>
<td>Heartbeat-Indikatoren</td>
<td>4 grüne/rote Punkte zeigen die Verbindung der Buttons an (Start1, Stop1, Start2, Stop2)</td>
</tr>
<tr>
<td>Timer-Anzeige</td>
<td>Live-Zeit für beide Bahnen</td>
</tr>
<tr>
<td>Status-Anzeige</td>
<td>Bereit, Läuft, Geschafft, Standby</td>
</tr>
<tr>
<td>Leaderboard</td>
<td>Top 6 Zeiten lokal gespeichert</td>
</tr>
<tr>
<td>Batterie-Warnung</td>
<td>Banner bei niedriger Batterie der Buttons</td>
</tr>
</table>
<h2>3. Erste Inbetriebnahme</h2>
<h3>3.1 Einschalten und Netzwerkverbindung</h3>
<ol>
<li><strong>Einschalten</strong>: Master einschalten</li>
<li><strong>Access Point finden</strong>: Suchen Sie nach dem WiFi-Netzwerk mit dem Namen <code>NinjaCross-XXXXX</code> (die letzten Zeichen sind eindeutig für Ihr Gerät)</li>
<li><strong>Verbinden</strong>: Das Netzwerk ist standardmäßig ohne Passwort</li>
<li><strong>IP-Adresse</strong>: Das Gerät hat die feste IP <code>192.168.10.1</code></li>
<li><strong>Alternative</strong>: Sie können auch <code>ninjacross.local</code> im Browser verwenden (mDNS)</li>
</ol>
<div class="warning">
<p><strong>Wichtig:</strong> Der Access Point benötigt kein Passwort.</p>
</div>
<h3>3.2 Web-Interface öffnen</h3>
<p>Öffnen Sie Ihren Webbrowser und geben Sie eine der folgenden Adressen ein:</p>
<ul>
<li><code>http://192.168.10.1</code> (direkte IP)</li>
<li><code>http://ninjacross.local</code> (falls mDNS unterstützt wird)</li>
</ul>
<h2>4. Hauptoberfläche</h2>
<h3>4.1 Timer-Ansicht</h3>
<p>Die Hauptseite zeigt:</p>
<ul>
<li><strong>Bahn 1</strong>: Links - Timer und Status</li>
<li><strong>Bahn 2</strong>: Rechts - Timer und Status</li>
<li><strong>Heartbeat-Indikatoren</strong>: Oben - Verbindungsstatus der Buttons</li>
<li><strong>Leaderboard</strong>: Unten - Top 6 lokale Zeiten</li>
<li><strong>Navigation</strong>:
<ul>
<li>🏆 = Leaderboard (Volansicht)</li>
<li>⚙️ = Einstellungen</li>
</ul>
</li>
</ul>
<h3>4.2 Timer-Bedienung</h3>
<ol>
<li><strong>Standby</strong>: "Drücke beide Buttons einmal" - Buttons initialisieren</li>
<li><strong>Bereit</strong>: Beide Buttons sind verbunden (grüne Heartbeats)</li>
<li><strong>Armiert</strong>: Startbutton gedrückt - Timer startet bei freigegebenem Button</li>
<li><strong>Läuft</strong>: Timer läuft - Zeit wird live angezeigt</li>
<li><strong>Geschafft</strong>: Stop-Button gedrückt - Zeit wird gespeichert</li>
</ol>
<div class="info">
<p><strong>Tipp:</strong> Die Anzeige blendet automatisch die Schwimmer-Namen ein, wenn sie via RFID erkannt werden.</p>
</div>
<h2>5. Button-Konfiguration</h2>
<h3>5.1 Anlernmodus</h3>
<p>Der erste Schritt ist das Anlernen Ihrer Wireless-Buttons:</p>
<ol>
<li>Öffnen Sie die <strong>Einstellungen</strong> (⚙️)</li>
<li>Scrollen Sie zu <strong>"Button-Konfiguration"</strong></li>
<li>Klicken Sie auf <strong>"🎯 Anlernmodus starten"</strong></li>
<li>Folgen Sie den Anweisungen:
<ol>
<li>Drücken Sie den Button für <strong>Bahn 1 Start</strong></li>
<li>Drücken Sie den Button für <strong>Bahn 1 Stop</strong></li>
<li>Drücken Sie den Button für <strong>Bahn 2 Start</strong></li>
<li>Drücken Sie den Button für <strong>Bahn 2 Stop</strong></li>
</ol>
</li>
<li>Die Anzeige zeigt automatisch an, welchen Button Sie drücken müssen</li>
<li>Nach erfolgreicher Konfiguration erhalten Sie eine Bestätigung</li>
</ol>
<div class="success">
<p><strong>Erfolg:</strong> Nach dem Anlernen sollten alle 4 Heartbeat-Indikatoren grün leuchten.</p>
</div>
<h3>5.2 Buttons verlernen</h3>
<p>Um alle Button-Zuweisungen zu löschen:</p>
<ol>
<li>Einstellungen öffnen</li>
<li>"❌ Buttons verlernen" klicken</li>
<li>Bestätigung erfordert</li>
</ol>
<h3>5.3 Button-Status anzeigen</h3>
<p>Klicken Sie auf <strong>"📊 Button-Status anzeigen"</strong> um zu sehen:</p>
<ul>
<li>Welche Buttons konfiguriert sind</li>
<li>Batteriestand jedes Buttons in Prozent</li>
</ul>
<h2>6. RFID-Benutzerverwaltung</h2>
<h3>6.1 RFID-Karte registrieren</h3>
<p>Die RFID-Funktion ermöglicht die automatische Zuordnung von Zeiten zu Nutzern:</p>
<ol>
<li>Öffnen Sie <strong>"RFID"</strong> (🏷️) aus dem Einstellungsmenü</li>
<li>Klicken Sie auf <strong>"📡 Read Chip"</strong></li>
<li>Halten Sie die RFID-Karte an den Reader des Masters</li>
<li>Die UID wird automatisch eingefügt</li>
<li>Geben Sie den <strong>Namen</strong> ein</li>
<li>Klicken Sie auf <strong>"💾 Speichern"</strong></li>
</ol>
<div class="info">
<p><strong>Funktionsweise:</strong> Beim nächsten Scannen der RFID-Karte an einem Button wird automatisch der Name angezeigt und die Zeit diesem Nutzer zugeordnet.</p>
</div>
<h3>6.2 Kontinuierliches Lesen</h3>
<p>Der "Read Chip" Button startet einen kontinuierlichen Lesemodus:</p>
<ul>
<li>Statusleiste zeigt: "RFID Lesen gestartet - Karte auflegen!"</li>
<li>Alle erkannten Karten werden automatisch übernommen</li>
<li>Nach erfolgreichem Lesen wird die Eingabe fokussiert</li>
</ul>
<h2>7. Einstellungen</h2>
<h3>7.1 Datum & Uhrzeit</h3>
<p>Die Uhrzeit kann manuell oder automatisch gesetzt werden:</p>
<ul>
<li><strong>Manuell</strong>: Datum und Uhrzeit eingeben, dann "🕐 Uhrzeit setzen"</li>
<li><strong>Automatisch</strong>: "💻 Browser-Zeit übernehmen" verwendet die Zeit Ihres Computers</li>
</ul>
<h3>7.2 Modus</h3>
<table>
<tr>
<th>Modus</th>
<th>Beschreibung</th>
</tr>
<tr>
<td>👤 Individual</td>
<td>Beide Bahnen arbeiten unabhängig - ideale für Training</td>
</tr>
<tr>
<td>🏆 Wettkampf</td>
<td>Beide Bahnen starten synchron - für Wettkämpfe</td>
</tr>
</table>
<h3>7.3 Lane-Konfiguration</h3>
<p>Die Bahnen können identisch oder unterschiedlich konfiguriert werden:</p>
<ul>
<li><strong>⚖️ Identische Lanes</strong>: Beide Bahnen sind gleich</li>
<li><strong>⚡ Unterschiedliche Lanes</strong>: Bahnen mit unterschiedlichen Schwierigkeiten
<ul>
<li>🟢 Leicht: Standard-Konfiguration</li>
<li>🔴 Schwer: Anspruchsvollere Hindernisse</li>
</ul>
</li>
</ul>
<h3>7.4 Grundeinstellungen</h3>
<table>
<tr>
<th>Einstellung</th>
<th>Standard</th>
<th>Beschreibung</th>
</tr>
<tr>
<td>Maximale Zeit</td>
<td>300 Sekunden</td>
<td>Nach dieser Zeit wird eine Bahn automatisch zurückgesetzt</td>
</tr>
<tr>
<td>Anzeigedauer</td>
<td>20 Sekunden</td>
<td>Wie lange die letzte Zeit angezeigt bleibt</td>
</tr>
<tr>
<td>Min. Zeit Leaderboard</td>
<td>5 Sekunden</td>
<td>Zeiten unter diesem Wert werden nicht gespeichert (Missbrauchsschutz)</td>
</tr>
</table>
<h3>7.5 WLAN-Konfiguration (Lizenz Level 3 erforderlich)</h3>
<div class="warning">
<p><strong>Wichtig:</strong> Um das System mit einem bestehenden WLAN zu verbinden wird eine Lizenz Level 3 oder höher.</p>
</div>
<p>Zur Konfiguration:</p>
<ol>
<li>WLAN Name (SSID) eingeben</li>
<li>WLAN Passwort eingeben</li>
<li>Aktueller STA IP-Status wird angezeigt</li>
<li>Nach dem Speichern startet das Gerät neu</li>
</ol>
<div class="info">
<p><strong>Dual-Mode:</strong> Das Gerät kann gleichzeitig Access Point (für direkte Verbindung) und WiFi Station (für Internet) betreiben.</p>
</div>
<h3>7.6 Standort (Lizenz Level 3 erforderlich)</h3>
<p>Wählen Sie Ihren Standort aus einem Dropdown-Menü:</p>
<ul>
<li>Beim Eingeben einer gültigen Lizenz werden verfügbare Standorte aus der API geladen</li>
<li>Ohne Lizenz werden Fallback-Standorte angezeigt</li>
<li>Der gewählte Standort wird lokal gespeichert</li>
</ul>
<h3>7.7 OTA Update (Lizenz Level 2 erforderlich)</h3>
<div class="warning">
<p><strong>Lizenz erforderlich:</strong> OTA-Updates benötigen Lizenz Level 2 oder höher.</p>
</div>
<ol>
<li>Klicken Sie auf <strong>"🔄 Update durchführen"</strong></li>
<li>Bestätigen Sie die Abfrage</li>
<li>Das Gerät lädt die neueste Firmware herunter und installiert sie automatisch</li>
<li>Während des Updates darf der Strom nicht unterbrochen werden!</li>
</ol>
<h3>7.8 Buttons Updaten</h3>
<p>Sendet eine Update-Nachricht über MQTT an alle konfigurierten Buttons:</p>
<ol>
<li>Klicken Sie auf <strong>"📡 Buttons Updaten"</strong></li>
<li>Die Buttons erhalten die aktuelle Konfiguration</li>
<li>Nutzen Sie dies nach Button-Wartung oder Konfigurationsänderungen</li>
</ol>
<h2>8. Leaderboard</h2>
<h3>8.1 Lokales Leaderboard</h3>
<p>Die Hauptseite zeigt die Top 6 Zeiten:</p>
<ul>
<li>🏆 Gold für Platz 1</li>
<li>🥈 Silber für Platz 2</li>
<li>🥉 Bronze für Platz 3</li>
<li>Platz 4-6 in Standard-Darstellung</li>
</ul>
<h3>8.2 Volle Leaderboard-Ansicht</h3>
<p>Öffnen Sie die Leaderboard-Seite (🏆):</p>
<ul>
<li>Zeigt alle erfassten Zeiten</li>
<li>Gruppiert in 2 Zeilen zu je 5 Einträgen</li>
<li>Wird alle 5 Sekunden automatisch aktualisiert</li>
</ul>
<h3>8.3 Beste Zeiten zurücksetzen</h3>
<p>Einstellungen → "🏆 Zeiten verwalten" → "🔄 Beste Zeiten zurücksetzen"</p>
<div class="warning">
<p><strong>Achtung:</strong> Diese Aktion kann nicht rückgängig gemacht werden!</p>
</div>
<h2>9. System-Information</h2>
<p>Die Einstellungsseite zeigt folgende Systemdaten:</p>
<table>
<tr>
<th>Information</th>
<th>Beschreibung</th>
</tr>
<tr>
<td>IP-Adresse</td>
<td>Access Point IP (meist 192.168.10.1)</td>
</tr>
<tr>
<td>Kanal</td>
<td>WiFi-Kanal</td>
</tr>
<tr>
<td>MAC-Adresse</td>
<td>Eindeutige Geräte-ID</td>
</tr>
<tr>
<td>Internet</td>
<td>Ja/Nein - Verbindung zum Internet</td>
</tr>
<tr>
<td>Freier Speicher</td>
<td>Verfügbarer RAM in Bytes</td>
</tr>
<tr>
<td>Verbundene Buttons</td>
<td>Anzahl konfigurierter Buttons (0-4)</td>
</tr>
<tr>
<td>Lizenz gültig</td>
<td>Status der Lizenz</td>
</tr>
<tr>
<td>Lizenz Level</td>
<td>0-3 - Bestimmt verfügbare Features</td>
</tr>
</table>
<h2>10. Lizenz-System</h2>
<h3>10.1 Lizenz-Level</h3>
<table>
<tr>
<th>Level</th>
<th>Features</th>
</tr>
<tr>
<td>0 (Basis)</td>
<td>Standard-Timer, lokales Leaderboard, RFID</td>
</tr>
<tr>
<td>1</td>
<td>Alle Level 0 Features</td>
</tr>
<tr>
<td>2</td>
<td>Level 1 + OTA Updates</td>
</tr>
<tr>
<td>3</td>
<td>Level 2 + WLAN-Station Mode, Standort-Konfiguration</td>
</tr>
</table>
<h3>10.2 Lizenz eingeben</h3>
<ol>
<li>Einstellungen → "🔧 Lizenz"</li>
<li>Lizenzschlüssel eingeben</li>
<li>"💾 Lizenz speichern" klicken</li>
<li>System-Information aktualisiert sich automatisch</li>
</ol>
<h2>11. Batterie-Überwachung</h2>
<p>Das System überwacht kontinuierlich die Batteriestände der Wireless-Buttons:</p>
<ul>
<li><strong>Warnung</strong>: Bei Batteriestand ≤ 15% erscheint ein Banner</li>
<li><strong>Anzeige</strong>: "⚠️ Niedrige Batterie erkannt!" mit Geräteliste</li>
<li><strong>Detailliert</strong>: Über Button-Status-Anzeige werden alle Batteriestände angezeigt</li>
</ul>
<div class="info">
<p><strong>Tipp:</strong> Der Banner blendet automatisch aus, sobald alle Batterien wieder über 15% sind.</p>
</div>
<h2>12. API & Technische Details</h2>
<h3>12.1 API-Endpunkte</h3>
<table>
<tr>
<th>Endpoint</th>
<th>Method</th>
<th>Funktion</th>
</tr>
<tr>
<td>/api/data</td>
<td>GET</td>
<td>Timer und Status abrufen</td>
</tr>
<tr>
<td>/api/reset-best</td>
<td>POST</td>
<td>Beste Zeiten zurücksetzen</td>
</tr>
<tr>
<td>/api/start-learning</td>
<td>POST</td>
<td>Anlernmodus starten</td>
</tr>
<tr>
<td>/api/learn/status</td>
<td>GET</td>
<td>Anlern-Status abrufen</td>
</tr>
<tr>
<td>/api/buttons/status</td>
<td>GET</td>
<td>Button-Konfiguration und Batterie</td>
</tr>
<tr>
<td>/api/set-max-time</td>
<td>POST</td>
<td>Timer-Einstellungen setzen</td>
</tr>
<tr>
<td>/api/get-settings</td>
<td>GET</td>
<td>Einstellungen abrufen</td>
</tr>
<tr>
<td>/api/set-wifi</td>
<td>POST</td>
<td>WiFi konfigurieren</td>
</tr>
<tr>
<td>/api/set-mode</td>
<td>POST</td>
<td>Modus setzen (Individual/Wettkampf)</td>
</tr>
<tr>
<td>/api/info</td>
<td>GET</td>
<td>System-Informationen</td>
</tr>
<tr>
<td>/ws</td>
<td>WebSocket</td>
<td>Live-Updates für Timer</td>
</tr>
</table>
<h3>12.2 WebSocket-Daten</h3>
<p>Der WebSocket liefert Echtzeit-Updates:</p>
<ul>
<li>Button-Status und Heartbeats</li>
<li>Timer-Daten (live)</li>
<li>RFID-Erkennung</li>
<li>Batterie-Status</li>
</ul>
<h2>13. Troubleshooting</h2>
<h3>13.1 Buttons verbinden sich nicht</h3>
<ul>
<li><strong>Heartbeat rot</strong>: Button außerhalb der Reichweite oder Batterie leer</li>
<li><strong>Lösung</strong>: Batterien prüfen, Button näher zum Master bringen</li>
<li><strong>Neu anlernen</strong>: Einstellungen → Buttons verlernen → Anlernmodus starten</li>
</ul>
<h3>13.2 WiFi-Verbindung funktioniert nicht</h3>
<ul>
<li>Standard: Nutzen Sie den Access Point <code>NinjaCross-XXXXX</code></li>
<li>Mit Lizenz Level 3: Konfigurieren Sie das WLAN in den Einstellungen</li>
<li>Falls Netzwerk nicht gefunden wird: Gerät neustarten</li>
</ul>
<h3>13.3 IP-Adresse unbekannt</h3>
<ul>
<li><code>192.168.10.1</code> ist die Standard IP</li>
<li>Alternative: <code>ninjacross.local</code></li>
<li>Router-Konfiguration: DHCP-Range darf 192.168.10.1 nicht blocken</li>
</ul>
<h3>13.4 Timer startet nicht</h3>
<ul>
<li>Prüfen Sie alle 4 Heartbeat-Indikatoren (müssen grün sein)</li>
<li>Start-Button muss vor dem Drücken des Stop-Buttons gedrückt werden</li>
<li>Bahn muss "Bereit" Status zeigen</li>
</ul>
<h3>13.5 RFID wird nicht erkannt</h3>
<ul>
<li>RFID-Lesemodus aktivieren: "📡 Read Chip" klicken</li>
<li>Karte langsam über den Reader führen</li>
<li>Neu versuchen wenn nach 5 Sekunden nichts passiert</li>
</ul>
<div class="warning">
<p><strong>Wichtig:</strong> Bei andauernden Problemen Gerät neustarten oder Support kontaktieren.</p>
</div>
<h2>14. Wartung</h2>
<h3>14.1 Regelmäßige Wartung</h3>
<ul>
<li><strong>Täglich</strong>: Batteriestände prüfen</li>
<li><strong>Wöchentlich</strong>: Leaderboard zurücksetzen (falls gewünscht)</li>
<li><strong>Monatlich</strong>: OTA Update prüfen</li>
<li><strong>Jährlich</strong>: Firmware aktualisieren</li>
</ul>
<h3>14.2 Firmware-Updates</h3>
<ol>
<li>Lizenz Level 2+ erforderlich</li>
<li>Einstellungen → OTA Update</li>
<li>Keine Unterbrechung während des Updates</li>
<li>Update dauert ca. 1-2 Minuten</li>
</ol>
<h2>15. Support & Kontakt</h2>
<p>Bei Fragen oder Problemen:</p>
<ul>
<li>Dokumentation prüfen</li>
<li>Troubleshooting-Abschnitt beachten</li>
<li>System-Informationen für Support bereitstellen</li>
</ul>
<div class="info">
<p><strong>Hinweis:</strong> Diese Anleitung basiert auf der aktuellen Firmware-Version. Neuere Versionen könnten abweichende Features haben.</p>
</div>
<h2>16. Anhang</h2>
<h3>16.1 Tastenkombinationen im Web-Interface</h3>
<ul>
<li><code>Enter</code> in UID-Feld: Sprung zum Namensfeld</li>
<li>Browser-Refresh: Aktualisiert alle Daten</li>
</ul>
<h3>16.2 Unterstützte Browser</h3>
<ul>
<li>Chrome/Edge (empfohlen)</li>
<li>Firefox</li>
<li>Safari</li>
<li>Mobile Browser (iOS/Android)</li>
</ul>
<h3>16.3 Technische Spezifikationen</h3>
<table>
<tr>
<th>Komponente</th>
<th>Spezifikation</th>
</tr>
<tr>
<td>ESP32 Version</td>
<td>ESP32-WROOM oder kompatibel</td>
</tr>
<tr>
<td>WiFi</td>
<td>2.4 GHz, WPA2</td>
</tr>
<tr>
<td>Protokoll</td>
<td>MQTT für Kommunikation</td>
</tr>
<tr>
<td>RFID</td>
<td>13.56 MHz, NFC-kompatibel</td>
</tr>
<tr>
<td>Timer-Genauigkeit</td>
<td>Millisekunden</td>
</tr>
</table>
<hr>
<p style="text-align: center; color: #888; margin-top: 50px;">
<strong>Ende der Bedienungsanleitung</strong><br>
NinjaCross Timer v1.0
</p>
</body>
</html>

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.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 The Gitea Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

245
README.md
View File

@@ -1,28 +1,233 @@
# Ninjacross Timer ⏱️
# Gitea MCP Server
Ein präziser, drahtloser Timer für Ninjacross- und Schwimmwettbewerbe. Entwickelt für Trainings- und Wettkampfumgebungen, bei denen Geschwindigkeit, Zuverlässigkeit und Benutzerfreundlichkeit entscheidend sind.
[繁體中文](README.zh-tw.md) | [简体中文](README.zh-cn.md)
## 🔧 Funktionen
**Gitea MCP Server** is an integration plugin designed to connect Gitea with Model Context Protocol (MCP) systems. This allows for seamless command execution and repository management through an MCP-compatible chat interface.
- **0.1 s Genauigkeit** bei der Zeitmessung
- **Drahtlose Kommunikation** über ESP-NOW oder Wi-Fi Mesh
- **Mehrere Timer-Zonen** (z.B. Start/Stop für zwei Bahnen)
- **Visualisierung in Echtzeit** auf einem zentralen Raspberry Pi Dashboard
- **Großanzeige** per 7-Segment-Display oder Browseranzeige
- **Einfache Bedienung** über robuste Hardware-Taster
- **Erweiterbar** für mehrere Bahnen und Disziplinen
[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=gitea&inputs=[{%22id%22:%22gitea_token%22,%22type%22:%22promptString%22,%22description%22:%22Gitea%20Personal%20Access%20Token%22,%22password%22:true}]&config={%22command%22:%22docker%22,%22args%22:[%22run%22,%22-i%22,%22--rm%22,%22-e%22,%22GITEA_ACCESS_TOKEN%22,%22docker.gitea.com/gitea-mcp-server%22],%22env%22:{%22GITEA_ACCESS_TOKEN%22:%22${input:gitea_token}%22}}&quality=insiders)
## 🛠️ Hardware-Komponenten
## Table of Contents
- ESP32 Mikrocontroller (pro Button oder Sensor ein Gerät)
- ESP32 Master mit MQTT Broker (zentrale Steuerung und Webserver)
- Taster oder Lichtschranken
- Optional: 7-Segment-Displays oder HDMI-Display
- Stabile WLAN-Verbindung (z.B. Wi-Fi Mesh)
- [Gitea MCP Server](#gitea-mcp-server)
- [Table of Contents](#table-of-contents)
- [What is Gitea?](#what-is-gitea)
- [What is MCP?](#what-is-mcp)
- [🚧 Installation](#-installation)
- [Usage with VS Code](#usage-with-vs-code)
- [📥 Download the official binary release](#-download-the-official-binary-release)
- [🔧 Build from Source](#-build-from-source)
- [📁 Add to PATH](#-add-to-path)
- [🚀 Usage](#-usage)
- [✅ Available Tools](#-available-tools)
- [🐛 Debugging](#-debugging)
- [🛠 Troubleshooting](#-troubleshooting)
## 📦 Aufbau
## What is Gitea?
Gitea is a community-managed lightweight code hosting solution written in Go. It is published under the MIT license. Gitea provides Git hosting including a repository viewer, issue tracking, pull requests, and more.
## What is MCP?
Model Context Protocol (MCP) is a protocol that allows for the integration of various tools and systems through a chat interface. It enables seamless command execution and management of repositories, users, and other resources.
## 🚧 Installation
### Usage with VS Code
For quick installation, use one of the one-click install buttons at the top of this README.
For manual installation, add the following JSON block to your User Settings (JSON) file in VS Code. You can do this by pressing `Ctrl + Shift + P` and typing `Preferences: Open User Settings (JSON)`.
Optionally, you can add it to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others.
> Note that the `mcp` key is not needed in the `.vscode/mcp.json` file.
```json
{
"mcp": {
"inputs": [
{
"type": "promptString",
"id": "gitea_token",
"description": "Gitea Personal Access Token",
"password": true
}
],
"servers": {
"gitea-mcp": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITEA_ACCESS_TOKEN",
"docker.gitea.com/gitea-mcp-server"
],
"env": {
"GITEA_ACCESS_TOKEN": "${input:gitea_token}"
}
}
}
}
}
```
### 📥 Download the official binary release
You can download the official release from [official Gitea MCP binary releases](https://gitea.com/gitea/gitea-mcp/releases).
### 🔧 Build from Source
You can download the source code by cloning the repository using Git:
```bash
git clone https://gitea.com/gitea/gitea-mcp.git
```
Before building, make sure you have the following installed:
- make
- Golang (Go 1.24 or later recommended)
Then run:
```bash
make install
```
### 📁 Add to PATH
After installing, copy the binary gitea-mcp to a directory included in your system's PATH. For example:
```bash
cp gitea-mcp /usr/local/bin/
```
## 🚀 Usage
This example is for Cursor, you can also use plugins in VSCode.
To configure the MCP server for Gitea, add the following to your MCP configuration file:
- **stdio mode**
```json
{
"mcpServers": {
"gitea": {
"command": "gitea-mcp",
"args": [
"-t",
"stdio",
"--host",
"https://gitea.com"
// "--token", "<your personal access token>"
],
"env": {
// "GITEA_HOST": "https://gitea.com",
// "GITEA_INSECURE": "true",
"GITEA_ACCESS_TOKEN": "<your personal access token>"
}
}
}
}
```
- **sse mode**
```json
{
"mcpServers": {
"gitea": {
"url": "http://localhost:8080/sse"
}
}
}
```
- **http mode**
```json
{
"mcpServers": {
"gitea": {
"url": "http://localhost:8080/mcp"
}
}
}
```
**Default log path**: `$HOME/.gitea-mcp/gitea-mcp.log`
> [!NOTE]
> You can provide your Gitea host and access token either as command-line arguments or environment variables.
> Command-line arguments have the highest priority
Once everything is set up, try typing the following in your MCP-compatible chatbox:
```text
[ESP32-Startbutton] --\
---> WLAN --> [ESP32 Master] --> [Browseranzeige / Display]
[ESP32-Stopbutton ] --/
list all my repositories
```
## ✅ Available Tools
The Gitea MCP Server supports the following tools:
| Tool | Scope | Description |
| :--------------------------: | :----------: | :------------------------------------------------------: |
| get_my_user_info | User | Get the information of the authenticated user |
| get_user_orgs | User | Get organizations associated with the authenticated user |
| create_repo | Repository | Create a new repository |
| fork_repo | Repository | Fork a repository |
| list_my_repos | Repository | List all repositories owned by the authenticated user |
| create_branch | Branch | Create a new branch |
| delete_branch | Branch | Delete a branch |
| list_branches | Branch | List all branches in a repository |
| create_release | Release | Create a new release in a repository |
| delete_release | Release | Delete a release from a repository |
| get_release | Release | Get a release |
| get_latest_release | Release | Get the latest release in a repository |
| list_releases | Release | List all releases in a repository |
| create_tag | Tag | Create a new tag |
| delete_tag | Tag | Delete a tag |
| get_tag | Tag | Get a tag |
| list_tags | Tag | List all tags in a repository |
| list_repo_commits | Commit | List all commits in a repository |
| get_file_content | File | Get the content and metadata of a file |
| get_dir_content | File | Get a list of entries in a directory |
| create_file | File | Create a new file |
| update_file | File | Update an existing file |
| delete_file | File | Delete a file |
| get_issue_by_index | Issue | Get an issue by its index |
| list_repo_issues | Issue | List all issues in a repository |
| create_issue | Issue | Create a new issue |
| create_issue_comment | Issue | Create a comment on an issue |
| edit_issue | Issue | Edit a issue |
| edit_issue_comment | Issue | Edit a comment on an issue |
| get_issue_comments_by_index | Issue | Get comments of an issue by its index |
| get_pull_request_by_index | Pull Request | Get a pull request by its index |
| list_repo_pull_requests | Pull Request | List all pull requests in a repository |
| create_pull_request | Pull Request | Create a new pull request |
| search_users | User | Search for users |
| search_org_teams | Organization | Search for teams in an organization |
| search_repos | Repository | Search for repositories |
| get_gitea_mcp_server_version | Server | Get the version of the Gitea MCP Server |
## 🐛 Debugging
To enable debug mode, add the `-d` flag when running the Gitea MCP Server with sse mode:
```sh
./gitea-mcp -t sse [--port 8080] --token <your personal access token> -d
```
## 🛠 Troubleshooting
If you encounter any issues, here are some common troubleshooting steps:
1. **Check your PATH**: Ensure that the `gitea-mcp` binary is in a directory included in your system's PATH.
2. **Verify dependencies**: Make sure you have all the required dependencies installed, such as `make` and `Golang`.
3. **Review configuration**: Double-check your MCP configuration file for any errors or missing information.
4. **Consult logs**: Check the logs for any error messages or warnings that can provide more information about the issue.
Enjoy exploring and managing your Gitea repositories via chat!

78
THIRD_PARTY_LICENSES.md Normal file
View File

@@ -0,0 +1,78 @@
## Third-Party Licenses and Notices
This project uses thirdparty libraries. Below is a summary of their licenses and the key obligations when you distribute firmware/binaries that include them.
### Summary of obligations
- Keep original copyright and license notices.
- Include this file (or equivalent notices) with any distribution.
- For LGPLlicensed components (ESPAsyncWebServer, AsyncTCP):
- You are not required to publish your entire application.
- If you distribute binaries, you must provide a way for users to relink the application with a modified version of the LGPL library (e.g., provide object files of your application or the full source code), publish any changes you made to the LGPL libs, and include the LGPL license text.
- PrettyOTA has a custom permissive license with attribution and no rebranding without a commercial license.
---
### Dependency overview
| Library | Version (as configured) | License | Project | License Text |
|---|---|---|---|---|
| ArduinoJson | ^7.4.1 | MIT | [bblanchon/ArduinoJson](https://github.com/bblanchon/ArduinoJson) | [LICENSE](https://github.com/bblanchon/ArduinoJson/blob/v7.4.1/LICENSE.md) |
| ESPAsyncWebServer (esp32async) | ^3.7.7 | LGPL3.0 | [esp32async/ESPAsyncWebServer](https://github.com/esp32async/ESPAsyncWebServer) | [LICENSE](https://github.com/esp32async/ESPAsyncWebServer/blob/master/LICENSE) |
| AsyncTCP (esp32async) | ^3.4.2 | LGPL3.0 | [esp32async/AsyncTCP](https://github.com/esp32async/AsyncTCP) | [LICENSE](https://github.com/esp32async/AsyncTCP/blob/master/LICENSE) |
| PicoMQTT | ^1.3.0 | MIT | [mlesniew/PicoMQTT](https://github.com/mlesniew/PicoMQTT) | [LICENSE](https://github.com/mlesniew/PicoMQTT/blob/master/LICENSE) |
| MFRC522 | ^1.4.12 | MIT | [miguelbalboa/rfid](https://github.com/miguelbalboa/rfid) | [LICENSE](https://github.com/miguelbalboa/rfid/blob/master/LICENSE) |
| RTClib | ^2.1.4 | MIT | [adafruit/RTClib](https://github.com/adafruit/RTClib) | [LICENSE](https://github.com/adafruit/RTClib/blob/master/license.txt) |
| PrettyOTA (vendored) | included | Custom (see below) | Included in `lib/PrettyOTA` | See “PrettyOTA License” below |
---
### Notes on LGPL3.0 components (ESPAsyncWebServer, AsyncTCP)
If you distribute firmware that statically links these libraries (typical on microcontrollers):
- Provide a method for relinking your application with a modified version of the LGPL library. Practically, either:
- Provide object files (.o/.a) of your application so users can relink against a modified LGPL lib, or
- Publish your full application source code (voluntary but simpler), or
- Replace these libraries with permissive alternatives to avoid LGPL obligations.
- Publish any changes you made to the LGPL libraries themselves.
- Include the full text of the LGPL3.0 license with your distribution.
For many teams, the simplest path is to provide application object files or to switch to a permissively licensed HTTP/WebServer.
---
### PrettyOTA License (full text)
The PrettyOTA component is included under the following license. This license imposes attribution requirements and restricts removing/replacing the name/logo without a separate commercial license.
```
# License
Copyright (c) 2025 Marc Schöndorf
Permission is granted to anyone to use this software for private and commercial applications, to alter it and redistribute it, subject to the following conditions:
1. The origin of this software must not be misrepresented. You must not
claim that you wrote the original software. If you use this Software in a product, acknowledgment in the product documentation or credits is required.
2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software.
3. You are not permitted to modify, replace or remove the name "PrettyOTA" or the original logo displayed within the Software's default user interface (if applicable), unless you have obtained a separate commercial license granting you such rights. This restriction applies even when redistributing modified versions of the source code.
4. This license notice must not be removed or altered from any source code distribution.
**Disclaimer:**
The software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and non-infringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software or the use or other dealings in the software.
## Commercial Licensing
A separate commercial license is required for specific rights not granted herein, particularly for white-labeling or rebranding (using a different name or logo). Please refer to the README file or contact the copyright holder for details on obtaining a commercial license.
```
---
### Recommended distribution checklist
- Include this `THIRD_PARTY_LICENSES.md` with your firmware or product documentation.
- Include the full LGPL3.0 license text if you distribute firmware using ESPAsyncWebServer/AsyncTCP.
- Provide either application object files for relinking or publish source (to satisfy LGPL).
- Keep all copyright and license notices intact in UI/docs where applicable (e.g., PrettyOTA attribution).

View File

@@ -15,3 +15,6 @@ v2.0
- ADD option point for location (read from online table and select the location via dropdown) DONE
- ADD option to enter a name, age DONE
- ADD upload to a Online Database () DONE
- Redo Database Backend -> New SQL Server and deploy backend to edge functions?! Maybe host evrythin myself in a VM!

View File

@@ -9,7 +9,7 @@ POST /api/unlearn-button
→ Verlernt alle Button-Zuordnungen
POST /api/set-max-time
→ Setzt die maximale Zeit und maxTimeDisplay
→ Setzt die maximale Zeit, maxTimeDisplay und minTimeForLeaderboard
GET /api/get-settings
→ Gibt die aktuellen Einstellungen zurück

Binary file not shown.

View File

@@ -11,8 +11,8 @@ html {
}
body {
font-family: "Arial", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
font-family: "Segoe UI", Arial, sans-serif;
background: linear-gradient(0deg, #0d1733 0%, #223c83 100%);
height: 100vh;
width: 100vw;
display: flex;
@@ -27,10 +27,12 @@ body {
.logo {
position: fixed;
top: 20px;
/* Vertikal zentriert im 60px-Header-Bereich (top:20px, height:60px → Mitte 50px) */
top: 50px;
left: 20px;
width: auto;
height: auto;
transform: translateY(-50%);
z-index: 1000;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
@@ -38,12 +40,12 @@ body {
text-decoration: none;
display: block;
cursor: pointer;
padding-left: 5px;
padding-right: 5px;
padding: 5px;
background:rgba(255, 255, 255, 0.6);
}
.logo:hover {
transform: scale(1.1);
transform: translateY(-50%) scale(1.1);
}
.logo img {
@@ -53,6 +55,32 @@ body {
border-radius: 10px;
}
.leaderboard-btn {
position: fixed;
top: 20px;
right: 90px;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 15px;
border-radius: 50%;
text-decoration: none;
font-size: 1.5rem;
transition: all 0.3s ease;
z-index: 1000;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.leaderboard-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: scale(1.1);
}
.settings-btn {
position: fixed;
top: 20px;
@@ -79,25 +107,114 @@ body {
transform: scale(1.1);
}
.heartbeat-indicators {
.live-clock {
position: fixed;
top: 20px;
right: 90px;
left: 25%;
transform: translateX(-50%);
height: 60px;
min-width: 150px;
display: flex;
gap: 15px;
align-items: center;
justify-content: center;
padding: 0 24px;
z-index: 1000;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 25px;
padding: 10px 20px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 30px;
font-family: "Consolas", "Menlo", "Courier New", monospace;
font-size: 1.6rem;
font-weight: 600;
letter-spacing: 2px;
color: rgba(255, 255, 255, 0.95);
font-variant-numeric: tabular-nums;
}
.heartbeat-indicators {
position: fixed;
top: 20px;
right: 160px;
height: 60px;
display: flex;
align-items: flex-end;
gap: 18px;
z-index: 1000;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 30px;
padding: 0 24px 10px 24px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
@media (max-width: 768px) {
.logo {
width: 40px;
height: 40px;
/* Mobile: Header-Band top:15px height:60px → Mitte 45px */
top: 45px;
left: 15px;
padding: 3px;
}
.leaderboard-btn {
top: 15px;
right: 60px;
padding: 10px;
font-size: 1.2rem;
}
.settings-btn {
top: 15px;
right: 15px;
padding: 10px;
font-size: 1.2rem;
}
.live-clock {
top: 15px;
height: 40px;
min-width: 100px;
padding: 0 14px;
font-size: 1rem;
letter-spacing: 1px;
border-radius: 20px;
}
.heartbeat-indicators {
top: 15px;
right: 90px;
height: 60px;
gap: 12px;
padding: 0 16px 10px 16px;
font-size: 0.8rem;
border-radius: 30px;
}
.heartbeat-indicator {
width: 18px;
height: 18px;
}
.heartbeat-indicator::before {
font-size: 8px;
top: -14px;
}
.header h1 {
font-size: clamp(1.2rem, 3vw, 1.8rem);
}
.header p {
font-size: clamp(0.7rem, 1.5vw, 0.9rem);
}
}
.heartbeat-indicator {
width: 20px;
height: 20px;
width: 26px;
height: 26px;
border-radius: 50%;
background: #e74c3c;
background: #f50f0f;
transition: all 0.3s ease;
position: relative;
}
@@ -105,7 +222,7 @@ body {
.heartbeat-indicator::before {
content: attr(data-label);
position: absolute;
top: -25px;
top: -18px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
@@ -115,8 +232,8 @@ body {
}
.heartbeat-indicator.active {
background: #2ecc71;
box-shadow: 0 0 10px rgba(46, 204, 113, 0.5);
background: #00ff15;
box-shadow: 0 0 10px rgba(73, 186, 228, 0.5);
}
/* Batterie-Banner Styling */
@@ -125,7 +242,7 @@ body {
top: -100px;
left: 0;
width: 100%;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
background: linear-gradient(135deg, #f59d0f 0%, #e67e22 100%);
color: white;
padding: 15px 20px;
text-align: center;
@@ -261,6 +378,9 @@ body {
font-size: clamp(1.8rem, 4vw, 2.5rem);
margin-bottom: 0.5vh;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.header p {
@@ -297,15 +417,20 @@ body {
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
justify-content: center;
justify-content: flex-start;
height: 100%;
overflow: hidden;
position: relative;
}
.lane h2 {
font-size: clamp(1.2rem, 2.5vw, 1.8rem);
margin-bottom: clamp(10px, 1vh, 15px);
color: #fff;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
flex-shrink: 0;
}
.swimmer-name {
@@ -338,37 +463,84 @@ body {
}
.time-display {
font-size: clamp(3rem, 9vw, 10rem);
font-size: clamp(3rem, 13vw, 13rem);
font-weight: bold;
margin: clamp(10px, 1vh, 15px) 0;
font-family: "Courier New", monospace;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
line-height: 1;
position: relative;
z-index: 1;
flex-shrink: 0;
order: 1;
}
.status {
font-size: clamp(3rem, 1.8vw, 1.2rem);
font-size: clamp(1.5rem, 4vw, 5rem);
margin: clamp(8px, 1vh, 12px) 0;
padding: clamp(6px, 1vh, 10px) clamp(12px, 2vw, 18px);
border-radius: 20px;
display: inline-block;
font-weight: 600;
position: relative;
z-index: 2;
}
.status.ready {
background-color: rgba(52, 152, 219, 0.3);
border: 2px solid #3498db;
.status:not(.large-status) {
position: relative;
order: 2;
margin-top: auto;
}
.status.running {
background-color: rgba(46, 204, 113, 0.3);
border: 2px solid #2ecc71;
animation: pulse 1s infinite;
.status.large-status {
font-size: clamp(1.8rem, 5vw, 5rem);
position: absolute;
left: 50%;
transform: translateX(-50%);
z-index: 10;
margin: 0 !important;
padding: clamp(8px, 1.5vh, 15px) clamp(15px, 3vw, 30px);
white-space: normal;
pointer-events: none;
text-align: center;
background-color: rgba(0, 0, 0, 0.85) !important;
backdrop-filter: blur(5px);
width: calc(100% - 40px);
max-width: calc(100% - 40px);
word-wrap: break-word;
line-height: 1.3;
overflow: visible;
}
.status.large-status.ready {
display: flex !important;
align-items: center !important;
justify-content: center !important;
white-space: nowrap;
line-height: 1;
padding: 8px 12px !important;
overflow: hidden;
}
.status.finished {
background-color: rgba(231, 76, 60, 0.3);
border: 2px solid #e74c3c;
background-color: rgba(73, 186, 228, 0.3);
border: 2px solid #49bae4;
}
.status.ready {
background-color: rgb(0 165 3 / 54%);
border: 2px solid #06ff00;
}
.status.armed {
background-color: rgba(245, 157, 15, 0.3);
border: 2px solid #f59d0f;
animation: pulse 1s infinite;
}
.status.running {
background-color: rgb(255 91 0 / 65%);
border: 2px solid #f59d0f;
}
@keyframes pulse {
@@ -384,8 +556,8 @@ body {
}
.status.standby {
background-color: rgba(255, 193, 7, 0.3);
border: 2px solid #ffc107;
background-color: rgba(220, 242, 250, 0.3);
border: 2px solid #DCF2FA;
animation: standbyBlink 2s infinite;
}
@@ -410,23 +582,60 @@ body {
}
}
.leaderboards-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: clamp(15px, 2vw, 30px);
width: 100%;
max-width: 100%;
padding: 0 2vw;
margin-top: 0.5vh;
box-sizing: border-box;
flex-shrink: 0;
}
@media (max-width: 768px) {
.leaderboards-row {
grid-template-columns: 1fr;
gap: clamp(15px, 3vw, 30px);
padding: 0 15px;
}
}
.best-times {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: clamp(10px, 1.5vh, 15px);
margin: 1vh 0 0 0;
width: 50%;
max-width: 50%;
border-radius: 12px;
padding: clamp(6px, 1vh, 10px);
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(4px, 0.8vh, 8px);
box-sizing: border-box;
min-width: 0;
}
.best-times--full {
grid-column: 1 / -1;
}
.leaderboard-list {
text-align: left;
display: flex;
flex-direction: column;
gap: clamp(4px, 0.8vh, 8px);
width: 100%;
}
.best-times h3 {
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
margin-bottom: clamp(5px, 0.5vh, 8px);
font-size: clamp(0.7rem, 1.2vw, 0.85rem);
margin: 0 auto;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
text-align: center;
}
.best-time-row {
@@ -440,9 +649,209 @@ body {
border-radius: 8px;
}
/* Leaderboard Styles */
.leaderboard-entry {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0;
font-size: clamp(0.7rem, 1.1vw, 0.9rem);
font-weight: 600;
background: rgba(255, 255, 255, 0.15);
padding: clamp(4px, 0.8vh, 7px) clamp(8px, 1.2vw, 12px);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
min-height: 0;
width: 100%;
box-sizing: border-box;
gap: 8px;
}
.leaderboard-entry:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
}
.leaderboard-entry.latest {
border: 2px solid #00ff88;
animation: latest-pulse 1.6s ease-in-out infinite;
position: relative;
z-index: 1;
}
.leaderboard-entry.latest .name {
color: #ffffff;
font-weight: 800;
text-shadow: 0 0 8px rgba(0, 255, 136, 0.7);
}
.leaderboard-entry.latest .time {
color: #ffffff;
animation: latest-time-flash 1.6s ease-in-out infinite;
}
.latest-badge {
display: inline-block;
background: #00ff88;
color: #0d1733;
font-weight: 900;
font-size: clamp(0.6rem, 1vw, 0.85rem);
letter-spacing: 1px;
padding: 3px 8px;
border-radius: 5px;
flex-shrink: 0;
text-transform: uppercase;
animation: latest-badge-pulse 1.6s ease-in-out infinite;
}
@keyframes latest-pulse {
0%,
100% {
background: linear-gradient(
135deg,
rgba(0, 255, 136, 0.28) 0%,
rgba(0, 200, 110, 0.18) 100%
);
box-shadow: 0 0 8px rgba(0, 255, 136, 0.35),
inset 0 0 6px rgba(0, 255, 136, 0.18);
border-color: #00ff88;
}
50% {
background: linear-gradient(
135deg,
rgba(0, 255, 136, 0.5) 0%,
rgba(0, 230, 120, 0.32) 100%
);
box-shadow: 0 0 16px rgba(0, 255, 136, 0.6),
0 0 32px rgba(0, 255, 136, 0.3),
inset 0 0 10px rgba(255, 255, 255, 0.25);
border-color: #ffffff;
}
}
@keyframes latest-badge-pulse {
0%,
100% {
background: #00ff88;
color: #0d1733;
box-shadow: 0 0 5px rgba(0, 255, 136, 0.5);
}
50% {
background: #ffffff;
color: #006a3a;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.7),
0 0 16px rgba(0, 255, 136, 0.55);
}
}
@keyframes latest-time-flash {
0%,
100% {
text-shadow: 0 0 6px rgba(0, 255, 136, 0.55);
}
50% {
text-shadow: 0 0 8px #ffffff, 0 0 14px rgba(0, 255, 136, 0.7);
}
}
.leaderboard-entry .rank {
color: #ffd700;
font-weight: bold;
min-width: 20px;
font-size: clamp(0.75rem, 1.2vw, 0.95rem);
flex-shrink: 0;
}
.leaderboard-entry .name {
flex: 1;
margin: 0;
color: #ffffff;
font-weight: 500;
font-size: clamp(0.7rem, 1.1vw, 0.9rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.leaderboard-entry .time {
color: #00ff88;
font-weight: bold;
font-family: 'Courier New', monospace;
min-width: 70px;
text-align: right;
font-size: clamp(1rem, 1.8vw, 1.3rem);
flex-shrink: 0;
}
.leaderboard-entry.gold {
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
border-color: #ffd700;
color: #b8860b;
font-weight: bold;
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3);
}
.leaderboard-entry.gold .rank {
color: #7a4d00;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
}
.leaderboard-entry.gold .time {
color: #0f5132;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
}
.leaderboard-entry.silver {
background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%);
border-color: #c0c0c0;
color: #696969;
font-weight: bold;
box-shadow: 0 4px 15px rgba(192, 192, 192, 0.3);
}
.leaderboard-entry.silver .rank {
color: #4b5563;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
}
.leaderboard-entry.silver .time {
color: #0f5132;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
}
.leaderboard-entry.bronze {
background: linear-gradient(135deg, #cd7f32 0%, #e6a85c 100%);
border-color: #cd7f32;
color: #8b4513;
font-weight: bold;
box-shadow: 0 4px 15px rgba(205, 127, 50, 0.3);
}
.leaderboard-entry.bronze .rank {
color: #7a3410;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
}
.leaderboard-entry.bronze .time {
color: #0f5132;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
}
.no-times {
text-align: center;
color: rgba(255, 255, 255, 0.7);
font-style: italic;
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
padding: 20px;
}
.learning-mode {
background: rgba(255, 193, 7, 0.2);
border: 2px solid #ffc107;
background: rgba(245, 157, 15, 0.2);
border: 2px solid #f59d0f;
border-radius: 15px;
padding: clamp(15px, 2vh, 20px);
margin: 2vh 0;
@@ -457,9 +866,12 @@ body {
}
.learning-mode h3 {
color: #ffc107;
color: #f59d0f;
margin-bottom: 10px;
font-size: clamp(1rem, 2vw, 1.3rem);
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.learning-mode p {

View File

@@ -15,14 +15,17 @@
<div>
<div class="banner-text">⚠️ Niedrige Batterie erkannt!</div>
<div class="banner-devices" id="battery-devices">
Geräte mit niedriger Batterie: <span id="low-battery-list"></span>
Deine Geräte mit niedriger Batterie:
<span id="low-battery-list"></span>
</div>
</div>
</div>
<button class="close-btn" onclick="closeBatteryBanner()">&times;</button>
</div>
<img src="/pictures/logo.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="/settings" class="settings-btn">⚙️</a>
<div class="heartbeat-indicators">
@@ -42,45 +45,41 @@
<div class="header">
<h1>🏊‍♀️ NinjaCross Timer</h1>
<p>Professioneller Zeitmesser für Ninjacross Wettkämpfe</p>
</div>
<div id="learning-display" class="learning-mode" style="display: none">
<h3>📚 Lernmodus aktiv</h3>
<p>
Bitte drücken Sie den Button für: <span id="learning-button"></span>
</p>
<p>Drücke jetzt den Button für: <span id="learning-button"></span></p>
</div>
<div class="timer-container">
<div class="lane">
<div id="name1" class="swimmer-name" style="display: none"></div>
<h2>🏊‍♀️ Bahn 1</h2>
<div id="time1" class="time-display">00.00</div>
<div id="status1" class="status standby">
Standby: Bitte beide 1x betätigen
Standby: Drücke beide Buttons einmal
</div>
<div id="time1" class="time-display">00.00</div>
</div>
<div class="lane">
<div id="name2" class="swimmer-name" style="display: none"></div>
<h2>🏊‍♂️ Bahn 2</h2>
<div id="time2" class="time-display">00.00</div>
<div id="status2" class="status standby">
Standby: Bitte beide 1x betätigen
Standby: Drücke beide Buttons einmal
</div>
<div id="time2" class="time-display">00.00</div>
</div>
</div>
<div class="best-times">
<h3>🏆 Beste Zeiten des Tages</h3>
<div class="best-time-row">
<span>Bahn 1:</span>
<span id="best1">--.-</span>
<div class="leaderboards-row">
<div class="best-times" id="best-times-1">
<h3 id="lb-title-1">🏊‍♀️ Bahn 1 — Letzte Zeiten</h3>
<div id="leaderboard-container-1" class="leaderboard-list"></div>
</div>
<div class="best-time-row">
<span>Bahn 2:</span>
<span id="best2">--.-</span>
<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>
@@ -97,6 +96,12 @@
let learningButton = "";
let name1 = "";
let name2 = "";
let leaderboardData = null;
// Lane Configuration
let laneConfigType = 0; // 0=Identical, 1=Different
let lane1DifficultyType = 0; // 0=Light, 1=Heavy
let lane2DifficultyType = 0; // 0=Light, 1=Heavy
// Batterie-Banner State
let lowBatteryDevices = new Set();
@@ -163,6 +168,12 @@
document.getElementById(indicatorId).classList.remove("active");
}
}
// Hinweis: Heartbeats und echte Tastendrücke kommen im WebSocket
// identisch als {button, mac, active: true} an. Eine optimistische
// Status-Übernahme (z. B. running→finished bei stop1) führte daher
// zu kurzem „Geschafft!"-Aufblitzen während des Laufs, sobald der
// Stop-Button einen periodischen Heartbeat sendete. Der Status
// kommt jetzt ausschließlich über syncFromBackend (1 s-Polling).
}
try {
@@ -188,24 +199,18 @@
}
// Namen-Handling
if (
(data.firstname == "" || data.lastname == "") &&
data.lane == "start1"
) {
if ((data.name == "" || !data.name) && data.lane == "start1") {
name1 = "";
}
if (
(data.firstname == "" || data.lastname == "") &&
data.lane == "start2"
) {
if ((data.name == "" || !data.name) && data.lane == "start2") {
name2 = "";
}
if (data.firstname && data.lastname && data.lane) {
if (data.name && data.lane) {
if (data.lane === "start1") {
name1 = `${data.firstname} ${data.lastname}`;
name1 = data.name;
} else if (data.lane === "start2") {
name2 = `${data.firstname} ${data.lastname}`;
name2 = data.name;
}
updateDisplay();
}
@@ -324,21 +329,372 @@
function getButtonDisplayName(button) {
switch (button) {
case "start1":
return "Start Bahn 1";
return "Start Button Bahn 1";
case "stop1":
return "Stop Bahn 1";
return "Stop Button Bahn 1";
case "start2":
return "Start Bahn 2";
return "Start Button Bahn 2";
case "stop2":
return "Stop Bahn 2";
return "Stop Button Bahn 2";
default:
return button;
}
}
// Passt "Bereit" so an, dass es die Status-Box maximal ausfüllt.
// Nutzt echte DOM-Messung via unsichtbarem Span → berechnet den
// Skalierungsfaktor und setzt font-size pixelgenau.
const fitReadyCache = { 1: { w: 0, h: 0, fs: 0 }, 2: { w: 0, h: 0, fs: 0 } };
function fitReadyText(statusEl, laneEl, laneNum) {
// Wir messen die Status-Box selbst — sie wurde vorher bereits
// positioniert (top/bottom/width gesetzt), hat also ihre finale Größe.
const sw = statusEl.clientWidth;
const sh = statusEl.clientHeight;
if (!sw || !sh) return;
const cache = fitReadyCache[laneNum];
if (cache.w === sw && cache.h === sh) {
statusEl.style.fontSize = cache.fs + "px";
return;
}
// Innenraum der Status-Box (nach Padding)
const cs = window.getComputedStyle(statusEl);
const pL = parseFloat(cs.paddingLeft) || 0;
const pR = parseFloat(cs.paddingRight) || 0;
const pT = parseFloat(cs.paddingTop) || 0;
const pB = parseFloat(cs.paddingBottom) || 0;
const availW = sw - pL - pR - 6;
const availH = sh - pT - pB - 6;
if (availW <= 0 || availH <= 0) return;
// Unsichtbarer Messspan im selben Font
let m = fitReadyText.m;
if (!m) {
m = document.createElement("span");
m.style.cssText =
"position:absolute;visibility:hidden;white-space:nowrap;" +
"left:-99999px;top:0;font-family:'Segoe UI',Arial,sans-serif;" +
"font-weight:600;line-height:1;padding:0;margin:0";
m.textContent = "Bereit";
document.body.appendChild(m);
fitReadyText.m = m;
}
const refSize = 200;
m.style.fontSize = refSize + "px";
const textW = m.offsetWidth || 1;
const textH = m.offsetHeight || 1;
// Skalierungsfaktor so wählen, dass Breite UND Höhe passen
const scale = Math.min(availW / textW, availH / textH);
const finalFs = Math.max(20, Math.floor(refSize * scale));
cache.w = sw;
cache.h = sh;
cache.fs = finalFs;
statusEl.style.fontSize = finalFs + "px";
}
// Passt die Timer-Zeit (Courier-Monospace) so an, dass sie den Platz
// zwischen h2 und Status maximal ausnutzt.
const fitTimeCache = {
1: { len: 0, lw: 0, lh: 0, fs: 0 },
2: { len: 0, lw: 0, lh: 0, fs: 0 },
};
function fitTimeText(timeEl, laneEl, laneNum) {
const text = timeEl.textContent;
const len = text.length;
const lw = laneEl.clientWidth;
const lh = laneEl.clientHeight;
if (!lw || !lh) return;
const cache = fitTimeCache[laneNum];
if (cache.len === len && cache.lw === lw && cache.lh === lh) {
timeEl.style.fontSize = cache.fs + "px";
return;
}
let m = fitTimeText.m;
if (!m) {
m = document.createElement("span");
m.style.cssText =
"position:absolute;visibility:hidden;white-space:nowrap;" +
"left:-99999px;top:0;font-family:'Courier New',monospace;" +
"font-weight:bold;line-height:1;padding:0;margin:0";
document.body.appendChild(m);
fitTimeText.m = m;
}
const refSize = 200;
m.style.fontSize = refSize + "px";
m.textContent = text;
const textW = m.offsetWidth || 1;
const textH = m.offsetHeight || 1;
// Aggressiv: 92% Breite, 62% Höhe (h2 oben + Status unten reserviert)
const availW = lw * 0.92;
const availH = lh * 0.62;
const scale = Math.min(availW / textW, availH / textH);
const fs = Math.max(30, Math.floor(refSize * scale));
cache.len = len;
cache.lw = lw;
cache.lh = lh;
cache.fs = fs;
timeEl.style.fontSize = fs + "px";
}
function formatTime(seconds) {
if (seconds === 0) return "00.00";
return seconds.toFixed(2);
const totalSeconds = Math.floor(seconds);
const minutes = Math.floor(totalSeconds / 60);
const remainingSeconds = totalSeconds % 60;
const milliseconds = Math.floor((seconds - totalSeconds) * 100);
// Zeige Minuten nur wenn über 60 Sekunden
if (totalSeconds >= 60) {
return `${minutes.toString().padStart(2, "0")}:${remainingSeconds
.toString()
.padStart(2, "0")}.${milliseconds.toString().padStart(2, "0")}`;
} else {
return `${remainingSeconds.toString().padStart(2, "0")}.${milliseconds
.toString()
.padStart(2, "0")}`;
}
}
// Leaderboard Funktionen
async function loadLeaderboard() {
try {
const response = await fetch("/api/leaderboard");
leaderboardData = await response.json();
updateLeaderboardDisplay();
} catch (error) {
console.error("Fehler beim Laden des Leaderboards:", error);
}
}
function formatEndTime(epochSeconds) {
if (!epochSeconds || epochSeconds < 1577836800) return ""; // < 2020 = kein NTP-Sync
const d = new Date(epochSeconds * 1000);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
function createEntryElement(entry, isLatest) {
const div = document.createElement("div");
div.className = "leaderboard-entry";
if (isLatest) div.classList.add("latest");
if (isLatest) {
const badge = document.createElement("span");
badge.className = "latest-badge";
badge.textContent = "NEU";
div.appendChild(badge);
}
const nameSpan = document.createElement("span");
nameSpan.className = "name";
let label = entry.name || "Unbekannt";
// Bei "Lauf N"-Einträgen die Endzeit in Klammern anhängen
if (/^Lauf\s+\d+$/.test(label)) {
const endTime = formatEndTime(entry.endEpoch);
if (endTime) label += ` (${endTime})`;
}
nameSpan.textContent = label;
const timeSpan = document.createElement("span");
timeSpan.className = "time";
timeSpan.textContent = entry.timeFormatted;
div.appendChild(nameSpan);
div.appendChild(timeSpan);
return div;
}
function fillLeaderboardContainer(container, entries) {
container.innerHTML = "";
if (!entries || entries.length === 0) {
const empty = document.createElement("div");
empty.className = "no-times";
empty.textContent = "Noch keine Zeiten";
container.appendChild(empty);
return;
}
entries.forEach((e, i) =>
container.appendChild(createEntryElement(e, i === 0))
);
}
// -------- 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;
}
// Reset Layout-Klassen
box1.classList.remove("best-times--full");
box2.style.display = "";
if (leaderboardData.mode === "different") {
// Unterschiedliche Lanes: eigene History pro Bahn unter jeder Lane
title1.textContent = "🏊‍♀️ Bahn 1 — Letzte Zeiten";
title2.textContent = "🏊‍♂️ Bahn 2 — Letzte Zeiten";
fillLeaderboardContainer(container1, leaderboardData.lane1);
fillLeaderboardContainer(container2, leaderboardData.lane2);
} else {
// Identische Lanes: ein gemeinsames Leaderboard über beide Spalten
title1.textContent = "🏆 Letzte Zeiten";
box1.classList.add("best-times--full");
box2.style.display = "none";
fillLeaderboardContainer(container1, leaderboardData.entries);
}
}
function updateDisplay() {
@@ -363,38 +719,213 @@
document.getElementById("time1").textContent = formatTime(display1);
const time1Element = document.getElementById("time1");
const lane1Element = time1Element.closest(".lane");
const h2_1 = lane1Element.querySelector("h2");
if (!lane1Connected) {
s1.className = "status standby";
s1.textContent = "Standby: Bitte beide Buttons 1x betätigen";
s1.className = "status standby large-status";
s1.textContent = "Standby: Drücke beide Buttons einmal";
time1Element.style.display = "none";
// Position über time-display, aber innerhalb des Containers
if (s1.classList.contains("large-status")) {
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const h2Bottom = h2Rect.bottom - lane1Rect.top;
const startTop = h2Bottom + 10;
s1.style.top = startTop + "px";
s1.style.left = "50%";
s1.style.transform = "translateX(-50%)";
s1.style.bottom = "20px";
s1.style.width = "calc(100% - 40px)";
s1.style.display = "flex";
s1.style.alignItems = "center";
s1.style.justifyContent = "center";
}
} else {
s1.className = `status ${status1}`;
s1.textContent =
status1 === "ready"
? "Bereit"
: status1 === "running"
? "Läuft..."
: "Beendet";
// Wenn status "ready" ist, verstecke Zeit und mache Status groß
if (status1 === "ready") {
s1.classList.add("large-status");
time1Element.style.display = "none";
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const h2Bottom = h2Rect.bottom - lane1Rect.top;
const startTop = h2Bottom + 10;
s1.style.top = startTop + "px";
s1.style.left = "50%";
s1.style.transform = "translateX(-50%)";
s1.style.bottom = "20px";
s1.style.width = "calc(100% - 40px)";
s1.style.display = "flex";
s1.style.alignItems = "center";
s1.style.justifyContent = "center";
fitReadyText(s1, lane1Element, 1);
} else {
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
time1Element.style.display = "";
if (status1 !== "running" && status1 !== "finished") {
s1.classList.add("large-status");
const time1Rect = time1Element.getBoundingClientRect();
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const time1Center = time1Rect.top - lane1Rect.top + time1Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane1Rect.top;
const startTop = h2Bottom + 10;
const statusHeight = s1.offsetHeight || 200;
const targetTop = Math.max(startTop, time1Center - statusHeight / 2);
s1.style.top = targetTop + "px";
s1.style.transform = "translateX(-50%)";
s1.style.height = "";
s1.style.width = "";
s1.style.display = "";
s1.style.alignItems = "";
s1.style.justifyContent = "";
s1.style.fontSize = "";
const maxHeight = lane1Rect.height - targetTop - 30;
s1.style.maxHeight = maxHeight + "px";
s1.style.overflow = "auto";
} else {
s1.classList.remove("large-status");
s1.style.top = "";
s1.style.transform = "";
s1.style.maxHeight = "";
s1.style.height = "";
s1.style.width = "";
s1.style.display = "";
s1.style.alignItems = "";
s1.style.justifyContent = "";
s1.style.fontSize = "";
s1.style.left = "";
s1.style.bottom = "";
fitTimeText(time1Element, lane1Element, 1);
}
}
switch (status1) {
case "ready":
s1.textContent = "Bereit";
break;
case "running":
s1.textContent = "Läuft - Gib alles!";
break;
case "finished":
s1.textContent = "Geschafft!";
break;
case "armed":
s1.textContent = "Bereit zum Start!";
break;
default:
s1.textContent = "Status unbekannt";
}
}
document.getElementById("time2").textContent = formatTime(display2);
const time2Element = document.getElementById("time2");
const lane2Element = time2Element.closest(".lane");
const h2_2 = lane2Element.querySelector("h2");
if (!lane2Connected) {
s2.className = "status standby";
s2.textContent = "Standby: Bitte beide 1x betätigen";
s2.className = "status standby large-status";
s2.textContent = "Standby: Drücke beide Buttons einmal";
time2Element.style.display = "none";
// Position über time-display, aber innerhalb des Containers
if (s2.classList.contains("large-status")) {
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
const h2Bottom = h2Rect.bottom - lane2Rect.top;
const startTop = h2Bottom + 10;
s2.style.top = startTop + "px";
s2.style.left = "50%";
s2.style.transform = "translateX(-50%)";
s2.style.bottom = "20px";
s2.style.width = "calc(100% - 40px)";
s2.style.display = "flex";
s2.style.alignItems = "center";
s2.style.justifyContent = "center";
}
} else {
s2.className = `status ${status2}`;
s2.textContent =
status2 === "ready"
? "Bereit"
: status2 === "running"
? "Läuft..."
: "Beendet";
// Wenn status "ready" ist, verstecke Zeit und mache Status groß
if (status2 === "ready") {
s2.classList.add("large-status");
time2Element.style.display = "none";
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
const h2Bottom = h2Rect.bottom - lane2Rect.top;
const startTop = h2Bottom + 10;
s2.style.top = startTop + "px";
s2.style.left = "50%";
s2.style.transform = "translateX(-50%)";
s2.style.bottom = "20px";
s2.style.width = "calc(100% - 40px)";
s2.style.display = "flex";
s2.style.alignItems = "center";
s2.style.justifyContent = "center";
fitReadyText(s2, lane2Element, 2);
} else {
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
time2Element.style.display = "";
if (status2 !== "running" && status2 !== "finished") {
s2.classList.add("large-status");
const time2Rect = time2Element.getBoundingClientRect();
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
const time2Center = time2Rect.top - lane2Rect.top + time2Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane2Rect.top;
const startTop = h2Bottom + 10;
const statusHeight = s2.offsetHeight || 200;
const targetTop = Math.max(startTop, time2Center - statusHeight / 2);
s2.style.top = targetTop + "px";
s2.style.transform = "translateX(-50%)";
s2.style.height = "";
s2.style.width = "";
s2.style.display = "";
s2.style.alignItems = "";
s2.style.justifyContent = "";
s2.style.fontSize = "";
const maxHeight = lane2Rect.height - targetTop - 30;
s2.style.maxHeight = maxHeight + "px";
s2.style.overflow = "auto";
} else {
s2.classList.remove("large-status");
s2.style.top = "";
s2.style.transform = "";
s2.style.maxHeight = "";
s2.style.height = "";
s2.style.width = "";
s2.style.display = "";
s2.style.alignItems = "";
s2.style.justifyContent = "";
s2.style.fontSize = "";
s2.style.left = "";
s2.style.bottom = "";
fitTimeText(time2Element, lane2Element, 2);
}
}
switch (status2) {
case "ready":
s2.textContent = "Bereit";
break;
case "running":
s2.textContent = "Läuft - Gib alles!";
break;
case "finished":
s2.textContent = "Geschafft!";
break;
case "armed":
s2.textContent = "Bereit zum Start!";
break;
default:
s2.textContent = "Status unbekannt";
}
}
document.getElementById("best1").textContent =
best1 > 0 ? formatTime(best1) + "s" : "--.-";
document.getElementById("best2").textContent =
best2 > 0 ? formatTime(best2) + "s" : "--.-";
// Leaderboard wird separat geladen
// Namen anzeigen/verstecken
const name1Element = document.getElementById("name1");
@@ -425,31 +956,117 @@
}
}
const validStatuses = ["ready", "running", "finished", "armed"];
function syncFromBackend() {
fetch("/api/data")
.then((response) => response.json())
.then((data) => {
timer1 = data.time1;
timer2 = data.time2;
status1 = data.status1;
status2 = data.status2;
// 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;
best2 = data.best2;
learningMode = data.learningMode;
learningButton = data.learningButton || "";
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) =>
console.error("Fehler beim Laden der Daten:", error)
console.error("Fehler beim Laden deiner Daten:", error)
);
}
function loadLaneConfig() {
fetch("/api/get-lane-config")
.then((response) => response.json())
.then((data) => {
laneConfigType = data.type === "different" ? 1 : 0;
lane1DifficultyType = data.lane1Difficulty === "heavy" ? 1 : 0;
lane2DifficultyType = data.lane2Difficulty === "heavy" ? 1 : 0;
updateLaneDisplay();
})
.catch((error) =>
console.error(
"Fehler beim Laden der Lane-Schwierigkeits-Konfiguration:",
error
)
);
}
function updateLaneDisplay() {
const lane1Title = document.querySelector(".lane h2");
const lane2Title = document.querySelectorAll(".lane h2")[1];
if (laneConfigType === 0) {
// Identische Lanes
lane1Title.textContent = "🏊‍♀️ Bahn 1";
lane2Title.textContent = "🏊‍♂️ Bahn 2";
} else {
// Unterschiedliche Lanes
const lane1Icon = lane1DifficultyType === 0 ? "🟢" : "🔴";
const lane2Icon = lane2DifficultyType === 0 ? "🟢" : "🔴";
const lane1Difficulty =
lane1DifficultyType === 0 ? "Leicht" : "Schwer";
const lane2Difficulty =
lane2DifficultyType === 0 ? "Leicht" : "Schwer";
lane1Title.textContent = `${lane1Icon} Bahn 1 (${lane1Difficulty})`;
lane2Title.textContent = `${lane2Icon} Bahn 2 (${lane2Difficulty})`;
}
}
// Sync with backend every 1 second
setInterval(syncFromBackend, 1000);
// Smooth update every 50ms
setInterval(updateDisplay, 50);
// Adaptive Update-Rate: 50 ms wenn mindestens eine Bahn läuft,
// sonst 500 ms. Über kickDisplayScheduler() kann der Zyklus sofort
// neu gestartet werden (WebSocket-Start-Event, frische Sync-Daten),
// damit beim Übergang Stand→Lauf nichts springt.
let displayTimer = null;
function scheduleDisplayUpdate() {
updateDisplay();
const anyRunning = status1 === "running" || status2 === "running";
displayTimer = setTimeout(scheduleDisplayUpdate, anyRunning ? 50 : 500);
}
function kickDisplayScheduler() {
if (displayTimer !== null) {
clearTimeout(displayTimer);
displayTimer = null;
}
scheduleDisplayUpdate();
}
scheduleDisplayUpdate();
// Heartbeat timeout check (every second)
setInterval(() => {
@@ -466,8 +1083,33 @@
});
}, 1000);
window.addEventListener("resize", () => {
fitReadyCache[1].w = 0;
fitReadyCache[2].w = 0;
fitTimeCache[1].lw = 0;
fitTimeCache[2].lw = 0;
updateDisplay();
});
// Initial load
syncFromBackend();
loadLaneConfig();
loadLeaderboard();
// Leaderboard alle 5 Sekunden aktualisieren
setInterval(loadLeaderboard, 5000);
// Live-Uhr im Header (HH:mm:ss, Browser-Lokalzeit)
function updateLiveClock() {
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const ss = String(now.getSeconds()).padStart(2, "0");
const el = document.getElementById("live-clock");
if (el) el.textContent = `${hh}:${mm}:${ss}`;
}
updateLiveClock();
setInterval(updateLiveClock, 1000);
</script>
</body>
</html>

367
data/leaderboard.css Normal file
View File

@@ -0,0 +1,367 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Arial, sans-serif;
background: linear-gradient(0deg, #0d1733 0%, #223c83 100%);
min-height: 100vh;
padding: 20px;
}
.back-btn {
position: fixed;
top: 20px;
left: 20px;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 15px;
border-radius: 50%;
text-decoration: none;
font-size: 1.5rem;
transition: all 0.3s ease;
z-index: 1000;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: scale(1.1);
}
.container {
max-width: 800px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: visible;
backdrop-filter: blur(10px);
}
.header {
background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white;
padding: 30px;
text-align: center;
position: relative;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
position: relative;
z-index: 1;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.content {
padding: 30px;
}
.leaderboard-container {
background: white;
border-radius: 12px;
padding: 20px;
border: 2px solid #e9ecef;
min-height: 150px;
max-height: none;
overflow: visible;
}
.leaderboard-row {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.leaderboard-row:last-child {
margin-bottom: 0;
}
@media (min-width: 768px) {
.leaderboard-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
align-items: start;
grid-auto-rows: min-content;
}
.leaderboard-row {
margin-bottom: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
}
.leaderboard-entry {
display: flex;
justify-content: space-between;
align-items: center;
margin: 15px 0;
font-size: 1.1em;
font-weight: 600;
background: #f8f9fa;
padding: 15px 20px;
border-radius: 10px;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
}
.leaderboard-entry:hover {
background: #e9ecef;
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.leaderboard-entry.gold {
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
border-color: #ffd700;
color: #b8860b;
font-weight: bold;
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3);
}
.leaderboard-entry.silver {
background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%);
border-color: #c0c0c0;
color: #696969;
font-weight: bold;
box-shadow: 0 4px 15px rgba(192, 192, 192, 0.3);
}
.leaderboard-entry.bronze {
background: linear-gradient(135deg, #cd7f32 0%, #e6a85c 100%);
border-color: #cd7f32;
color: #8b4513;
font-weight: bold;
box-shadow: 0 4px 15px rgba(205, 127, 50, 0.3);
}
.leaderboard-entry .rank {
font-weight: bold;
min-width: 40px;
font-size: 1.2em;
text-align: center;
}
.leaderboard-entry .name {
flex: 1;
margin: 0 20px;
font-weight: 600;
}
.leaderboard-entry .time {
font-weight: bold;
font-family: 'Courier New', monospace;
min-width: 100px;
text-align: right;
font-size: 1.1em;
}
.no-entries {
text-align: center;
color: #6c757d;
font-style: italic;
font-size: 1.1em;
padding: 40px;
}
.loading {
text-align: center;
color: #49bae4;
font-size: 1.1em;
padding: 40px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
/* Modern Notification Toast */
.notification-toast {
position: fixed;
top: 24px;
right: 24px;
min-width: 320px;
max-width: 400px;
background: rgba(255, 255, 255, 0.98);
border-radius: 16px;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.05);
backdrop-filter: blur(20px);
z-index: 99999;
display: none;
align-items: flex-start;
gap: 12px;
padding: 16px;
transform: translateX(100%);
opacity: 0;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: auto;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.notification-toast.show {
transform: translateX(0);
opacity: 1;
}
.notification-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #10b981, #059669);
}
.notification-body {
flex: 1;
min-width: 0;
}
.notification-title {
font-size: 14px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
line-height: 1.2;
}
.notification-message {
font-size: 13px;
color: #6b7280;
line-height: 1.4;
word-wrap: break-word;
}
.notification-close {
flex-shrink: 0;
width: 32px;
height: 32px;
border: none;
background: none;
color: #9ca3af;
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
margin-top: -4px;
margin-right: -4px;
}
.notification-close:hover {
background: rgba(0, 0, 0, 0.05);
color: #374151;
}
.notification-close:active {
transform: scale(0.95);
}
/* Toast Types */
.notification-toast.success .notification-icon {
background: linear-gradient(135deg, #10b981, #059669);
}
.notification-toast.error .notification-icon {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
.notification-toast.info .notification-icon {
background: linear-gradient(135deg, #3b82f6, #2563eb);
}
.notification-toast.warning .notification-icon {
background: linear-gradient(135deg, #f59e0b, #d97706);
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.container {
margin: 10px;
border-radius: 15px;
}
.content {
padding: 20px;
}
.leaderboard-entry {
flex-direction: column;
gap: 10px;
text-align: center;
}
.leaderboard-entry .name {
margin: 0;
order: 1;
}
.leaderboard-entry .rank {
order: 2;
}
.leaderboard-entry .time {
order: 3;
}
/* Mobile notification adjustments */
.notification-toast {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
font-size: 14px;
padding: 12px 16px;
}
}
@media (max-width: 480px) {
.header h1 {
font-size: 2em;
}
.leaderboard-entry {
padding: 12px 15px;
font-size: 1em;
}
.leaderboard-entry .rank {
font-size: 1.1em;
}
.leaderboard-entry .time {
font-size: 1em;
}
}

227
data/leaderboard.html Normal file
View File

@@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="de">
<head>
<!-- Meta Tags -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico" />
<!-- Stylesheets -->
<link rel="stylesheet" href="leaderboard.css" />
<title>Ninjacross Timer - Leaderboard</title>
</head>
<body>
<!-- Modern Notification Toast -->
<div
id="notificationBubble"
class="notification-toast"
style="display: none"
>
<div class="notification-icon">
<span id="notificationIcon"></span>
</div>
<div class="notification-body">
<div class="notification-title" id="notificationTitle">Erfolg</div>
<div class="notification-message" id="notificationText">Bereit</div>
</div>
<button class="notification-close" onclick="hideNotification()">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"
/>
</svg>
</button>
</div>
<!-- Zurück Button -->
<a href="/" class="back-btn">🏠</a>
<div class="container">
<!-- Header Section -->
<div class="header">
<h1>🏆 Leaderboard</h1>
</div>
<div class="content">
<!-- Leaderboard Section -->
<div id="leaderboard-container" class="leaderboard-container">
<div class="loading">Lade Leaderboard...</div>
</div>
</div>
</div>
<!-- JavaScript Code -->
<script>
let leaderboardData = [];
let lastUpdateTime = null;
// Seite laden
window.onload = function () {
loadLeaderboard();
// Leaderboard alle 5 Sekunden aktualisieren
setInterval(loadLeaderboard, 5000);
};
// Leaderboard laden
async function loadLeaderboard() {
try {
const response = await fetch("/api/leaderboard-full");
const data = await response.json();
leaderboardData = data.leaderboard || [];
lastUpdateTime = new Date();
updateLeaderboardDisplay();
} catch (error) {
console.error("Fehler beim Laden des Leaderboards:", error);
showMessage("Fehler beim Laden des Leaderboards", "error");
}
}
// Leaderboard anzeigen
function updateLeaderboardDisplay() {
const container = document.getElementById("leaderboard-container");
container.innerHTML = "";
if (leaderboardData.length === 0) {
container.innerHTML =
'<div class="no-entries">Noch keine Zeiten erfasst</div>';
return;
}
// Alle Einträge anzeigen
const displayData = leaderboardData;
// Erstelle zwei Reihen
const row1 = document.createElement("div");
row1.className = "leaderboard-row";
const row2 = document.createElement("div");
row2.className = "leaderboard-row";
displayData.forEach((entry, index) => {
const entryDiv = document.createElement("div");
entryDiv.className = "leaderboard-entry";
// Podium-Plätze hervorheben
if (index === 0) {
entryDiv.classList.add("gold");
} else if (index === 1) {
entryDiv.classList.add("silver");
} else if (index === 2) {
entryDiv.classList.add("bronze");
}
const rankSpan = document.createElement("span");
rankSpan.className = "rank";
rankSpan.textContent = entry.rank + ".";
const nameSpan = document.createElement("span");
nameSpan.className = "name";
nameSpan.textContent = entry.name;
const timeSpan = document.createElement("span");
timeSpan.className = "time";
timeSpan.textContent = entry.timeFormatted;
entryDiv.appendChild(rankSpan);
entryDiv.appendChild(nameSpan);
entryDiv.appendChild(timeSpan);
// Erste 5 Einträge in die erste Reihe, nächste 5 in die zweite Reihe
if (index < 5) {
row1.appendChild(entryDiv);
} else {
row2.appendChild(entryDiv);
}
});
container.appendChild(row1);
if (displayData.length > 5) {
container.appendChild(row2);
}
}
// Moderne Notification anzeigen
function showMessage(message, type = "info") {
console.log("showMessage called:", message, type);
const toast = document.getElementById("notificationBubble");
const icon = document.getElementById("notificationIcon");
const title = document.getElementById("notificationTitle");
const text = document.getElementById("notificationText");
if (!toast || !icon || !title || !text) {
console.error("Notification elements not found!");
return;
}
// Clear any existing timeout
if (window.notificationTimeout) {
clearTimeout(window.notificationTimeout);
}
// Set content
text.textContent = message;
// Set type-specific styling and content
toast.className = "notification-toast";
switch (type) {
case "success":
toast.classList.add("success");
icon.textContent = "✓";
title.textContent = "Erfolg";
break;
case "error":
toast.classList.add("error");
icon.textContent = "✕";
title.textContent = "Fehler";
break;
case "info":
toast.classList.add("info");
icon.textContent = "";
title.textContent = "Information";
break;
case "warning":
toast.classList.add("warning");
icon.textContent = "⚠";
title.textContent = "Warnung";
break;
default:
toast.classList.add("info");
icon.textContent = "";
title.textContent = "Information";
}
// Show toast with animation
toast.style.display = "flex";
// Force reflow
toast.offsetHeight;
// Add show class after a small delay to ensure display is set
setTimeout(() => {
toast.classList.add("show");
}, 10);
// Auto-hide after 5 seconds
window.notificationTimeout = setTimeout(() => {
hideNotification();
}, 5000);
}
// Notification verstecken mit Animation
function hideNotification() {
const toast = document.getElementById("notificationBubble");
if (!toast) return;
// Clear timeout if exists
if (window.notificationTimeout) {
clearTimeout(window.notificationTimeout);
}
// Remove show class for animation
toast.classList.remove("show");
// Hide after animation completes
setTimeout(() => {
toast.style.display = "none";
}, 400); // Match CSS transition duration
}
</script>
</body>
</html>

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

23
data/pictures/logo.svg Normal file
View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Logo" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 132.7 76.6">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<g id="Schriftzug">
<g id="Schriftzug1">
<path d="M13.3.7l10.2,19.9h0V.7h6.7v35.8h-5.3L14,15.6h0v20.9h-6.6V.7h5.9,0Z" fill="#223c83"/>
<path d="M42.7,36.4h-6.9V.6h6.9v35.8Z" fill="#223c83"/>
<path d="M54,.7l10.2,19.9h0V.7h6.7v35.8h-5.3l-10.9-20.9h0v20.9h-6.6V.7h5.9,0Z" fill="#223c83"/>
<path d="M80.5,36.4c-4.6,0-6.2-2.7-6.2-6v-7l6.7-1.3v8.2h5.8V.5h7v29.8c0,3.3-1.5,6-6.2,6h-7.2l.1.1Z" fill="#223c83"/>
<path d="M113.2,29.3h-8.8l-1.8,7.1h-6.6L105.8.6h6.3l10,35.8h-7.3l-1.8-7.1h.2ZM110,15.9l-1-4.7h0l-1.1,4.7-1.8,7.5h5.8l-1.8-7.5h-.1Z" fill="#223c83"/>
<path d="M0,46.6c0-3.3,1.5-6,6.2-6h9.5c4.6,0,6.1,2.7,6.1,6v5l-6.7,1.4v-6.4H7v23.8h8.1v-7.3l6.7,1.3v6.1c0,3.3-1.5,6-6.1,6H6.2c-4.6,0-6.2-2.7-6.2-6v-23.9Z" fill="#223c83"/>
<path d="M41.6,40.7c4.6,0,6.2,2.7,6.2,6v10c0,3.3-1.5,5.8-5.8,5.9l7.7,14h-8l-7.2-14h-1.4v14h-6.9v-35.8h15.5l-.1-.1ZM33,56.4h6.2c.9,0,1.6-.8,1.6-1.7v-6.5c0-.9-.8-1.7-1.6-1.7h-6.2v9.9Z" fill="#223c83"/>
<path d="M75.9,70.5c0,3.3-1.5,6-6.1,6h-11.5c-4.6,0-6.2-2.7-6.2-6v-23.9c0-3.3,1.5-6,6.2-6h11.5c4.6,0,6.1,2.7,6.1,6v23.9ZM69,46.7h-9.9v23.8h9.9v-23.8Z" fill="#223c83"/>
<path d="M87.5,52.6c0,1.7.6,2.2,2.3,2.4l6,.2c4.5.2,6.1,2.7,6.1,6v9.3c0,3.3-1.5,6-6.1,6h-10c-4.5,0-6.1-2.7-6.1-6v-3.6l6.8-1.3v3.2c0,.9.8,1.7,1.6,1.7h5.3c.9,0,1.6-.8,1.6-1.7v-5c0-1.7-.5-2.2-2.3-2.2l-6-.3c-4.6-.2-6.2-2.6-6.2-6v-8.7c0-3.3,1.5-6,6.2-6h8.8c4.6,0,6.1,2.7,6.1,6v3.3l-6.8,1.4v-3c0-.9-.7-1.7-1.5-1.7h-4.3c-.9,0-1.6.8-1.6,1.7v4.3h.1Z" fill="#223c83"/>
<path d="M112.9,52.6c0,1.7.6,2.2,2.3,2.4l6,.2c4.5.2,6.1,2.7,6.1,6v9.3c0,3.3-1.5,6-6.1,6h-10c-4.5,0-6.1-2.7-6.1-6v-3.6l6.8-1.3v3.2c0,.9.8,1.7,1.6,1.7h5.3c.9,0,1.6-.8,1.6-1.7v-5c0-1.7-.5-2.2-2.3-2.2l-6-.3c-4.6-.2-6.2-2.6-6.2-6v-8.7c0-3.3,1.5-6,6.2-6h8.9c4.6,0,6.1,2.7,6.1,6v3.3l-6.8,1.4v-3c0-.9-.7-1.7-1.5-1.7h-4.3c-.9,0-1.6.8-1.6,1.7v4.3h0Z" fill="#223c83"/>
</g>
</g>
<g id="Registered_Symbol">
<g id="Registered_Symbol1">
<path d="M126.7,0c3.7,0,6,2.8,6,6.3s-2.3,6.3-6,6.3-6-2.8-6-6.3,2.2-6.3,6-6.3ZM126.7,11.3c2.8,0,4.4-2.2,4.4-5s-1.6-5-4.4-5-4.4,2.2-4.4,5,1.6,5,4.4,5ZM126.1,7.2v2.8h-1.8V2.5h2.5c2.2,0,2.7,1.1,2.7,2.4h0c0,.8-.3,1.5-1,1.9l1.2,3.1h-2l-.9-2.8h-.6l-.1.1ZM126.1,5.7h.6c.8,0,1-.3,1-.8h0c0-.5-.3-.9-1-.9h-.6v1.7h0Z" fill="#223c83"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
@@ -62,7 +62,7 @@
type="button"
id="readUidBtn"
class="read-uid-btn"
onclick="readRFIDUID()"
onclick="toggleRFIDReading()"
>
📡 Read Chip
</button>
@@ -70,47 +70,16 @@
</div>
<div class="form-group">
<label for="vorname">Vorname <span class="required">*</span></label>
<label for="name">Name <span class="required">*</span></label>
<input
type="text"
id="vorname"
name="vorname"
placeholder="Vorname eingeben"
id="name"
name="name"
placeholder="Name eingeben"
required
/>
</div>
<div class="form-group">
<label for="nachname">Nachname <span class="required">*</span></label>
<input
type="text"
id="nachname"
name="nachname"
placeholder="Nachname eingeben"
required
/>
</div>
<div class="form-group">
<label for="geburtsdatum"
>Geburtsdatum <span class="required">*</span></label
>
<div class="date-input-group">
<input
type="date"
id="geburtsdatum"
name="geburtsdatum"
required
max=""
/>
<div
id="ageDisplay"
class="age-display"
style="display: none"
></div>
</div>
</div>
<div class="btn-container">
<button type="submit" class="btn btn-primary">💾 Speichern</button>
<button type="button" class="btn btn-secondary" onclick="clearForm()">
@@ -124,60 +93,8 @@
// Globale Variablen
let rfidData = [];
let isLoading = false;
let DBUrl = "db.reptilfpv.de:3000";
var APIKey;
// Maximales Datum auf heute setzen
document.addEventListener("DOMContentLoaded", function () {
const today = new Date().toISOString().split("T")[0];
document.getElementById("geburtsdatum").setAttribute("max", today);
});
// Alter berechnen und anzeigen
function calculateAge(birthDate) {
const today = new Date();
const birth = new Date(birthDate);
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && today.getDate() < birth.getDate())
) {
age--;
}
return age;
}
// Geburtsdatum Change Event
document
.getElementById("geburtsdatum")
.addEventListener("change", function (e) {
const birthDate = e.target.value;
const ageDisplay = document.getElementById("ageDisplay");
if (birthDate) {
const age = calculateAge(birthDate);
if (age >= 0 && age <= 150) {
ageDisplay.textContent = `${age} Jahre`;
ageDisplay.style.display = "block";
} else {
ageDisplay.style.display = "none";
if (age < 0) {
showErrorMessage(
"Das Geburtsdatum kann nicht in der Zukunft liegen!",
);
e.target.value = "";
} else {
showErrorMessage("Bitte überprüfen Sie das Geburtsdatum!");
e.target.value = "";
}
}
} else {
ageDisplay.style.display = "none";
}
});
// Lokale Benutzer-Speicherung (geht bei Neustart verloren)
let localUsers = [];
// Form Submit Handler
document
@@ -189,45 +106,40 @@
// Daten aus dem Formular holen
const uid = document.getElementById("uid").value.trim();
const vorname = document.getElementById("vorname").value.trim();
const nachname = document.getElementById("nachname").value.trim();
const geburtsdatum = document.getElementById("geburtsdatum").value;
const name = document.getElementById("name").value.trim();
// Validierung
if (!uid || !vorname || !nachname || !geburtsdatum) {
if (!uid || !name) {
showErrorMessage("Bitte füllen Sie alle Pflichtfelder aus!");
return;
}
// Alter berechnen
const alter = calculateAge(geburtsdatum);
if (alter < 0) {
showErrorMessage(
"Das Geburtsdatum kann nicht in der Zukunft liegen!",
);
return;
}
// Loading State
setLoadingState(true);
try {
// API Aufruf zum Erstellen des Benutzers
// API Aufruf zum Erstellen des Benutzers (lokal)
const requestData = {
uid: uid,
name: name,
};
console.log("Sende Daten:", requestData);
console.log("JSON String:", JSON.stringify(requestData));
const response = await fetch(`/api/users/insert`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
uid: uid,
vorname: vorname,
nachname: nachname,
geburtsdatum: geburtsdatum,
alter: alter, // Berechnetes Alter wird mit gesendet
}),
body: JSON.stringify(requestData),
});
console.log("Response Status:", response.status);
console.log("Response Headers:", response.headers);
const result = await response.json();
console.log("Response Result:", result);
if (result.success) {
// Erfolg anzeigen
@@ -243,13 +155,13 @@
} else {
// Fehler anzeigen
showErrorMessage(
result.error || "Fehler beim Speichern der Daten",
result.error || "Fehler beim Speichern der Daten"
);
}
} catch (error) {
console.error("Fehler beim Speichern:", error);
showErrorMessage(
"Verbindungsfehler zum Server. Bitte versuchen Sie es später erneut.",
"Verbindungsfehler zum Server. Bitte versuchen Sie es später erneut."
);
} finally {
setLoadingState(false);
@@ -312,7 +224,6 @@
function clearForm() {
document.getElementById("rfidForm").reset();
document.getElementById("ageDisplay").style.display = "none";
document.getElementById("uid").focus();
}
@@ -320,14 +231,13 @@
window.addEventListener("load", function () {
document.getElementById("uid").focus();
checkServerStatus();
loadLicence();
});
// Enter-Taste in UID Feld zum nächsten Feld springen
document.getElementById("uid").addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
document.getElementById("vorname").focus();
document.getElementById("name").focus();
}
});
@@ -339,18 +249,159 @@
e.target.value = value;
});
// RFID UID lesen
let rfidReadingMode = false;
let statusInterval = null;
// Toggle RFID Reading Mode
async function toggleRFIDReading() {
const readBtn = document.getElementById("readUidBtn");
try {
const response = await fetch(`/api/rfid/toggle`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (result.success) {
rfidReadingMode = result.reading_mode;
if (rfidReadingMode) {
// RFID Reading gestartet
readBtn.innerHTML = "🛑 Stop Reading";
readBtn.className = "read-uid-btn reading";
showSuccessMessage("RFID Lesen gestartet - Karte auflegen!");
// Status Polling starten
startStatusPolling();
} else {
// RFID Reading gestoppt
readBtn.innerHTML = "📡 Read Chip";
readBtn.className = "read-uid-btn";
showSuccessMessage("RFID Lesen gestoppt");
// Status Polling stoppen
stopStatusPolling();
}
} else {
showErrorMessage("Fehler beim Toggle RFID: " + result.message);
}
} catch (error) {
console.error("Toggle RFID Error:", error);
showErrorMessage("Fehler beim Toggle RFID");
}
}
// Status Polling für kontinuierliches Lesen
function startStatusPolling() {
if (statusInterval) {
clearInterval(statusInterval);
}
statusInterval = setInterval(async () => {
try {
const response = await fetch(`/api/rfid/status`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (result.success && result.last_uid && result.last_uid !== "") {
// Neue UID gelesen - automatisch stoppen
const uidInput = document.getElementById("uid");
uidInput.value = result.last_uid;
// Visuelles Feedback
uidInput.style.borderColor = "#28a745";
setTimeout(() => {
uidInput.style.borderColor = "#e1e5e9";
}, 2000);
showSuccessMessage("UID gelesen: " + result.last_uid);
// Automatisch zum nächsten Feld springen
setTimeout(() => {
document.getElementById("name").focus();
}, 500);
// RFID Lesen automatisch stoppen
stopRFIDReading();
// UID im Backend zurücksetzen
clearBackendUID();
}
} catch (error) {
console.error("Status Poll Error:", error);
}
}, 500); // Alle 500ms prüfen
}
// Status Polling stoppen
function stopStatusPolling() {
if (statusInterval) {
clearInterval(statusInterval);
statusInterval = null;
}
}
// RFID Reading komplett stoppen (Frontend + Backend)
async function stopRFIDReading() {
// Status Polling stoppen
stopStatusPolling();
// Backend stoppen
try {
const response = await fetch(`/api/rfid/toggle`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (result.success && !result.reading_mode) {
rfidReadingMode = false;
// Button zurücksetzen
const readBtn = document.getElementById("readUidBtn");
readBtn.innerHTML = "📡 Read Chip";
readBtn.className = "read-uid-btn";
}
} catch (error) {
console.error("Stop RFID Error:", error);
}
}
// UID im Backend zurücksetzen
async function clearBackendUID() {
try {
await fetch(`/api/rfid/clear`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
} catch (error) {
console.error("Clear UID Error:", error);
}
}
// Einzelnes Lesen (für Kompatibilität)
async function readRFIDUID() {
const readBtn = document.getElementById("readUidBtn");
const uidInput = document.getElementById("uid");
// Button Status ändern
readBtn.disabled = true;
readBtn.className = "read-uid-btn reading";
readBtn.innerHTML = "📡 Lese UID...";
readBtn.innerHTML = "📡 Lese...";
try {
// API Aufruf zum RFID Reader
const response = await fetch(`/api/rfid/read`, {
method: "GET",
headers: {
@@ -361,11 +412,7 @@
const result = await response.json();
if (result.success && result.uid) {
// UID in das Eingabefeld setzen
uidInput.value = result.uid
.match(/.{1,2}/g)
.join(":")
.toUpperCase();
uidInput.value = result.uid;
uidInput.focus();
// Visuelles Feedback
@@ -374,50 +421,34 @@
uidInput.style.borderColor = "#e1e5e9";
}, 2000);
showSuccessMessage("UID erfolgreich gelesen!");
showSuccessMessage("UID gelesen: " + result.uid);
// Automatisch zum nächsten Feld springen
setTimeout(() => {
document.getElementById("vorname").focus();
document.getElementById("name").focus();
}, 500);
} else {
// Fehler beim Lesen
const errorMsg = result.error || "Keine UID gefunden";
showErrorMessage(`RFID Fehler: ${errorMsg}`);
// UID Feld rot markieren
uidInput.style.borderColor = "#dc3545";
setTimeout(() => {
uidInput.style.borderColor = "#e1e5e9";
}, 10000);
showErrorMessage("Keine Karte erkannt");
}
} catch (error) {
console.error("Fehler beim Lesen der UID:", error);
showErrorMessage(
"Verbindungsfehler zum RFID Reader. Bitte prüfen Sie die Verbindung.",
);
// UID Feld rot markieren
uidInput.style.borderColor = "#dc3545";
setTimeout(() => {
uidInput.style.borderColor = "#e1e5e9";
}, 3000);
console.error("RFID Read Error:", error);
showErrorMessage("Fehler beim Lesen");
} finally {
// Button Status zurücksetzen
readBtn.disabled = false;
readBtn.className = "read-uid-btn";
readBtn.innerHTML = "📡 Read Chip";
}
}
async function checkServerStatus() {
try {
const response = await fetch("/api/health");
const response = await fetch("/api/health", {
headers: {},
});
const data = await response.json();
if (!data.status || data.status !== "connected") {
showErrorMessage(
"Server nicht verbunden. Einige Funktionen könnten eingeschränkt sein.",
"Server nicht verbunden. Einige Funktionen könnten eingeschränkt sein."
);
return false;
}
@@ -430,16 +461,19 @@
}
}
function loadLicence() {
fetch("/api/get-licence")
.then((response) => response.json())
.then((data) => {
APIKey = data.licence || "";
})
.catch((error) =>
showMessage("Fehler beim Laden der Lizenz", "error"),
);
}
// Seite laden - RFID Status initialisieren
document.addEventListener("DOMContentLoaded", function () {
// Status Polling stoppen falls aktiv
stopStatusPolling();
// Server Status prüfen
checkServerStatus();
});
// Seite verlassen - RFID Reading komplett stoppen
window.addEventListener("beforeunload", function () {
stopRFIDReading();
});
</script>
</body>
</html>

View File

@@ -1,416 +1,568 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
body {
font-family: "Segoe UI", Arial, sans-serif;
background: linear-gradient(0deg, #0d1733 0%, #223c83 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
backdrop-filter: blur(10px);
}
.container {
max-width: 600px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
backdrop-filter: blur(10px);
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
position: relative;
}
.header {
background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white;
padding: 30px;
text-align: center;
position: relative;
}
.header::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><defs><radialGradient id="a" cx="50%" cy="40%" r="50%"><stop offset="0%" stop-color="white" stop-opacity="0.1"/><stop offset="100%" stop-color="white" stop-opacity="0"/></radialGradient></defs><rect width="100" height="20" fill="url(%23a)"/></svg>');
opacity: 0.3;
}
.header::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><defs><radialGradient id="a" cx="50%" cy="40%" r="50%"><stop offset="0%" stop-color="white" stop-opacity="0.1"/><stop offset="100%" stop-color="white" stop-opacity="0"/></radialGradient></defs><rect width="100" height="20" fill="url(%23a)"/></svg>');
opacity: 0.3;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
position: relative;
z-index: 1;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
position: relative;
z-index: 1;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
position: relative;
z-index: 1;
}
.header p {
font-size: 1.1em;
opacity: 0.9;
position: relative;
z-index: 1;
}
.content {
padding: 40px;
}
.content {
padding: 40px;
}
.nav-buttons {
display: flex;
gap: 15px;
margin-bottom: 30px;
}
.nav-buttons {
display: flex;
gap: 15px;
margin-bottom: 30px;
}
.nav-button {
flex: 1;
padding: 12px 20px;
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 10px;
color: #495057;
text-decoration: none;
text-align: center;
font-weight: 600;
transition: all 0.3s ease;
}
.nav-button {
flex: 1;
padding: 12px 20px;
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 10px;
color: #495057;
text-decoration: none;
text-align: center;
font-weight: 600;
transition: all 0.3s ease;
}
.nav-button:hover {
background: #667eea;
color: white;
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
.nav-button:hover {
background: #49bae4;
color: white;
border-color: #49bae4;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(73, 186, 228, 0.3);
}
.section {
margin-bottom: 30px;
background: #f8f9fa;
border-radius: 15px;
padding: 25px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
}
.section {
margin-bottom: 30px;
background: #f8f9fa;
border-radius: 15px;
padding: 25px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
}
.section h2 {
color: #495057;
margin-bottom: 20px;
font-size: 1.4em;
display: flex;
align-items: center;
gap: 10px;
}
.section h2 {
color: #495057;
margin-bottom: 20px;
font-size: 1.4em;
display: flex;
align-items: center;
gap: 10px;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.section h2::before {
content: "";
width: 4px;
height: 25px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 2px;
}
.section h2::before {
content: "";
width: 4px;
height: 25px;
background: linear-gradient(135deg, #49bae4, #223c83);
border-radius: 2px;
}
.form-group {
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #495057;
font-weight: 600;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #495057;
font-weight: 600;
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 2px solid #e9ecef;
border-radius: 10px;
font-size: 16px;
transition: all 0.3s ease;
}
.form-group input {
width: 100%;
padding: 12px 15px;
border: 2px solid #e9ecef;
border-radius: 10px;
font-size: 16px;
transition: all 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input:focus {
outline: none;
border-color: #49bae4;
box-shadow: 0 0 0 3px rgba(73, 186, 228, 0.1);
}
.time-row {
display: flex;
gap: 15px;
align-items: end;
}
.time-row {
display: flex;
gap: 15px;
align-items: end;
}
.time-input {
flex: 1;
}
.time-input {
flex: 1;
}
.current-time {
background: white;
padding: 15px;
border-radius: 10px;
text-align: center;
font-family: monospace;
font-size: 18px;
color: #495057;
border: 2px solid #e9ecef;
margin-bottom: 15px;
}
.current-time {
background: white;
padding: 15px;
border-radius: 10px;
text-align: center;
font-family: monospace;
font-size: 18px;
color: #495057;
border: 2px solid #e9ecef;
margin-bottom: 15px;
}
.button-group {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.button-group {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.btn {
padding: 15px 25px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
text-align: center;
min-width: 150px;
}
.btn {
padding: 15px 25px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-block;
text-align: center;
min-width: 150px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary {
background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(73, 186, 228, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
color: white;
}
.btn-secondary {
background: linear-gradient(135deg, #DCF2FA 0%, #49bae4 100%);
color: #223c83;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(116, 185, 255, 0.3);
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(220, 242, 250, 0.3);
}
.btn-warning {
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
color: #d84315;
}
.btn-warning {
background: linear-gradient(135deg, #f59d0f 0%, #e67e22 100%);
color: white;
}
.btn-warning:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(252, 182, 159, 0.3);
}
.btn-warning:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(245, 157, 15, 0.3);
}
.btn-danger {
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
color: #c62828;
}
.btn-danger {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
color: white;
}
.btn-danger:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(255, 154, 158, 0.3);
}
.btn-danger:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(231, 76, 60, 0.3);
}
.btn-disabled {
background: #e9ecef !important;
color: #6c757d !important;
cursor: not-allowed !important;
transform: none !important;
box-shadow: none !important;
}
.btn-disabled {
background: #e9ecef !important;
color: #6c757d !important;
cursor: not-allowed !important;
transform: none !important;
box-shadow: none !important;
}
.btn-disabled:hover {
transform: none !important;
box-shadow: none !important;
}
.btn-disabled:hover {
transform: none !important;
box-shadow: none !important;
}
.restriction-notice {
background: #fff3cd;
color: #856404;
padding: 15px;
border-radius: 10px;
border: 2px solid #ffeaa7;
margin-bottom: 15px;
font-weight: 600;
text-align: center;
}
/* Toggle Buttons für Modus-Auswahl */
.mode-toggle {
display: flex;
gap: 0;
border: 2px solid #e9ecef;
border-radius: 12px;
overflow: hidden;
background: white;
}
.status-message {
margin-top: 20px;
padding: 15px;
border-radius: 10px;
font-weight: 600;
text-align: center;
display: none;
}
.mode-button {
flex: 1;
padding: 15px 25px;
border: none;
background: white;
color: #495057;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.status-success {
background: #d4edda;
color: #155724;
border: 2px solid #c3e6cb;
}
.mode-button.active {
background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white;
}
.status-error {
background: #f8d7da;
color: #721c24;
border: 2px solid #f5c6cb;
}
.mode-button:not(.active):hover {
background: #f8f9fa;
color: #49bae4;
}
.status-info {
background: #cce7ff;
color: #004085;
border: 2px solid #b3d9ff;
}
.mode-button:first-child {
border-right: 1px solid #e9ecef;
}
.learning-mode {
display: none;
text-align: center;
padding: 30px;
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
border-radius: 15px;
margin-top: 20px;
}
/* Lane Difficulty Selection Styles */
.lane-difficulty-selection {
margin-top: 20px;
padding: 20px;
background: rgba(73, 186, 228, 0.1);
border-radius: 12px;
border: 2px solid rgba(73, 186, 228, 0.2);
}
.learning-mode h3 {
color: #d84315;
font-size: 1.5em;
margin-bottom: 15px;
}
.lane-difficulty-selection .form-group {
margin-bottom: 25px;
}
.learning-mode p {
color: #bf360c;
font-size: 1.2em;
margin-bottom: 20px;
}
.lane-difficulty-selection .form-group:last-child {
margin-bottom: 0;
}
.pulse {
animation: pulse 2s infinite;
}
.lane-difficulty-selection label {
font-weight: 600;
color: #223c83;
margin-bottom: 12px;
display: block;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.restriction-notice {
background: #fff3cd;
color: #856404;
padding: 15px;
border-radius: 10px;
border: 2px solid #ffeaa7;
margin-bottom: 15px;
font-weight: 600;
text-align: center;
}
@media (max-width: 600px) {
.container {
margin: 10px;
border-radius: 15px;
}
/* Modern Notification Toast */
.notification-toast {
position: fixed;
top: 24px;
right: 24px;
min-width: 320px;
max-width: 400px;
background: rgba(255, 255, 255, 0.98);
border-radius: 16px;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(0, 0, 0, 0.05);
backdrop-filter: blur(20px);
z-index: 99999;
display: none;
align-items: flex-start;
gap: 12px;
padding: 16px;
transform: translateX(100%);
opacity: 0;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: auto;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.content {
padding: 20px;
}
.notification-toast.show {
transform: translateX(0);
opacity: 1;
}
.nav-buttons {
flex-direction: column;
}
.notification-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #10b981, #059669);
}
.button-group {
flex-direction: column;
}
.notification-body {
flex: 1;
min-width: 0;
}
.btn {
width: 100%;
}
.notification-title {
font-size: 14px;
font-weight: 600;
color: #111827;
margin-bottom: 4px;
line-height: 1.2;
}
.time-row {
flex-direction: column;
gap: 10px;
}
}
.notification-message {
font-size: 13px;
color: #6b7280;
line-height: 1.4;
word-wrap: break-word;
}
.section select {
width: 100%;
padding: 12px 16px;
font-size: 16px;
font-family: inherit;
border: 2px solid #e1e5e9;
border-radius: 8px;
background-color: white;
background-image: url("data:image/svg+xml;charset=US-ASCII,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'><path fill='%23666' d='M2 0L0 2h4zm0 5L0 3h4z'/></svg>");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
transition: all 0.3s ease;
}
.notification-close {
flex-shrink: 0;
width: 32px;
height: 32px;
border: none;
background: none;
color: #9ca3af;
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
margin-top: -4px;
margin-right: -4px;
}
.section select:hover {
border-color: #007bff;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
}
.notification-close:hover {
background: rgba(0, 0, 0, 0.05);
color: #374151;
}
.section select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.notification-close:active {
transform: scale(0.95);
}
.section select:disabled {
background-color: #f8f9fa;
color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
border-color: #dee2e6;
}
/* Toast Types */
.notification-toast.success .notification-icon {
background: linear-gradient(135deg, #10b981, #059669);
}
.section select:disabled:hover {
border-color: #dee2e6;
box-shadow: none;
}
.notification-toast.error .notification-icon {
background: linear-gradient(135deg, #ef4444, #dc2626);
}
/* Option Styling */
.section select option {
padding: 8px;
font-size: 16px;
background-color: white;
color: #333;
}
.notification-toast.info .notification-icon {
background: linear-gradient(135deg, #3b82f6, #2563eb);
}
.section select option:hover {
background-color: #f8f9fa;
}
.notification-toast.warning .notification-icon {
background: linear-gradient(135deg, #f59e0b, #d97706);
}
.section select option:disabled {
color: #6c757d;
background-color: #f8f9fa;
}
/* Animation */
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Form Group für bessere Abstände */
.section .form-group {
margin-bottom: 20px;
}
@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
.section .form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
font-size: 14px;
}
/* Responsive Design für kleinere Bildschirme */
@media (max-width: 768px) {
.section select {
font-size: 16px; /* Verhindert Zoom auf iOS */
padding: 14px 16px;
}
}
.learning-mode {
display: none;
text-align: center;
padding: 30px;
background: linear-gradient(135deg, #f59d0f 0%, #e67e22 100%);
border-radius: 15px;
margin-top: 20px;
}
.learning-mode h3 {
color: white;
font-size: 1.5em;
margin-bottom: 15px;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.learning-mode p {
color: white;
font-size: 1.2em;
margin-bottom: 20px;
}
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
.section select {
width: 100%;
padding: 12px 16px;
font-size: 16px;
font-family: inherit;
border: 2px solid #e1e5e9;
border-radius: 8px;
background-color: white;
background-image: url("data:image/svg+xml;charset=US-ASCII,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'><path fill='%23666' d='M2 0L0 2h4zm0 5L0 3h4z'/></svg>");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
transition: all 0.3s ease;
}
.section select:hover {
border-color: #007bff;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
}
.section select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.section select:disabled {
background-color: #f8f9fa;
color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
border-color: #dee2e6;
}
@media (max-width: 600px) {
.container {
margin: 10px;
border-radius: 15px;
}
.content {
padding: 20px;
}
.nav-buttons {
flex-direction: column;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
}
.time-row {
flex-direction: column;
gap: 10px;
}
.mode-toggle {
flex-direction: column;
gap: 0;
}
.mode-button:first-child {
border-right: none;
border-bottom: 1px solid #e9ecef;
}
/* Mobile notification bubble adjustments */
.notification-bubble {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
font-size: 14px;
padding: 12px 16px;
}
}

View File

@@ -11,6 +11,22 @@
<title>Ninjacross Timer - Einstellungen</title>
</head>
<body>
<!-- Modern Notification Toast -->
<div id="notificationBubble" class="notification-toast" style="display: none;">
<div class="notification-icon">
<span id="notificationIcon"></span>
</div>
<div class="notification-body">
<div class="notification-title" id="notificationTitle">Erfolg</div>
<div class="notification-message" id="notificationText">Bereit</div>
</div>
<button class="notification-close" onclick="hideNotification()">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
</svg>
</button>
</div>
<div class="container">
<!-- Header Section -->
<div class="header">
@@ -22,12 +38,9 @@
<!-- Navigation Buttons -->
<div class="nav-buttons">
<a href="/" class="nav-button">🏠 Hauptseite</a>
<a href="/rfid" class="nav-button">📡 RFID</a>
<a href="/rfid.html" class="nav-button">🏷️ RFID</a>
</div>
<!-- Status Message Container -->
<div id="statusMessage" class="status-message"></div>
<!-- Date & Time Section -->
<div class="section">
<h2>🕐 Datum & Uhrzeit</h2>
@@ -66,6 +79,79 @@
</form>
</div>
<!-- Mode Selection Section -->
<div class="section">
<h2>🎯 Modus</h2>
<form id="modeForm">
<div class="form-group">
<label>Modus auswählen:</label>
<div class="mode-toggle">
<button type="button" class="mode-button active" data-mode="individual" onclick="selectMode('individual')">
👤 Individual
</button>
<button type="button" class="mode-button" data-mode="wettkampf" onclick="selectMode('wettkampf')">
🏆 Wettkampf
</button>
</div>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">
💾 Modus speichern
</button>
</div>
</form>
</div>
<!-- Lane Configuration Section -->
<div class="section">
<h2>🏊‍♀️ Lane-Konfiguration</h2>
<form id="laneForm">
<div class="form-group">
<label>Lane-Typ auswählen:</label>
<div class="mode-toggle">
<button type="button" class="mode-button active" data-lane-type="identical" onclick="selectLaneType('identical')">
⚖️ Identische Lanes
</button>
<button type="button" class="mode-button" data-lane-type="different" onclick="selectLaneType('different')">
⚡ Unterschiedliche Lanes
</button>
</div>
</div>
<div id="laneDifficultySelection" class="lane-difficulty-selection" style="display: none;">
<div class="form-group">
<label>Lane 1 Schwierigkeit:</label>
<div class="mode-toggle">
<button type="button" class="mode-button active" data-lane="1" data-difficulty="light" onclick="selectLaneDifficulty(1, 'light')">
🟢 Leicht
</button>
<button type="button" class="mode-button" data-lane="1" data-difficulty="heavy" onclick="selectLaneDifficulty(1, 'heavy')">
🔴 Schwer
</button>
</div>
</div>
<div class="form-group">
<label>Lane 2 Schwierigkeit:</label>
<div class="mode-toggle">
<button type="button" class="mode-button active" data-lane="2" data-difficulty="light" onclick="selectLaneDifficulty(2, 'light')">
🟢 Leicht
</button>
<button type="button" class="mode-button" data-lane="2" data-difficulty="heavy" onclick="selectLaneDifficulty(2, 'heavy')">
🔴 Schwer
</button>
</div>
</div>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">
💾 Lane-Konfiguration speichern
</button>
</div>
</form>
</div>
<!-- Basic Settings Section -->
<div class="section">
<h2>🔧 Grundeinstellungen</h2>
@@ -96,6 +182,18 @@
title="Zeit nach der die angezeigte Zeit zurückgesetzt wird"
/>
</div>
<div class="form-group">
<label for="minTimeForLeaderboard">Minimale Zeit für Leaderboard (Sekunden):</label>
<input
type="number"
id="minTimeForLeaderboard"
name="minTimeForLeaderboard"
min="1"
max="300"
value="5"
title="Zeiten unter diesem Wert werden nicht ins lokale Leaderboard eingetragen (Missbrauchsschutz)"
/>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">
💾 Einstellungen speichern
@@ -132,7 +230,7 @@
<div id="learningMode" class="learning-mode">
<h3>🎯 Anlernmodus aktiv</h3>
<p id="learningInstruction" class="pulse">
Drücken Sie jetzt den Button für: <strong>Bahn 1 Start</strong>
Drücke jetzt den Button für: <strong>Bahn 1 Start</strong>
</p>
<button onclick="cancelLearningMode()" class="btn btn-danger">
❌ Abbrechen
@@ -320,6 +418,8 @@
loadCurrentTime();
updateCurrentTimeDisplay();
loadWifiSettings();
loadMode();
loadLaneConfig();
};
// Aktuelle Zeit anzeigen (Live-Update)
@@ -386,7 +486,7 @@
document.getElementById("currentTimeInput").value = now
.toTimeString()
.split(" ")[0];
showMessage("Browser-Zeit übernommen", "info");
showMessage("Deine Browser-Zeit wurde übernommen", "info");
// Jetzt auch direkt an den Server senden:
const dateValue = document.getElementById("currentDate").value;
@@ -430,7 +530,7 @@
const timeValue = document.getElementById("currentTimeInput").value;
if (!dateValue || !timeValue) {
showMessage("Bitte Datum und Uhrzeit eingeben", "error");
showMessage("Bitte gib Datum und Uhrzeit ein", "error");
return;
}
@@ -447,7 +547,7 @@
.then((response) => response.json())
.then((data) => {
if (data.success) {
showMessage("Uhrzeit erfolgreich gesetzt!", "success");
showMessage("Die Uhrzeit wurde erfolgreich gesetzt!", "success");
} else {
showMessage("Fehler beim Setzen der Uhrzeit", "error");
}
@@ -464,6 +564,164 @@
saveLicence();
});
// Mode selection function
// Remove active class from all mode buttons
function selectMode(mode) {
document.querySelectorAll('.mode-button').forEach(button => {
button.classList.remove('active');
});
// Add active class to selected button
document.querySelector(`[data-mode="${mode}"]`).classList.add('active');
}
// Mode form handler
document.getElementById('modeForm').addEventListener('submit', function(e) {
e.preventDefault();
const activeButton = document.querySelector('.mode-button.active');
const selectedMode = activeButton ? activeButton.getAttribute('data-mode') : 'individual';
fetch('/api/set-mode', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'mode=' + encodeURIComponent(selectedMode)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Modus erfolgreich gespeichert!', 'success');
} else {
showMessage('Fehler beim Speichern des Modus', 'error');
}
})
.catch(error => showMessage('Verbindungsfehler', 'error'));
});
function loadMode() {
fetch("/api/get-mode")
.then((response) => response.json())
.then((data) => {
const mode = data.mode || "individual";
document.querySelectorAll('.mode-button').forEach(button => {
button.classList.remove('active');
});
const btn = document.querySelector(`.mode-button[data-mode="${mode}"]`);
if (btn) btn.classList.add('active');
})
.catch((error) => {
showMessage("Fehler beim Laden des Modus", "error");
});
}
// Lane Type selection function
function selectLaneType(type) {
// Remove active class from all lane type buttons
document.querySelectorAll('[data-lane-type]').forEach(button => {
button.classList.remove('active');
});
// Add active class to selected button
document.querySelector(`[data-lane-type="${type}"]`).classList.add('active');
// Show/hide lane difficulty selection
const difficultySelection = document.getElementById('laneDifficultySelection');
if (type === 'different') {
difficultySelection.style.display = 'block';
} else {
difficultySelection.style.display = 'none';
}
}
// Lane Difficulty selection function
function selectLaneDifficulty(lane, difficulty) {
// Remove active class from all buttons for this lane
document.querySelectorAll(`[data-lane="${lane}"]`).forEach(button => {
button.classList.remove('active');
});
// Add active class to selected button
document.querySelector(`[data-lane="${lane}"][data-difficulty="${difficulty}"]`).classList.add('active');
}
// Lane form handler
document.getElementById('laneForm').addEventListener('submit', function(e) {
e.preventDefault();
const activeLaneTypeButton = document.querySelector('[data-lane-type].active');
const laneType = activeLaneTypeButton ? activeLaneTypeButton.getAttribute('data-lane-type') : 'identical';
let laneConfig = {
type: laneType
};
if (laneType === 'different') {
const lane1Difficulty = document.querySelector('[data-lane="1"].active')?.getAttribute('data-difficulty') || 'light';
const lane2Difficulty = document.querySelector('[data-lane="2"].active')?.getAttribute('data-difficulty') || 'light';
laneConfig.lane1Difficulty = lane1Difficulty;
laneConfig.lane2Difficulty = lane2Difficulty;
}
fetch('/api/set-lane-config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(laneConfig)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showMessage('Lane-Schwierigkeits-Konfiguration erfolgreich gespeichert!', 'success');
} else {
showMessage('Fehler beim Speichern der Lane-Schwierigkeits-Konfiguration', 'error');
}
})
.catch(error => showMessage('Verbindungsfehler', 'error'));
});
function loadLaneConfig() {
fetch("/api/get-lane-config")
.then((response) => response.json())
.then((data) => {
const laneType = data.type || "identical";
const lane1Difficulty = data.lane1Difficulty || "light";
const lane2Difficulty = data.lane2Difficulty || "light";
// Set lane type
document.querySelectorAll('[data-lane-type]').forEach(button => {
button.classList.remove('active');
});
const laneTypeBtn = document.querySelector(`[data-lane-type="${laneType}"]`);
if (laneTypeBtn) laneTypeBtn.classList.add('active');
// Set lane difficulties
document.querySelectorAll('[data-lane]').forEach(button => {
button.classList.remove('active');
});
const lane1Btn = document.querySelector(`[data-lane="1"][data-difficulty="${lane1Difficulty}"]`);
const lane2Btn = document.querySelector(`[data-lane="2"][data-difficulty="${lane2Difficulty}"]`);
if (lane1Btn) lane1Btn.classList.add('active');
if (lane2Btn) lane2Btn.classList.add('active');
// Show/hide difficulty selection
const difficultySelection = document.getElementById('laneDifficultySelection');
if (laneType === 'different') {
difficultySelection.style.display = 'block';
} else {
difficultySelection.style.display = 'none';
}
})
.catch((error) => {
showMessage("Fehler beim Laden der Lane-Schwierigkeits-Konfiguration", "error");
});
}
// Einstellungen laden
function loadSettings() {
fetch("/api/get-settings")
@@ -472,6 +730,8 @@
document.getElementById("maxTime").value = data.maxTime || 300;
document.getElementById("maxTimeDisplay").value =
data.maxTimeDisplay || 20;
document.getElementById("minTimeForLeaderboard").value =
data.minTimeForLeaderboard || 5;
})
.catch((error) =>
showMessage("Fehler beim Laden der Einstellungen", "error")
@@ -516,7 +776,7 @@
.then((response) => response.json())
.then((data) => {
document.getElementById("licencekey").value = data.licence || "";
loadLocations();
loadLocationsFromBackend();
})
.catch((error) =>
showMessage("Fehler beim Laden der Lizenz", "error")
@@ -725,6 +985,9 @@
const maxTimeDisplay = parseInt(
document.getElementById("maxTimeDisplay").value
);
const minTimeForLeaderboard = parseInt(
document.getElementById("minTimeForLeaderboard").value
);
fetch("/api/set-max-time", {
method: "POST",
@@ -735,7 +998,9 @@
"maxTime=" +
encodeURIComponent(maxTime) +
"&maxTimeDisplay=" +
encodeURIComponent(maxTimeDisplay),
encodeURIComponent(maxTimeDisplay) +
"&minTimeForLeaderboard=" +
encodeURIComponent(minTimeForLeaderboard),
})
.then((response) => response.json())
.then((data) => {
@@ -925,44 +1190,84 @@
//location functions
// Locations laden und Dropdown befüllen
function loadLocations() {
const licence = document.getElementById("licencekey").value; // Get the licence key from the input field
fetch("http://db.reptilfpv.de:3000/api/location/", {
method: "GET",
headers: {
Authorization: `Bearer ${licence}`, // Add Bearer token using licenkey
},
})
.then((response) => response.json())
.then((data) => {
const select = document.getElementById("locationSelect");
function loadLocationsFromBackend() {
const select = document.getElementById("locationSelect");
// Vorhandene Optionen löschen (außer der ersten "Bitte wählen...")
while (select.children.length > 1) {
select.removeChild(select.lastChild);
}
// Vorhandene Optionen löschen (außer der ersten "Bitte wählen...")
while (select.children.length > 1) {
select.removeChild(select.lastChild);
}
// Neue Optionen aus Backend-Response hinzufügen
// Neue Optionen aus Backend-Response hinzufügen
data.forEach((location) => {
const option = document.createElement("option");
option.value = location.id;
option.textContent = location.name;
select.appendChild(option);
});
// Aktuell gespeicherten Standort laden
loadCurrentLocation();
// Fallback: Statische Standorte falls API nicht verfügbar ist
const staticLocations = [
{ id: "1", name: "Hauptstandort" },
{ id: "2", name: "Standort A" },
{ id: "3", name: "Standort B" },
{ id: "4", name: "Teststandort" }
];
// Versuche zuerst die echte API zu verwenden
const licence = document.getElementById("licencekey").value;
if (licence && licence.trim() !== "") {
fetch("https://ninja.reptilfpv.de/api/v1/private/locations", {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${licence}`,
},
})
.catch((error) => {
console.log("Locations konnten nicht geladen werden:", error);
showMessage("Fehler beim Laden der Locations", "error");
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
})
.then((data) => {
if (data.success && data.data) {
// API erfolgreich - verwende echte Daten
data.data.forEach((location) => {
const option = document.createElement("option");
option.value = location.id;
option.textContent = location.name;
select.appendChild(option);
});
showMessage("Standorte erfolgreich von API geladen", "success");
} else {
throw new Error("Ungültige API-Response");
}
// Aktuell gespeicherten Standort laden
loadSavedLocation();
})
.catch((error) => {
console.log("API nicht verfügbar, verwende statische Daten:", error);
// API fehlgeschlagen - verwende statische Daten als Fallback
staticLocations.forEach((location) => {
const option = document.createElement("option");
option.value = location.id;
option.textContent = location.name;
select.appendChild(option);
});
showMessage("Standorte geladen (statische Daten - API nicht verfügbar)", "warning");
// Aktuell gespeicherten Standort laden
loadSavedLocation();
});
} else {
// Kein Lizenz-Key - verwende statische Daten
staticLocations.forEach((location) => {
const option = document.createElement("option");
option.value = location.id;
option.textContent = location.name;
select.appendChild(option);
});
showMessage("Standorte geladen (statische Daten - kein Lizenz-Key)", "warning");
// Aktuell gespeicherten Standort laden
loadSavedLocation();
}
}
// Aktuell gespeicherten Standort laden
function loadCurrentLocation() {
fetch("/api/get-location")
function loadSavedLocation() {
fetch("/api/get-local-location")
.then((response) => response.json())
.then((data) => {
if (data.locationId) {
@@ -1035,7 +1340,7 @@
}
// Standort an Backend senden
fetch("/api/set-location", {
fetch("/api/set-local-location", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -1053,18 +1358,90 @@
.catch((error) => showMessage("Verbindungsfehler", "error"));
});
// Status-Nachricht anzeigen
function showMessage(message, type) {
const statusDiv = document.getElementById("statusMessage");
statusDiv.textContent = message;
statusDiv.className = `status-message status-${type}`;
statusDiv.style.display = "block";
// Moderne Notification anzeigen
function showMessage(message, type = 'info') {
console.log("showMessage called:", message, type);
const toast = document.getElementById("notificationBubble");
const icon = document.getElementById("notificationIcon");
const title = document.getElementById("notificationTitle");
const text = document.getElementById("notificationText");
if (!toast || !icon || !title || !text) {
console.error("Notification elements not found!");
return;
}
// Clear any existing timeout
if (window.notificationTimeout) {
clearTimeout(window.notificationTimeout);
}
// Set content
text.textContent = message;
// Set type-specific styling and content
toast.className = "notification-toast";
switch(type) {
case 'success':
toast.classList.add('success');
icon.textContent = '✓';
title.textContent = 'Erfolg';
break;
case 'error':
toast.classList.add('error');
icon.textContent = '✕';
title.textContent = 'Fehler';
break;
case 'info':
toast.classList.add('info');
icon.textContent = '';
title.textContent = 'Information';
break;
case 'warning':
toast.classList.add('warning');
icon.textContent = '⚠';
title.textContent = 'Warnung';
break;
default:
toast.classList.add('info');
icon.textContent = '';
title.textContent = 'Information';
}
// Show toast with animation
toast.style.display = "flex";
// Force reflow
toast.offsetHeight;
// Add show class after a small delay to ensure display is set
setTimeout(() => {
statusDiv.style.display = "none";
toast.classList.add('show');
}, 10);
// Auto-hide after 5 seconds
window.notificationTimeout = setTimeout(() => {
hideNotification();
}, 5000);
}
// Notification verstecken mit Animation
function hideNotification() {
const toast = document.getElementById("notificationBubble");
if (!toast) return;
// Clear timeout if exists
if (window.notificationTimeout) {
clearTimeout(window.notificationTimeout);
}
// Remove show class for animation
toast.classList.remove('show');
// Hide after animation completes
setTimeout(() => {
toast.style.display = "none";
}, 400); // Match CSS transition duration
}
// System-Info alle 30 Sekunden aktualisieren
setInterval(loadSystemInfo, 30000);
</script>

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

Binary file not shown.

Binary file not shown.

View File

@@ -19,17 +19,20 @@ lib_compat_mode = strict
[env:wemos_d1_mini32]
board = wemos_d1_mini32
monitor_speed = 115200
build_flags =
build_flags =
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
-DBATTERY_PIN=16
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
targets = uploadfs
board_build.psram = disabled
lib_deps =
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing_OTA]
@@ -37,7 +40,7 @@ board = esp32thing
monitor_speed = 115200
upload_protocol = espota
upload_port = 192.168.1.96
build_flags =
build_flags =
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
-DBATTERY_PIN=36
@@ -45,18 +48,20 @@ board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
targets = uploadfs
board_build.psram = disabled
lib_deps =
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing]
board = esp32thing_plus
monitor_speed = 115200
build_flags =
build_flags =
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
-DBATTERY_PIN=36
@@ -64,44 +69,73 @@ board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
targets = uploadfs
board_build.psram = disabled
lib_deps =
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing_CI]
platform = espressif32
board = esp32dev
framework = arduino
build_flags =
build_flags =
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
-DBATTERY_PIN=36
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
lib_deps =
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32-s3-devkitc-1]
board = esp32-s3-devkitc-1
[env:um_feathers3]
board = um_feathers3
monitor_speed = 115200
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
build_flags =
-DARDUINO_USB_CDC_ON_BOOT=1
-DBATTERY_PIN=35
lib_deps =
board_upload.wait_for_upload_port = false
build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1
-D BATTERY_PIN=35
-D ARDUINO_USB_MODE=1
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12
adafruit/RTClib@^2.1.4
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:um_feathers3_debug]
board = um_feathers3
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
board_upload.wait_for_upload_port = false
build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1
-D BATTERY_PIN=35
-D ARDUINO_USB_MODE=0
build_type = debug
debug_speed = 12000
debug_tool = esp-builtin
upload_port = COM5
monitor_speed = 115200
monitor_port = COM7
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4

View File

@@ -2,6 +2,8 @@
#include "master.h"
#include <Arduino.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <PicoMQTT.h>
@@ -12,6 +14,7 @@
#include "statusled.h"
#include "timesync.h"
#include "webserverrouter.h"
#include <gamemodes.h>
#include <map>
/**
@@ -43,6 +46,20 @@ typedef struct {
// MQTT-Server-Instanz
PicoMQTT::Server mqtt;
// Tracking der Quelle für jede Lane
bool start1FoundLocally = false;
bool start2FoundLocally = false;
String start1UID = "";
String start2UID = "";
// Hilfsfunktionen um die Quelle abzufragen
bool wasStart1FoundLocally() { return start1FoundLocally; }
bool wasStart2FoundLocally() { return start2FoundLocally; }
String getStart1UID() { return start1UID; }
String getStart2UID() { return start2UID; }
/**
* Liest eine Button-JSON-Nachricht, extrahiert Typ, MAC und Timestamp,
* prüft die Button-Zuordnung und ruft die entsprechende Handler-Funktion auf.
@@ -88,16 +105,20 @@ void readButtonJSON(const char *topic, const char *payload) {
// Button-Zuordnung prüfen und entsprechende Aktion ausführen
if (memcmp(macBytes.data(), buttonConfigs.start1.mac, 6) == 0 &&
(pressType == 2)) {
handleStart1(timestamp);
// handleStart1(timestamp);
triggerAction("start", 2, 1, timestamp);
} else if (memcmp(macBytes.data(), buttonConfigs.stop1.mac, 6) == 0 &&
(pressType == 1)) {
handleStop1(timestamp);
// handleStop1(timestamp);
triggerAction("stop", 1, 1, timestamp);
} else if (memcmp(macBytes.data(), buttonConfigs.start2.mac, 6) == 0 &&
(pressType == 2)) {
handleStart2(timestamp);
// handleStart2(timestamp);
triggerAction("start", 2, 2, timestamp);
} else if (memcmp(macBytes.data(), buttonConfigs.stop2.mac, 6) == 0 &&
(pressType == 1)) {
handleStop2(timestamp);
// handleStop2(timestamp);
triggerAction("stop", 1, 2, timestamp);
}
// Flash status LED to indicate received message
@@ -244,35 +265,116 @@ void publishLaneStatus(int lane, String status) {
* sendet diese ggf. als JSON an das Frontend.
*/
void readRFIDfromButton(const char *topic, const char *payload) {
loadLicenceFromPrefs();
String topicStr(topic);
int lastSlash = topicStr.lastIndexOf('/');
if (lastSlash < 0)
return;
String macStr = topicStr.substring(lastSlash + 1);
// Create a JSON document to hold the button press data
StaticJsonDocument<256> doc;
DeserializationError error = deserializeJson(doc, payload);
if (!error) {
const char *mac = doc["buttonmac"] | "unknown";
const char *uid = doc["uid"] | "unknown";
Serial.printf("RFID Read from Button:\n");
Serial.printf(" Button MAC: %s\n", mac);
Serial.printf(" Button MAC: %s\n", macStr.c_str());
Serial.printf(" UID: %s\n", uid);
String debugUpperUid = String(uid);
debugUpperUid.toUpperCase();
Serial.printf(" UID (Upper): %s\n", debugUpperUid.c_str());
// Convert buttonmac to byte array for comparison
auto macBytes = macStringToBytes(mac);
auto macBytes = macStringToBytes(macStr.c_str());
// Check if the buttonmac matches buttonConfigs.start1.mac
if (memcmp(macBytes.data(), buttonConfigs.start1.mac, 6) == 0) {
// Fetch user data
UserData userData = checkUser(uid);
if (userData.exists) {
// Log user data
Serial.printf("User found for start1: %s %s, Alter: %d\n",
userData.firstname.c_str(), userData.lastname.c_str(),
userData.alter);
// Prüfe ob Lane 1 bereit ist
if (timerData1.isRunning || timerData1.isArmed) {
Serial.println("Lane 1 läuft - ignoriere RFID: " + String(uid));
return;
}
// Create JSON message to send to the frontend
// Zuerst lokal suchen (UID in Großbuchstaben konvertieren)
String upperUid = String(uid);
upperUid.toUpperCase();
UserData userData = checkUser(upperUid);
start1FoundLocally = userData.exists; // Merken ob lokal gefunden
start1UID = upperUid; // UID für später speichern
if (!userData.exists) {
// Nicht lokal gefunden - Online-Server fragen
Serial.println("User nicht lokal gefunden, suche online...");
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
http.begin(String(BACKEND_SERVER) + "/api/v1/private/users/find");
http.addHeader("Content-Type", "application/json");
http.addHeader("Authorization", String("Bearer ") + licence);
Serial.println("Online-Suche mit Token: " + licence);
StaticJsonDocument<200> requestDoc;
String upperUidForRequest = String(uid);
upperUidForRequest.toUpperCase();
requestDoc["uid"] =
upperUidForRequest; // UID in Großbuchstaben konvertieren
String requestBody;
serializeJson(requestDoc, requestBody);
Serial.println("Request Body: " + requestBody);
int httpCode = http.POST(requestBody);
if (httpCode == HTTP_CODE_OK) {
String response = http.getString();
Serial.println("Response: " + response);
StaticJsonDocument<512> responseDoc;
DeserializationError parseError =
deserializeJson(responseDoc, response);
if (!parseError && responseDoc["success"].as<bool>() &&
responseDoc["data"]["exists"].as<bool>()) {
// Online gefundenen Benutzer verwenden (nicht lokal speichern)
String firstName = responseDoc["data"]["firstname"].as<String>();
String lastName = responseDoc["data"]["lastname"].as<String>();
String fullName = firstName + " " + lastName;
// UserData für Frontend erstellen
userData.uid = upperUid;
userData.firstname = firstName;
userData.lastname = "";
userData.alter = 0;
userData.exists = true;
Serial.println("User online gefunden: " + fullName);
} else {
Serial.println("User auch online nicht gefunden für UID: " +
upperUid);
}
} else {
Serial.printf("Online-Suche fehlgeschlagen: HTTP %d\n", httpCode);
}
http.end();
} else {
Serial.println("Keine Internetverbindung für Online-Suche");
}
}
// Wenn Benutzer gefunden wurde (lokal oder online)
if (userData.exists) {
// Bestimme ob lokal oder online gefunden (bereits oben gesetzt)
String source = start1FoundLocally ? "lokal" : "online";
// Log user data mit Quelle
Serial.printf("User %s gefunden für start1: %s\n", source.c_str(),
userData.firstname.c_str());
// Create JSON message to send to the frontend (ohne source)
StaticJsonDocument<128> messageDoc;
messageDoc["firstname"] = userData.firstname;
messageDoc["lastname"] = userData.lastname;
messageDoc["lane"] = "start1"; // Add lane information
messageDoc["name"] = userData.firstname;
messageDoc["lane"] = "start1";
String message;
serializeJson(messageDoc, message);
@@ -282,24 +384,110 @@ void readRFIDfromButton(const char *topic, const char *payload) {
Serial.printf("Pushed user data for start1 to frontend: %s\n",
message.c_str());
} else {
Serial.println("User not found for UID: " + String(uid));
Serial.println("User nicht gefunden für UID: " + upperUid);
// Sende UID an Frontend wenn kein User gefunden wurde
StaticJsonDocument<128> messageDoc;
messageDoc["name"] = upperUid; // UID als Name senden
messageDoc["lane"] = "start1";
String message;
serializeJson(messageDoc, message);
// Push die UID an das Frontend
pushUpdateToFrontend(message);
Serial.printf("Sende UID an Frontend für start1: %s\n",
message.c_str());
}
}
// Check if the buttonmac matches buttonConfigs.start2.mac
else if (memcmp(macBytes.data(), buttonConfigs.start2.mac, 6) == 0) {
// Fetch user data
UserData userData = checkUser(uid);
if (userData.exists) {
// Log user data
Serial.printf("User found for start2: %s %s, Alter: %d\n",
userData.firstname.c_str(), userData.lastname.c_str(),
userData.alter);
// Prüfe ob Lane 2 bereit ist
if (timerData2.isRunning || timerData2.isArmed) {
Serial.println("Lane 2 nicht bereit - ignoriere RFID: " + String(uid));
return;
}
// Create JSON message to send to the frontend
// Zuerst lokal suchen (UID in Großbuchstaben konvertieren)
String upperUid = String(uid);
upperUid.toUpperCase();
UserData userData = checkUser(upperUid);
start2FoundLocally = userData.exists; // Merken ob lokal gefunden
start2UID = upperUid; // UID für später speichern
if (!userData.exists) {
// Nicht lokal gefunden - Online-Server fragen
Serial.println("User nicht lokal gefunden, suche online...");
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
http.begin(String(BACKEND_SERVER) + "/api/v1/private/users/find");
http.addHeader("Content-Type", "application/json");
http.addHeader("Authorization", String("Bearer ") + licence);
Serial.println("Online-Suche mit Token: " + licence);
StaticJsonDocument<200> requestDoc;
String upperUidForRequest2 = String(uid);
upperUidForRequest2.toUpperCase();
requestDoc["uid"] =
upperUidForRequest2; // UID in Großbuchstaben konvertieren
String requestBody;
serializeJson(requestDoc, requestBody);
Serial.println("Request Body: " + requestBody);
int httpCode = http.POST(requestBody);
if (httpCode == HTTP_CODE_OK) {
String response = http.getString();
Serial.println("Response: " + response);
StaticJsonDocument<512> responseDoc;
DeserializationError parseError =
deserializeJson(responseDoc, response);
if (!parseError && responseDoc["success"].as<bool>() &&
responseDoc["data"]["exists"].as<bool>()) {
// Online gefundenen Benutzer verwenden (nicht lokal speichern)
String firstName = responseDoc["data"]["firstname"].as<String>();
String lastName = responseDoc["data"]["lastname"].as<String>();
String fullName = firstName + " " + lastName;
// UserData für Frontend erstellen
userData.uid = upperUid;
userData.firstname = firstName;
userData.lastname = "";
userData.alter = 0;
userData.exists = true;
Serial.println("User online gefunden: " + fullName);
} else {
Serial.println("User auch online nicht gefunden für UID: " +
upperUid);
}
} else {
Serial.printf("Online-Suche fehlgeschlagen: HTTP %d\n", httpCode);
}
http.end();
} else {
Serial.println("Keine Internetverbindung für Online-Suche");
}
}
// Wenn Benutzer gefunden wurde (lokal oder online)
if (userData.exists) {
// Bestimme ob lokal oder online gefunden (bereits oben gesetzt)
String source = start2FoundLocally ? "lokal" : "online";
// Log user data mit Quelle
Serial.printf("User %s gefunden für start2: %s\n", source.c_str(),
userData.firstname.c_str());
// Create JSON message to send to the frontend (ohne source)
StaticJsonDocument<128> messageDoc;
messageDoc["firstname"] = userData.firstname;
messageDoc["lastname"] = userData.lastname;
messageDoc["lane"] = "start2"; // Add lane information
messageDoc["name"] = userData.firstname;
messageDoc["lane"] = "start2";
String message;
serializeJson(messageDoc, message);
@@ -309,7 +497,20 @@ void readRFIDfromButton(const char *topic, const char *payload) {
Serial.printf("Pushed user data for start2 to frontend: %s\n",
message.c_str());
} else {
Serial.println("User not found for UID: " + String(uid));
Serial.println("User nicht gefunden für UID: " + upperUid);
// Sende UID an Frontend wenn kein User gefunden wurde
StaticJsonDocument<128> messageDoc;
messageDoc["name"] = upperUid; // UID als Name senden
messageDoc["lane"] = "start2";
String message;
serializeJson(messageDoc, message);
// Push die UID an das Frontend
pushUpdateToFrontend(message);
Serial.printf("Sende UID an Frontend für start2: %s\n",
message.c_str());
}
} else {
Serial.println("Button MAC does not match start1.mac or start2.mac");
@@ -330,15 +531,20 @@ void setupMqttServer() {
mqtt.subscribe("#", [](const char *topic, const char *payload) {
// Message received callback
// Serial.printf("Received message on topic '%s': %s\n", topic, payload);
if (strncmp(topic, "aquacross/button/", 17) == 0) {
readButtonJSON(topic, payload);
} else if (strncmp(topic, "aquacross/button/rfid/", 22) == 0) {
if (strncmp(topic, "aquacross/button/rfid/", 22) == 0) {
readRFIDfromButton(topic, payload);
// Handle RFID read messages
} else if (strncmp(topic, "aquacross/button/", 17) == 0) {
readButtonJSON(topic, payload);
} else if (strncmp(topic, "aquacross/battery/", 17) == 0) {
handleBatteryTopic(topic, payload);
} else if (strncmp(topic, "heartbeat/alive/", 16) == 0) {
handleHeartbeatTopic(topic, payload);
} else if (strncmp(topic, "aquacross/competition/toMaster", 30) == 0) {
// Handle competition lane messages
// payload ist sendMQTTMessage("aquacross/competition/toMaster", "start");
startCompetition = (payload != nullptr && strcmp(payload, "start") == 0);
runCompetition();
}
updateStatusLED(3);
});

View File

@@ -1,11 +1,25 @@
#pragma once
#include "master.h"
#include <Arduino.h>
#include <ArduinoJson.h>
#include <ESPAsyncWebServer.h>
#include <HTTPClient.h>
#include <algorithm>
#include <preferencemanager.h>
#include <vector>
const char *BACKEND_SERVER = "http://db.reptilfpv.de:3000";
extern String licence; // Declare licence as an external variable defined elsewhere
String BACKEND_TOKEN = licence; // Use the licence as the token for authentication
const char *BACKEND_SERVER = "https://ninja.reptilfpv.de";
extern String
licence; // Declare licence as an external variable defined elsewhere
// Lokale Benutzer-Struktur
struct LocalUser {
String uid;
String name;
unsigned long timestamp; // Zeitstempel der Erstellung
};
// Lokale Benutzer-Speicherung (geht bei Neustart verloren)
std::vector<LocalUser> localUsers;
bool backendOnline() {
@@ -17,8 +31,8 @@ bool backendOnline() {
}
HTTPClient http;
http.begin(String(BACKEND_SERVER) + "/api/health");
http.addHeader("Authorization", String("Bearer ") + BACKEND_TOKEN);
http.begin(String(BACKEND_SERVER) + "/api/v1/private/health");
http.addHeader("Authorization", String("Bearer ") + licence);
int httpCode = http.GET();
bool isOnline = (httpCode == HTTP_CODE_OK);
@@ -41,88 +55,38 @@ struct UserData {
bool exists;
};
// Forward declarations für Leaderboard-Funktionen
void addLocalTime(String uid, String name, unsigned long timeMs, int lane);
// Prüft, ob ein Benutzer mit der angegebenen UID in der Datenbank existiert und
// gibt dessen Daten zurück.
UserData checkUser(const String &uid) {
UserData userData = {"", "", "", 0, false};
String upperUid = uid;
upperUid.toUpperCase(); // UID in Großbuchstaben konvertieren
if (!backendOnline()) {
Serial.println("No internet connection, cannot check user.");
return userData;
}
HTTPClient http;
http.begin(String(BACKEND_SERVER) + "/api/users/find");
http.addHeader("Content-Type", "application/json");
http.addHeader("Authorization", String("Bearer ") + BACKEND_TOKEN);
// Create JSON payload
StaticJsonDocument<200> requestDoc;
requestDoc["uid"] = uid;
String requestBody;
serializeJson(requestDoc, requestBody);
int httpCode = http.POST(requestBody);
if (httpCode == HTTP_CODE_OK) {
String payload = http.getString();
StaticJsonDocument<512> responseDoc;
DeserializationError error = deserializeJson(responseDoc, payload);
if (!error) {
userData.uid = responseDoc["uid"].as<String>();
userData.firstname = responseDoc["firstname"].as<String>();
userData.lastname = responseDoc["lastname"].as<String>();
userData.alter = responseDoc["alter"] | 0;
// Lokale Benutzer durchsuchen
for (const auto &user : localUsers) {
String userUpperUid = user.uid;
userUpperUid.toUpperCase();
if (userUpperUid == upperUid) {
userData.uid = user.uid;
userData.firstname = user.name;
userData.lastname = ""; // Nicht mehr verwendet
userData.alter = 0; // Nicht mehr verwendet
userData.exists = true;
Serial.println("Lokaler Benutzer gefunden: " + user.name);
return userData;
}
} else {
Serial.printf("User check failed, HTTP code: %d\n", httpCode);
}
http.end();
Serial.println("Benutzer mit UID " + uid +
" nicht in lokaler Datenbank gefunden");
return userData;
}
// Fügt einen neuen Benutzer mit den angegebenen Daten in die Datenbank ein.
bool enterUserData(const String &uid, const String &firstname,
const String &lastname, const String &geburtsdatum,
int alter) {
if (!backendOnline()) {
Serial.println("No internet connection, cannot enter user data.");
return false;
}
HTTPClient http;
http.begin(String(BACKEND_SERVER) + "/api/users/insert");
http.addHeader("Content-Type", "application/json");
http.addHeader("Authorization", String("Bearer ") + BACKEND_TOKEN);
// Create JSON payload
StaticJsonDocument<256> requestDoc;
requestDoc["uid"] = uid;
requestDoc["vorname"] = firstname;
requestDoc["nachname"] = lastname;
requestDoc["geburtsdatum"] = geburtsdatum;
requestDoc["alter"] = alter;
String requestBody;
serializeJson(requestDoc, requestBody);
int httpCode = http.POST(requestBody);
if (httpCode == HTTP_CODE_CREATED) {
Serial.println("User data entered successfully.");
http.end();
return true;
} else {
Serial.printf("Failed to enter user data, HTTP code: %d\n", httpCode);
http.end();
return false;
}
}
// Holt alle Standorte aus der Datenbank und gibt sie als JSON-Dokument zurück.
JsonDocument getAllLocations() {
JsonDocument locations; // Allocate memory for the JSON document
@@ -133,8 +97,8 @@ JsonDocument getAllLocations() {
}
HTTPClient http;
http.begin(String(BACKEND_SERVER) + "/api/location/");
http.addHeader("Authorization", String("Bearer ") + BACKEND_TOKEN);
http.begin(String(BACKEND_SERVER) + "/v1/private/locations");
http.addHeader("Authorization", String("Bearer ") + licence);
int httpCode = http.GET();
@@ -157,6 +121,50 @@ JsonDocument getAllLocations() {
// (Kompatibilitätsfunktion).
bool userExists(const String &uid) { return checkUser(uid).exists; }
// Fügt einen neuen Benutzer in die Datenbank ein
bool enterUserData(const String &uid, const String &name) {
String upperUid = uid;
upperUid.toUpperCase(); // UID in Großbuchstaben konvertieren
// Prüfen ob Benutzer bereits existiert
for (const auto &user : localUsers) {
String userUpperUid = user.uid;
userUpperUid.toUpperCase();
if (userUpperUid == upperUid) {
Serial.println("Benutzer mit UID " + upperUid + " existiert bereits!");
return false;
}
}
// Neuen Benutzer erstellen
LocalUser newUser;
newUser.uid = upperUid; // UID in Großbuchstaben speichern
newUser.name = name;
newUser.timestamp = millis();
// Benutzer zum lokalen Array hinzufügen
localUsers.push_back(newUser);
Serial.println("Benutzer lokal gespeichert:");
Serial.println("UID: " + upperUid);
Serial.println("Name: " + name);
Serial.println("Gespeicherte Benutzer: " + String(localUsers.size()));
return true;
}
// Gibt alle lokalen Benutzer zurück (für Debugging)
String getLocalUsersList() {
String result = "Lokale Benutzer (" + String(localUsers.size()) + "):\n";
for (const auto &user : localUsers) {
result += "- UID: " + user.uid + ", Name: " + user.name +
", Erstellt: " + String(user.timestamp) + "\n";
}
return result;
}
// Richtet die HTTP-Routen für die Backend-API ein (z.B. Health-Check, User- und
// Location-Abfragen).
void setupBackendRoutes(AsyncWebServer &server) {
@@ -170,14 +178,143 @@ void setupBackendRoutes(AsyncWebServer &server) {
});
server.on("/api/users", HTTP_GET, [](AsyncWebServerRequest *request) {
if (!backendOnline()) {
request->send(503, "application/json",
"{\"error\":\"Database not connected\"}");
return;
// Lokale Benutzer als JSON zurückgeben
DynamicJsonDocument doc(2048);
JsonArray usersArray = doc.createNestedArray("users");
for (const auto &user : localUsers) {
JsonObject userObj = usersArray.createNestedObject();
userObj["uid"] = user.uid;
userObj["name"] = user.name;
userObj["timestamp"] = user.timestamp;
}
// Handle user retrieval logic here
doc["count"] = localUsers.size();
String response;
serializeJson(doc, response);
request->send(200, "application/json", response);
});
// Route zum Erstellen eines neuen Benutzers
server.on(
"/api/users/insert", HTTP_POST,
[](AsyncWebServerRequest *request) {
Serial.println("API: /api/users/insert aufgerufen");
},
NULL,
[](AsyncWebServerRequest *request, uint8_t *data, size_t len,
size_t index, size_t total) {
// Diese Funktion wird für den Body aufgerufen
static String bodyBuffer = "";
// Daten anhängen
for (size_t i = 0; i < len; i++) {
bodyBuffer += (char)data[i];
}
// Wenn alle Daten empfangen wurden
if (index + len == total) {
Serial.println("Request Body empfangen: '" + bodyBuffer + "'");
if (bodyBuffer.length() == 0) {
Serial.println("FEHLER: Request Body ist leer!");
DynamicJsonDocument response(200);
response["success"] = false;
response["error"] = "Request Body ist leer";
String jsonString;
serializeJson(response, jsonString);
request->send(400, "application/json", jsonString);
bodyBuffer = "";
return;
}
DynamicJsonDocument doc(512);
DeserializationError error = deserializeJson(doc, bodyBuffer);
if (error) {
Serial.println("JSON Parse Error: " + String(error.c_str()));
DynamicJsonDocument response(200);
response["success"] = false;
response["error"] = "Invalid JSON: " + String(error.c_str());
String jsonString;
serializeJson(response, jsonString);
request->send(400, "application/json", jsonString);
bodyBuffer = "";
return;
}
String uid = doc["uid"].as<String>();
String name = doc["name"].as<String>();
Serial.println("Extrahierte UID: " + uid);
Serial.println("Extrahierter Name: " + name);
if (uid.length() == 0 || name.length() == 0) {
DynamicJsonDocument response(200);
response["success"] = false;
response["error"] = "UID und Name sind erforderlich";
String jsonString;
serializeJson(response, jsonString);
request->send(400, "application/json", jsonString);
bodyBuffer = "";
return;
}
// Prüfen ob Benutzer bereits existiert
bool userExists = false;
for (const auto &user : localUsers) {
if (user.uid == uid) {
userExists = true;
break;
}
}
if (userExists) {
DynamicJsonDocument response(200);
response["success"] = false;
response["error"] = "Benutzer bereits vorhanden";
String jsonString;
serializeJson(response, jsonString);
request->send(409, "application/json", jsonString);
bodyBuffer = "";
return;
}
// Neuen Benutzer direkt in das Array einfügen
LocalUser newUser;
newUser.uid = uid;
newUser.name = name;
newUser.timestamp = millis();
localUsers.push_back(newUser);
Serial.println("Benutzer über API eingefügt:");
Serial.println("UID: " + uid);
Serial.println("Name: " + name);
Serial.println("Gespeicherte Benutzer: " + String(localUsers.size()));
DynamicJsonDocument response(200);
response["success"] = true;
response["message"] = "Benutzer erfolgreich erstellt";
response["uid"] = uid;
response["name"] = name;
String jsonString;
serializeJson(response, jsonString);
request->send(200, "application/json", jsonString);
// Buffer zurücksetzen
bodyBuffer = "";
}
});
// Debug-Route für lokale Benutzer
server.on("/api/debug/users", HTTP_GET, [](AsyncWebServerRequest *request) {
String userList = getLocalUsersList();
request->send(200, "text/plain", userList);
});
// Location routes /api/location/
server.on("/api/location/", HTTP_GET, [](AsyncWebServerRequest *request) {
String result;
@@ -185,5 +322,264 @@ void setupBackendRoutes(AsyncWebServer &server) {
request->send(200, "application/json", result);
});
server.on("/api/set-local-location", HTTP_POST,
[](AsyncWebServerRequest *request) {
Serial.println("/api/set-local-location called");
String locationId;
if (request->hasParam("locationId", true)) {
locationId = request->getParam("locationId", true)->value();
}
if (locationId.length() > 0) {
saveLocationIdToPrefs(locationId);
DynamicJsonDocument doc(64);
doc["success"] = true;
String result;
serializeJson(doc, result);
request->send(200, "application/json", result);
} else {
request->send(400, "application/json", "{\"success\":false}");
}
});
server.on("/api/get-local-location", HTTP_GET,
[](AsyncWebServerRequest *request) {
String locationId = getLocationIdFromPrefs();
DynamicJsonDocument doc(64);
doc["locationId"] = locationId;
String result;
serializeJson(doc, result);
request->send(200, "application/json", result);
// Andere Logik wie in getBestLocs
});
// 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) {
auto formatTime = [](unsigned long timeMs) -> String {
float seconds = timeMs / 1000.0;
int totalSeconds = (int)seconds;
int minutes = totalSeconds / 60;
int remainingSeconds = totalSeconds % 60;
int milliseconds = (int)((seconds - totalSeconds) * 100);
if (minutes > 0) {
return String(minutes) + ":" + (remainingSeconds < 10 ? "0" : "") +
String(remainingSeconds) + "." +
(milliseconds < 10 ? "0" : "") + String(milliseconds);
}
return String(remainingSeconds) + "." +
(milliseconds < 10 ? "0" : "") + String(milliseconds);
};
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;
serializeJson(doc, result);
request->send(200, "application/json", result);
});
// Erweiterte Leaderboard API (für Leaderboard-Seite - 10 Einträge)
server.on(
"/api/leaderboard-full", HTTP_GET, [](AsyncWebServerRequest *request) {
// Sortiere eine Kopie nach Zeit (beste zuerst). Niemals die globale
// localTimes-Liste sortieren - sonst geht die chronologische
// Reihenfolge verloren, die /api/leaderboard für "letzte Zeiten"
// braucht (und die per saveBestTimes auch persistiert wird).
std::vector<LocalTime> sortedTimes(localTimes);
std::sort(sortedTimes.begin(), sortedTimes.end(),
[](const LocalTime &a, const LocalTime &b) {
return a.timeMs < b.timeMs;
});
DynamicJsonDocument doc(2048);
JsonArray leaderboard = doc.createNestedArray("leaderboard");
// Nimm die besten 10
int count = 0;
for (const auto &time : sortedTimes) {
if (count >= 10)
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 minutes = totalSeconds / 60;
int remainingSeconds = totalSeconds % 60;
int milliseconds = (int)((seconds - totalSeconds) * 100);
String timeFormatted;
if (minutes > 0) {
timeFormatted =
String(minutes) + ":" + (remainingSeconds < 10 ? "0" : "") +
String(remainingSeconds) + "." +
(milliseconds < 10 ? "0" : "") + String(milliseconds);
} else {
timeFormatted = String(remainingSeconds) + "." +
(milliseconds < 10 ? "0" : "") +
String(milliseconds);
}
entry["timeFormatted"] = timeFormatted;
count++;
}
String result;
serializeJson(doc, result);
request->send(200, "application/json", result);
});
// Add more routes as needed
}
// Hilfsfunktionen um UID und Status abzufragen (aus communication.h)
String getStart1UID();
String getStart2UID();
bool wasStart1FoundLocally();
bool wasStart2FoundLocally();
// Funktion um Zeit an Online-API zu senden
void sendTimeToOnlineAPI(int lane, String uid, float timeInSeconds) {
// Nur senden wenn User online gefunden wurde
bool wasOnlineFound =
(lane == 1) ? !wasStart1FoundLocally() : !wasStart2FoundLocally();
if (!wasOnlineFound) {
Serial.println("Zeit nicht gesendet - User wurde lokal gefunden");
return;
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Keine Internetverbindung - Zeit nicht gesendet");
return;
}
Serial.println("Sende Zeit an Online-API für Lane " + String(lane));
HTTPClient http;
http.begin(String(BACKEND_SERVER) + "/api/v1/private/create-time");
http.addHeader("Content-Type", "application/json");
http.addHeader("Authorization", String("Bearer ") + licence);
// Zeit in M:SS.mmm Format konvertieren (ohne führende Null bei Minuten)
int minutes = (int)(timeInSeconds / 60);
int seconds = (int)timeInSeconds % 60;
int milliseconds = (int)((timeInSeconds - (int)timeInSeconds) * 1000);
String formattedTime =
String(minutes) + ":" + (seconds < 10 ? "0" : "") + String(seconds) +
"." + (milliseconds < 10 ? "00" : (milliseconds < 100 ? "0" : "")) +
String(milliseconds);
StaticJsonDocument<200> requestDoc;
requestDoc["rfiduid"] = uid;
requestDoc["location_name"] =
getLocationIdFromPrefs(); // Aus den Einstellungen
requestDoc["recorded_time"] = formattedTime;
String requestBody;
serializeJson(requestDoc, requestBody);
Serial.println("API Request Body: " + requestBody);
int httpCode = http.POST(requestBody);
if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_CREATED) {
String response = http.getString();
Serial.println("Zeit erfolgreich gesendet: " + response);
} else {
Serial.printf("Fehler beim Senden der Zeit: HTTP %d\n", httpCode);
if (httpCode > 0) {
String response = http.getString();
Serial.println("Response: " + response);
}
}
http.end();
}
// Funktionen für lokales Leaderboard
void addLocalTime(String uid, String name, unsigned long timeMs, int lane) {
// Prüfe minimale Zeit für Leaderboard-Eintrag
if (timeMs < minTimeForLeaderboard) {
Serial.printf(
"Zeit zu kurz für Leaderboard: %s (%s) - %.2fs (Minimum: %.2fs)\n",
name.c_str(), uid.c_str(), timeMs / 1000.0,
minTimeForLeaderboard / 1000.0);
return; // Zeit wird nicht ins Leaderboard eingetragen
}
LocalTime newTime;
newTime.uid = uid;
newTime.name = name;
newTime.timeMs = timeMs;
// 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);
// Speichere das Leaderboard automatisch
saveBestTimes();
Serial.printf("Lokale Zeit hinzugefügt: %s (%s) - %.2fs\n", name.c_str(),
uid.c_str(), timeMs / 1000.0);
}
// Leert das lokale Leaderboard
void clearLocalLeaderboard() {
localTimes.clear();
saveBestTimes(); // Speichere das geleerte Leaderboard
Serial.println("Lokales Leaderboard geleert");
}

View File

@@ -7,8 +7,8 @@
#include <sys/time.h>
#include <time.h>
#include "communication.h"
#include "gamemodes.h"
void setupDebugAPI(AsyncWebServer &server);
@@ -16,22 +16,26 @@ void setupDebugAPI(AsyncWebServer &server) {
// DEBUG
server.on("/api/debug/start1", HTTP_GET, [](AsyncWebServerRequest *request) {
handleStart1(0);
// handleStart1(0);
IndividualMode("start",2,1,millis());
request->send(200, "text/plain", "handleStart1() called");
});
server.on("/api/debug/stop1", HTTP_GET, [](AsyncWebServerRequest *request) {
handleStop1(0);
// handleStop1(0);
IndividualMode("stop",1,1,millis());
request->send(200, "text/plain", "handleStop1() called");
});
server.on("/api/debug/start2", HTTP_GET, [](AsyncWebServerRequest *request) {
handleStart2(0);
// handleStart2(0);
IndividualMode("start",2,2,millis());
request->send(200, "text/plain", "handleStart2() called");
});
server.on("/api/debug/stop2", HTTP_GET, [](AsyncWebServerRequest *request) {
handleStop2(0);
// handleStop2(0);
IndividualMode("stop",1,2,millis());
request->send(200, "text/plain", "handleStop2() called");
});

371
src/gamemodes.h Normal file
View File

@@ -0,0 +1,371 @@
void publishLaneStatus(int lane, String status);
void pushUpdateToFrontend(const String &message);
#pragma once
#include <Arduino.h>
#include <master.h>
#include <ArduinoJson.h>
#include <ESPAsyncWebServer.h>
#include <communication.h>
#include <webserverrouter.h>
void IndividualMode(const char *action, int press, int lane,
uint64_t timestamp = 0);
void CompetitionMode(const char *action, int press, int lane,
uint64_t timestamp = 0);
void triggerAction(const char *action, int press, int lane,
uint64_t _timestamp) {
if (gamemode == 0) {
Serial.println("Individual Mode aktiv");
IndividualMode(action, press, lane, _timestamp);
} else if (gamemode == 1) {
Serial.println("Wettkampf Mode aktiv");
CompetitionMode(action, press, lane, _timestamp);
} else {
Serial.println("Unbekannter Modus, bitte überprüfen");
}
}
void IndividualMode(const char *action, int press, int lane,
uint64_t timestamp) {
if (action == "start" && press == 2 && lane == 1) {
if (!timerData1.isRunning && timerData1.isReady) {
timerData1.isReady = false;
timerData1.startTime = (timestamp > 0) ? timestamp : millis();
timerData1.localStartTime = millis(); // Set local start time
timerData1.isRunning = true;
timerData1.endTime = 0;
timerData1.isArmed = false; // Reset armed status
publishLaneStatus(1, "running");
Serial.println("Bahn 1 gestartet");
}
}
if (action == "stop" && press == 1 && lane == 1) {
if (timerData1.isRunning) {
timerData1.endTime = (timestamp > 0) ? timestamp : millis();
timerData1.finishedSince = millis(); // Set finished time
timerData1.isRunning = false;
uint64_t currentTime = timerData1.endTime - timerData1.startTime;
if (timerData1.bestTime == 0 || currentTime < timerData1.bestTime) {
timerData1.bestTime = currentTime;
saveBestTimes();
}
publishLaneStatus(1, "stopped");
Serial.println("Bahn 1 gestoppt - Zeit: " + String(currentTime / 1000.0) +
"s");
// Speichere Zeit immer lokal
if (wasStart1FoundLocally() && getStart1UID().length() > 0) {
// Finde den Namen des lokalen Users
UserData userData = checkUser(getStart1UID());
if (userData.exists) {
addLocalTime(getStart1UID(), userData.firstname, currentTime, 1);
} else {
// User lokal gefunden aber keine Daten - speichere ohne Namen
addLocalTime(getStart1UID(), "Unbekannt", currentTime, 1);
}
} else if (!wasStart1FoundLocally() && getStart1UID().length() > 0) {
// Sende Zeit an Online-API wenn User online gefunden wurde
sendTimeToOnlineAPI(1, getStart1UID(), currentTime / 1000.0);
} else {
// Kein User gefunden - speichere Zeit ohne UID und Namen
addLocalTime("", "Lauf " + String((localTimes.size() + 1)),
currentTime, 1);
}
}
}
if (action == "start" && press == 2 && lane == 2) {
if (!timerData2.isRunning && timerData2.isReady) {
timerData2.isReady = false;
timerData2.startTime = (timestamp > 0) ? timestamp : millis();
timerData2.localStartTime = millis(); // Set local start time
timerData2.isRunning = true;
timerData2.endTime = 0;
timerData2.isArmed = false; // Reset armed status
publishLaneStatus(2, "running");
Serial.println("Bahn 2 gestartet");
}
}
if (action == "stop" && press == 1 && lane == 2) {
if (timerData2.isRunning) {
timerData2.endTime = (timestamp > 0) ? timestamp : millis();
timerData2.finishedSince = millis(); // Set finished time
timerData2.isRunning = false;
uint64_t currentTime = timerData2.endTime - timerData2.startTime;
if (timerData2.bestTime == 0 || currentTime < timerData2.bestTime) {
timerData2.bestTime = currentTime;
saveBestTimes();
}
publishLaneStatus(2, "stopped");
Serial.println("Bahn 2 gestoppt - Zeit: " + String(currentTime / 1000.0) +
"s");
// Speichere Zeit immer lokal
if (wasStart2FoundLocally() && getStart2UID().length() > 0) {
// Finde den Namen des lokalen Users
UserData userData = checkUser(getStart2UID());
if (userData.exists) {
addLocalTime(getStart2UID(), userData.firstname, currentTime, 2);
} else {
// User lokal gefunden aber keine Daten - speichere ohne Namen
addLocalTime(getStart2UID(), "Unbekannt", currentTime, 2);
}
} else if (!wasStart2FoundLocally() && getStart2UID().length() > 0) {
// Sende Zeit an Online-API wenn User online gefunden wurde
sendTimeToOnlineAPI(2, getStart2UID(), currentTime / 1000.0);
} else {
// Kein User gefunden - speichere Zeit ohne UID und Namen
addLocalTime("", "Lauf " + String((localTimes.size() + 1)),
currentTime, 2);
}
}
}
Serial.printf("Individual Mode Action: %s on Lane %d at %llu\n", action, lane,
timestamp);
// Implement individual mode logic here
}
void CompetitionMode(const char *action, int press, int lane,
uint64_t timestamp) {
Serial.printf("Competition Mode Action: %s on Lane %d at %llu\n", action,
lane, timestamp);
int armedAtTime1;
int armedAtTime2;
int armTimeout = 10000; // Zeit in Millisekunden, die die Bahn armiert bleibt
if (action == "start" && press == 2 && lane == 1) {
if (!timerData1.isRunning && timerData1.isReady) {
timerData1.isReady = false;
timerData1.isArmed = true; // Set Bahn 1 as armed
publishLaneStatus(1, "armed");
Serial.println("Bahn 1 armiert");
armedAtTime1 = millis(); // Set armed time for Bahn 1
}
}
if (action == "start" && press == 2 && lane == 2) {
if (!timerData2.isRunning && timerData2.isReady) {
timerData2.isReady = false;
timerData2.isArmed = true; // Set Bahn 2 as armed
publishLaneStatus(2, "armed");
Serial.println("Bahn 2 armiert");
armedAtTime2 = millis(); // Set armed time for Bahn 2
}
}
if (armedAtTime1 > armedAtTime1 + armTimeout) {
timerData1.isArmed = false; // Reset Bahn 1 if armed time exceeded
timerData1.isReady = true; // Set Bahn 1 back to ready
Serial.println("Bahn 1 automatisch zurückgesetzt (armiert)");
publishLaneStatus(1, "ready");
}
if (armedAtTime2 > armedAtTime2 + armTimeout) {
timerData2.isArmed = false; // Reset Bahn 2 if armed time exceeded
timerData2.isReady = true; // Set Bahn 2 back to ready
Serial.println("Bahn 2 automatisch zurückgesetzt (armiert)");
publishLaneStatus(2, "ready");
}
if (timerData1.isArmed && timerData2.isArmed) {
sendMQTTMessage("aquacross/competition/toSignal", "armed");
}
if ((action == "stop" && press == 1 && lane == 1)) {
if (timerData1.isRunning) {
timerData1.endTime = (timestamp > 0) ? timestamp : millis();
timerData1.finishedSince = millis(); // Set finished time
timerData1.isRunning = false;
uint64_t currentTime1 = timerData1.endTime - timerData1.startTime;
if (timerData1.bestTime == 0 || currentTime1 < timerData1.bestTime) {
timerData1.bestTime = currentTime1;
saveBestTimes();
}
publishLaneStatus(1, "stopped");
Serial.println(
"Bahn 1 gestoppt - Zeit: " + String(currentTime1 / 1000.0) + "s");
}
}
if (action == "stop" && press == 1 && lane == 2) {
if (timerData2.isRunning) {
timerData2.endTime = (timestamp > 0) ? timestamp : millis();
timerData2.finishedSince = millis(); // Set finished time
timerData2.isRunning = false;
uint64_t currentTime2 = timerData2.endTime - timerData2.startTime;
if (timerData2.bestTime == 0 || currentTime2 < timerData2.bestTime) {
timerData2.bestTime = currentTime2;
saveBestTimes();
}
publishLaneStatus(2, "stopped");
Serial.println(
"Bahn 2 gestoppt - Zeit: " + String(currentTime2 / 1000.0) + "s");
}
}
}
void runCompetition() {
if (timerData1.isArmed && timerData2.isArmed && startCompetition) {
timerData1.isReady = false;
uint64_t startNow = getCurrentTimestampMs();
timerData1.startTime = startNow;
timerData1.localStartTime = millis(); // Set local start time
timerData1.isRunning = true;
timerData1.endTime = 0; // Reset end time for Bahn 1
timerData1.isArmed = false; // Reset Bahn 1 armed status
publishLaneStatus(1, "running");
Serial.println("Bahn 1 gestartet");
timerData2.isReady = false;
timerData2.startTime = startNow;
timerData2.localStartTime = millis(); // Set local start time
timerData2.isRunning = true;
timerData2.endTime = 0; // Reset end time for Bahn 2
timerData2.isArmed = false; // Reset Bahn 2 armed status
publishLaneStatus(2, "running");
Serial.println("Bahn 2 gestartet");
} else {
Serial.println(
"Bahn 1 und Bahn 2 müssen armiert sein, um den Wettkampf zu starten.");
}
}
void checkAutoReset() {
unsigned long currentTime = millis();
if (gamemode == 0) { // Individual Mode: Bahnen unabhängig zurücksetzen
if (!timerData1.isRunning && timerData1.endTime > 0 &&
timerData1.finishedSince > 0) {
if (currentTime - timerData1.finishedSince > maxTimeDisplay) {
timerData1.startTime = 0;
timerData1.endTime = 0;
timerData1.finishedSince = 0;
timerData1.isReady = true;
JsonDocument messageDoc;
messageDoc["firstname"] = "";
messageDoc["lastname"] = "";
messageDoc["lane"] = "start1";
String message;
serializeJson(messageDoc, message);
pushUpdateToFrontend(message);
publishLaneStatus(1, "ready");
Serial.println("Bahn 1 automatisch auf 'Bereit' zurückgesetzt");
}
}
if (!timerData2.isRunning && timerData2.endTime > 0 &&
timerData2.finishedSince > 0) {
if (currentTime - timerData2.finishedSince > maxTimeDisplay) {
timerData2.startTime = 0;
timerData2.endTime = 0;
timerData2.finishedSince = 0;
timerData2.isReady = true;
JsonDocument messageDoc;
messageDoc["firstname"] = "";
messageDoc["lastname"] = "";
messageDoc["lane"] = "start2";
String message;
serializeJson(messageDoc, message);
pushUpdateToFrontend(message);
publishLaneStatus(2, "ready");
Serial.println("Bahn 2 automatisch auf 'Bereit' zurückgesetzt");
}
}
} else if (gamemode ==
1) { // Competition Mode: Beide Bahnen gemeinsam zurücksetzen
bool bothStopped = !timerData1.isRunning && !timerData2.isRunning &&
timerData1.endTime > 0 && timerData2.endTime > 0 &&
timerData1.finishedSince > 0 &&
timerData2.finishedSince > 0;
unsigned long latestFinish =
timerData1.finishedSince > timerData2.finishedSince
? timerData1.finishedSince
: timerData2.finishedSince;
if (bothStopped && (currentTime - latestFinish > maxTimeDisplay)) {
// Bahn 1 zurücksetzen
timerData1.startTime = 0;
timerData1.endTime = 0;
timerData1.finishedSince = 0;
timerData1.isReady = true;
publishLaneStatus(1, "ready");
Serial.println("Bahn 1 automatisch auf 'Bereit' zurückgesetzt");
// Bahn 2 zurücksetzen
timerData2.startTime = 0;
timerData2.endTime = 0;
timerData2.finishedSince = 0;
timerData2.isReady = true;
publishLaneStatus(2, "ready");
Serial.println("Bahn 2 automatisch auf 'Bereit' zurückgesetzt");
// Optional: Frontend-Update für beide Bahnen
for (int lane = 1; lane <= 2; ++lane) {
JsonDocument messageDoc;
messageDoc["firstname"] = "";
messageDoc["lastname"] = "";
messageDoc["lane"] = lane == 1 ? "start1" : "start2";
String message;
serializeJson(messageDoc, message);
pushUpdateToFrontend(message);
}
}
}
}
String getTimerDataJSON() {
DynamicJsonDocument doc(1024);
unsigned long currentTime = millis();
// Bahn 1
if (timerData1.isRunning) {
doc["time1"] = (currentTime - timerData1.localStartTime) / 1000.0;
doc["status1"] = "running";
} else if (timerData1.endTime > 0) {
doc["time1"] = (timerData1.endTime - timerData1.startTime) / 1000.0;
doc["status1"] = "finished";
} else if (timerData1.isArmed) {
doc["time1"] = 0;
doc["status1"] = "armed"; // Status für Bahn 1, wenn sie armiert ist
} else {
doc["time1"] = 0;
doc["status1"] = "ready";
}
// Bahn 2
if (timerData2.isRunning) {
doc["time2"] = (currentTime - timerData2.localStartTime) / 1000.0;
doc["status2"] = "running";
} else if (timerData2.endTime > 0) {
doc["time2"] = (timerData2.endTime - timerData2.startTime) / 1000.0;
doc["status2"] = "finished";
} else if (timerData2.isArmed) {
doc["time2"] = 0;
doc["status2"] = "armed"; // Status für Bahn 2, wenn sie armiert ist
} else {
doc["time2"] = 0;
doc["status2"] = "ready";
}
// Beste Zeiten
doc["best1"] = timerData1.bestTime / 1000.0;
doc["best2"] = timerData2.bestTime / 1000.0;
// Lernmodus
doc["learningMode"] = learningMode;
if (learningMode) {
String buttons[] = {"Start Bahn 1", "Stop Bahn 1", "Start Bahn 2",
"Stop Bahn 2"};
doc["learningButton"] = buttons[learningStep];
}
String result;
serializeJson(doc, result);
return result;
}

View File

@@ -16,265 +16,18 @@
#include <communication.h>
#include <databasebackend.h>
#include <debug.h>
#include <gamemodes.h>
#include <licenceing.h>
#include <preferencemanager.h>
#include <rfid.h>
#include <timesync.h>
#include <rtcsync.h>
#include <webserverrouter.h>
#include <wificlass.h>
const char *firmwareversion = "1.0.0"; // Version der Firmware
const char *firmwareversion = "1.1.1"; // Version der Firmware
void handleStart1(uint64_t timestamp = 0) {
if (!timerData.isRunning1 && timerData.isReady1) {
timerData.isReady1 = false;
timerData.startTime1 = (timestamp > 0) ? timestamp : millis();
timerData.localStartTime1 = millis(); // Set local start time
timerData.isRunning1 = true;
timerData.endTime1 = 0;
publishLaneStatus(1, "running");
Serial.println("Bahn 1 gestartet");
}
}
void handleStop1(uint64_t timestamp = 0) {
if (timerData.isRunning1) {
timerData.endTime1 = (timestamp > 0) ? timestamp : millis();
timerData.finishedSince1 = millis(); // Set finished time
timerData.isRunning1 = false;
unsigned long currentTime = timerData.endTime1 - timerData.startTime1;
if (timerData.bestTime1 == 0 || currentTime < timerData.bestTime1) {
timerData.bestTime1 = currentTime;
saveBestTimes();
}
publishLaneStatus(1, "stopped");
Serial.println("Bahn 1 gestoppt - Zeit: " + String(currentTime / 1000.0) +
"s");
}
}
void handleStart2(uint64_t timestamp = 0) {
if (!timerData.isRunning2 && timerData.isReady2) {
timerData.isReady2 = false;
timerData.startTime2 = (timestamp > 0) ? timestamp : millis();
timerData.localStartTime2 = millis(); // Set local start time
timerData.isRunning2 = true;
timerData.endTime2 = 0;
publishLaneStatus(2, "running");
Serial.println("Bahn 2 gestartet");
}
}
void handleStop2(uint64_t timestamp = 0) {
if (timerData.isRunning2) {
timerData.endTime2 = (timestamp > 0) ? timestamp : millis();
timerData.finishedSince2 = millis(); // Set finished time
timerData.isRunning2 = false;
unsigned long currentTime = timerData.endTime2 - timerData.startTime2;
if (timerData.bestTime2 == 0 || currentTime < timerData.bestTime2) {
timerData.bestTime2 = currentTime;
saveBestTimes();
}
publishLaneStatus(2, "stopped");
Serial.println("Bahn 2 gestoppt - Zeit: " + String(currentTime / 1000.0) +
"s");
}
}
void checkAutoReset() {
unsigned long currentTime = millis();
if (timerData.isRunning1 &&
(currentTime - timerData.localStartTime1 > maxTimeBeforeReset)) {
timerData.isRunning1 = false;
timerData.startTime1 = 0;
publishLaneStatus(1, "ready");
Serial.println("Bahn 1 automatisch zurückgesetzt");
}
if (timerData.isRunning2 &&
(currentTime - timerData.localStartTime2 > maxTimeBeforeReset)) {
timerData.isRunning2 = false;
timerData.startTime2 = 0;
publishLaneStatus(2, "ready");
Serial.println("Bahn 2 automatisch zurückgesetzt");
}
// Automatischer Reset nach 10 Sekunden "Beendet"
if (!timerData.isRunning1 && timerData.endTime1 > 0 &&
timerData.finishedSince1 > 0) {
if (millis() - timerData.finishedSince1 > maxTimeDisplay) {
timerData.startTime1 = 0;
timerData.endTime1 = 0;
timerData.finishedSince1 = 0;
timerData.isReady1 = true; // Zurücksetzen auf "Bereit"
JsonDocument messageDoc;
messageDoc["firstname"] = "";
messageDoc["lastname"] = "";
messageDoc["lane"] = "start1"; // Add lane information
String message;
serializeJson(messageDoc, message);
// Push the message to the frontend
pushUpdateToFrontend(message);
publishLaneStatus(1, "ready");
Serial.println("Bahn 1 automatisch auf 'Bereit' zurückgesetzt");
}
}
if (!timerData.isRunning2 && timerData.endTime2 > 0 &&
timerData.finishedSince2 > 0) {
if (currentTime - timerData.finishedSince2 > maxTimeDisplay) {
timerData.startTime2 = 0;
timerData.endTime2 = 0;
timerData.finishedSince2 = 0;
timerData.isReady2 = true; // Zurücksetzen auf "Bereit"
JsonDocument messageDoc;
messageDoc["firstname"] = "";
messageDoc["lastname"] = "";
messageDoc["lane"] = "start2"; // Add lane information
String message;
serializeJson(messageDoc, message);
// Push the message to the frontend
pushUpdateToFrontend(message);
publishLaneStatus(2, "ready");
Serial.println("Bahn 2 automatisch auf 'Bereit' zurückgesetzt");
}
}
}
void saveButtonConfig() {
preferences.begin("buttons", false);
preferences.putBytes("config", &buttonConfigs, sizeof(buttonConfigs));
preferences.end();
}
void loadButtonConfig() {
preferences.begin("buttons", true);
size_t schLen = preferences.getBytesLength("config");
if (schLen == sizeof(buttonConfigs)) {
preferences.getBytes("config", &buttonConfigs, schLen);
}
preferences.end();
}
void saveBestTimes() {
preferences.begin("times", false);
preferences.putULong("best1", timerData.bestTime1);
preferences.putULong("best2", timerData.bestTime2);
preferences.end();
}
void loadBestTimes() {
preferences.begin("times", true);
timerData.bestTime1 = preferences.getULong("best1", 0);
timerData.bestTime2 = preferences.getULong("best2", 0);
preferences.end();
}
void saveSettings() {
preferences.begin("settings", false);
preferences.putULong("maxTime", maxTimeBeforeReset);
preferences.putULong("maxTimeDisplay", maxTimeDisplay);
preferences.end();
}
void loadSettings() {
preferences.begin("settings", true);
maxTimeBeforeReset = preferences.getULong("maxTime", 300000);
maxTimeDisplay = preferences.getULong("maxTimeDisplay", 20000);
preferences.end();
}
void saveWifiSettings() {
preferences.begin("wifi", false);
preferences.putString("ssid", ssidSTA);
preferences.putString("password", passwordSTA);
preferences.end();
delay(500); // Warte 2 Sekunden, bevor der Neustart erfolgt
ESP.restart(); // Neustart des ESP32
}
void loadLocationSettings() {
preferences.begin("location", true);
masterlocation = preferences.getString("location", "");
preferences.end();
}
void saveLocationSettings() {
preferences.begin("location", false);
preferences.putString("location", masterlocation);
preferences.end();
}
void loadWifiSettings() {
preferences.begin("wifi", true);
String ssid = preferences.getString("ssid", "");
String password = preferences.getString("password", "");
ssidSTA = strdup(ssid.c_str());
passwordSTA = strdup(password.c_str());
preferences.end();
}
int checkLicence() {
loadLicenceFromPrefs();
String id = getUniqueDeviceID();
int tier = getLicenseTier(id, licence); // licence = stored or entered key
return tier;
}
String getTimerDataJSON() {
DynamicJsonDocument doc(1024);
unsigned long currentTime = millis();
// Bahn 1
if (timerData.isRunning1) {
doc["time1"] = (currentTime - timerData.localStartTime1) / 1000.0;
doc["status1"] = "running";
} else if (timerData.endTime1 > 0) {
doc["time1"] = (timerData.endTime1 - timerData.startTime1) / 1000.0;
doc["status1"] = "finished";
} else {
doc["time1"] = 0;
doc["status1"] = "ready";
}
// Bahn 2
if (timerData.isRunning2) {
doc["time2"] = (currentTime - timerData.localStartTime2) / 1000.0;
doc["status2"] = "running";
} else if (timerData.endTime2 > 0) {
doc["time2"] = (timerData.endTime2 - timerData.startTime2) / 1000.0;
doc["status2"] = "finished";
} else {
doc["time2"] = 0;
doc["status2"] = "ready";
}
// Beste Zeiten
doc["best1"] = timerData.bestTime1 / 1000.0;
doc["best2"] = timerData.bestTime2 / 1000.0;
// Lernmodus
doc["learningMode"] = learningMode;
if (learningMode) {
String buttons[] = {"Start Bahn 1", "Stop Bahn 1", "Start Bahn 2",
"Stop Bahn 2"};
doc["learningButton"] = buttons[learningStep];
}
String result;
serializeJson(doc, result);
return result;
}
// moved to preferencemanager.h
void setup() {
Serial.begin(115200);
@@ -298,7 +51,18 @@ void setup() {
loadWifiSettings();
loadLocationSettings();
setupRTC(); // RTC zuerst, damit Systemzeit vor WiFi plausibel ist
setupWifi(); // WiFi initialisieren
// wificlass.h hat intern bereits syncTimeWithNTP() versucht.
// Falls die Systemzeit jetzt plausibel ist, in RTC persistieren und Bookkeeping setzen —
// ohne einen zweiten NTP-Roundtrip zu provozieren.
if (WiFi.status() == WL_CONNECTED && time(NULL) >= 1735689600L) {
time_t nowEpoch = time(NULL);
persistSystemTimeToRTC(nowEpoch);
ntpEverSynced = true;
lastNtpSyncEpoch = nowEpoch;
lastStaConnected = true; // Edge bereits "konsumiert"
}
setupOTA(&server);
setupRoutes();
@@ -306,13 +70,25 @@ void setup() {
setupLED();
setupMqttServer(); // MQTT Server initialisieren
// setupBattery();
// setupRFID();
setupRFID(); // RFID initialisieren (ganz einfach)
}
void loop() {
checkAutoReset();
loopMqttServer(); // MQTT Server in der Loop aufrufen
loopRTC();
// MQTT hat höchste Priorität (wird zuerst verarbeitet)
loopMqttServer();
// WebSocket verarbeiten
loopWebSocket();
// loopBattery(); // Batterie-Loop aufrufen
// loopRFID(); // RFID Loop aufrufen
// RFID Loop nur wenn aktiv (spart CPU-Zyklen)
if (isRFIDReadingActive()) {
loopRFID();
}
// Kurze Pause um anderen Tasks Zeit zu geben
delay(1);
}

View File

@@ -4,6 +4,7 @@
#include <ESPAsyncWebServer.h>
#include <sys/time.h>
#include <time.h>
#include <vector>
const char *ssidAP;
const char *passwordAP = nullptr;
@@ -11,22 +12,39 @@ const char *passwordAP = nullptr;
char *ssidSTA = nullptr;
char *passwordSTA = nullptr;
// Timer Struktur
struct TimerData {
unsigned long startTime1 = 0;
unsigned long startTime2 = 0;
unsigned long localStartTime1 = 0;
unsigned long localStartTime2 = 0;
unsigned long finishedSince1 = 0;
unsigned long finishedSince2 = 0;
unsigned long endTime1 = 0;
unsigned long endTime2 = 0;
unsigned long bestTime1 = 0;
unsigned long bestTime2 = 0;
bool isRunning1 = false;
bool isRunning2 = false;
bool isReady1 = true; // Status für Bahn 1
bool isReady2 = true; // Status für Bahn 2
// Timer Struktur für Bahn 1
struct TimerData1 {
unsigned long startTime = 0;
unsigned long localStartTime = 0;
unsigned long finishedSince = 0;
unsigned long endTime = 0;
unsigned long bestTime = 0;
bool isRunning = false;
bool isReady = true; // Status für Bahn 1
bool isArmed = false; // Status für Bahn 1 (armiert/nicht armiert)
char RFIDUID[32] = "";
};
// Struktur für lokale Zeiten (Leaderboard)
struct LocalTime {
String uid;
String name;
unsigned long timeMs;
unsigned long timestamp;
int lane; // 1 = Bahn 1, 2 = Bahn 2, 0 = unbekannt
};
// Timer Struktur für Bahn 2
struct TimerData2 {
unsigned long startTime = 0;
unsigned long localStartTime = 0;
unsigned long finishedSince = 0;
unsigned long endTime = 0;
unsigned long bestTime = 0;
bool isRunning = false;
bool isReady = true; // Status für Bahn 2
bool isArmed = false; // Status für Bahn 2 (armiert/nicht armiert)
char RFIDUID[32] = "";
};
// Button Konfiguration
@@ -48,23 +66,32 @@ struct ButtonConfigs {
extern const char *firmwareversion;
// Globale Variablen
TimerData timerData;
TimerData1 timerData1;
TimerData2 timerData2;
ButtonConfigs buttonConfigs;
bool learningMode = false;
int learningStep = 0; // 0=Start1, 1=Stop1, 2=Start2, 3=Stop2
unsigned long maxTimeBeforeReset = 300000; // 5 Minuten default
unsigned long maxTimeDisplay = 20000; // 20 Sekunden Standard (in ms)
bool wifimodeAP = false; // AP-Modus deaktiviert
unsigned long minTimeForLeaderboard =
5000; // 5 Sekunden minimum für Leaderboard (in ms)
bool wifimodeAP = false; // AP-Modus deaktiviert
String masterlocation;
int gamemode; // 0=Individual, 1=Wettkampf
bool startCompetition = false; // Flag, ob der Timer gestartet wurde
// Lane Configuration
int laneConfigType = 0; // 0=Identical, 1=Different
int lane1DifficultyType = 0; // 0=Light, 1=Heavy (difficulty)
int lane2DifficultyType = 0; // 0=Light, 1=Heavy (difficulty)
// Lokales Leaderboard
std::vector<LocalTime> localTimes;
// Function Declarations
void OnDataRecv(const uint8_t *mac, const uint8_t *incomingData, int len);
void handleLearningMode(const uint8_t *mac);
void handleStartLearning();
void handleStart1(uint64_t timestamp);
void handleStop1(uint64_t timestamp);
void handleStart2(uint64_t timestamp);
void handleStop2(uint64_t timestamp);
void checkAutoReset();
void saveButtonConfig();
void loadButtonConfig();
@@ -73,6 +100,7 @@ void loadBestTimes();
void saveSettings();
void loadSettings();
void loadWifiSettings();
void clearLocalLeaderboard();
void saveWifiSettings();
void loadLocationSettings();
void saveLocationSettings();

160
src/preferencemanager.h Normal file
View File

@@ -0,0 +1,160 @@
#pragma once
#include <Arduino.h>
#include <Preferences.h>
#include <licenceing.h>
#include <master.h>
// Persist and load button configuration
void saveButtonConfig() {
preferences.begin("buttons", false);
preferences.putBytes("config", &buttonConfigs, sizeof(buttonConfigs));
preferences.end();
}
void loadButtonConfig() {
preferences.begin("buttons", true);
size_t schLen = preferences.getBytesLength("config");
if (schLen == sizeof(buttonConfigs)) {
preferences.getBytes("config", &buttonConfigs, schLen);
}
preferences.end();
}
// Persist and load local leaderboard
void saveBestTimes() {
preferences.begin("leaderboard", false);
// Speichere Anzahl der Einträge
preferences.putUInt("count", localTimes.size());
// Speichere jeden Eintrag (kurze Schlüssel für NVS)
for (size_t i = 0; i < localTimes.size(); i++) {
String key = "e" + String(i); // e0, e1, e2, etc.
preferences.putString((key + "u").c_str(),
localTimes[i].uid); // e0u, e1u, etc.
preferences.putString((key + "n").c_str(),
localTimes[i].name); // e0n, e1n, etc.
preferences.putULong((key + "t").c_str(),
localTimes[i].timeMs); // e0t, e1t, etc.
preferences.putULong((key + "s").c_str(),
localTimes[i].timestamp); // e0s, e1s, etc.
preferences.putInt((key + "l").c_str(),
localTimes[i].lane); // e0l, e1l, etc.
}
preferences.end();
Serial.println("Lokales Leaderboard gespeichert: " +
String(localTimes.size()) + " Einträge");
}
void loadBestTimes() {
preferences.begin("leaderboard", true);
// Leere das aktuelle Leaderboard
localTimes.clear();
// Lade Anzahl der Einträge
uint32_t count = preferences.getUInt("count", 0);
// Lade jeden Eintrag (kurze Schlüssel für NVS)
for (uint32_t i = 0; i < count; i++) {
LocalTime entry;
String key = "e" + String(i); // e0, e1, e2, etc.
entry.uid =
preferences.getString((key + "u").c_str(), ""); // e0u, e1u, etc.
entry.name =
preferences.getString((key + "n").c_str(), ""); // e0n, e1n, etc.
entry.timeMs =
preferences.getULong((key + "t").c_str(), 0); // e0t, e1t, etc.
entry.timestamp =
preferences.getULong((key + "s").c_str(), 0); // e0s, e1s, etc.
entry.lane =
preferences.getInt((key + "l").c_str(), 0); // e0l, e1l, etc.
localTimes.push_back(entry);
}
preferences.end();
Serial.println("Lokales Leaderboard geladen: " + String(localTimes.size()) +
" Einträge");
}
// Persist and load general settings
void saveSettings() {
preferences.begin("settings", false);
preferences.putULong("maxTime", maxTimeBeforeReset);
preferences.putULong("maxTimeDisplay", maxTimeDisplay);
preferences.putULong("minTime", minTimeForLeaderboard);
preferences.putUInt("gamemode", gamemode);
preferences.putUInt("laneConfigType", laneConfigType);
preferences.putUInt("lane1Diff", lane1DifficultyType);
preferences.putUInt("lane2Diff", lane2DifficultyType);
preferences.end();
}
void loadSettings() {
preferences.begin("settings", true);
maxTimeBeforeReset = preferences.getULong("maxTime", 300000);
maxTimeDisplay = preferences.getULong("maxTimeDisplay", 20000);
minTimeForLeaderboard = preferences.getULong("minTime", 5000);
gamemode = preferences.getUInt("gamemode", 0);
laneConfigType = preferences.getUInt("laneConfigType", 0);
lane1DifficultyType = preferences.getUInt("lane1Diff", 0);
lane2DifficultyType = preferences.getUInt("lane2Diff", 0);
preferences.end();
}
// Persist and load WiFi settings
void saveWifiSettings() {
preferences.begin("wifi", false);
preferences.putString("ssid", ssidSTA);
preferences.putString("password", passwordSTA);
preferences.end();
delay(500);
ESP.restart();
}
void loadWifiSettings() {
preferences.begin("wifi", true);
String ssid = preferences.getString("ssid", "");
String password = preferences.getString("password", "");
ssidSTA = strdup(ssid.c_str());
passwordSTA = strdup(password.c_str());
preferences.end();
}
// Persist and load location settings
void loadLocationSettings() {
preferences.begin("location", true);
masterlocation = preferences.getString("location", "");
preferences.end();
}
void saveLocationSettings() {
preferences.begin("location", false);
preferences.putString("location", masterlocation);
preferences.end();
}
// Licence helper
int checkLicence() {
loadLicenceFromPrefs();
String id = getUniqueDeviceID();
int tier = getLicenseTier(id, licence);
return tier;
}
void saveLocationIdToPrefs(const String &locationId) {
preferences.begin("locationid", false);
preferences.putString("locationid", locationId);
preferences.end();
}
String getLocationIdFromPrefs() {
preferences.begin("locationid", true);
String locationId = preferences.getString("locationid", "");
preferences.end();
return locationId;
}

View File

@@ -1,187 +1,158 @@
#pragma once
#include <Adafruit_PN532.h>
#include <Arduino.h>
#include <ArduinoJson.h>
#include <MFRC522.h>
#include <SPI.h>
#include <Wire.h>
// RFID Konfiguration
#define RST_PIN 21 // Configurable, see typical pin layout above
#define SS_PIN 5 // Configurable, see typical pin layout above
// RFID Konfiguration - KORREKTE ESP32 Thing Plus Pins
#define SDA_PIN 23 // ESP32 Thing Plus SDA
#define SCL_PIN 22 // ESP32 Thing Plus SCL
#define IRQ_PIN 14
#define RST_PIN 15
MFRC522 mfrc522(SS_PIN, RST_PIN); // Create MFRC522 instance
std::map<String, unsigned long>
blockedUIDs; // Map to store blocked UIDs and their timestamps
const unsigned long BLOCK_DURATION = 10 * 1000; // 10 Seconds in milliseconds
// PN532 RFID Reader (mit IRQ und Reset-Pin)
Adafruit_PN532 nfc(IRQ_PIN, RST_PIN);
// Neue Variablen für API-basiertes Lesen
bool rfidReadRequested = false;
// RFID Variablen
bool rfidInitialized = false;
bool readingMode = false;
String lastReadUID = "";
bool rfidReadSuccess = false;
unsigned long rfidReadStartTime = 0;
const unsigned long RFID_READ_TIMEOUT =
10000; // 10 Sekunden Timeout für API Requests
unsigned long lastReadTime = 0;
// Initialisiert den RFID-Reader und das SPI-Interface.
// Hilfsfunktion um Reading-Mode zu prüfen
bool isRFIDReadingActive() { return readingMode; }
// Initialisiert den RFID-Reader
void setupRFID() {
// RFID-Hardware-Initialisierung deaktiviert (Leser aktuell nicht verwendet);
// spart Boot-Zeit durch den PN532-Probe-Timeout. Bei Bedarf Block unten
// wieder aktivieren.
Serial.println("RFID: Hardware-Init übersprungen (deaktiviert)");
rfidInitialized = false;
// SPI und RFID initialisieren
SPI.begin(); // Init SPI bus
mfrc522.PCD_Init(); // Init MFRC522
delay(4); // Optional delay. Some boards need more time after init to be ready
mfrc522.PCD_DumpVersionToSerial(); // Show details of PCD - MFRC522 Card
// Reader details
/*
// I2C starten mit korrekten Pins
Wire.begin(SDA_PIN, SCL_PIN, 100000);
delay(100);
// PN532 initialisieren
if (!nfc.begin()) {
Serial.println("RFID: PN532 nicht gefunden!");
return;
}
// Firmware prüfen
uint32_t versiondata = nfc.getFirmwareVersion();
if (!versiondata) {
Serial.println("RFID: Firmware nicht lesbar!");
return;
}
// SAM Config
nfc.SAMConfig();
rfidInitialized = true;
Serial.println("RFID: Setup erfolgreich!");
*/
}
// Liest automatisch eine RFID-Karte ein und blockiert die UID für eine
// bestimmte Zeit.
void handleAutomaticRFID() {
if (!mfrc522.PICC_IsNewCardPresent()) {
return;
// Prüft ob RFID funktioniert
bool checkRFID() {
if (!rfidInitialized) {
return false;
}
uint32_t versiondata = nfc.getFirmwareVersion();
return (versiondata != 0);
}
// Liest RFID-Karte - NICHT BLOCKIEREND
String readRFIDCard() {
if (!checkRFID()) {
return "";
}
// Select one of the cards
if (!mfrc522.PICC_ReadCardSerial()) {
return;
uint8_t uid[] = {0, 0, 0, 0, 0, 0, 0};
uint8_t uidLength;
// Nicht-blockierender Aufruf mit sehr kurzer Timeout
uint8_t success =
nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLength,
50); // 50ms Timeout statt Standard 100ms
if (!success) {
return ""; // Keine Karte oder Timeout
}
// Read the UID
String uid = "";
for (byte i = 0; i < mfrc522.uid.size; i++) {
// UID zu String
String uidString = "";
for (uint8_t i = 0; i < uidLength; i++) {
if (i > 0)
uid += ":";
if (mfrc522.uid.uidByte[i] < 0x10)
uid += "0";
uid += String(mfrc522.uid.uidByte[i], HEX);
uidString += ":";
if (uid[i] < 0x10)
uidString += "0";
uidString += String(uid[i], HEX);
}
uidString.toUpperCase();
Serial.println("RFID: " + uidString);
return uidString;
}
// RFID Loop - kontinuierliches Lesen wenn aktiviert (MQTT-optimiert)
void loopRFID() {
if (!readingMode) {
return; // Lesen nicht aktiviert
}
// Check if the UID is blocked
unsigned long currentTime = millis();
if (blockedUIDs.find(uid) != blockedUIDs.end()) {
if (currentTime - blockedUIDs[uid] < BLOCK_DURATION) {
Serial.print(F("UID blocked for 10 seconds. Remaining time: "));
Serial.print((BLOCK_DURATION - (currentTime - blockedUIDs[uid])) / 1000);
Serial.println(F(" seconds."));
Serial.println(uid);
return;
} else {
// Remove the UID from the blocked list if the block duration has passed
blockedUIDs.erase(uid);
static unsigned long lastCheck = 0;
// Nur alle 300ms prüfen (weniger belastend für MQTT)
if (millis() - lastCheck < 300) {
return;
}
lastCheck = millis();
// Versuchen zu lesen (mit kurzer Timeout)
String uid = readRFIDCard();
if (uid.length() > 0) {
// Nur neue UIDs oder nach 2 Sekunden Pause
if (uid != lastReadUID || millis() - lastReadTime > 2000) {
lastReadUID = uid;
lastReadTime = millis();
Serial.println("RFID gelesen: " + uid);
}
}
// Process the UID
Serial.print(F("UID: "));
Serial.println(uid);
// Block the UID for 10 seconds
blockedUIDs[uid] = currentTime;
// show the remaining time for the block
Serial.print(F("UID blocked for 10 seconds. Remaining time: "));
Serial.print((BLOCK_DURATION - (currentTime - blockedUIDs[uid])) / 1000);
Serial.println(F(" seconds."));
// Halt the card
mfrc522.PICC_HaltA();
}
// Neue Funktion für API-basiertes RFID Lesen
// Liest eine RFID-Karte im API-Modus ein (für Web-Requests).
void handleAPIRFIDRead() {
unsigned long currentTime = millis();
// Timeout prüfen
if (currentTime - rfidReadStartTime > RFID_READ_TIMEOUT) {
Serial.println("RFID API Timeout - keine Karte erkannt");
rfidReadRequested = false;
rfidReadSuccess = false;
lastReadUID = "";
return;
}
// Prüfen ob neue Karte vorhanden ist
if (!mfrc522.PICC_IsNewCardPresent()) {
return;
}
// Karte auswählen
if (!mfrc522.PICC_ReadCardSerial()) {
return;
}
// UID für API lesen (ohne Doppelpunkt-Trenner, Großbuchstaben)
String uid = "";
for (byte i = 0; i < mfrc522.uid.size; i++) {
if (mfrc522.uid.uidByte[i] < 0x10) {
uid += "0"; // Leading Zero für einstellige Hex-Werte
}
uid += String(mfrc522.uid.uidByte[i], HEX);
}
// UID in Großbuchstaben konvertieren
uid.toUpperCase();
Serial.println("RFID API UID gelesen: " + uid);
// Ergebnis speichern
lastReadUID = uid;
rfidReadSuccess = true;
rfidReadRequested = false;
// Karte "halt" setzen
mfrc522.PICC_HaltA();
mfrc522.PCD_StopCrypto1();
}
// API Funktion: RFID Lesevorgang starten
// Startet einen neuen RFID-Lesevorgang über die API.
void startRFIDRead() {
Serial.println("RFID API Lesevorgang gestartet...");
rfidReadRequested = true;
rfidReadSuccess = false;
lastReadUID = "";
rfidReadStartTime = millis();
}
// API Funktion: Prüfen ob Lesevorgang abgeschlossen
// Prüft, ob der aktuelle RFID-Lesevorgang abgeschlossen ist.
bool isRFIDReadComplete() { return !rfidReadRequested; }
// API Funktion: Ergebnis des Lesevorgangs abrufen
// Gibt das Ergebnis des letzten RFID-Lesevorgangs zurück.
String getRFIDReadResult(bool &success) {
success = rfidReadSuccess;
return lastReadUID;
}
// Richtet die HTTP-API-Routen für RFID-Operationen ein.
// API Routes
void setupRFIDRoute(AsyncWebServer &server) {
server.on("/api/rfid/read", HTTP_GET, [](AsyncWebServerRequest *request) {
Serial.println("api/rfid/read");
// Start RFID-Lesevorgang
startRFIDRead();
unsigned long startTime = millis();
// Warten, bis eine UID gelesen wird oder Timeout eintritt
while (!isRFIDReadComplete()) {
if (millis() - startTime > RFID_READ_TIMEOUT) {
break;
}
delay(10); // Kurze Pause, um die CPU nicht zu blockieren
}
// Toggle RFID Reading Mode
server.on("/api/rfid/toggle", HTTP_POST, [](AsyncWebServerRequest *request) {
readingMode = !readingMode;
DynamicJsonDocument response(200);
response["success"] = true;
response["reading_mode"] = readingMode;
response["message"] =
readingMode ? "RFID Lesen gestartet" : "RFID Lesen gestoppt";
if (rfidReadSuccess && lastReadUID.length() > 0) {
String jsonString;
serializeJson(response, jsonString);
request->send(200, "application/json", jsonString);
});
// Einzelnes Lesen (wie vorher)
server.on("/api/rfid/read", HTTP_GET, [](AsyncWebServerRequest *request) {
String uid = readRFIDCard();
DynamicJsonDocument response(200);
if (uid.length() > 0) {
response["success"] = true;
response["uid"] = lastReadUID;
response["message"] = "UID erfolgreich gelesen";
response["uid"] = uid;
response["message"] = "Karte gelesen";
} else {
response["success"] = false;
response["error"] = "Keine RFID Karte erkannt oder Timeout";
response["error"] = "Keine Karte gefunden";
response["uid"] = "";
}
@@ -190,107 +161,32 @@ void setupRFIDRoute(AsyncWebServer &server) {
request->send(200, "application/json", jsonString);
});
server.on(
"/api/users/insert", HTTP_POST, [](AsyncWebServerRequest *request) {},
NULL,
[](AsyncWebServerRequest *request, uint8_t *data, size_t len,
size_t index, size_t total) {
Serial.println("/api/users/insert");
// Status und letzte gelesene UID
server.on("/api/rfid/status", HTTP_GET, [](AsyncWebServerRequest *request) {
DynamicJsonDocument response(300);
response["success"] = true;
response["rfid_initialized"] = rfidInitialized;
response["reading_mode"] = readingMode;
response["last_uid"] = lastReadUID;
response["message"] =
readingMode ? "RFID Lesen aktiv" : "RFID Lesen inaktiv";
// Parse the incoming JSON payload
DynamicJsonDocument doc(512);
DeserializationError error = deserializeJson(doc, data, len);
String jsonString;
serializeJson(response, jsonString);
request->send(200, "application/json", jsonString);
});
DynamicJsonDocument response(200);
// UID zurücksetzen (nach erfolgreichem Lesen)
server.on("/api/rfid/clear", HTTP_POST, [](AsyncWebServerRequest *request) {
lastReadUID = ""; // UID zurücksetzen
lastReadTime = 0; // Zeit auch zurücksetzen
if (error) {
Serial.println("Fehler beim Parsen der JSON-Daten");
response["success"] = false;
response["error"] = "Ungültige JSON-Daten";
} else {
// Extract user data from the JSON payload
String uid = doc["uid"] | "";
String vorname = doc["vorname"] | "";
String nachname = doc["nachname"] | "";
String geburtsdatum = doc["geburtsdatum"] | "";
int alter = doc["alter"] | 0;
DynamicJsonDocument response(200);
response["success"] = true;
response["message"] = "UID zurückgesetzt";
// Validate the data
if (uid.isEmpty() || vorname.isEmpty() || nachname.isEmpty() ||
geburtsdatum.isEmpty() || alter <= 0) {
Serial.println("Ungültige Eingabedaten");
response["success"] = false;
response["error"] = "Ungültige Eingabedaten";
} else {
// Process the data using the enterUserData function
Serial.println("Benutzerdaten empfangen:");
Serial.println("UID: " + uid);
Serial.println("Vorname: " + vorname);
Serial.println("Nachname: " + nachname);
Serial.println("Alter: " + String(alter));
bool dbSuccess =
enterUserData(uid, vorname, nachname, geburtsdatum, alter);
if (dbSuccess) {
response["success"] = true;
response["message"] = "Benutzer erfolgreich gespeichert";
} else {
response["success"] = false;
response["error"] = "Fehler beim Speichern in der Datenbank";
}
}
}
// Send the response back to the client
String jsonString;
serializeJson(response, jsonString);
request->send(200, "application/json", jsonString);
});
}
// API Funktion: RFID Reader Status prüfen
// Prüft, ob der RFID-Reader korrekt funktioniert und gibt den Status zurück.
bool checkRFIDReaderStatus() {
byte version = mfrc522.PCD_ReadRegister(mfrc522.VersionReg);
// Bekannte MFRC522 Versionen: 0x91, 0x92
if (version == 0x91 || version == 0x92) {
Serial.println("RFID Reader OK (Version: 0x" + String(version, HEX) + ")");
return true;
} else {
Serial.println("RFID Reader Fehler (Version: 0x" + String(version, HEX) +
")");
return false;
}
}
// Hilfsfunktion: Blockierte UIDs aufräumen
// Entfernt UIDs aus der Blockliste, deren Blockdauer abgelaufen ist.
void cleanupBlockedUIDs() {
unsigned long currentTime = millis();
// Iterator für sicheres Löschen während der Iteration
for (auto it = blockedUIDs.begin(); it != blockedUIDs.end();) {
if (currentTime - it->second >= BLOCK_DURATION) {
it = blockedUIDs.erase(it);
} else {
++it;
}
}
}
// Hauptschleife für das RFID-Handling (automatisch und API-basiert).
void loopRFID() {
// Originale Funktionalität für automatisches Lesen
if (!rfidReadRequested) {
handleAutomaticRFID();
}
// API-basiertes Lesen verarbeiten
if (rfidReadRequested) {
handleAPIRFIDRead();
}
}
String jsonString;
serializeJson(response, jsonString);
request->send(200, "application/json", jsonString);
});
}

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

@@ -1,14 +1,15 @@
// Zeit-bezogene Variablen und Includes
#pragma once
#include "RTClib.h"
#include <Arduino.h>
#include <ArduinoJson.h>
#include <ESPAsyncWebServer.h>
#include <Wire.h>
#include <sys/time.h>
#include <time.h>
RTC_PCF8523 rtc;
// Weak hooks — falls rtcsync.h kompiliert/gelinkt wird, überschreibt es diese.
// Ohne rtcsync.h sind beide Symbole nullptr und werden nicht aufgerufen.
void onSystemTimeSet(time_t t) __attribute__((weak));
void appendTimeStatus(JsonDocument &doc) __attribute__((weak));
// Globale Zeitvariablen
struct timeval tv;
@@ -28,7 +29,7 @@ String getCurrentTimeJSON() {
gettimeofday(&tv, &tz);
now = tv.tv_sec;
StaticJsonDocument<200> doc;
JsonDocument doc;
doc["timestamp"] = (long)now;
doc["success"] = true;
@@ -37,19 +38,24 @@ String getCurrentTimeJSON() {
char timeStr[64];
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo);
doc["formatted"] = String(timeStr);
doc["year"] = timeinfo.tm_year + 1900;
doc["month"] = timeinfo.tm_mon + 1;
doc["day"] = timeinfo.tm_mday;
doc["hour"] = timeinfo.tm_hour;
doc["year"] = timeinfo.tm_year + 1900;
doc["month"] = timeinfo.tm_mon + 1;
doc["day"] = timeinfo.tm_mday;
doc["hour"] = timeinfo.tm_hour;
doc["minute"] = timeinfo.tm_min;
doc["second"] = timeinfo.tm_sec;
// Optionale RTC/Sync-Status-Felder, falls rtcsync.h gelinkt ist
if (appendTimeStatus) {
appendTimeStatus(doc);
}
String response;
serializeJson(doc, response);
return response;
}
void syncTimeWithNTP(const char *ntpServer = "pool.ntp.org",
bool syncTimeWithNTP(const char *ntpServer = "pool.ntp.org",
long gmtOffset_sec = 3600, int daylightOffset_sec = 0) {
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
Serial.println("Warte auf NTP-Zeit (max 5s)...");
@@ -70,6 +76,7 @@ void syncTimeWithNTP(const char *ntpServer = "pool.ntp.org",
} else {
Serial.println("\nNTP-Sync fehlgeschlagen (Timeout nach 5s)");
}
return synced;
}
// Hilfsfunktion: Setzt die Systemzeit auf den angegebenen Zeitstempel.
@@ -80,6 +87,9 @@ bool setSystemTime(long timestamp) {
if (settimeofday(&tv, NULL) == 0) {
Serial.println("Zeit erfolgreich gesetzt: " + String(timestamp));
if (onSystemTimeSet) {
onSystemTimeSet((time_t)timestamp);
}
return true;
} else {
Serial.println("Fehler beim Setzen der Zeit");
@@ -90,8 +100,6 @@ bool setSystemTime(long timestamp) {
// Initialisiert die Zeit-API und richtet die HTTP-Endpunkte ein.
void setupTimeAPI(AsyncWebServer &server) {
// setupRTC();
// API-Endpunkt: Aktuelle Zeit abrufen
server.on("/api/time", HTTP_GET, [](AsyncWebServerRequest *request) {
String response = getCurrentTimeJSON();

View File

@@ -12,6 +12,7 @@ void sendMQTTMessage(const char *topic, const char *message);
#include "communication.h"
#include <buttonassigh.h>
#include <gamemodes.h>
#include <wificlass.h>
AsyncWebServer server(80);
@@ -32,8 +33,8 @@ void setupRoutes() {
request->send(SPIFFS, "/settings.html", "text/html");
});
server.on("/rfid", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(SPIFFS, "/rfid.html", "text/html");
server.on("/leaderboard", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(SPIFFS, "/leaderboard.html", "text/html");
});
server.on("/firmware.bin", HTTP_GET, [](AsyncWebServerRequest *request) {
@@ -52,9 +53,10 @@ void setupRoutes() {
server.on("/api/reset-best", HTTP_POST, [](AsyncWebServerRequest *request) {
Serial.println("/api/reset-best called");
timerData.bestTime1 = 0;
timerData.bestTime2 = 0;
timerData1.bestTime = 0;
timerData2.bestTime = 0;
saveBestTimes();
clearLocalLeaderboard(); // Leere auch das lokale Leaderboard
DynamicJsonDocument doc(64);
doc["success"] = true;
String result;
@@ -82,6 +84,12 @@ void setupRoutes() {
request->getParam("maxTimeDisplay", true)->value().toInt() * 1000;
changed = true;
}
if (request->hasParam("minTimeForLeaderboard", true)) {
minTimeForLeaderboard =
request->getParam("minTimeForLeaderboard", true)->value().toInt() *
1000;
changed = true;
}
if (changed) {
saveSettings();
DynamicJsonDocument doc(32);
@@ -99,6 +107,7 @@ void setupRoutes() {
DynamicJsonDocument doc(256);
doc["maxTime"] = maxTimeBeforeReset / 1000;
doc["maxTimeDisplay"] = maxTimeDisplay / 1000;
doc["minTimeForLeaderboard"] = minTimeForLeaderboard / 1000;
String result;
serializeJson(doc, result);
request->send(200, "application/json", result);
@@ -280,6 +289,105 @@ void setupRoutes() {
request->send(200, "application/json", "{\"success\":true}");
});
server.on("/api/set-mode", HTTP_POST, [](AsyncWebServerRequest *request) {
Serial.println("/api/set-mode called");
String mode;
if (request->hasParam("mode", true)) {
mode = request->getParam("mode", true)->value();
}
if (mode.length() > 0) {
// Speichere den Modus
gamemode = mode == "individual" ? 0 : 1;
Serial.printf("Operational mode set to: %s\n",
gamemode == 0 ? "Individual" : "Wettkampf");
// Rückmeldung
DynamicJsonDocument doc(64);
doc["success"] = true;
String result;
serializeJson(doc, result);
request->send(200, "application/json", result);
saveSettings();
} else {
request->send(400, "application/json",
"{\"success\":false,\"error\":\"Modus fehlt\"}");
}
});
server.on("/api/get-mode", HTTP_GET, [](AsyncWebServerRequest *request) {
DynamicJsonDocument doc(32);
doc["mode"] = gamemode == 0 ? "individual" : "wettkampf";
String result;
serializeJson(doc, result);
request->send(200, "application/json", result);
});
// Lane Configuration API Routes
server.on(
"/api/set-lane-config", HTTP_POST, [](AsyncWebServerRequest *request) {},
NULL,
[](AsyncWebServerRequest *request, uint8_t *data, size_t len,
size_t index, size_t total) {
Serial.println("/api/set-lane-config called");
DynamicJsonDocument doc(256);
DeserializationError error = deserializeJson(doc, data, len);
if (error) {
Serial.println("JSON parsing error");
request->send(400, "application/json",
"{\"success\":false,\"error\":\"Invalid JSON\"}");
return;
}
if (doc.containsKey("type")) {
String laneType = doc["type"];
laneConfigType = (laneType == "identical") ? 0 : 1;
if (laneConfigType == 1 && doc.containsKey("lane1Difficulty") &&
doc.containsKey("lane2Difficulty")) {
String lane1Difficulty = doc["lane1Difficulty"];
String lane2Difficulty = doc["lane2Difficulty"];
lane1DifficultyType = (lane1Difficulty == "light") ? 0 : 1;
lane2DifficultyType = (lane2Difficulty == "light") ? 0 : 1;
}
Serial.printf(
"Lane configuration set - Type: %s, Lane1: %s, Lane2: %s\n",
laneType.c_str(),
(laneConfigType == 1)
? ((lane1DifficultyType == 0) ? "light" : "heavy")
: "identical",
(laneConfigType == 1)
? ((lane2DifficultyType == 0) ? "light" : "heavy")
: "identical");
DynamicJsonDocument response(64);
response["success"] = true;
String result;
serializeJson(response, result);
request->send(200, "application/json", result);
saveSettings();
} else {
request->send(400, "application/json",
"{\"success\":false,\"error\":\"Lane type missing\"}");
}
});
server.on(
"/api/get-lane-config", HTTP_GET, [](AsyncWebServerRequest *request) {
DynamicJsonDocument doc(128);
doc["type"] = laneConfigType == 0 ? "identical" : "different";
if (laneConfigType == 1) {
doc["lane1Difficulty"] = lane1DifficultyType == 0 ? "light" : "heavy";
doc["lane2Difficulty"] = lane2DifficultyType == 0 ? "light" : "heavy";
}
String result;
serializeJson(doc, result);
request->send(200, "application/json", result);
});
// Statische Dateien
server.serveStatic("/", SPIFFS, "/");
server.begin();

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>