Compare commits

...

11 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
30 changed files with 6740 additions and 645 deletions

82
API.md
View File

@@ -7,87 +7,87 @@ All API endpoints return JSON unless otherwise noted.
## Static Files ## Static Files
| Route | Method | Description | Response Type | | Route | Method | Description | Response Type |
|------------------|--------|------------------------------|--------------| | --------------- | ------ | ---------------------- | ------------- |
| `/` | GET | Main page | HTML | | `/` | GET | Main page | HTML |
| `/settings` | GET | Settings page | HTML | | `/settings` | GET | Settings page | HTML |
| `/rfid` | GET | RFID page | HTML | | `/rfid` | GET | RFID page | HTML |
| `/firmware.bin` | GET | Firmware file (SPIFFS) | Binary | | `/firmware.bin` | GET | Firmware file (SPIFFS) | Binary |
--- ---
## Timer & Data ## Timer & Data
| Route | Method | Description | Request Body/Params | Response Example | | Route | Method | Description | Request Body/Params | Response Example |
|-------------------|--------|-------------------------------------|--------------------|------------------| | ----------------- | ------ | --------------------------------- | ------------------- | --------------------- |
| `/api/data` | GET | Get current timer and status data | | `{...}` | | `/api/data` | GET | Get current timer and status data | | `{...}` |
| `/api/reset-best` | POST | Reset best times | | `{ "success": true }` | | `/api/reset-best` | POST | Reset best times | | `{ "success": true }` |
--- ---
## Button Learning ## Button Learning
| Route | Method | Description | Request Body/Params | Response Example | | Route | Method | Description | Request Body/Params | Response Example |
|------------------------|--------|-------------------------------------|--------------------|------------------| | --------------------- | ------ | --------------------------------- | ------------------- | ------------------------------------------------------- |
| `/api/unlearn-button` | POST | Remove all button assignments | | `{ "success": true }` | | `/api/unlearn-button` | POST | Remove all button assignments | | `{ "success": true }` |
| `/api/start-learning` | POST | Start button learning mode | | `{ "success": true }` | | `/api/start-learning` | POST | Start button learning mode | | `{ "success": true }` |
| `/api/stop-learning` | POST | Stop 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/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, ... }` | | `/api/buttons/status` | GET | Get button assignment and voltage | | `{ "lane1Start": true, "lane1StartVoltage": 3.3, ... }` |
--- ---
## Settings ## Settings
| Route | Method | Description | Request Body/Params | Response Example | | 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/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 }` | | `/api/get-settings` | GET | Get current timer settings | | `{ "maxTime": 300, "maxTimeDisplay": 20, "minTimeForLeaderboard": 5 }` |
--- ---
## WiFi Configuration ## WiFi Configuration
| Route | Method | Description | Request Body/Params | Response Example | | Route | Method | Description | Request Body/Params | Response Example |
|-------------------|--------|-------------------------------------|--------------------|------------------| | --------------- | ------ | ---------------------------------- | -------------------------------- | -------------------------------------- |
| `/api/set-wifi` | POST | Set WiFi SSID and password | `ssid`, `password` (form params) | `{ "success": true }` | | `/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": "..." }` | | `/api/get-wifi` | GET | Get current WiFi SSID and password | | `{ "ssid": "...", "password": "..." }` |
--- ---
## Location Configuration ## Location Configuration
| Route | Method | Description | Request Body/Params | Response Example | | Route | Method | Description | Request Body/Params | Response Example |
|----------------------|--------|-------------------------------------|--------------------|------------------| | ------------------- | ------ | ------------------------ | -------------------------- | ------------------------- |
| `/api/set-location` | POST | Set location name and ID | `id`, `name` (form params) | `{ "success": true }` | | `/api/set-location` | POST | Set location name and ID | `id`, `name` (form params) | `{ "success": true }` |
| `/api/get-location` | GET | Get current location | | `{ "locationid": "..." }` | | `/api/get-location` | GET | Get current location | | `{ "locationid": "..." }` |
--- ---
## Button Update & Mode ## Button Update & Mode
| Route | Method | Description | Request Body/Params | Response Example | | Route | Method | Description | Request Body/Params | Response Example |
|----------------------|--------|-------------------------------------|--------------------|------------------| | -------------------- | ------ | ------------------------------- | ------------------------------------------------ | -------------------------- |
| `/api/updateButtons` | GET | Trigger MQTT update for buttons | | `{ "success": true }` | | `/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/set-mode` | POST | Set operational mode | `mode` (form param: "individual" or "wettkampf") | `{ "success": true }` |
| `/api/get-mode` | GET | Get current operational mode | | `{ "mode": "individual" }` | | `/api/get-mode` | GET | Get current operational mode | | `{ "mode": "individual" }` |
--- ---
## System Info ## System Info
| Route | Method | Description | Request Body/Params | Response Example | | 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 }` | | `/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 ## WebSocket
| Route | Description | | Route | Description |
|---------|------------------------------------| | ----- | ----------------------------------- |
| `/ws` | WebSocket endpoint for live updates| | `/ws` | WebSocket endpoint for live updates |
--- ---
**All API endpoints return JSON unless otherwise noted. POST requests expect form parameters (not JSON body).** **All API endpoints return JSON unless otherwise noted. POST requests expect form parameters (not JSON body).**

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 → Verlernt alle Button-Zuordnungen
POST /api/set-max-time POST /api/set-max-time
→ Setzt die maximale Zeit und maxTimeDisplay → Setzt die maximale Zeit, maxTimeDisplay und minTimeForLeaderboard
GET /api/get-settings GET /api/get-settings
→ Gibt die aktuellen Einstellungen zurück → Gibt die aktuellen Einstellungen zurück

Binary file not shown.

View File

