Compare commits

...

18 Commits

Author SHA1 Message Date
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
33 changed files with 7155 additions and 720 deletions

80
API.md
View File

@@ -7,86 +7,86 @@ All API endpoints return JSON unless otherwise noted.
## Static Files
| 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 |
| 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 |
---
## Timer & Data
| 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 }` |
| 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 }` |
---
## Button Learning
| 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, ... }` |
| 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, ... }` |
---
## Settings
| Route | Method | Description | Request Body/Params | Response Example |
|----------------------|--------|-------------------------------------|--------------------|------------------|
| `/api/set-max-time` | POST | Set max timer and display time | `maxTime`, `maxTimeDisplay` (form params, seconds) | `{ "success": true }` |
| `/api/get-settings` | GET | Get current timer settings | | `{ "maxTime": 300, "maxTimeDisplay": 20 }` |
| 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 }` |
---
## WiFi Configuration
| 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": "..." }` |
| 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": "..." }` |
---
## Location Configuration
| 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": "..." }` |
| 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": "..." }` |
---
## Button Update & Mode
| 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" }` |
| 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 }` |
| 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|
| Route | Description |
| ----- | ----------------------------------- |
| `/ws` | WebSocket endpoint for live updates |
---

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>

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;
@@ -38,8 +38,8 @@ 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 {
@@ -53,6 +53,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;
@@ -82,7 +108,7 @@ body {
.heartbeat-indicators {
position: fixed;
top: 20px;
right: 90px;
right: 160px;
display: flex;
gap: 15px;
z-index: 1000;
@@ -93,11 +119,61 @@ body {
border: 1px solid rgba(255, 255, 255, 0.2);
}
@media (max-width: 768px) {
.logo {
width: 40px;
height: 40px;
top: 15px;
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;
}
.heartbeat-indicators {
top: 15px;
right: 90px;
gap: 8px;
padding: 8px 12px;
font-size: 0.8rem;
}
.heartbeat-indicator {
width: 12px;
height: 12px;
}
.heartbeat-indicator::before {
font-size: 8px;
top: -20px;
}
.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;
border-radius: 50%;
background: #e74c3c;
background: #f50f0f;
transition: all 0.3s ease;
position: relative;
}
@@ -115,8 +191,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 +201,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 +337,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 +376,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,43 +422,75 @@ 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:not(.large-status) {
position: relative;
order: 2;
margin-top: auto;
}
.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.finished {
background-color: rgba(52, 152, 219, 0.3);
border: 2px solid #3498db;
background-color: rgba(73, 186, 228, 0.3);
border: 2px solid #49bae4;
}
.status.ready {
background-color: rgba(46, 204, 113, 0.3);
border: 2px solid #2ecc71;
background-color: rgb(0 165 3 / 54%);
border: 2px solid #06ff00;
animation: pulse 1s infinite;
}
.status.armed {
background-color: rgb(197, 194, 0);
border: 2px solid #fbff00;
background-color: rgba(245, 157, 15, 0.3);
border: 2px solid #f59d0f;
animation: pulse 1s infinite;
}
.status.running {
background-color: rgba(231, 76, 60, 0.3);
border: 2px solid #e74c3c;
background-color: rgb(255 91 0 / 65%);
border: 2px solid #f59d0f;
}
@keyframes pulse {
@@ -390,8 +506,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;
}
@@ -422,17 +538,40 @@ body {
border-radius: 15px;
padding: clamp(10px, 1.5vh, 15px);
margin: 1vh 0 0 0;
width: 50%;
max-width: 50%;
width: clamp(320px, 80vw, 960px);
max-width: 960px;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.2);
flex-shrink: 0;
align-self: center;
display: flex;
flex-direction: column;
align-items: stretch;
gap: clamp(12px, 2vh, 20px);
box-sizing: border-box;
}
#leaderboard-container {
text-align: left;
display: grid;
grid-template-columns: 1fr;
gap: clamp(12px, 2vh, 20px);
width: 100%;
}
@media (min-width: 768px) {
#leaderboard-container {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.best-times h3 {
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
margin-bottom: clamp(5px, 0.5vh, 8px);
margin: 0 auto;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
text-align: center;
}
.best-time-row {
@@ -446,9 +585,121 @@ body {
border-radius: 8px;
}
/* Leaderboard Styles */
#leaderboard-container {
text-align: left;
}
.leaderboard-entry {
display: flex;
justify-content: space-between;
align-items: center;
margin: clamp(8px, 1vh, 12px) 0;
font-size: clamp(1.1rem, 2.2vw, 1.4rem);
font-weight: 600;
background: rgba(255, 255, 255, 0.15);
padding: clamp(12px, 2vh, 16px) clamp(16px, 3vw, 24px);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
min-height: 50px;
width: 100%;
box-sizing: border-box;
}
.leaderboard-entry:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.leaderboard-entry .rank {
color: #ffd700;
font-weight: bold;
min-width: 30px;
font-size: clamp(1.2rem, 2.4vw, 1.5rem);
}
.leaderboard-entry .name {
flex: 1;
margin: 0 15px;
color: #ffffff;
font-weight: 600;
}
.leaderboard-entry .time {
color: #00ff88;
font-weight: bold;
font-family: 'Courier New', monospace;
min-width: 80px;
text-align: right;
}
.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;
@@ -463,9 +714,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,16 @@
<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" />
<a href="/leaderboard.html" class="leaderboard-btn">🏆</a>
<a href="/settings" class="settings-btn">⚙️</a>
<div class="heartbeat-indicators">
@@ -42,46 +44,37 @@
<div class="header">
<h1>🏊‍♀️ NinjaCross Timer</h1>
<p>Professioneller Zeitmesser für Ninjacross Wettkämpfe</p>
<p>Dein 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>
<div class="best-time-row">
<span>Bahn 2:</span>
<span id="best2">--.-</span>
</div>
<h3>🏆 Lokales Leaderboard</h3>
<div id="leaderboard-container"></div>
</div>
<script>
@@ -97,6 +90,12 @@
let learningButton = "";
let name1 = "";
let name2 = "";
let leaderboardData = [];
// 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();
@@ -188,24 +187,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,13 +317,13 @@
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;
}
@@ -338,7 +331,93 @@
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");
const data = await response.json();
leaderboardData = data.leaderboard || [];
updateLeaderboardDisplay();
} catch (error) {
console.error("Fehler beim Laden des Leaderboards:", error);
}
}
function updateLeaderboardDisplay() {
const container = document.getElementById("leaderboard-container");
container.innerHTML = "";
if (leaderboardData.length === 0) {
container.innerHTML =
'<div class="no-times">Noch keine Zeiten erfasst</div>';
return;
}
// Erstelle zwei Reihen für 2x3 Layout
const row1 = document.createElement("div");
row1.className = "leaderboard-row";
const row2 = document.createElement("div");
row2.className = "leaderboard-row";
leaderboardData.forEach((entry, index) => {
const entryDiv = document.createElement("div");
entryDiv.className = "leaderboard-entry";
// Podium-Plätze hervorheben
if (index === 0) {
entryDiv.classList.add("gold");
} else if (index === 1) {
entryDiv.classList.add("silver");
} else if (index === 2) {
entryDiv.classList.add("bronze");
}
const rankSpan = document.createElement("span");
rankSpan.className = "rank";
rankSpan.textContent = entry.rank + ".";
const nameSpan = document.createElement("span");
nameSpan.className = "name";
nameSpan.textContent = entry.name;
const timeSpan = document.createElement("span");
timeSpan.className = "time";
timeSpan.textContent = entry.timeFormatted;
entryDiv.appendChild(rankSpan);
entryDiv.appendChild(nameSpan);
entryDiv.appendChild(timeSpan);
// Erste 3 Einträge in die erste Reihe, nächste 3 in die zweite Reihe
if (index < 3) {
row1.appendChild(entryDiv);
} else if (index < 6) {
row2.appendChild(entryDiv);
}
});
container.appendChild(row1);
if (leaderboardData.length > 3) {
container.appendChild(row2);
}
}
function updateDisplay() {
@@ -363,60 +442,165 @@
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";
// Position über time-display, aber innerhalb des Containers
if (s1.classList.contains("large-status")) {
const time1Rect = time1Element.getBoundingClientRect();
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const time1Center = time1Rect.top - lane1Rect.top + time1Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane1Rect.top;
// Stelle sicher, dass die obere Kante der Status-Box unter h2 beginnt
// Beginne unter h2 (ohne translate(-50%, -50%) beginnt die Box von oben)
const startTop = h2Bottom + 10;
// Positioniere so, dass die Box über time-display zentriert ist, aber nicht über h2 hinausragt
// Berechne die benötigte Höhe, um über time-display zentriert zu sein
const statusHeight = s1.offsetHeight || 200; // Verwende tatsächliche Höhe oder Schätzwert
const targetTop = Math.max(startTop, time1Center - statusHeight / 2);
s1.style.top = targetTop + "px";
s1.style.transform = "translateX(-50%)";
// Stelle sicher, dass die Box innerhalb des Containers bleibt
const maxHeight = lane1Rect.height - targetTop - 30;
s1.style.maxHeight = maxHeight + "px";
s1.style.overflow = "auto";
}
} else {
s1.className = `status ${status1}`;
// Add large-status class if not running and not finished
if (status1 !== "running" && status1 !== "finished") {
s1.classList.add("large-status");
// Position über time-display, aber innerhalb des Containers
const time1Rect = time1Element.getBoundingClientRect();
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const time1Center = time1Rect.top - lane1Rect.top + time1Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane1Rect.top;
// Stelle sicher, dass die obere Kante der Status-Box unter h2 beginnt
// Beginne unter h2 (ohne translate(-50%, -50%) beginnt die Box von oben)
const startTop = h2Bottom + 10;
// Positioniere so, dass die Box über time-display zentriert ist, aber nicht über h2 hinausragt
// Berechne die benötigte Höhe, um über time-display zentriert zu sein
const statusHeight = s1.offsetHeight || 200; // Verwende tatsächliche Höhe oder Schätzwert
const targetTop = Math.max(startTop, time1Center - statusHeight / 2);
s1.style.top = targetTop + "px";
s1.style.transform = "translateX(-50%)";
// Stelle sicher, dass die Box innerhalb des Containers bleibt
const maxHeight = lane1Rect.height - targetTop - 30;
s1.style.maxHeight = maxHeight + "px";
s1.style.overflow = "auto";
} else {
s1.classList.remove("large-status");
s1.style.top = "";
s1.style.transform = "";
s1.style.maxHeight = "";
}
switch (status1) {
case "ready":
s1.textContent = "Bereit";
s1.textContent = "Bereit für den Start!";
break;
case "running":
s1.textContent = "Läuft...";
s1.textContent = "Läuft - Gib alles!";
break;
case "finished":
s1.textContent = "Beendet";
s1.textContent = "Geschafft!";
break;
case "armed":
s1.textContent = "Armiert";
s1.textContent = "Bereit zum Start!";
break;
default:
s1.textContent = "Unbekannter Status";
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 Buttons 1x betätigen";
s2.className = "status standby large-status";
s2.textContent = "Standby: Drücke beide Buttons einmal";
// Position über time-display, aber innerhalb des Containers
if (s2.classList.contains("large-status")) {
const time2Rect = time2Element.getBoundingClientRect();
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
const time2Center = time2Rect.top - lane2Rect.top + time2Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane2Rect.top;
// Stelle sicher, dass die obere Kante der Status-Box unter h2 beginnt
// Beginne unter h2 (ohne translate(-50%, -50%) beginnt die Box von oben)
const startTop = h2Bottom + 10;
// Positioniere so, dass die Box über time-display zentriert ist, aber nicht über h2 hinausragt
// Berechne die benötigte Höhe, um über time-display zentriert zu sein
const statusHeight = s2.offsetHeight || 200; // Verwende tatsächliche Höhe oder Schätzwert
const targetTop = Math.max(startTop, time2Center - statusHeight / 2);
s2.style.top = targetTop + "px";
s2.style.transform = "translateX(-50%)";
// Stelle sicher, dass die Box innerhalb des Containers bleibt
const maxHeight = lane2Rect.height - targetTop - 30;
s2.style.maxHeight = maxHeight + "px";
s2.style.overflow = "auto";
}
} else {
s2.className = `status ${status2}`;
// Add large-status class if not running and not finished
if (status2 !== "running" && status2 !== "finished") {
s2.classList.add("large-status");
// Position über time-display, aber innerhalb des Containers
const time2Rect = time2Element.getBoundingClientRect();
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
// Stelle sicher, dass die obere Kante der Status-Box unter h2 beginnt
const h2Bottom = h2Rect.bottom - lane2Rect.top;
const time2Center = time2Rect.top - lane2Rect.top + time2Rect.height / 2;
// Beginne unter h2 (ohne translate(-50%, -50%) beginnt die Box von oben)
const startTop = h2Bottom + 10;
// Positioniere so, dass die Box über time-display zentriert ist, aber nicht über h2 hinausragt
// Berechne die benötigte Höhe, um über time-display zentriert zu sein
const statusHeight = s2.offsetHeight || 200; // Verwende tatsächliche Höhe oder Schätzwert
const targetTop = Math.max(startTop, time2Center - statusHeight / 2);
s2.style.top = targetTop + "px";
s2.style.transform = "translateX(-50%)";
// Stelle sicher, dass die Box innerhalb des Containers bleibt
const maxHeight = lane2Rect.height - targetTop - 30;
s2.style.maxHeight = maxHeight + "px";
s2.style.overflow = "auto";
} else {
s2.classList.remove("large-status");
s2.style.top = "";
s2.style.transform = "";
s2.style.maxHeight = "";
}
switch (status2) {
case "ready":
s2.textContent = "Bereit";
s2.textContent = "Bereit für den Start!";
break;
case "running":
s2.textContent = "Läuft...";
s2.textContent = "Läuft - Gib alles!";
break;
case "finished":
s2.textContent = "Beendet";
s2.textContent = "Geschafft!";
break;
case "armed":
s2.textContent = "Armiert"; // Neuer Status für armiert
s2.textContent = "Bereit zum Start!";
break;
default:
s2.textContent = "Unbekannter Status";
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");
@@ -463,10 +647,49 @@
updateDisplay();
})
.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);
@@ -490,6 +713,11 @@
// Initial load
syncFromBackend();
loadLaneConfig();
loadLeaderboard();
// Leaderboard alle 5 Sekunden aktualisieren
setInterval(loadLeaderboard, 5000);
</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

@@ -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 = "ninja.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,46 +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",
...(APIKey && { Authorization: `Bearer ${APIKey}` }),
},
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
@@ -313,7 +224,6 @@
function clearForm() {
document.getElementById("rfidForm").reset();
document.getElementById("ageDisplay").style.display = "none";
document.getElementById("uid").focus();
}
@@ -321,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();
}
});
@@ -340,34 +249,170 @@
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: {
"Content-Type": "application/json",
...(APIKey && { Authorization: `Bearer ${APIKey}` }),
},
});
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
@@ -376,38 +421,20 @@
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";
}
}
@@ -415,9 +442,7 @@
async function checkServerStatus() {
try {
const response = await fetch("/api/health", {
headers: {
...(APIKey && { Authorization: `Bearer ${APIKey}` }),
},
headers: {},
});
const data = await response.json();
@@ -436,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

@@ -5,8 +5,8 @@
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, 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%);
min-height: 100vh;
padding: 20px;
}
@@ -22,7 +22,7 @@
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white;
padding: 30px;
text-align: center;
@@ -45,6 +45,9 @@
margin-bottom: 10px;
position: relative;
z-index: 1;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
}
.header p {
@@ -78,11 +81,11 @@
}
.nav-button:hover {
background: #667eea;
background: #49bae4;
color: white;
border-color: #667eea;
border-color: #49bae4;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
box-shadow: 0 5px 15px rgba(73, 186, 228, 0.3);
}
.section {
@@ -100,13 +103,16 @@
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);
background: linear-gradient(135deg, #49bae4, #223c83);
border-radius: 2px;
}
@@ -132,8 +138,8 @@
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
border-color: #49bae4;
box-shadow: 0 0 0 3px rgba(73, 186, 228, 0.1);
}
.time-row {
@@ -179,43 +185,43 @@
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
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);
box-shadow: 0 10px 25px rgba(73, 186, 228, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
color: white;
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);
box-shadow: 0 10px 25px rgba(220, 242, 250, 0.3);
}
.btn-warning {
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
color: #d84315;
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);
box-shadow: 0 10px 25px rgba(245, 157, 15, 0.3);
}
.btn-danger {
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
color: #c62828;
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);
box-shadow: 0 10px 25px rgba(231, 76, 60, 0.3);
}
.btn-disabled {
@@ -255,19 +261,43 @@
}
.mode-button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white;
}
.mode-button:not(.active):hover {
background: #f8f9fa;
color: #667eea;
color: #49bae4;
}
.mode-button:first-child {
border-right: 1px solid #e9ecef;
}
/* 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);
}
.lane-difficulty-selection .form-group {
margin-bottom: 25px;
}
.lane-difficulty-selection .form-group:last-child {
margin-bottom: 0;
}
.lane-difficulty-selection label {
font-weight: 600;
color: #223c83;
margin-bottom: 12px;
display: block;
}
.restriction-notice {
background: #fff3cd;
color: #856404;
@@ -415,19 +445,22 @@
display: none;
text-align: center;
padding: 30px;
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
background: linear-gradient(135deg, #f59d0f 0%, #e67e22 100%);
border-radius: 15px;
margin-top: 20px;
}
.learning-mode h3 {
color: #d84315;
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: #bf360c;
color: white;
font-size: 1.2em;
margin-bottom: 20px;
}

View File

@@ -38,7 +38,7 @@
<!-- 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>
<!-- Date & Time Section -->
@@ -102,6 +102,55 @@
</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">
@@ -133,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
@@ -169,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
@@ -358,6 +419,7 @@
updateCurrentTimeDisplay();
loadWifiSettings();
loadMode();
loadLaneConfig();
};
// Aktuelle Zeit anzeigen (Live-Update)
@@ -424,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;
@@ -468,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;
}
@@ -485,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");
}
@@ -555,6 +617,110 @@
});
}
// 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() {
@@ -564,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")
@@ -817,6 +985,9 @@
const maxTimeDisplay = parseInt(
document.getElementById("maxTimeDisplay").value
);
const minTimeForLeaderboard = parseInt(
document.getElementById("minTimeForLeaderboard").value
);
fetch("/api/set-max-time", {
method: "POST",
@@ -827,7 +998,9 @@
"maxTime=" +
encodeURIComponent(maxTime) +
"&maxTimeDisplay=" +
encodeURIComponent(maxTimeDisplay),
encodeURIComponent(maxTimeDisplay) +
"&minTimeForLeaderboard=" +
encodeURIComponent(minTimeForLeaderboard),
})
.then((response) => response.json())
.then((data) => {

4
mock-server/.gitignore vendored Normal file
View File

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

179
mock-server/README.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

108
mock-server/mqtt_broker.js Normal file
View File

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

1922
mock-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
mock-server/package.json Normal file
View File

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

47
mock-server/start_all.js Normal file
View File

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

View File

@@ -22,6 +22,9 @@ monitor_speed = 115200
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 =
@@ -29,8 +32,7 @@ lib_deps =
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
[env:esp32thing_OTA]
board = esp32thing
@@ -50,8 +52,9 @@ lib_deps =
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
[env:esp32thing]
board = esp32thing_plus
@@ -69,8 +72,7 @@ lib_deps =
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
[env:esp32thing_CI]
platform = espressif32
@@ -87,21 +89,47 @@ lib_deps =
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
[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
board_upload.wait_for_upload_port = false
build_flags =
-DARDUINO_USB_CDC_ON_BOOT=1
-DBATTERY_PIN=35
-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
[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

View File

@@ -2,6 +2,8 @@
#include "master.h"
#include <Arduino.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <PicoMQTT.h>
@@ -44,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.
@@ -249,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);
@@ -287,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);
@@ -314,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");
@@ -335,11 +531,11 @@ 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) {

View File

@@ -1,14 +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 = "https://ninja.reptilfpv.de";
extern String
licence; // Declare licence as an external variable defined elsewhere
String BACKEND_TOKEN =
licence; // Use the licence as the token for authentication
// 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() {
@@ -20,8 +31,8 @@ bool backendOnline() {
}
HTTPClient http;
http.begin(String(BACKEND_SERVER) + "/v1/private/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);
@@ -44,87 +55,38 @@ struct UserData {
bool exists;
};
// Forward declarations für Leaderboard-Funktionen
void addLocalTime(String uid, String name, unsigned long timeMs);
// 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) + "/v1/private/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) + "/v1/private/create-player");
http.addHeader("Content-Type", "application/json");
http.addHeader("Authorization", String("Bearer ") + BACKEND_TOKEN);
// Create JSON payload
StaticJsonDocument<256> requestDoc;
requestDoc["rfiduid"] = uid;
requestDoc["firstname"] = firstname;
requestDoc["lastname"] = lastname;
requestDoc["birthdate"] = geburtsdatum;
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
@@ -136,7 +98,7 @@ JsonDocument getAllLocations() {
HTTPClient http;
http.begin(String(BACKEND_SERVER) + "/v1/private/locations");
http.addHeader("Authorization", String("Bearer ") + BACKEND_TOKEN);
http.addHeader("Authorization", String("Bearer ") + licence);
int httpCode = http.GET();
@@ -159,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) {
@@ -172,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;
@@ -218,5 +353,206 @@ void setupBackendRoutes(AsyncWebServer &server) {
// Andere Logik wie in getBestLocs
});
// Lokales Leaderboard API (für Hauptseite - 6 Einträge)
server.on("/api/leaderboard", HTTP_GET, [](AsyncWebServerRequest *request) {
// Sortiere nach Zeit (beste zuerst)
std::sort(localTimes.begin(), localTimes.end(),
[](const LocalTime &a, const LocalTime &b) {
return a.timeMs < b.timeMs;
});
DynamicJsonDocument doc(2048);
JsonArray leaderboard = doc.createNestedArray("leaderboard");
// Nimm die besten 6
int count = 0;
for (const auto &time : localTimes) {
if (count >= 6)
break;
JsonObject entry = leaderboard.createNestedObject();
entry["rank"] = count + 1;
entry["name"] = time.name;
entry["uid"] = time.uid;
entry["time"] = time.timeMs / 1000.0;
// Format time inline
float seconds = time.timeMs / 1000.0;
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);
});
// Erweiterte Leaderboard API (für Leaderboard-Seite - 10 Einträge)
server.on(
"/api/leaderboard-full", HTTP_GET, [](AsyncWebServerRequest *request) {
// Sortiere nach Zeit (beste zuerst)
std::sort(localTimes.begin(), localTimes.end(),
[](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 : localTimes) {
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) {
// 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;
newTime.timestamp = millis();
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

@@ -56,6 +56,25 @@ void IndividualMode(const char *action, int press, int lane,
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);
} else {
// User lokal gefunden aber keine Daten - speichere ohne Namen
addLocalTime(getStart1UID(), "Unbekannt", currentTime);
}
} else if (!wasStart1FoundLocally() && getStart1UID().length() > 0) {
// Sende Zeit an Online-API wenn User online gefunden wurde
sendTimeToOnlineAPI(1, getStart1UID(), currentTime / 1000.0);
} else {
// Kein User gefunden - speichere Zeit ohne UID und Namen
addLocalTime("", "Spieler " + String((localTimes.size() + 1)),
currentTime);
}
}
}
if (action == "start" && press == 2 && lane == 2) {
@@ -84,6 +103,25 @@ void IndividualMode(const char *action, int press, int lane,
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);
} else {
// User lokal gefunden aber keine Daten - speichere ohne Namen
addLocalTime(getStart2UID(), "Unbekannt", currentTime);
}
} else if (!wasStart2FoundLocally() && getStart2UID().length() > 0) {
// Sende Zeit an Online-API wenn User online gefunden wurde
sendTimeToOnlineAPI(2, getStart2UID(), currentTime / 1000.0);
} else {
// Kein User gefunden - speichere Zeit ohne UID und Namen
addLocalTime("", "Spieler " + String((localTimes.size() + 1)),
currentTime);
}
}
}

View File

@@ -18,18 +18,16 @@
#include <debug.h>
#include <gamemodes.h>
#include <licenceing.h>
#include <preferencemanager.h>
#include <rfid.h>
#include <timesync.h>
#include <webserverrouter.h>
#include <wificlass.h>
#include <preferencemanager.h>
const char *firmwareversion = "1.0.0"; // Version der Firmware
// moved to preferencemanager.h
void setup() {
Serial.begin(115200);
@@ -52,7 +50,6 @@ void setup() {
loadWifiSettings();
loadLocationSettings();
setupWifi(); // WiFi initialisieren
setupOTA(&server);
@@ -61,13 +58,24 @@ void setup() {
setupLED();
setupMqttServer(); // MQTT Server initialisieren
// setupBattery();
// setupRFID();
setupRFID(); // RFID initialisieren (ganz einfach)
}
void loop() {
checkAutoReset();
loopMqttServer(); // MQTT Server in der Loop aufrufen
// 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;
@@ -21,6 +22,15 @@ struct TimerData1 {
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;
};
// Timer Struktur für Bahn 2
@@ -33,6 +43,7 @@ struct TimerData2 {
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
@@ -61,11 +72,21 @@ 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);
@@ -78,6 +99,7 @@ void loadBestTimes();
void saveSettings();
void loadSettings();
void loadWifiSettings();
void clearLocalLeaderboard();
void saveWifiSettings();
void loadLocationSettings();
void saveLocationSettings();

View File

@@ -2,8 +2,8 @@
#include <Arduino.h>
#include <Preferences.h>
#include <master.h>
#include <licenceing.h>
#include <master.h>
// Persist and load button configuration
void saveButtonConfig() {
@@ -21,19 +21,60 @@ void loadButtonConfig() {
preferences.end();
}
// Persist and load best times
// Persist and load local leaderboard
void saveBestTimes() {
preferences.begin("times", false);
preferences.putULong("best1", timerData1.bestTime);
preferences.putULong("best2", timerData2.bestTime);
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.end();
Serial.println("Lokales Leaderboard gespeichert: " +
String(localTimes.size()) + " Einträge");
}
void loadBestTimes() {
preferences.begin("times", true);
timerData1.bestTime = preferences.getULong("best1", 0);
timerData2.bestTime = preferences.getULong("best2", 0);
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.
localTimes.push_back(entry);
}
preferences.end();
Serial.println("Lokales Leaderboard geladen: " + String(localTimes.size()) +
" Einträge");
}
// Persist and load general settings
@@ -41,7 +82,11 @@ 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();
}
@@ -49,7 +94,11 @@ 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();
}

View File

@@ -1,187 +1,150 @@
#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() {
// I2C starten mit korrekten Pins
Wire.begin(SDA_PIN, SCL_PIN, 100000);
delay(100);
// 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
// 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 +153,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);
});
}

View File

@@ -1,15 +1,11 @@
// 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;
// Globale Zeitvariablen
struct timeval tv;
struct timezone tz;
@@ -90,8 +86,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

@@ -33,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) {
@@ -56,6 +56,7 @@ void setupRoutes() {
timerData1.bestTime = 0;
timerData2.bestTime = 0;
saveBestTimes();
clearLocalLeaderboard(); // Leere auch das lokale Leaderboard
DynamicJsonDocument doc(64);
doc["success"] = true;
String result;
@@ -83,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);
@@ -100,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);
@@ -315,6 +323,71 @@ void setupRoutes() {
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();