@@ -53,6 +53,32 @@ body {
border-radius: 10px; 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 { .settings-btn {
position: fixed; position: fixed;
top: 20px; top: 20px;
@@ -82,7 +108,7 @@ body {
.heartbeat-indicators { .heartbeat-indicators {
position: fixed; position: fixed;
top: 20px; top: 20px;
right: 90px; right: 160px;
display: flex; display: flex;
gap: 15px; gap: 15px;
z-index: 1000; z-index: 1000;
@@ -93,6 +119,56 @@ body {
border: 1px solid rgba(255, 255, 255, 0.2); 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 { .heartbeat-indicator {
width: 20px; width: 20px;
height: 20px; height: 20px;
@@ -300,9 +376,10 @@ body {
transition: transform 0.3s ease; transition: transform 0.3s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: flex-start;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
position: relative;
} }
.lane h2 { .lane h2 {
@@ -312,6 +389,7 @@ body {
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif; font-family: "Segoe UI", Arial, sans-serif;
flex-shrink: 0;
} }
.swimmer-name { .swimmer-name {
@@ -344,21 +422,53 @@ body {
} }
.time-display { .time-display {
font-size: clamp(3rem, 9vw, 10rem); font-size: clamp(3rem, 13vw, 13rem);
font-weight: bold; font-weight: bold;
margin: clamp(10px, 1vh, 15px) 0; margin: clamp(10px, 1vh, 15px) 0;
font-family: "Courier New", monospace; font-family: "Courier New", monospace;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
line-height: 1; line-height: 1;
position: relative;
z-index: 1;
flex-shrink: 0;
order: 1;
} }
.status { .status {
font-size: clamp(3rem, 1.8vw, 1.2rem); font-size: clamp(1.5rem, 4vw, 5rem);
margin: clamp(8px, 1vh, 12px) 0; margin: clamp(8px, 1vh, 12px) 0;
padding: clamp(6px, 1vh, 10px) clamp(12px, 2vw, 18px); padding: clamp(6px, 1vh, 10px) clamp(12px, 2vw, 18px);
border-radius: 20px; border-radius: 20px;
display: inline-block; display: inline-block;
font-weight: 600; 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 { .status.finished {
@@ -428,20 +538,40 @@ body {
border-radius: 15px; border-radius: 15px;
padding: clamp(10px, 1.5vh, 15px); padding: clamp(10px, 1.5vh, 15px);
margin: 1vh 0 0 0; margin: 1vh 0 0 0;
width: 50%; width: clamp(320px, 80vw, 960px);
max-width: 50%; max-width: 960px;
text-align: center; text-align: center;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
flex-shrink: 0; flex-shrink: 0;
align-self: center; 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 { .best-times h3 {
font-size: clamp(0.9rem, 1.8vw, 1.1rem); font-size: clamp(0.9rem, 1.8vw, 1.1rem);
margin-bottom: clamp(5px, 0.5vh, 8px); margin: 0 auto;
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif; font-family: "Segoe UI", Arial, sans-serif;
text-align: center;
} }
.best-time-row { .best-time-row {
@@ -455,6 +585,118 @@ body {
border-radius: 8px; 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 { .learning-mode {
background: rgba(245, 157, 15, 0.2); background: rgba(245, 157, 15, 0.2);
border: 2px solid #f59d0f; border: 2px solid #f59d0f;

View File

@@ -15,7 +15,8 @@
<div> <div>
<div class="banner-text">⚠️ Niedrige Batterie erkannt!</div> <div class="banner-text">⚠️ Niedrige Batterie erkannt!</div>
<div class="banner-devices" id="battery-devices"> <div class="banner-devices" id="battery-devices">
Deine 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> </div>
</div> </div>
@@ -23,6 +24,7 @@
</div> </div>
<img src="/pictures/erlebniss.png" class="logo" alt="NinjaCross Logo" /> <img src="/pictures/erlebniss.png" class="logo" alt="NinjaCross Logo" />
<a href="/leaderboard.html" class="leaderboard-btn">🏆</a>
<a href="/settings" class="settings-btn">⚙️</a> <a href="/settings" class="settings-btn">⚙️</a>
<div class="heartbeat-indicators"> <div class="heartbeat-indicators">
@@ -47,41 +49,32 @@
<div id="learning-display" class="learning-mode" style="display: none"> <div id="learning-display" class="learning-mode" style="display: none">
<h3>📚 Lernmodus aktiv</h3> <h3>📚 Lernmodus aktiv</h3>
<p> <p>Drücke jetzt den Button für: <span id="learning-button"></span></p>
Drücke jetzt den Button für: <span id="learning-button"></span>
</p>
</div> </div>
<div class="timer-container"> <div class="timer-container">
<div class="lane"> <div class="lane">
<div id="name1" class="swimmer-name" style="display: none"></div> <div id="name1" class="swimmer-name" style="display: none"></div>
<h2>🏊‍♀️ Bahn 1</h2> <h2>🏊‍♀️ Bahn 1</h2>
<div id="time1" class="time-display">00.00</div>
<div id="status1" class="status standby"> <div id="status1" class="status standby">
Standby: Drücke beide Buttons einmal Standby: Drücke beide Buttons einmal
</div> </div>
<div id="time1" class="time-display">00.00</div>
</div> </div>
<div class="lane"> <div class="lane">
<div id="name2" class="swimmer-name" style="display: none"></div> <div id="name2" class="swimmer-name" style="display: none"></div>
<h2>🏊‍♂️ Bahn 2</h2> <h2>🏊‍♂️ Bahn 2</h2>
<div id="time2" class="time-display">00.00</div>
<div id="status2" class="status standby"> <div id="status2" class="status standby">
Standby: Drücke beide Buttons einmal Standby: Drücke beide Buttons einmal
</div> </div>
<div id="time2" class="time-display">00.00</div>
</div> </div>
</div> </div>
<div class="best-times"> <div class="best-times">
<h3>🏆 Deine Bestzeiten heute</h3> <h3>🏆 Lokales Leaderboard</h3>
<div class="best-time-row"> <div id="leaderboard-container"></div>
<span>Bahn 1:</span>
<span id="best1">--.-</span>
</div>
<div class="best-time-row">
<span>Bahn 2:</span>
<span id="best2">--.-</span>
</div>
</div> </div>
<script> <script>
@@ -97,6 +90,7 @@
let learningButton = ""; let learningButton = "";
let name1 = ""; let name1 = "";
let name2 = ""; let name2 = "";
let leaderboardData = [];
// Lane Configuration // Lane Configuration
let laneConfigType = 0; // 0=Identical, 1=Different let laneConfigType = 0; // 0=Identical, 1=Different
@@ -193,24 +187,18 @@
} }
// Namen-Handling // Namen-Handling
if ( if ((data.name == "" || !data.name) && data.lane == "start1") {
(data.firstname == "" || data.lastname == "") &&
data.lane == "start1"
) {
name1 = ""; name1 = "";
} }
if ( if ((data.name == "" || !data.name) && data.lane == "start2") {
(data.firstname == "" || data.lastname == "") &&
data.lane == "start2"
) {
name2 = ""; name2 = "";
} }
if (data.firstname && data.lastname && data.lane) { if (data.name && data.lane) {
if (data.lane === "start1") { if (data.lane === "start1") {
name1 = `${data.firstname} ${data.lastname}`; name1 = data.name;
} else if (data.lane === "start2") { } else if (data.lane === "start2") {
name2 = `${data.firstname} ${data.lastname}`; name2 = data.name;
} }
updateDisplay(); updateDisplay();
} }
@@ -343,7 +331,93 @@
function formatTime(seconds) { function formatTime(seconds) {
if (seconds === 0) return "00.00"; 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() { function updateDisplay() {
@@ -368,18 +442,72 @@
document.getElementById("time1").textContent = formatTime(display1); document.getElementById("time1").textContent = formatTime(display1);
const time1Element = document.getElementById("time1");
const lane1Element = time1Element.closest(".lane");
const h2_1 = lane1Element.querySelector("h2");
if (!lane1Connected) { if (!lane1Connected) {
s1.className = "status standby"; s1.className = "status standby large-status";
s1.textContent = "Standby: Drücke beide Buttons einmal"; 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 { } else {
s1.className = `status ${status1}`; 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) { switch (status1) {
case "ready": case "ready":
s1.textContent = "Bereit für den Start!"; s1.textContent = "Bereit für den Start!";
break; break;
case "running": case "running":
s1.textContent = "Läuft - Du schaffst das!"; s1.textContent = "Läuft - Gib alles!";
break; break;
case "finished": case "finished":
s1.textContent = "Geschafft!"; s1.textContent = "Geschafft!";
@@ -394,18 +522,72 @@
document.getElementById("time2").textContent = formatTime(display2); document.getElementById("time2").textContent = formatTime(display2);
const time2Element = document.getElementById("time2");
const lane2Element = time2Element.closest(".lane");
const h2_2 = lane2Element.querySelector("h2");
if (!lane2Connected) { if (!lane2Connected) {
s2.className = "status standby"; s2.className = "status standby large-status";
s2.textContent = "Standby: Drücke beide Buttons einmal"; 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 { } else {
s2.className = `status ${status2}`; 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) { switch (status2) {
case "ready": case "ready":
s2.textContent = "Bereit für den Start!"; s2.textContent = "Bereit für den Start!";
break; break;
case "running": case "running":
s2.textContent = "Läuft - Du schaffst das!"; s2.textContent = "Läuft - Gib alles!";
break; break;
case "finished": case "finished":
s2.textContent = "Geschafft!"; s2.textContent = "Geschafft!";
@@ -418,10 +600,7 @@
} }
} }
document.getElementById("best1").textContent = // Leaderboard wird separat geladen
best1 > 0 ? formatTime(best1) + "s" : "--.-";
document.getElementById("best2").textContent =
best2 > 0 ? formatTime(best2) + "s" : "--.-";
// Namen anzeigen/verstecken // Namen anzeigen/verstecken
const name1Element = document.getElementById("name1"); const name1Element = document.getElementById("name1");
@@ -482,14 +661,17 @@
updateLaneDisplay(); updateLaneDisplay();
}) })
.catch((error) => .catch((error) =>
console.error("Fehler beim Laden der Lane-Schwierigkeits-Konfiguration:", error) console.error(
"Fehler beim Laden der Lane-Schwierigkeits-Konfiguration:",
error
)
); );
} }
function updateLaneDisplay() { function updateLaneDisplay() {
const lane1Title = document.querySelector('.lane h2'); const lane1Title = document.querySelector(".lane h2");
const lane2Title = document.querySelectorAll('.lane h2')[1]; const lane2Title = document.querySelectorAll(".lane h2")[1];
if (laneConfigType === 0) { if (laneConfigType === 0) {
// Identische Lanes // Identische Lanes
lane1Title.textContent = "🏊‍♀️ Bahn 1"; lane1Title.textContent = "🏊‍♀️ Bahn 1";
@@ -498,9 +680,11 @@
// Unterschiedliche Lanes // Unterschiedliche Lanes
const lane1Icon = lane1DifficultyType === 0 ? "🟢" : "🔴"; const lane1Icon = lane1DifficultyType === 0 ? "🟢" : "🔴";
const lane2Icon = lane2DifficultyType === 0 ? "🟢" : "🔴"; const lane2Icon = lane2DifficultyType === 0 ? "🟢" : "🔴";
const lane1Difficulty = lane1DifficultyType === 0 ? "Leicht" : "Schwer"; const lane1Difficulty =
const lane2Difficulty = lane2DifficultyType === 0 ? "Leicht" : "Schwer"; lane1DifficultyType === 0 ? "Leicht" : "Schwer";
const lane2Difficulty =
lane2DifficultyType === 0 ? "Leicht" : "Schwer";
lane1Title.textContent = `${lane1Icon} Bahn 1 (${lane1Difficulty})`; lane1Title.textContent = `${lane1Icon} Bahn 1 (${lane1Difficulty})`;
lane2Title.textContent = `${lane2Icon} Bahn 2 (${lane2Difficulty})`; lane2Title.textContent = `${lane2Icon} Bahn 2 (${lane2Difficulty})`;
} }
@@ -530,6 +714,10 @@
// Initial load // Initial load
syncFromBackend(); syncFromBackend();
loadLaneConfig(); loadLaneConfig();
loadLeaderboard();
// Leaderboard alle 5 Sekunden aktualisieren
setInterval(loadLeaderboard, 5000);
</script> </script>
</body> </body>
</html> </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

@@ -62,7 +62,7 @@
type="button" type="button"
id="readUidBtn" id="readUidBtn"
class="read-uid-btn" class="read-uid-btn"
onclick="readRFIDUID()" onclick="toggleRFIDReading()"
> >
📡 Read Chip 📡 Read Chip
</button> </button>
@@ -70,47 +70,16 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="vorname">Vorname <span class="required">*</span></label> <label for="name">Name <span class="required">*</span></label>
<input <input
type="text" type="text"
id="vorname" id="name"
name="vorname" name="name"
placeholder="Vorname eingeben" placeholder="Name eingeben"
required required
/> />
</div> </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"> <div class="btn-container">
<button type="submit" class="btn btn-primary">💾 Speichern</button> <button type="submit" class="btn btn-primary">💾 Speichern</button>
<button type="button" class="btn btn-secondary" onclick="clearForm()"> <button type="button" class="btn btn-secondary" onclick="clearForm()">
@@ -124,60 +93,8 @@
// Globale Variablen // Globale Variablen
let rfidData = []; let rfidData = [];
let isLoading = false; let isLoading = false;
let DBUrl = "ninja.reptilfpv.de:3000"; // Lokale Benutzer-Speicherung (geht bei Neustart verloren)
var APIKey; let localUsers = [];
// 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";
}
});
// Form Submit Handler // Form Submit Handler
document document
@@ -189,46 +106,40 @@
// Daten aus dem Formular holen // Daten aus dem Formular holen
const uid = document.getElementById("uid").value.trim(); const uid = document.getElementById("uid").value.trim();
const vorname = document.getElementById("vorname").value.trim(); const name = document.getElementById("name").value.trim();
const nachname = document.getElementById("nachname").value.trim();
const geburtsdatum = document.getElementById("geburtsdatum").value;
// Validierung // Validierung
if (!uid || !vorname || !nachname || !geburtsdatum) { if (!uid || !name) {
showErrorMessage("Bitte füllen Sie alle Pflichtfelder aus!"); showErrorMessage("Bitte füllen Sie alle Pflichtfelder aus!");
return; return;
} }
// Alter berechnen
const alter = calculateAge(geburtsdatum);
if (alter < 0) {
showErrorMessage(
"Das Geburtsdatum kann nicht in der Zukunft liegen!"
);
return;
}
// Loading State // Loading State
setLoadingState(true); setLoadingState(true);
try { 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`, { const response = await fetch(`/api/users/insert`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(APIKey && { Authorization: `Bearer ${APIKey}` }),
}, },
body: JSON.stringify({ body: JSON.stringify(requestData),
uid: uid,
vorname: vorname,
nachname: nachname,
geburtsdatum: geburtsdatum,
alter: alter, // Berechnetes Alter wird mit gesendet
}),
}); });
console.log("Response Status:", response.status);
console.log("Response Headers:", response.headers);
const result = await response.json(); const result = await response.json();
console.log("Response Result:", result);
if (result.success) { if (result.success) {
// Erfolg anzeigen // Erfolg anzeigen
@@ -313,7 +224,6 @@
function clearForm() { function clearForm() {
document.getElementById("rfidForm").reset(); document.getElementById("rfidForm").reset();
document.getElementById("ageDisplay").style.display = "none";
document.getElementById("uid").focus(); document.getElementById("uid").focus();
} }
@@ -321,14 +231,13 @@
window.addEventListener("load", function () { window.addEventListener("load", function () {
document.getElementById("uid").focus(); document.getElementById("uid").focus();
checkServerStatus(); checkServerStatus();
loadLicence();
}); });
// Enter-Taste in UID Feld zum nächsten Feld springen // Enter-Taste in UID Feld zum nächsten Feld springen
document.getElementById("uid").addEventListener("keydown", function (e) { document.getElementById("uid").addEventListener("keydown", function (e) {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
document.getElementById("vorname").focus(); document.getElementById("name").focus();
} }
}); });
@@ -340,34 +249,170 @@
e.target.value = value; 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() { async function readRFIDUID() {
const readBtn = document.getElementById("readUidBtn"); const readBtn = document.getElementById("readUidBtn");
const uidInput = document.getElementById("uid"); const uidInput = document.getElementById("uid");
// Button Status ändern
readBtn.disabled = true; readBtn.disabled = true;
readBtn.className = "read-uid-btn reading"; readBtn.innerHTML = "📡 Lese...";
readBtn.innerHTML = "📡 Lese UID...";
try { try {
// API Aufruf zum RFID Reader
const response = await fetch(`/api/rfid/read`, { const response = await fetch(`/api/rfid/read`, {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(APIKey && { Authorization: `Bearer ${APIKey}` }),
}, },
}); });
const result = await response.json(); const result = await response.json();
if (result.success && result.uid) { if (result.success && result.uid) {
// UID in das Eingabefeld setzen uidInput.value = result.uid;
uidInput.value = result.uid
.match(/.{1,2}/g)
.join(":")
.toUpperCase();
uidInput.focus(); uidInput.focus();
// Visuelles Feedback // Visuelles Feedback
@@ -376,38 +421,20 @@
uidInput.style.borderColor = "#e1e5e9"; uidInput.style.borderColor = "#e1e5e9";
}, 2000); }, 2000);
showSuccessMessage("UID erfolgreich gelesen!"); showSuccessMessage("UID gelesen: " + result.uid);
// Automatisch zum nächsten Feld springen // Automatisch zum nächsten Feld springen
setTimeout(() => { setTimeout(() => {
document.getElementById("vorname").focus(); document.getElementById("name").focus();
}, 500); }, 500);
} else { } else {
// Fehler beim Lesen showErrorMessage("Keine Karte erkannt");
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);
} }
} catch (error) { } catch (error) {
console.error("Fehler beim Lesen der UID:", error); console.error("RFID Read Error:", error);
showErrorMessage( showErrorMessage("Fehler beim Lesen");
"Verbindungsfehler zum RFID Reader. Bitte prüfen Sie die Verbindung."
);
// UID Feld rot markieren
uidInput.style.borderColor = "#dc3545";
setTimeout(() => {
uidInput.style.borderColor = "#e1e5e9";
}, 3000);
} finally { } finally {
// Button Status zurücksetzen
readBtn.disabled = false; readBtn.disabled = false;
readBtn.className = "read-uid-btn";
readBtn.innerHTML = "📡 Read Chip"; readBtn.innerHTML = "📡 Read Chip";
} }
} }
@@ -415,9 +442,7 @@
async function checkServerStatus() { async function checkServerStatus() {
try { try {
const response = await fetch("/api/health", { const response = await fetch("/api/health", {
headers: { headers: {},
...(APIKey && { Authorization: `Bearer ${APIKey}` }),
},
}); });
const data = await response.json(); const data = await response.json();
@@ -436,16 +461,19 @@
} }
} }
function loadLicence() { // Seite laden - RFID Status initialisieren
fetch("/api/get-licence") document.addEventListener("DOMContentLoaded", function () {
.then((response) => response.json()) // Status Polling stoppen falls aktiv
.then((data) => { stopStatusPolling();
APIKey = data.licence || "";
}) // Server Status prüfen
.catch((error) => checkServerStatus();
showMessage("Fehler beim Laden der Lizenz", "error") });
);
} // Seite verlassen - RFID Reading komplett stoppen
window.addEventListener("beforeunload", function () {
stopRFIDReading();
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -38,6 +38,7 @@
<!-- Navigation Buttons --> <!-- Navigation Buttons -->
<div class="nav-buttons"> <div class="nav-buttons">
<a href="/" class="nav-button">🏠 Hauptseite</a> <a href="/" class="nav-button">🏠 Hauptseite</a>
<a href="/rfid.html" class="nav-button">🏷️ RFID</a>
</div> </div>
<!-- Date & Time Section --> <!-- Date & Time Section -->
@@ -181,6 +182,18 @@
title="Zeit nach der die angezeigte Zeit zurückgesetzt wird" title="Zeit nach der die angezeigte Zeit zurückgesetzt wird"
/> />
</div> </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"> <div class="button-group">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
💾 Einstellungen speichern 💾 Einstellungen speichern
@@ -717,6 +730,8 @@
document.getElementById("maxTime").value = data.maxTime || 300; document.getElementById("maxTime").value = data.maxTime || 300;
document.getElementById("maxTimeDisplay").value = document.getElementById("maxTimeDisplay").value =
data.maxTimeDisplay || 20; data.maxTimeDisplay || 20;
document.getElementById("minTimeForLeaderboard").value =
data.minTimeForLeaderboard || 5;
}) })
.catch((error) => .catch((error) =>
showMessage("Fehler beim Laden der Einstellungen", "error") showMessage("Fehler beim Laden der Einstellungen", "error")
@@ -970,6 +985,9 @@
const maxTimeDisplay = parseInt( const maxTimeDisplay = parseInt(
document.getElementById("maxTimeDisplay").value document.getElementById("maxTimeDisplay").value
); );
const minTimeForLeaderboard = parseInt(
document.getElementById("minTimeForLeaderboard").value
);
fetch("/api/set-max-time", { fetch("/api/set-max-time", {
method: "POST", method: "POST",
@@ -980,7 +998,9 @@
"maxTime=" + "maxTime=" +
encodeURIComponent(maxTime) + encodeURIComponent(maxTime) +
"&maxTimeDisplay=" + "&maxTimeDisplay=" +
encodeURIComponent(maxTimeDisplay), encodeURIComponent(maxTimeDisplay) +
"&minTimeForLeaderboard=" +
encodeURIComponent(minTimeForLeaderboard),
}) })
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .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 = build_flags =
-DBOARD_HAS_PSRAM -DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue -mfix-esp32-psram-cache-issue
-DBATTERY_PIN=16
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
targets = uploadfs targets = uploadfs
board_build.psram = disabled board_build.psram = disabled
lib_deps = lib_deps =
@@ -29,8 +32,7 @@ lib_deps =
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing_OTA] [env:esp32thing_OTA]
board = esp32thing board = esp32thing
@@ -50,8 +52,9 @@ lib_deps =
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing] [env:esp32thing]
board = esp32thing_plus board = esp32thing_plus
@@ -69,8 +72,7 @@ lib_deps =
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing_CI] [env:esp32thing_CI]
platform = espressif32 platform = espressif32
@@ -87,21 +89,47 @@ lib_deps =
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32-s3-devkitc-1] [env:um_feathers3]
board = esp32-s3-devkitc-1 board = um_feathers3
monitor_speed = 115200 monitor_speed = 115200
board_upload.flash_size = 16MB board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv board_build.partitions = default_16MB.csv
board_upload.wait_for_upload_port = false
build_flags = build_flags =
-DARDUINO_USB_CDC_ON_BOOT=1 -D ARDUINO_USB_CDC_ON_BOOT=1
-DBATTERY_PIN=35 -D BATTERY_PIN=35
-D ARDUINO_USB_MODE=1
lib_deps = lib_deps =
bblanchon/ArduinoJson@^7.4.1 bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:um_feathers3_debug]
board = um_feathers3
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
board_upload.wait_for_upload_port = false
build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1
-D BATTERY_PIN=35
-D ARDUINO_USB_MODE=0
build_type = debug
debug_speed = 12000
debug_tool = esp-builtin
upload_port = COM5
monitor_speed = 115200
monitor_port = COM7
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4

View File

@@ -2,6 +2,8 @@
#include "master.h" #include "master.h"
#include <Arduino.h> #include <Arduino.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <PicoMQTT.h> #include <PicoMQTT.h>
@@ -44,6 +46,20 @@ typedef struct {
// MQTT-Server-Instanz // MQTT-Server-Instanz
PicoMQTT::Server mqtt; 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, * Liest eine Button-JSON-Nachricht, extrahiert Typ, MAC und Timestamp,
* prüft die Button-Zuordnung und ruft die entsprechende Handler-Funktion auf. * 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. * sendet diese ggf. als JSON an das Frontend.
*/ */
void readRFIDfromButton(const char *topic, const char *payload) { 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 // Create a JSON document to hold the button press data
StaticJsonDocument<256> doc; StaticJsonDocument<256> doc;
DeserializationError error = deserializeJson(doc, payload); DeserializationError error = deserializeJson(doc, payload);
if (!error) { if (!error) {
const char *mac = doc["buttonmac"] | "unknown";
const char *uid = doc["uid"] | "unknown"; const char *uid = doc["uid"] | "unknown";
Serial.printf("RFID Read from Button:\n"); 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); 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 // 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 // Check if the buttonmac matches buttonConfigs.start1.mac
if (memcmp(macBytes.data(), buttonConfigs.start1.mac, 6) == 0) { if (memcmp(macBytes.data(), buttonConfigs.start1.mac, 6) == 0) {
// Fetch user data // Prüfe ob Lane 1 bereit ist
UserData userData = checkUser(uid); if (timerData1.isRunning || timerData1.isArmed) {
if (userData.exists) { Serial.println("Lane 1 läuft - ignoriere RFID: " + String(uid));
// Log user data return;
Serial.printf("User found for start1: %s %s, Alter: %d\n", }
userData.firstname.c_str(), userData.lastname.c_str(),
userData.alter);
// 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; StaticJsonDocument<128> messageDoc;
messageDoc["firstname"] = userData.firstname; messageDoc["name"] = userData.firstname;
messageDoc["lastname"] = userData.lastname; messageDoc["lane"] = "start1";
messageDoc["lane"] = "start1"; // Add lane information
String message; String message;
serializeJson(messageDoc, 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", Serial.printf("Pushed user data for start1 to frontend: %s\n",
message.c_str()); message.c_str());
} else { } 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 // Check if the buttonmac matches buttonConfigs.start2.mac
else if (memcmp(macBytes.data(), buttonConfigs.start2.mac, 6) == 0) { else if (memcmp(macBytes.data(), buttonConfigs.start2.mac, 6) == 0) {
// Fetch user data // Prüfe ob Lane 2 bereit ist
UserData userData = checkUser(uid); if (timerData2.isRunning || timerData2.isArmed) {
if (userData.exists) { Serial.println("Lane 2 nicht bereit - ignoriere RFID: " + String(uid));
// Log user data return;
Serial.printf("User found for start2: %s %s, Alter: %d\n", }
userData.firstname.c_str(), userData.lastname.c_str(),
userData.alter);
// 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; StaticJsonDocument<128> messageDoc;
messageDoc["firstname"] = userData.firstname; messageDoc["name"] = userData.firstname;
messageDoc["lastname"] = userData.lastname; messageDoc["lane"] = "start2";
messageDoc["lane"] = "start2"; // Add lane information
String message; String message;
serializeJson(messageDoc, 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", Serial.printf("Pushed user data for start2 to frontend: %s\n",
message.c_str()); message.c_str());
} else { } 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 { } else {
Serial.println("Button MAC does not match start1.mac or start2.mac"); 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) { mqtt.subscribe("#", [](const char *topic, const char *payload) {
// Message received callback // Message received callback
// Serial.printf("Received message on topic '%s': %s\n", topic, payload); // Serial.printf("Received message on topic '%s': %s\n", topic, payload);
if (strncmp(topic, "aquacross/button/", 17) == 0) { if (strncmp(topic, "aquacross/button/rfid/", 22) == 0) {
readButtonJSON(topic, payload);
} else if (strncmp(topic, "aquacross/button/rfid/", 22) == 0) {
readRFIDfromButton(topic, payload); readRFIDfromButton(topic, payload);
// Handle RFID read messages // Handle RFID read messages
} else if (strncmp(topic, "aquacross/button/", 17) == 0) {
readButtonJSON(topic, payload);
} else if (strncmp(topic, "aquacross/battery/", 17) == 0) { } else if (strncmp(topic, "aquacross/battery/", 17) == 0) {
handleBatteryTopic(topic, payload); handleBatteryTopic(topic, payload);
} else if (strncmp(topic, "heartbeat/alive/", 16) == 0) { } else if (strncmp(topic, "heartbeat/alive/", 16) == 0) {

View File

@@ -3,13 +3,23 @@
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <ESPAsyncWebServer.h> #include <ESPAsyncWebServer.h>
#include <HTTPClient.h> #include <HTTPClient.h>
#include <algorithm>
#include <preferencemanager.h> #include <preferencemanager.h>
#include <vector>
const char *BACKEND_SERVER = "https://ninja.reptilfpv.de"; const char *BACKEND_SERVER = "https://ninja.reptilfpv.de";
extern String extern String
licence; // Declare licence as an external variable defined elsewhere 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() { bool backendOnline() {
@@ -21,8 +31,8 @@ bool backendOnline() {
} }
HTTPClient http; HTTPClient http;
http.begin(String(BACKEND_SERVER) + "/v1/private/health"); http.begin(String(BACKEND_SERVER) + "/api/v1/private/health");
http.addHeader("Authorization", String("Bearer ") + BACKEND_TOKEN); http.addHeader("Authorization", String("Bearer ") + licence);
int httpCode = http.GET(); int httpCode = http.GET();
bool isOnline = (httpCode == HTTP_CODE_OK); bool isOnline = (httpCode == HTTP_CODE_OK);
@@ -45,47 +55,35 @@ struct UserData {
bool exists; 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 // Prüft, ob ein Benutzer mit der angegebenen UID in der Datenbank existiert und
// gibt dessen Daten zurück. // gibt dessen Daten zurück.
UserData checkUser(const String &uid) { UserData checkUser(const String &uid) {
UserData userData = {"", "", "", 0, false}; UserData userData = {"", "", "", 0, false};
String upperUid = uid;
upperUid.toUpperCase(); // UID in Großbuchstaben konvertieren
if (!backendOnline()) { // Lokale Benutzer durchsuchen
Serial.println("No internet connection, cannot check user."); for (const auto &user : localUsers) {
return userData; 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;
HTTPClient http; Serial.println("Lokaler Benutzer gefunden: " + user.name);
http.begin(String(BACKEND_SERVER) + "/v1/private/users/find"); return userData;
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;
userData.exists = responseDoc["exists"] | false;
} }
} 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; return userData;
} }
@@ -100,7 +98,7 @@ JsonDocument getAllLocations() {
HTTPClient http; HTTPClient http;
http.begin(String(BACKEND_SERVER) + "/v1/private/locations"); 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(); int httpCode = http.GET();
@@ -124,42 +122,47 @@ JsonDocument getAllLocations() {
bool userExists(const String &uid) { return checkUser(uid).exists; } bool userExists(const String &uid) { return checkUser(uid).exists; }
// Fügt einen neuen Benutzer in die Datenbank ein // Fügt einen neuen Benutzer in die Datenbank ein
bool enterUserData(const String &uid, const String &vorname, bool enterUserData(const String &uid, const String &name) {
const String &nachname, const String &geburtsdatum, String upperUid = uid;
int alter) { upperUid.toUpperCase(); // UID in Großbuchstaben konvertieren
if (!backendOnline()) {
Serial.println("No internet connection, cannot enter user data."); // Prüfen ob Benutzer bereits existiert
return false; for (const auto &user : localUsers) {
String userUpperUid = user.uid;
userUpperUid.toUpperCase();
if (userUpperUid == upperUid) {
Serial.println("Benutzer mit UID " + upperUid + " existiert bereits!");
return false;
}
} }
HTTPClient http; // Neuen Benutzer erstellen
http.begin(String(BACKEND_SERVER) + "/v1/private/users/insert"); LocalUser newUser;
http.addHeader("Content-Type", "application/json"); newUser.uid = upperUid; // UID in Großbuchstaben speichern
http.addHeader("Authorization", String("Bearer ") + BACKEND_TOKEN); newUser.name = name;
newUser.timestamp = millis();
// Create JSON payload // Benutzer zum lokalen Array hinzufügen
StaticJsonDocument<512> requestDoc; localUsers.push_back(newUser);
requestDoc["uid"] = uid;
requestDoc["firstname"] = vorname;
requestDoc["lastname"] = nachname;
requestDoc["geburtsdatum"] = geburtsdatum;
requestDoc["alter"] = alter;
String requestBody; Serial.println("Benutzer lokal gespeichert:");
serializeJson(requestDoc, requestBody); Serial.println("UID: " + upperUid);
Serial.println("Name: " + name);
Serial.println("Gespeicherte Benutzer: " + String(localUsers.size()));
int httpCode = http.POST(requestBody); return true;
}
bool success = (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_CREATED); // Gibt alle lokalen Benutzer zurück (für Debugging)
String getLocalUsersList() {
String result = "Lokale Benutzer (" + String(localUsers.size()) + "):\n";
if (success) { for (const auto &user : localUsers) {
Serial.println("User data successfully entered into database"); result += "- UID: " + user.uid + ", Name: " + user.name +
} else { ", Erstellt: " + String(user.timestamp) + "\n";
Serial.printf("Failed to enter user data, HTTP code: %d\n", httpCode);
} }
http.end(); return result;
return success;
} }
// Richtet die HTTP-Routen für die Backend-API ein (z.B. Health-Check, User- und // Richtet die HTTP-Routen für die Backend-API ein (z.B. Health-Check, User- und
@@ -175,14 +178,143 @@ void setupBackendRoutes(AsyncWebServer &server) {
}); });
server.on("/api/users", HTTP_GET, [](AsyncWebServerRequest *request) { server.on("/api/users", HTTP_GET, [](AsyncWebServerRequest *request) {
if (!backendOnline()) { // Lokale Benutzer als JSON zurückgeben
request->send(503, "application/json", DynamicJsonDocument doc(2048);
"{\"error\":\"Database not connected\"}"); JsonArray usersArray = doc.createNestedArray("users");
return;
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/ // Location routes /api/location/
server.on("/api/location/", HTTP_GET, [](AsyncWebServerRequest *request) { server.on("/api/location/", HTTP_GET, [](AsyncWebServerRequest *request) {
String result; String result;
@@ -221,5 +353,206 @@ void setupBackendRoutes(AsyncWebServer &server) {
// Andere Logik wie in getBestLocs // 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 // 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"); publishLaneStatus(1, "stopped");
Serial.println("Bahn 1 gestoppt - Zeit: " + String(currentTime / 1000.0) + Serial.println("Bahn 1 gestoppt - Zeit: " + String(currentTime / 1000.0) +
"s"); "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) { if (action == "start" && press == 2 && lane == 2) {
@@ -84,6 +103,25 @@ void IndividualMode(const char *action, int press, int lane,
publishLaneStatus(2, "stopped"); publishLaneStatus(2, "stopped");
Serial.println("Bahn 2 gestoppt - Zeit: " + String(currentTime / 1000.0) + Serial.println("Bahn 2 gestoppt - Zeit: " + String(currentTime / 1000.0) +
"s"); "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);
}
} }
} }
@@ -330,4 +368,4 @@ String getTimerDataJSON() {
String result; String result;
serializeJson(doc, result); serializeJson(doc, result);
return result; return result;
} }

View File

@@ -18,18 +18,16 @@
#include <debug.h> #include <debug.h>
#include <gamemodes.h> #include <gamemodes.h>
#include <licenceing.h> #include <licenceing.h>
#include <preferencemanager.h>
#include <rfid.h> #include <rfid.h>
#include <timesync.h> #include <timesync.h>
#include <webserverrouter.h> #include <webserverrouter.h>
#include <wificlass.h> #include <wificlass.h>
#include <preferencemanager.h>
const char *firmwareversion = "1.0.0"; // Version der Firmware const char *firmwareversion = "1.0.0"; // Version der Firmware
// moved to preferencemanager.h // moved to preferencemanager.h
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
@@ -52,7 +50,6 @@ void setup() {
loadWifiSettings(); loadWifiSettings();
loadLocationSettings(); loadLocationSettings();
setupWifi(); // WiFi initialisieren setupWifi(); // WiFi initialisieren
setupOTA(&server); setupOTA(&server);
@@ -61,13 +58,24 @@ void setup() {
setupLED(); setupLED();
setupMqttServer(); // MQTT Server initialisieren setupMqttServer(); // MQTT Server initialisieren
// setupBattery(); // setupBattery();
// setupRFID();
setupRFID(); // RFID initialisieren (ganz einfach)
} }
void loop() { void loop() {
checkAutoReset(); checkAutoReset();
loopMqttServer(); // MQTT Server in der Loop aufrufen
// MQTT hat höchste Priorität (wird zuerst verarbeitet)
loopMqttServer();
// WebSocket verarbeiten
loopWebSocket(); 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 <ESPAsyncWebServer.h>
#include <sys/time.h> #include <sys/time.h>
#include <time.h> #include <time.h>
#include <vector>
const char *ssidAP; const char *ssidAP;
const char *passwordAP = nullptr; const char *passwordAP = nullptr;
@@ -24,6 +25,14 @@ struct TimerData1 {
char RFIDUID[32] = ""; 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 // Timer Struktur für Bahn 2
struct TimerData2 { struct TimerData2 {
unsigned long startTime = 0; unsigned long startTime = 0;
@@ -63,7 +72,9 @@ bool learningMode = false;
int learningStep = 0; // 0=Start1, 1=Stop1, 2=Start2, 3=Stop2 int learningStep = 0; // 0=Start1, 1=Stop1, 2=Start2, 3=Stop2
unsigned long maxTimeBeforeReset = 300000; // 5 Minuten default unsigned long maxTimeBeforeReset = 300000; // 5 Minuten default
unsigned long maxTimeDisplay = 20000; // 20 Sekunden Standard (in ms) 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; String masterlocation;
int gamemode; // 0=Individual, 1=Wettkampf int gamemode; // 0=Individual, 1=Wettkampf
bool startCompetition = false; // Flag, ob der Timer gestartet wurde bool startCompetition = false; // Flag, ob der Timer gestartet wurde
@@ -73,6 +84,9 @@ int laneConfigType = 0; // 0=Identical, 1=Different
int lane1DifficultyType = 0; // 0=Light, 1=Heavy (difficulty) int lane1DifficultyType = 0; // 0=Light, 1=Heavy (difficulty)
int lane2DifficultyType = 0; // 0=Light, 1=Heavy (difficulty) int lane2DifficultyType = 0; // 0=Light, 1=Heavy (difficulty)
// Lokales Leaderboard
std::vector<LocalTime> localTimes;
// Function Declarations // Function Declarations
void OnDataRecv(const uint8_t *mac, const uint8_t *incomingData, int len); void OnDataRecv(const uint8_t *mac, const uint8_t *incomingData, int len);
void handleLearningMode(const uint8_t *mac); void handleLearningMode(const uint8_t *mac);
@@ -85,6 +99,7 @@ void loadBestTimes();
void saveSettings(); void saveSettings();
void loadSettings(); void loadSettings();
void loadWifiSettings(); void loadWifiSettings();
void clearLocalLeaderboard();
void saveWifiSettings(); void saveWifiSettings();
void loadLocationSettings(); void loadLocationSettings();
void saveLocationSettings(); void saveLocationSettings();

View File

@@ -21,19 +21,60 @@ void loadButtonConfig() {
preferences.end(); preferences.end();
} }
// Persist and load best times // Persist and load local leaderboard
void saveBestTimes() { void saveBestTimes() {
preferences.begin("times", false); preferences.begin("leaderboard", false);
preferences.putULong("best1", timerData1.bestTime);
preferences.putULong("best2", timerData2.bestTime); // 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(); preferences.end();
Serial.println("Lokales Leaderboard gespeichert: " +
String(localTimes.size()) + " Einträge");
} }
void loadBestTimes() { void loadBestTimes() {
preferences.begin("times", true); preferences.begin("leaderboard", true);
timerData1.bestTime = preferences.getULong("best1", 0);
timerData2.bestTime = preferences.getULong("best2", 0); // 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(); preferences.end();
Serial.println("Lokales Leaderboard geladen: " + String(localTimes.size()) +
" Einträge");
} }
// Persist and load general settings // Persist and load general settings
@@ -41,6 +82,7 @@ void saveSettings() {
preferences.begin("settings", false); preferences.begin("settings", false);
preferences.putULong("maxTime", maxTimeBeforeReset); preferences.putULong("maxTime", maxTimeBeforeReset);
preferences.putULong("maxTimeDisplay", maxTimeDisplay); preferences.putULong("maxTimeDisplay", maxTimeDisplay);
preferences.putULong("minTime", minTimeForLeaderboard);
preferences.putUInt("gamemode", gamemode); preferences.putUInt("gamemode", gamemode);
preferences.putUInt("laneConfigType", laneConfigType); preferences.putUInt("laneConfigType", laneConfigType);
preferences.putUInt("lane1Diff", lane1DifficultyType); preferences.putUInt("lane1Diff", lane1DifficultyType);
@@ -52,6 +94,7 @@ void loadSettings() {
preferences.begin("settings", true); preferences.begin("settings", true);
maxTimeBeforeReset = preferences.getULong("maxTime", 300000); maxTimeBeforeReset = preferences.getULong("maxTime", 300000);
maxTimeDisplay = preferences.getULong("maxTimeDisplay", 20000); maxTimeDisplay = preferences.getULong("maxTimeDisplay", 20000);
minTimeForLeaderboard = preferences.getULong("minTime", 5000);
gamemode = preferences.getUInt("gamemode", 0); gamemode = preferences.getUInt("gamemode", 0);
laneConfigType = preferences.getUInt("laneConfigType", 0); laneConfigType = preferences.getUInt("laneConfigType", 0);
lane1DifficultyType = preferences.getUInt("lane1Diff", 0); lane1DifficultyType = preferences.getUInt("lane1Diff", 0);

View File

@@ -1,189 +1,150 @@
#pragma once #pragma once
#include "databasebackend.h" #include <Adafruit_PN532.h>
#include <Arduino.h> #include <Arduino.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <MFRC522.h> #include <Wire.h>
#include <SPI.h>
// 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
// RFID Konfiguration // PN532 RFID Reader (mit IRQ und Reset-Pin)
#define RST_PIN 21 // Configurable, see typical pin layout above Adafruit_PN532 nfc(IRQ_PIN, RST_PIN);
#define SS_PIN 5 // Configurable, see typical pin layout above
MFRC522 mfrc522(SS_PIN, RST_PIN); // Create MFRC522 instance // RFID Variablen
std::map<String, unsigned long> bool rfidInitialized = false;
blockedUIDs; // Map to store blocked UIDs and their timestamps bool readingMode = false;
const unsigned long BLOCK_DURATION = 10 * 1000; // 10 Seconds in milliseconds
// Neue Variablen für API-basiertes Lesen
bool rfidReadRequested = false;
String lastReadUID = ""; String lastReadUID = "";
bool rfidReadSuccess = false; unsigned long lastReadTime = 0;
unsigned long rfidReadStartTime = 0;
const unsigned long RFID_READ_TIMEOUT =
10000; // 10 Sekunden Timeout für API Requests
// 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() { void setupRFID() {
// I2C starten mit korrekten Pins
Wire.begin(SDA_PIN, SCL_PIN, 100000);
delay(100);
// SPI und RFID initialisieren // PN532 initialisieren
SPI.begin(); // Init SPI bus if (!nfc.begin()) {
mfrc522.PCD_Init(); // Init MFRC522 Serial.println("RFID: PN532 nicht gefunden!");
delay(4); // Optional delay. Some boards need more time after init to be ready return;
mfrc522.PCD_DumpVersionToSerial(); // Show details of PCD - MFRC522 Card }
// Reader details
// 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 // Prüft ob RFID funktioniert
// bestimmte Zeit. bool checkRFID() {
void handleAutomaticRFID() { if (!rfidInitialized) {
if (!mfrc522.PICC_IsNewCardPresent()) { return false;
return; }
uint32_t versiondata = nfc.getFirmwareVersion();
return (versiondata != 0);
}
// Liest RFID-Karte - NICHT BLOCKIEREND
String readRFIDCard() {
if (!checkRFID()) {
return "";
} }
// Select one of the cards uint8_t uid[] = {0, 0, 0, 0, 0, 0, 0};
if (!mfrc522.PICC_ReadCardSerial()) { uint8_t uidLength;
return;
// 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 // UID zu String
String uid = ""; String uidString = "";
for (byte i = 0; i < mfrc522.uid.size; i++) { for (uint8_t i = 0; i < uidLength; i++) {
if (i > 0) if (i > 0)
uid += ":"; uidString += ":";
if (mfrc522.uid.uidByte[i] < 0x10) if (uid[i] < 0x10)
uid += "0"; uidString += "0";
uid += String(mfrc522.uid.uidByte[i], HEX); 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 static unsigned long lastCheck = 0;
unsigned long currentTime = millis();
if (blockedUIDs.find(uid) != blockedUIDs.end()) { // Nur alle 300ms prüfen (weniger belastend für MQTT)
if (currentTime - blockedUIDs[uid] < BLOCK_DURATION) { if (millis() - lastCheck < 300) {
Serial.print(F("UID blocked for 10 seconds. Remaining time: ")); return;
Serial.print((BLOCK_DURATION - (currentTime - blockedUIDs[uid])) / 1000); }
Serial.println(F(" seconds.")); lastCheck = millis();
Serial.println(uid);
return; // Versuchen zu lesen (mit kurzer Timeout)
} else { String uid = readRFIDCard();
// Remove the UID from the blocked list if the block duration has passed if (uid.length() > 0) {
blockedUIDs.erase(uid); // 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 // API Routes
// 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.
void setupRFIDRoute(AsyncWebServer &server) { void setupRFIDRoute(AsyncWebServer &server) {
server.on("/api/rfid/read", HTTP_GET, [](AsyncWebServerRequest *request) { // Toggle RFID Reading Mode
Serial.println("api/rfid/read"); server.on("/api/rfid/toggle", HTTP_POST, [](AsyncWebServerRequest *request) {
readingMode = !readingMode;
// 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
}
DynamicJsonDocument response(200); 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["success"] = true;
response["uid"] = lastReadUID; response["uid"] = uid;
response["message"] = "UID erfolgreich gelesen"; response["message"] = "Karte gelesen";
} else { } else {
response["success"] = false; response["success"] = false;
response["error"] = "Keine RFID Karte erkannt oder Timeout"; response["error"] = "Keine Karte gefunden";
response["uid"] = ""; response["uid"] = "";
} }
@@ -192,107 +153,32 @@ void setupRFIDRoute(AsyncWebServer &server) {
request->send(200, "application/json", jsonString); request->send(200, "application/json", jsonString);
}); });
server.on( // Status und letzte gelesene UID
"/api/users/insert", HTTP_POST, [](AsyncWebServerRequest *request) {}, server.on("/api/rfid/status", HTTP_GET, [](AsyncWebServerRequest *request) {
NULL, DynamicJsonDocument response(300);
[](AsyncWebServerRequest *request, uint8_t *data, size_t len, response["success"] = true;
size_t index, size_t total) { response["rfid_initialized"] = rfidInitialized;
Serial.println("/api/users/insert"); response["reading_mode"] = readingMode;
response["last_uid"] = lastReadUID;
response["message"] =
readingMode ? "RFID Lesen aktiv" : "RFID Lesen inaktiv";
// Parse the incoming JSON payload String jsonString;
DynamicJsonDocument doc(512); serializeJson(response, jsonString);
DeserializationError error = deserializeJson(doc, data, len); 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) { DynamicJsonDocument response(200);
Serial.println("Fehler beim Parsen der JSON-Daten"); response["success"] = true;
response["success"] = false; response["message"] = "UID zurückgesetzt";
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;
// Validate the data String jsonString;
if (uid.isEmpty() || vorname.isEmpty() || nachname.isEmpty() || serializeJson(response, jsonString);
geburtsdatum.isEmpty() || alter <= 0) { request->send(200, "application/json", jsonString);
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();
}
}

View File

@@ -1,15 +1,11 @@
// Zeit-bezogene Variablen und Includes // Zeit-bezogene Variablen und Includes
#pragma once #pragma once
#include "RTClib.h"
#include <Arduino.h> #include <Arduino.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <ESPAsyncWebServer.h> #include <ESPAsyncWebServer.h>
#include <Wire.h>
#include <sys/time.h> #include <sys/time.h>
#include <time.h> #include <time.h>
RTC_PCF8523 rtc;
// Globale Zeitvariablen // Globale Zeitvariablen
struct timeval tv; struct timeval tv;
struct timezone tz; struct timezone tz;
@@ -90,8 +86,6 @@ bool setSystemTime(long timestamp) {
// Initialisiert die Zeit-API und richtet die HTTP-Endpunkte ein. // Initialisiert die Zeit-API und richtet die HTTP-Endpunkte ein.
void setupTimeAPI(AsyncWebServer &server) { void setupTimeAPI(AsyncWebServer &server) {
// setupRTC();
// API-Endpunkt: Aktuelle Zeit abrufen // API-Endpunkt: Aktuelle Zeit abrufen
server.on("/api/time", HTTP_GET, [](AsyncWebServerRequest *request) { server.on("/api/time", HTTP_GET, [](AsyncWebServerRequest *request) {
String response = getCurrentTimeJSON(); String response = getCurrentTimeJSON();

View File

@@ -33,6 +33,10 @@ void setupRoutes() {
request->send(SPIFFS, "/settings.html", "text/html"); request->send(SPIFFS, "/settings.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) { server.on("/firmware.bin", HTTP_GET, [](AsyncWebServerRequest *request) {
if (SPIFFS.exists("/firmware.bin")) { if (SPIFFS.exists("/firmware.bin")) {
request->send(SPIFFS, "/firmware.bin", "application/octet-stream"); request->send(SPIFFS, "/firmware.bin", "application/octet-stream");
@@ -52,6 +56,7 @@ void setupRoutes() {
timerData1.bestTime = 0; timerData1.bestTime = 0;
timerData2.bestTime = 0; timerData2.bestTime = 0;
saveBestTimes(); saveBestTimes();
clearLocalLeaderboard(); // Leere auch das lokale Leaderboard
DynamicJsonDocument doc(64); DynamicJsonDocument doc(64);
doc["success"] = true; doc["success"] = true;
String result; String result;
@@ -79,6 +84,12 @@ void setupRoutes() {
request->getParam("maxTimeDisplay", true)->value().toInt() * 1000; request->getParam("maxTimeDisplay", true)->value().toInt() * 1000;
changed = true; changed = true;
} }
if (request->hasParam("minTimeForLeaderboard", true)) {
minTimeForLeaderboard =
request->getParam("minTimeForLeaderboard", true)->value().toInt() *
1000;
changed = true;
}
if (changed) { if (changed) {
saveSettings(); saveSettings();
DynamicJsonDocument doc(32); DynamicJsonDocument doc(32);
@@ -96,6 +107,7 @@ void setupRoutes() {
DynamicJsonDocument doc(256); DynamicJsonDocument doc(256);
doc["maxTime"] = maxTimeBeforeReset / 1000; doc["maxTime"] = maxTimeBeforeReset / 1000;
doc["maxTimeDisplay"] = maxTimeDisplay / 1000; doc["maxTimeDisplay"] = maxTimeDisplay / 1000;
doc["minTimeForLeaderboard"] = minTimeForLeaderboard / 1000;
String result; String result;
serializeJson(doc, result); serializeJson(doc, result);
request->send(200, "application/json", result); request->send(200, "application/json", result);