Compare commits
10 Commits
1ed3a30340
...
a67e29b9e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a67e29b9e4 | ||
|
|
5ef5e6d636 | ||
|
|
77f1ebc1f1 | ||
|
|
2a832257ba | ||
|
|
5ca67d8804 | ||
|
|
8fac847a75 | ||
|
|
36c35ba161 | ||
|
|
e383e54e41 | ||
|
|
9de327bfb3 | ||
|
|
7e9705902e |
82
API.md
82
API.md
@@ -7,87 +7,87 @@ All API endpoints return JSON unless otherwise noted.
|
||||
|
||||
## Static Files
|
||||
|
||||
| Route | Method | Description | Response Type |
|
||||
|------------------|--------|------------------------------|--------------|
|
||||
| `/` | GET | Main page | HTML |
|
||||
| `/settings` | GET | Settings page | HTML |
|
||||
| `/rfid` | GET | RFID page | HTML |
|
||||
| `/firmware.bin` | GET | Firmware file (SPIFFS) | Binary |
|
||||
| Route | Method | Description | Response Type |
|
||||
| --------------- | ------ | ---------------------- | ------------- |
|
||||
| `/` | GET | Main page | HTML |
|
||||
| `/settings` | GET | Settings page | HTML |
|
||||
| `/rfid` | GET | RFID page | HTML |
|
||||
| `/firmware.bin` | GET | Firmware file (SPIFFS) | Binary |
|
||||
|
||||
---
|
||||
|
||||
## Timer & Data
|
||||
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
|-------------------|--------|-------------------------------------|--------------------|------------------|
|
||||
| `/api/data` | GET | Get current timer and status data | – | `{...}` |
|
||||
| `/api/reset-best` | POST | Reset best times | – | `{ "success": true }` |
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
| ----------------- | ------ | --------------------------------- | ------------------- | --------------------- |
|
||||
| `/api/data` | GET | Get current timer and status data | – | `{...}` |
|
||||
| `/api/reset-best` | POST | Reset best times | – | `{ "success": true }` |
|
||||
|
||||
---
|
||||
|
||||
## Button Learning
|
||||
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
|------------------------|--------|-------------------------------------|--------------------|------------------|
|
||||
| `/api/unlearn-button` | POST | Remove all button assignments | – | `{ "success": true }` |
|
||||
| `/api/start-learning` | POST | Start button learning mode | – | `{ "success": true }` |
|
||||
| `/api/stop-learning` | POST | Stop button learning mode | – | `{ "success": true }` |
|
||||
| `/api/learn/status` | GET | Get learning mode status | – | `{ "active": true, "step": 1 }` |
|
||||
| `/api/buttons/status` | GET | Get button assignment and voltage | – | `{ "lane1Start": true, "lane1StartVoltage": 3.3, ... }` |
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
| --------------------- | ------ | --------------------------------- | ------------------- | ------------------------------------------------------- |
|
||||
| `/api/unlearn-button` | POST | Remove all button assignments | – | `{ "success": true }` |
|
||||
| `/api/start-learning` | POST | Start button learning mode | – | `{ "success": true }` |
|
||||
| `/api/stop-learning` | POST | Stop button learning mode | – | `{ "success": true }` |
|
||||
| `/api/learn/status` | GET | Get learning mode status | – | `{ "active": true, "step": 1 }` |
|
||||
| `/api/buttons/status` | GET | Get button assignment and voltage | – | `{ "lane1Start": true, "lane1StartVoltage": 3.3, ... }` |
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
|----------------------|--------|-------------------------------------|--------------------|------------------|
|
||||
| `/api/set-max-time` | POST | Set max timer and display time | `maxTime`, `maxTimeDisplay` (form params, seconds) | `{ "success": true }` |
|
||||
| `/api/get-settings` | GET | Get current timer settings | – | `{ "maxTime": 300, "maxTimeDisplay": 20 }` |
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
| ------------------- | ------ | ------------------------------ | --------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `/api/set-max-time` | POST | Set max timer and display time | `maxTime`, `maxTimeDisplay`, `minTimeForLeaderboard` (form params, seconds) | `{ "success": true }` |
|
||||
| `/api/get-settings` | GET | Get current timer settings | – | `{ "maxTime": 300, "maxTimeDisplay": 20, "minTimeForLeaderboard": 5 }` |
|
||||
|
||||
---
|
||||
|
||||
## WiFi Configuration
|
||||
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
|-------------------|--------|-------------------------------------|--------------------|------------------|
|
||||
| `/api/set-wifi` | POST | Set WiFi SSID and password | `ssid`, `password` (form params) | `{ "success": true }` |
|
||||
| `/api/get-wifi` | GET | Get current WiFi SSID and password | – | `{ "ssid": "...", "password": "..." }` |
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
| --------------- | ------ | ---------------------------------- | -------------------------------- | -------------------------------------- |
|
||||
| `/api/set-wifi` | POST | Set WiFi SSID and password | `ssid`, `password` (form params) | `{ "success": true }` |
|
||||
| `/api/get-wifi` | GET | Get current WiFi SSID and password | – | `{ "ssid": "...", "password": "..." }` |
|
||||
|
||||
---
|
||||
|
||||
## Location Configuration
|
||||
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
|----------------------|--------|-------------------------------------|--------------------|------------------|
|
||||
| `/api/set-location` | POST | Set location name and ID | `id`, `name` (form params) | `{ "success": true }` |
|
||||
| `/api/get-location` | GET | Get current location | – | `{ "locationid": "..." }` |
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
| ------------------- | ------ | ------------------------ | -------------------------- | ------------------------- |
|
||||
| `/api/set-location` | POST | Set location name and ID | `id`, `name` (form params) | `{ "success": true }` |
|
||||
| `/api/get-location` | GET | Get current location | – | `{ "locationid": "..." }` |
|
||||
|
||||
---
|
||||
|
||||
## Button Update & Mode
|
||||
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
|----------------------|--------|-------------------------------------|--------------------|------------------|
|
||||
| `/api/updateButtons` | GET | Trigger MQTT update for buttons | – | `{ "success": true }` |
|
||||
| `/api/set-mode` | POST | Set operational mode | `mode` (form param: "individual" or "wettkampf") | `{ "success": true }` |
|
||||
| `/api/get-mode` | GET | Get current operational mode | – | `{ "mode": "individual" }` |
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
| -------------------- | ------ | ------------------------------- | ------------------------------------------------ | -------------------------- |
|
||||
| `/api/updateButtons` | GET | Trigger MQTT update for buttons | – | `{ "success": true }` |
|
||||
| `/api/set-mode` | POST | Set operational mode | `mode` (form param: "individual" or "wettkampf") | `{ "success": true }` |
|
||||
| `/api/get-mode` | GET | Get current operational mode | – | `{ "mode": "individual" }` |
|
||||
|
||||
---
|
||||
|
||||
## System Info
|
||||
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
|---------------|--------|-------------------------------------|--------------------|------------------|
|
||||
| `/api/info` | GET | Get system info (IP, MAC, memory, license, etc.) | – | `{ "ip": "...", "ipSTA": "...", "channel": 1, "mac": "...", "freeMemory": 123456, "connectedButtons": 3, "isOnline": true, "valid": "Ja", "tier": 1 }` |
|
||||
| Route | Method | Description | Request Body/Params | Response Example |
|
||||
| ----------- | ------ | ------------------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `/api/info` | GET | Get system info (IP, MAC, memory, license, etc.) | – | `{ "ip": "...", "ipSTA": "...", "channel": 1, "mac": "...", "freeMemory": 123456, "connectedButtons": 3, "isOnline": true, "valid": "Ja", "tier": 1 }` |
|
||||
|
||||
---
|
||||
|
||||
## WebSocket
|
||||
|
||||
| Route | Description |
|
||||
|---------|------------------------------------|
|
||||
| `/ws` | WebSocket endpoint for live updates|
|
||||
| Route | Description |
|
||||
| ----- | ----------------------------------- |
|
||||
| `/ws` | WebSocket endpoint for live updates |
|
||||
|
||||
---
|
||||
|
||||
**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).**
|
||||
|
||||
674
Bedienungsanleitung_NinjaCross_Timer.html
Normal file
674
Bedienungsanleitung_NinjaCross_Timer.html
Normal 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>
|
||||
@@ -9,7 +9,7 @@ POST /api/unlearn-button
|
||||
→ Verlernt alle Button-Zuordnungen
|
||||
|
||||
POST /api/set-max-time
|
||||
→ Setzt die maximale Zeit und maxTimeDisplay
|
||||
→ Setzt die maximale Zeit, maxTimeDisplay und minTimeForLeaderboard
|
||||
|
||||
GET /api/get-settings
|
||||
→ Gibt die aktuellen Einstellungen zurück
|
||||
|
||||
Binary file not shown.
256
data/index.css
256
data/index.css
@@ -53,6 +53,32 @@ body {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.leaderboard-btn {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 90px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 15px;
|
||||
border-radius: 50%;
|
||||
text-decoration: none;
|
||||
font-size: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1000;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.leaderboard-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
@@ -82,7 +108,7 @@ body {
|
||||
.heartbeat-indicators {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 90px;
|
||||
right: 160px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
z-index: 1000;
|
||||
@@ -93,6 +119,56 @@ body {
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.leaderboard-btn {
|
||||
top: 15px;
|
||||
right: 60px;
|
||||
padding: 10px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
padding: 10px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.heartbeat-indicators {
|
||||
top: 15px;
|
||||
right: 90px;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.heartbeat-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.heartbeat-indicator::before {
|
||||
font-size: 8px;
|
||||
top: -20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: clamp(1.2rem, 3vw, 1.8rem);
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: clamp(0.7rem, 1.5vw, 0.9rem);
|
||||
}
|
||||
}
|
||||
|
||||
.heartbeat-indicator {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
@@ -300,9 +376,10 @@ body {
|
||||
transition: transform 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lane h2 {
|
||||
@@ -312,6 +389,7 @@ body {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
font-family: "Segoe UI", Arial, sans-serif;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.swimmer-name {
|
||||
@@ -344,21 +422,53 @@ body {
|
||||
}
|
||||
|
||||
.time-display {
|
||||
font-size: clamp(3rem, 9vw, 10rem);
|
||||
font-size: clamp(3rem, 13vw, 13rem);
|
||||
font-weight: bold;
|
||||
margin: clamp(10px, 1vh, 15px) 0;
|
||||
font-family: "Courier New", monospace;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: clamp(3rem, 1.8vw, 1.2rem);
|
||||
font-size: clamp(1.5rem, 4vw, 5rem);
|
||||
margin: clamp(8px, 1vh, 12px) 0;
|
||||
padding: clamp(6px, 1vh, 10px) clamp(12px, 2vw, 18px);
|
||||
border-radius: 20px;
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.status:not(.large-status) {
|
||||
position: relative;
|
||||
order: 2;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.status.large-status {
|
||||
font-size: clamp(1.8rem, 5vw, 5rem);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
margin: 0 !important;
|
||||
padding: clamp(8px, 1.5vh, 15px) clamp(15px, 3vw, 30px);
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
background-color: rgba(0, 0, 0, 0.85) !important;
|
||||
backdrop-filter: blur(5px);
|
||||
width: calc(100% - 40px);
|
||||
max-width: calc(100% - 40px);
|
||||
word-wrap: break-word;
|
||||
line-height: 1.3;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.status.finished {
|
||||
@@ -428,20 +538,40 @@ body {
|
||||
border-radius: 15px;
|
||||
padding: clamp(10px, 1.5vh, 15px);
|
||||
margin: 1vh 0 0 0;
|
||||
width: 50%;
|
||||
max-width: 50%;
|
||||
width: clamp(320px, 80vw, 960px);
|
||||
max-width: 960px;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: clamp(12px, 2vh, 20px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#leaderboard-container {
|
||||
text-align: left;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: clamp(12px, 2vh, 20px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
#leaderboard-container {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.best-times h3 {
|
||||
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
|
||||
margin-bottom: clamp(5px, 0.5vh, 8px);
|
||||
margin: 0 auto;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
font-family: "Segoe UI", Arial, sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.best-time-row {
|
||||
@@ -455,6 +585,118 @@ body {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Leaderboard Styles */
|
||||
#leaderboard-container {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.leaderboard-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: clamp(8px, 1vh, 12px) 0;
|
||||
font-size: clamp(1.1rem, 2.2vw, 1.4rem);
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
padding: clamp(12px, 2vh, 16px) clamp(16px, 3vw, 24px);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
min-height: 50px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.leaderboard-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.leaderboard-entry .rank {
|
||||
color: #ffd700;
|
||||
font-weight: bold;
|
||||
min-width: 30px;
|
||||
font-size: clamp(1.2rem, 2.4vw, 1.5rem);
|
||||
}
|
||||
|
||||
.leaderboard-entry .name {
|
||||
flex: 1;
|
||||
margin: 0 15px;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.leaderboard-entry .time {
|
||||
color: #00ff88;
|
||||
font-weight: bold;
|
||||
font-family: 'Courier New', monospace;
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.leaderboard-entry.gold {
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
|
||||
border-color: #ffd700;
|
||||
color: #b8860b;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
.leaderboard-entry.gold .rank {
|
||||
color: #7a4d00;
|
||||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.leaderboard-entry.gold .time {
|
||||
color: #0f5132;
|
||||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.leaderboard-entry.silver {
|
||||
background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%);
|
||||
border-color: #c0c0c0;
|
||||
color: #696969;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 4px 15px rgba(192, 192, 192, 0.3);
|
||||
}
|
||||
|
||||
.leaderboard-entry.silver .rank {
|
||||
color: #4b5563;
|
||||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.leaderboard-entry.silver .time {
|
||||
color: #0f5132;
|
||||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.leaderboard-entry.bronze {
|
||||
background: linear-gradient(135deg, #cd7f32 0%, #e6a85c 100%);
|
||||
border-color: #cd7f32;
|
||||
color: #8b4513;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 4px 15px rgba(205, 127, 50, 0.3);
|
||||
}
|
||||
|
||||
.leaderboard-entry.bronze .rank {
|
||||
color: #7a3410;
|
||||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.leaderboard-entry.bronze .time {
|
||||
color: #0f5132;
|
||||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.no-times {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-style: italic;
|
||||
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.learning-mode {
|
||||
background: rgba(245, 157, 15, 0.2);
|
||||
border: 2px solid #f59d0f;
|
||||
|
||||
272
data/index.html
272
data/index.html
@@ -15,7 +15,8 @@
|
||||
<div>
|
||||
<div class="banner-text">⚠️ Niedrige Batterie erkannt!</div>
|
||||
<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>
|
||||
@@ -23,6 +24,7 @@
|
||||
</div>
|
||||
|
||||
<img src="/pictures/erlebniss.png" class="logo" alt="NinjaCross Logo" />
|
||||
<a href="/leaderboard.html" class="leaderboard-btn">🏆</a>
|
||||
<a href="/settings" class="settings-btn">⚙️</a>
|
||||
|
||||
<div class="heartbeat-indicators">
|
||||
@@ -47,41 +49,32 @@
|
||||
|
||||
<div id="learning-display" class="learning-mode" style="display: none">
|
||||
<h3>📚 Lernmodus aktiv</h3>
|
||||
<p>
|
||||
Drücke jetzt den Button für: <span id="learning-button"></span>
|
||||
</p>
|
||||
<p>Drücke jetzt den Button für: <span id="learning-button"></span></p>
|
||||
</div>
|
||||
|
||||
<div class="timer-container">
|
||||
<div class="lane">
|
||||
<div id="name1" class="swimmer-name" style="display: none"></div>
|
||||
<h2>🏊♀️ Bahn 1</h2>
|
||||
<div id="time1" class="time-display">00.00</div>
|
||||
<div id="status1" class="status standby">
|
||||
Standby: Drücke beide Buttons einmal
|
||||
</div>
|
||||
<div id="time1" class="time-display">00.00</div>
|
||||
</div>
|
||||
|
||||
<div class="lane">
|
||||
<div id="name2" class="swimmer-name" style="display: none"></div>
|
||||
<h2>🏊♂️ Bahn 2</h2>
|
||||
<div id="time2" class="time-display">00.00</div>
|
||||
<div id="status2" class="status standby">
|
||||
Standby: Drücke beide Buttons einmal
|
||||
</div>
|
||||
<div id="time2" class="time-display">00.00</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="best-times">
|
||||
<h3>🏆 Deine Bestzeiten heute</h3>
|
||||
<div class="best-time-row">
|
||||
<span>Bahn 1:</span>
|
||||
<span id="best1">--.-</span>
|
||||
</div>
|
||||
<div class="best-time-row">
|
||||
<span>Bahn 2:</span>
|
||||
<span id="best2">--.-</span>
|
||||
</div>
|
||||
<h3>🏆 Lokales Leaderboard</h3>
|
||||
<div id="leaderboard-container"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -97,6 +90,7 @@
|
||||
let learningButton = "";
|
||||
let name1 = "";
|
||||
let name2 = "";
|
||||
let leaderboardData = [];
|
||||
|
||||
// Lane Configuration
|
||||
let laneConfigType = 0; // 0=Identical, 1=Different
|
||||
@@ -193,24 +187,18 @@
|
||||
}
|
||||
|
||||
// Namen-Handling
|
||||
if (
|
||||
(data.firstname == "" || data.lastname == "") &&
|
||||
data.lane == "start1"
|
||||
) {
|
||||
if ((data.name == "" || !data.name) && data.lane == "start1") {
|
||||
name1 = "";
|
||||
}
|
||||
if (
|
||||
(data.firstname == "" || data.lastname == "") &&
|
||||
data.lane == "start2"
|
||||
) {
|
||||
if ((data.name == "" || !data.name) && data.lane == "start2") {
|
||||
name2 = "";
|
||||
}
|
||||
|
||||
if (data.firstname && data.lastname && data.lane) {
|
||||
if (data.name && data.lane) {
|
||||
if (data.lane === "start1") {
|
||||
name1 = `${data.firstname} ${data.lastname}`;
|
||||
name1 = data.name;
|
||||
} else if (data.lane === "start2") {
|
||||
name2 = `${data.firstname} ${data.lastname}`;
|
||||
name2 = data.name;
|
||||
}
|
||||
updateDisplay();
|
||||
}
|
||||
@@ -343,7 +331,93 @@
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (seconds === 0) return "00.00";
|
||||
return seconds.toFixed(2);
|
||||
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
const milliseconds = Math.floor((seconds - totalSeconds) * 100);
|
||||
|
||||
// Zeige Minuten nur wenn über 60 Sekunden
|
||||
if (totalSeconds >= 60) {
|
||||
return `${minutes.toString().padStart(2, "0")}:${remainingSeconds
|
||||
.toString()
|
||||
.padStart(2, "0")}.${milliseconds.toString().padStart(2, "0")}`;
|
||||
} else {
|
||||
return `${remainingSeconds.toString().padStart(2, "0")}.${milliseconds
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Leaderboard Funktionen
|
||||
async function loadLeaderboard() {
|
||||
try {
|
||||
const response = await fetch("/api/leaderboard");
|
||||
const data = await response.json();
|
||||
leaderboardData = data.leaderboard || [];
|
||||
updateLeaderboardDisplay();
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Laden des Leaderboards:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLeaderboardDisplay() {
|
||||
const container = document.getElementById("leaderboard-container");
|
||||
container.innerHTML = "";
|
||||
|
||||
if (leaderboardData.length === 0) {
|
||||
container.innerHTML =
|
||||
'<div class="no-times">Noch keine Zeiten erfasst</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Erstelle zwei Reihen für 2x3 Layout
|
||||
const row1 = document.createElement("div");
|
||||
row1.className = "leaderboard-row";
|
||||
const row2 = document.createElement("div");
|
||||
row2.className = "leaderboard-row";
|
||||
|
||||
leaderboardData.forEach((entry, index) => {
|
||||
const entryDiv = document.createElement("div");
|
||||
entryDiv.className = "leaderboard-entry";
|
||||
|
||||
// Podium-Plätze hervorheben
|
||||
if (index === 0) {
|
||||
entryDiv.classList.add("gold");
|
||||
} else if (index === 1) {
|
||||
entryDiv.classList.add("silver");
|
||||
} else if (index === 2) {
|
||||
entryDiv.classList.add("bronze");
|
||||
}
|
||||
|
||||
const rankSpan = document.createElement("span");
|
||||
rankSpan.className = "rank";
|
||||
rankSpan.textContent = entry.rank + ".";
|
||||
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "name";
|
||||
nameSpan.textContent = entry.name;
|
||||
|
||||
const timeSpan = document.createElement("span");
|
||||
timeSpan.className = "time";
|
||||
timeSpan.textContent = entry.timeFormatted;
|
||||
|
||||
entryDiv.appendChild(rankSpan);
|
||||
entryDiv.appendChild(nameSpan);
|
||||
entryDiv.appendChild(timeSpan);
|
||||
|
||||
// Erste 3 Einträge in die erste Reihe, nächste 3 in die zweite Reihe
|
||||
if (index < 3) {
|
||||
row1.appendChild(entryDiv);
|
||||
} else if (index < 6) {
|
||||
row2.appendChild(entryDiv);
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(row1);
|
||||
if (leaderboardData.length > 3) {
|
||||
container.appendChild(row2);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDisplay() {
|
||||
@@ -368,18 +442,72 @@
|
||||
|
||||
document.getElementById("time1").textContent = formatTime(display1);
|
||||
|
||||
const time1Element = document.getElementById("time1");
|
||||
const lane1Element = time1Element.closest(".lane");
|
||||
const h2_1 = lane1Element.querySelector("h2");
|
||||
|
||||
if (!lane1Connected) {
|
||||
s1.className = "status standby";
|
||||
s1.className = "status standby large-status";
|
||||
s1.textContent = "Standby: Drücke beide Buttons einmal";
|
||||
// Position über time-display, aber innerhalb des Containers
|
||||
if (s1.classList.contains("large-status")) {
|
||||
const time1Rect = time1Element.getBoundingClientRect();
|
||||
const lane1Rect = lane1Element.getBoundingClientRect();
|
||||
const h2Rect = h2_1.getBoundingClientRect();
|
||||
const time1Center = time1Rect.top - lane1Rect.top + time1Rect.height / 2;
|
||||
const h2Bottom = h2Rect.bottom - lane1Rect.top;
|
||||
// Stelle sicher, dass die obere Kante der Status-Box unter h2 beginnt
|
||||
// Beginne unter h2 (ohne translate(-50%, -50%) beginnt die Box von oben)
|
||||
const startTop = h2Bottom + 10;
|
||||
// Positioniere so, dass die Box über time-display zentriert ist, aber nicht über h2 hinausragt
|
||||
// Berechne die benötigte Höhe, um über time-display zentriert zu sein
|
||||
const statusHeight = s1.offsetHeight || 200; // Verwende tatsächliche Höhe oder Schätzwert
|
||||
const targetTop = Math.max(startTop, time1Center - statusHeight / 2);
|
||||
s1.style.top = targetTop + "px";
|
||||
s1.style.transform = "translateX(-50%)";
|
||||
// Stelle sicher, dass die Box innerhalb des Containers bleibt
|
||||
const maxHeight = lane1Rect.height - targetTop - 30;
|
||||
s1.style.maxHeight = maxHeight + "px";
|
||||
s1.style.overflow = "auto";
|
||||
}
|
||||
} else {
|
||||
s1.className = `status ${status1}`;
|
||||
|
||||
// Add large-status class if not running and not finished
|
||||
if (status1 !== "running" && status1 !== "finished") {
|
||||
s1.classList.add("large-status");
|
||||
// Position über time-display, aber innerhalb des Containers
|
||||
const time1Rect = time1Element.getBoundingClientRect();
|
||||
const lane1Rect = lane1Element.getBoundingClientRect();
|
||||
const h2Rect = h2_1.getBoundingClientRect();
|
||||
const time1Center = time1Rect.top - lane1Rect.top + time1Rect.height / 2;
|
||||
const h2Bottom = h2Rect.bottom - lane1Rect.top;
|
||||
// Stelle sicher, dass die obere Kante der Status-Box unter h2 beginnt
|
||||
// Beginne unter h2 (ohne translate(-50%, -50%) beginnt die Box von oben)
|
||||
const startTop = h2Bottom + 10;
|
||||
// Positioniere so, dass die Box über time-display zentriert ist, aber nicht über h2 hinausragt
|
||||
// Berechne die benötigte Höhe, um über time-display zentriert zu sein
|
||||
const statusHeight = s1.offsetHeight || 200; // Verwende tatsächliche Höhe oder Schätzwert
|
||||
const targetTop = Math.max(startTop, time1Center - statusHeight / 2);
|
||||
s1.style.top = targetTop + "px";
|
||||
s1.style.transform = "translateX(-50%)";
|
||||
// Stelle sicher, dass die Box innerhalb des Containers bleibt
|
||||
const maxHeight = lane1Rect.height - targetTop - 30;
|
||||
s1.style.maxHeight = maxHeight + "px";
|
||||
s1.style.overflow = "auto";
|
||||
} else {
|
||||
s1.classList.remove("large-status");
|
||||
s1.style.top = "";
|
||||
s1.style.transform = "";
|
||||
s1.style.maxHeight = "";
|
||||
}
|
||||
|
||||
switch (status1) {
|
||||
case "ready":
|
||||
s1.textContent = "Bereit für den Start!";
|
||||
break;
|
||||
case "running":
|
||||
s1.textContent = "Läuft - Du schaffst das!";
|
||||
s1.textContent = "Läuft - Gib alles!";
|
||||
break;
|
||||
case "finished":
|
||||
s1.textContent = "Geschafft!";
|
||||
@@ -394,18 +522,72 @@
|
||||
|
||||
document.getElementById("time2").textContent = formatTime(display2);
|
||||
|
||||
const time2Element = document.getElementById("time2");
|
||||
const lane2Element = time2Element.closest(".lane");
|
||||
const h2_2 = lane2Element.querySelector("h2");
|
||||
|
||||
if (!lane2Connected) {
|
||||
s2.className = "status standby";
|
||||
s2.className = "status standby large-status";
|
||||
s2.textContent = "Standby: Drücke beide Buttons einmal";
|
||||
// Position über time-display, aber innerhalb des Containers
|
||||
if (s2.classList.contains("large-status")) {
|
||||
const time2Rect = time2Element.getBoundingClientRect();
|
||||
const lane2Rect = lane2Element.getBoundingClientRect();
|
||||
const h2Rect = h2_2.getBoundingClientRect();
|
||||
const time2Center = time2Rect.top - lane2Rect.top + time2Rect.height / 2;
|
||||
const h2Bottom = h2Rect.bottom - lane2Rect.top;
|
||||
// Stelle sicher, dass die obere Kante der Status-Box unter h2 beginnt
|
||||
// Beginne unter h2 (ohne translate(-50%, -50%) beginnt die Box von oben)
|
||||
const startTop = h2Bottom + 10;
|
||||
// Positioniere so, dass die Box über time-display zentriert ist, aber nicht über h2 hinausragt
|
||||
// Berechne die benötigte Höhe, um über time-display zentriert zu sein
|
||||
const statusHeight = s2.offsetHeight || 200; // Verwende tatsächliche Höhe oder Schätzwert
|
||||
const targetTop = Math.max(startTop, time2Center - statusHeight / 2);
|
||||
s2.style.top = targetTop + "px";
|
||||
s2.style.transform = "translateX(-50%)";
|
||||
// Stelle sicher, dass die Box innerhalb des Containers bleibt
|
||||
const maxHeight = lane2Rect.height - targetTop - 30;
|
||||
s2.style.maxHeight = maxHeight + "px";
|
||||
s2.style.overflow = "auto";
|
||||
}
|
||||
} else {
|
||||
s2.className = `status ${status2}`;
|
||||
|
||||
// Add large-status class if not running and not finished
|
||||
if (status2 !== "running" && status2 !== "finished") {
|
||||
s2.classList.add("large-status");
|
||||
// Position über time-display, aber innerhalb des Containers
|
||||
const time2Rect = time2Element.getBoundingClientRect();
|
||||
const lane2Rect = lane2Element.getBoundingClientRect();
|
||||
const h2Rect = h2_2.getBoundingClientRect();
|
||||
// Stelle sicher, dass die obere Kante der Status-Box unter h2 beginnt
|
||||
const h2Bottom = h2Rect.bottom - lane2Rect.top;
|
||||
const time2Center = time2Rect.top - lane2Rect.top + time2Rect.height / 2;
|
||||
// Beginne unter h2 (ohne translate(-50%, -50%) beginnt die Box von oben)
|
||||
const startTop = h2Bottom + 10;
|
||||
// Positioniere so, dass die Box über time-display zentriert ist, aber nicht über h2 hinausragt
|
||||
// Berechne die benötigte Höhe, um über time-display zentriert zu sein
|
||||
const statusHeight = s2.offsetHeight || 200; // Verwende tatsächliche Höhe oder Schätzwert
|
||||
const targetTop = Math.max(startTop, time2Center - statusHeight / 2);
|
||||
s2.style.top = targetTop + "px";
|
||||
s2.style.transform = "translateX(-50%)";
|
||||
// Stelle sicher, dass die Box innerhalb des Containers bleibt
|
||||
const maxHeight = lane2Rect.height - targetTop - 30;
|
||||
s2.style.maxHeight = maxHeight + "px";
|
||||
s2.style.overflow = "auto";
|
||||
} else {
|
||||
s2.classList.remove("large-status");
|
||||
s2.style.top = "";
|
||||
s2.style.transform = "";
|
||||
s2.style.maxHeight = "";
|
||||
}
|
||||
|
||||
switch (status2) {
|
||||
case "ready":
|
||||
s2.textContent = "Bereit für den Start!";
|
||||
break;
|
||||
case "running":
|
||||
s2.textContent = "Läuft - Du schaffst das!";
|
||||
s2.textContent = "Läuft - Gib alles!";
|
||||
break;
|
||||
case "finished":
|
||||
s2.textContent = "Geschafft!";
|
||||
@@ -418,10 +600,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("best1").textContent =
|
||||
best1 > 0 ? formatTime(best1) + "s" : "--.-";
|
||||
document.getElementById("best2").textContent =
|
||||
best2 > 0 ? formatTime(best2) + "s" : "--.-";
|
||||
// Leaderboard wird separat geladen
|
||||
|
||||
// Namen anzeigen/verstecken
|
||||
const name1Element = document.getElementById("name1");
|
||||
@@ -482,14 +661,17 @@
|
||||
updateLaneDisplay();
|
||||
})
|
||||
.catch((error) =>
|
||||
console.error("Fehler beim Laden der Lane-Schwierigkeits-Konfiguration:", error)
|
||||
console.error(
|
||||
"Fehler beim Laden der Lane-Schwierigkeits-Konfiguration:",
|
||||
error
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function updateLaneDisplay() {
|
||||
const lane1Title = document.querySelector('.lane h2');
|
||||
const lane2Title = document.querySelectorAll('.lane h2')[1];
|
||||
|
||||
const lane1Title = document.querySelector(".lane h2");
|
||||
const lane2Title = document.querySelectorAll(".lane h2")[1];
|
||||
|
||||
if (laneConfigType === 0) {
|
||||
// Identische Lanes
|
||||
lane1Title.textContent = "🏊♀️ Bahn 1";
|
||||
@@ -498,9 +680,11 @@
|
||||
// Unterschiedliche Lanes
|
||||
const lane1Icon = lane1DifficultyType === 0 ? "🟢" : "🔴";
|
||||
const lane2Icon = lane2DifficultyType === 0 ? "🟢" : "🔴";
|
||||
const lane1Difficulty = lane1DifficultyType === 0 ? "Leicht" : "Schwer";
|
||||
const lane2Difficulty = lane2DifficultyType === 0 ? "Leicht" : "Schwer";
|
||||
|
||||
const lane1Difficulty =
|
||||
lane1DifficultyType === 0 ? "Leicht" : "Schwer";
|
||||
const lane2Difficulty =
|
||||
lane2DifficultyType === 0 ? "Leicht" : "Schwer";
|
||||
|
||||
lane1Title.textContent = `${lane1Icon} Bahn 1 (${lane1Difficulty})`;
|
||||
lane2Title.textContent = `${lane2Icon} Bahn 2 (${lane2Difficulty})`;
|
||||
}
|
||||
@@ -530,6 +714,10 @@
|
||||
// Initial load
|
||||
syncFromBackend();
|
||||
loadLaneConfig();
|
||||
loadLeaderboard();
|
||||
|
||||
// Leaderboard alle 5 Sekunden aktualisieren
|
||||
setInterval(loadLeaderboard, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
367
data/leaderboard.css
Normal file
367
data/leaderboard.css
Normal 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
227
data/leaderboard.html
Normal 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>
|
||||
352
data/rfid.html
352
data/rfid.html
@@ -62,7 +62,7 @@
|
||||
type="button"
|
||||
id="readUidBtn"
|
||||
class="read-uid-btn"
|
||||
onclick="readRFIDUID()"
|
||||
onclick="toggleRFIDReading()"
|
||||
>
|
||||
📡 Read Chip
|
||||
</button>
|
||||
@@ -70,47 +70,16 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="vorname">Vorname <span class="required">*</span></label>
|
||||
<label for="name">Name <span class="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="vorname"
|
||||
name="vorname"
|
||||
placeholder="Vorname eingeben"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Name eingeben"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nachname">Nachname <span class="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="nachname"
|
||||
name="nachname"
|
||||
placeholder="Nachname eingeben"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="geburtsdatum"
|
||||
>Geburtsdatum <span class="required">*</span></label
|
||||
>
|
||||
<div class="date-input-group">
|
||||
<input
|
||||
type="date"
|
||||
id="geburtsdatum"
|
||||
name="geburtsdatum"
|
||||
required
|
||||
max=""
|
||||
/>
|
||||
<div
|
||||
id="ageDisplay"
|
||||
class="age-display"
|
||||
style="display: none"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-container">
|
||||
<button type="submit" class="btn btn-primary">💾 Speichern</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="clearForm()">
|
||||
@@ -124,60 +93,8 @@
|
||||
// Globale Variablen
|
||||
let rfidData = [];
|
||||
let isLoading = false;
|
||||
let DBUrl = "ninja.reptilfpv.de:3000";
|
||||
var APIKey;
|
||||
|
||||
// Maximales Datum auf heute setzen
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
document.getElementById("geburtsdatum").setAttribute("max", today);
|
||||
});
|
||||
|
||||
// Alter berechnen und anzeigen
|
||||
function calculateAge(birthDate) {
|
||||
const today = new Date();
|
||||
const birth = new Date(birthDate);
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const monthDiff = today.getMonth() - birth.getMonth();
|
||||
|
||||
if (
|
||||
monthDiff < 0 ||
|
||||
(monthDiff === 0 && today.getDate() < birth.getDate())
|
||||
) {
|
||||
age--;
|
||||
}
|
||||
|
||||
return age;
|
||||
}
|
||||
|
||||
// Geburtsdatum Change Event
|
||||
document
|
||||
.getElementById("geburtsdatum")
|
||||
.addEventListener("change", function (e) {
|
||||
const birthDate = e.target.value;
|
||||
const ageDisplay = document.getElementById("ageDisplay");
|
||||
|
||||
if (birthDate) {
|
||||
const age = calculateAge(birthDate);
|
||||
if (age >= 0 && age <= 150) {
|
||||
ageDisplay.textContent = `${age} Jahre`;
|
||||
ageDisplay.style.display = "block";
|
||||
} else {
|
||||
ageDisplay.style.display = "none";
|
||||
if (age < 0) {
|
||||
showErrorMessage(
|
||||
"Das Geburtsdatum kann nicht in der Zukunft liegen!"
|
||||
);
|
||||
e.target.value = "";
|
||||
} else {
|
||||
showErrorMessage("Bitte überprüfen Sie das Geburtsdatum!");
|
||||
e.target.value = "";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ageDisplay.style.display = "none";
|
||||
}
|
||||
});
|
||||
// Lokale Benutzer-Speicherung (geht bei Neustart verloren)
|
||||
let localUsers = [];
|
||||
|
||||
// Form Submit Handler
|
||||
document
|
||||
@@ -189,46 +106,40 @@
|
||||
|
||||
// Daten aus dem Formular holen
|
||||
const uid = document.getElementById("uid").value.trim();
|
||||
const vorname = document.getElementById("vorname").value.trim();
|
||||
const nachname = document.getElementById("nachname").value.trim();
|
||||
const geburtsdatum = document.getElementById("geburtsdatum").value;
|
||||
const name = document.getElementById("name").value.trim();
|
||||
|
||||
// Validierung
|
||||
if (!uid || !vorname || !nachname || !geburtsdatum) {
|
||||
if (!uid || !name) {
|
||||
showErrorMessage("Bitte füllen Sie alle Pflichtfelder aus!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Alter berechnen
|
||||
const alter = calculateAge(geburtsdatum);
|
||||
if (alter < 0) {
|
||||
showErrorMessage(
|
||||
"Das Geburtsdatum kann nicht in der Zukunft liegen!"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Loading State
|
||||
setLoadingState(true);
|
||||
|
||||
try {
|
||||
// API Aufruf zum Erstellen des Benutzers
|
||||
// API Aufruf zum Erstellen des Benutzers (lokal)
|
||||
const requestData = {
|
||||
uid: uid,
|
||||
name: name,
|
||||
};
|
||||
|
||||
console.log("Sende Daten:", requestData);
|
||||
console.log("JSON String:", JSON.stringify(requestData));
|
||||
|
||||
const response = await fetch(`/api/users/insert`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(APIKey && { Authorization: `Bearer ${APIKey}` }),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
uid: uid,
|
||||
vorname: vorname,
|
||||
nachname: nachname,
|
||||
geburtsdatum: geburtsdatum,
|
||||
alter: alter, // Berechnetes Alter wird mit gesendet
|
||||
}),
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
console.log("Response Status:", response.status);
|
||||
console.log("Response Headers:", response.headers);
|
||||
|
||||
const result = await response.json();
|
||||
console.log("Response Result:", result);
|
||||
|
||||
if (result.success) {
|
||||
// Erfolg anzeigen
|
||||
@@ -313,7 +224,6 @@
|
||||
|
||||
function clearForm() {
|
||||
document.getElementById("rfidForm").reset();
|
||||
document.getElementById("ageDisplay").style.display = "none";
|
||||
document.getElementById("uid").focus();
|
||||
}
|
||||
|
||||
@@ -321,14 +231,13 @@
|
||||
window.addEventListener("load", function () {
|
||||
document.getElementById("uid").focus();
|
||||
checkServerStatus();
|
||||
loadLicence();
|
||||
});
|
||||
|
||||
// Enter-Taste in UID Feld zum nächsten Feld springen
|
||||
document.getElementById("uid").addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
document.getElementById("vorname").focus();
|
||||
document.getElementById("name").focus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -340,34 +249,170 @@
|
||||
e.target.value = value;
|
||||
});
|
||||
|
||||
// RFID UID lesen
|
||||
let rfidReadingMode = false;
|
||||
let statusInterval = null;
|
||||
|
||||
// Toggle RFID Reading Mode
|
||||
async function toggleRFIDReading() {
|
||||
const readBtn = document.getElementById("readUidBtn");
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/rfid/toggle`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
rfidReadingMode = result.reading_mode;
|
||||
|
||||
if (rfidReadingMode) {
|
||||
// RFID Reading gestartet
|
||||
readBtn.innerHTML = "🛑 Stop Reading";
|
||||
readBtn.className = "read-uid-btn reading";
|
||||
showSuccessMessage("RFID Lesen gestartet - Karte auflegen!");
|
||||
|
||||
// Status Polling starten
|
||||
startStatusPolling();
|
||||
} else {
|
||||
// RFID Reading gestoppt
|
||||
readBtn.innerHTML = "📡 Read Chip";
|
||||
readBtn.className = "read-uid-btn";
|
||||
showSuccessMessage("RFID Lesen gestoppt");
|
||||
|
||||
// Status Polling stoppen
|
||||
stopStatusPolling();
|
||||
}
|
||||
} else {
|
||||
showErrorMessage("Fehler beim Toggle RFID: " + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Toggle RFID Error:", error);
|
||||
showErrorMessage("Fehler beim Toggle RFID");
|
||||
}
|
||||
}
|
||||
|
||||
// Status Polling für kontinuierliches Lesen
|
||||
function startStatusPolling() {
|
||||
if (statusInterval) {
|
||||
clearInterval(statusInterval);
|
||||
}
|
||||
|
||||
statusInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/rfid/status`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.last_uid && result.last_uid !== "") {
|
||||
// Neue UID gelesen - automatisch stoppen
|
||||
const uidInput = document.getElementById("uid");
|
||||
uidInput.value = result.last_uid;
|
||||
|
||||
// Visuelles Feedback
|
||||
uidInput.style.borderColor = "#28a745";
|
||||
setTimeout(() => {
|
||||
uidInput.style.borderColor = "#e1e5e9";
|
||||
}, 2000);
|
||||
|
||||
showSuccessMessage("UID gelesen: " + result.last_uid);
|
||||
|
||||
// Automatisch zum nächsten Feld springen
|
||||
setTimeout(() => {
|
||||
document.getElementById("name").focus();
|
||||
}, 500);
|
||||
|
||||
// RFID Lesen automatisch stoppen
|
||||
stopRFIDReading();
|
||||
|
||||
// UID im Backend zurücksetzen
|
||||
clearBackendUID();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Status Poll Error:", error);
|
||||
}
|
||||
}, 500); // Alle 500ms prüfen
|
||||
}
|
||||
|
||||
// Status Polling stoppen
|
||||
function stopStatusPolling() {
|
||||
if (statusInterval) {
|
||||
clearInterval(statusInterval);
|
||||
statusInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// RFID Reading komplett stoppen (Frontend + Backend)
|
||||
async function stopRFIDReading() {
|
||||
// Status Polling stoppen
|
||||
stopStatusPolling();
|
||||
|
||||
// Backend stoppen
|
||||
try {
|
||||
const response = await fetch(`/api/rfid/toggle`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && !result.reading_mode) {
|
||||
rfidReadingMode = false;
|
||||
|
||||
// Button zurücksetzen
|
||||
const readBtn = document.getElementById("readUidBtn");
|
||||
readBtn.innerHTML = "📡 Read Chip";
|
||||
readBtn.className = "read-uid-btn";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stop RFID Error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// UID im Backend zurücksetzen
|
||||
async function clearBackendUID() {
|
||||
try {
|
||||
await fetch(`/api/rfid/clear`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Clear UID Error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Einzelnes Lesen (für Kompatibilität)
|
||||
async function readRFIDUID() {
|
||||
const readBtn = document.getElementById("readUidBtn");
|
||||
const uidInput = document.getElementById("uid");
|
||||
|
||||
// Button Status ändern
|
||||
readBtn.disabled = true;
|
||||
readBtn.className = "read-uid-btn reading";
|
||||
readBtn.innerHTML = "📡 Lese UID...";
|
||||
readBtn.innerHTML = "📡 Lese...";
|
||||
|
||||
try {
|
||||
// API Aufruf zum RFID Reader
|
||||
const response = await fetch(`/api/rfid/read`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(APIKey && { Authorization: `Bearer ${APIKey}` }),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.uid) {
|
||||
// UID in das Eingabefeld setzen
|
||||
uidInput.value = result.uid
|
||||
.match(/.{1,2}/g)
|
||||
.join(":")
|
||||
.toUpperCase();
|
||||
uidInput.value = result.uid;
|
||||
uidInput.focus();
|
||||
|
||||
// Visuelles Feedback
|
||||
@@ -376,38 +421,20 @@
|
||||
uidInput.style.borderColor = "#e1e5e9";
|
||||
}, 2000);
|
||||
|
||||
showSuccessMessage("UID erfolgreich gelesen!");
|
||||
showSuccessMessage("UID gelesen: " + result.uid);
|
||||
|
||||
// Automatisch zum nächsten Feld springen
|
||||
setTimeout(() => {
|
||||
document.getElementById("vorname").focus();
|
||||
document.getElementById("name").focus();
|
||||
}, 500);
|
||||
} else {
|
||||
// Fehler beim Lesen
|
||||
const errorMsg = result.error || "Keine UID gefunden";
|
||||
showErrorMessage(`RFID Fehler: ${errorMsg}`);
|
||||
|
||||
// UID Feld rot markieren
|
||||
uidInput.style.borderColor = "#dc3545";
|
||||
setTimeout(() => {
|
||||
uidInput.style.borderColor = "#e1e5e9";
|
||||
}, 10000);
|
||||
showErrorMessage("Keine Karte erkannt");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Lesen der UID:", error);
|
||||
showErrorMessage(
|
||||
"Verbindungsfehler zum RFID Reader. Bitte prüfen Sie die Verbindung."
|
||||
);
|
||||
|
||||
// UID Feld rot markieren
|
||||
uidInput.style.borderColor = "#dc3545";
|
||||
setTimeout(() => {
|
||||
uidInput.style.borderColor = "#e1e5e9";
|
||||
}, 3000);
|
||||
console.error("RFID Read Error:", error);
|
||||
showErrorMessage("Fehler beim Lesen");
|
||||
} finally {
|
||||
// Button Status zurücksetzen
|
||||
readBtn.disabled = false;
|
||||
readBtn.className = "read-uid-btn";
|
||||
readBtn.innerHTML = "📡 Read Chip";
|
||||
}
|
||||
}
|
||||
@@ -415,9 +442,7 @@
|
||||
async function checkServerStatus() {
|
||||
try {
|
||||
const response = await fetch("/api/health", {
|
||||
headers: {
|
||||
...(APIKey && { Authorization: `Bearer ${APIKey}` }),
|
||||
},
|
||||
headers: {},
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
@@ -436,16 +461,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
function loadLicence() {
|
||||
fetch("/api/get-licence")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
APIKey = data.licence || "";
|
||||
})
|
||||
.catch((error) =>
|
||||
showMessage("Fehler beim Laden der Lizenz", "error")
|
||||
);
|
||||
}
|
||||
// Seite laden - RFID Status initialisieren
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Status Polling stoppen falls aktiv
|
||||
stopStatusPolling();
|
||||
|
||||
// Server Status prüfen
|
||||
checkServerStatus();
|
||||
});
|
||||
|
||||
// Seite verlassen - RFID Reading komplett stoppen
|
||||
window.addEventListener("beforeunload", function () {
|
||||
stopRFIDReading();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<!-- Navigation Buttons -->
|
||||
<div class="nav-buttons">
|
||||
<a href="/" class="nav-button">🏠 Hauptseite</a>
|
||||
<a href="/rfid.html" class="nav-button">🏷️ RFID</a>
|
||||
</div>
|
||||
|
||||
<!-- Date & Time Section -->
|
||||
@@ -181,6 +182,18 @@
|
||||
title="Zeit nach der die angezeigte Zeit zurückgesetzt wird"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="minTimeForLeaderboard">Minimale Zeit für Leaderboard (Sekunden):</label>
|
||||
<input
|
||||
type="number"
|
||||
id="minTimeForLeaderboard"
|
||||
name="minTimeForLeaderboard"
|
||||
min="1"
|
||||
max="300"
|
||||
value="5"
|
||||
title="Zeiten unter diesem Wert werden nicht ins lokale Leaderboard eingetragen (Missbrauchsschutz)"
|
||||
/>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
💾 Einstellungen speichern
|
||||
@@ -717,6 +730,8 @@
|
||||
document.getElementById("maxTime").value = data.maxTime || 300;
|
||||
document.getElementById("maxTimeDisplay").value =
|
||||
data.maxTimeDisplay || 20;
|
||||
document.getElementById("minTimeForLeaderboard").value =
|
||||
data.minTimeForLeaderboard || 5;
|
||||
})
|
||||
.catch((error) =>
|
||||
showMessage("Fehler beim Laden der Einstellungen", "error")
|
||||
@@ -970,6 +985,9 @@
|
||||
const maxTimeDisplay = parseInt(
|
||||
document.getElementById("maxTimeDisplay").value
|
||||
);
|
||||
const minTimeForLeaderboard = parseInt(
|
||||
document.getElementById("minTimeForLeaderboard").value
|
||||
);
|
||||
|
||||
fetch("/api/set-max-time", {
|
||||
method: "POST",
|
||||
@@ -980,7 +998,9 @@
|
||||
"maxTime=" +
|
||||
encodeURIComponent(maxTime) +
|
||||
"&maxTimeDisplay=" +
|
||||
encodeURIComponent(maxTimeDisplay),
|
||||
encodeURIComponent(maxTimeDisplay) +
|
||||
"&minTimeForLeaderboard=" +
|
||||
encodeURIComponent(minTimeForLeaderboard),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
|
||||
4
mock-server/.gitignore
vendored
Normal file
4
mock-server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
179
mock-server/README.md
Normal file
179
mock-server/README.md
Normal 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
|
||||
273
mock-server/debug_server/debug.css
Normal file
273
mock-server/debug_server/debug.css
Normal 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;
|
||||
}
|
||||
}
|
||||
378
mock-server/debug_server/debug.js
Normal file
378
mock-server/debug_server/debug.js
Normal 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();
|
||||
}
|
||||
139
mock-server/debug_server/index.html
Normal file
139
mock-server/debug_server/index.html
Normal 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>
|
||||
718
mock-server/mock_esp32_server.js
Normal file
718
mock-server/mock_esp32_server.js
Normal 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
108
mock-server/mqtt_broker.js
Normal 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
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
28
mock-server/package.json
Normal 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
47
mock-server/start_all.js
Normal 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
|
||||
@@ -22,6 +22,9 @@ monitor_speed = 115200
|
||||
build_flags =
|
||||
-DBOARD_HAS_PSRAM
|
||||
-mfix-esp32-psram-cache-issue
|
||||
-DBATTERY_PIN=16
|
||||
board_upload.flash_size = 16MB
|
||||
board_build.partitions = default_16MB.csv
|
||||
targets = uploadfs
|
||||
board_build.psram = disabled
|
||||
lib_deps =
|
||||
@@ -29,8 +32,7 @@ lib_deps =
|
||||
esp32async/ESPAsyncWebServer@^3.7.7
|
||||
esp32async/AsyncTCP@^3.4.2
|
||||
mlesniew/PicoMQTT@^1.3.0
|
||||
miguelbalboa/MFRC522@^1.4.12
|
||||
adafruit/RTClib@^2.1.4
|
||||
adafruit/Adafruit PN532@^1.3.4
|
||||
|
||||
[env:esp32thing_OTA]
|
||||
board = esp32thing
|
||||
@@ -50,8 +52,9 @@ lib_deps =
|
||||
esp32async/ESPAsyncWebServer@^3.7.7
|
||||
esp32async/AsyncTCP@^3.4.2
|
||||
mlesniew/PicoMQTT@^1.3.0
|
||||
miguelbalboa/MFRC522@^1.4.12
|
||||
adafruit/RTClib@^2.1.4
|
||||
adafruit/Adafruit PN532@^1.3.4
|
||||
|
||||
|
||||
|
||||
[env:esp32thing]
|
||||
board = esp32thing_plus
|
||||
@@ -69,8 +72,7 @@ lib_deps =
|
||||
esp32async/ESPAsyncWebServer@^3.7.7
|
||||
esp32async/AsyncTCP@^3.4.2
|
||||
mlesniew/PicoMQTT@^1.3.0
|
||||
miguelbalboa/MFRC522@^1.4.12
|
||||
adafruit/RTClib@^2.1.4
|
||||
adafruit/Adafruit PN532@^1.3.4
|
||||
|
||||
[env:esp32thing_CI]
|
||||
platform = espressif32
|
||||
@@ -87,21 +89,47 @@ lib_deps =
|
||||
esp32async/ESPAsyncWebServer@^3.7.7
|
||||
esp32async/AsyncTCP@^3.4.2
|
||||
mlesniew/PicoMQTT@^1.3.0
|
||||
miguelbalboa/MFRC522@^1.4.12
|
||||
adafruit/RTClib@^2.1.4
|
||||
adafruit/Adafruit PN532@^1.3.4
|
||||
|
||||
[env:esp32-s3-devkitc-1]
|
||||
board = esp32-s3-devkitc-1
|
||||
[env:um_feathers3]
|
||||
board = um_feathers3
|
||||
monitor_speed = 115200
|
||||
board_upload.flash_size = 16MB
|
||||
board_build.partitions = default_16MB.csv
|
||||
board_upload.wait_for_upload_port = false
|
||||
build_flags =
|
||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||
-DBATTERY_PIN=35
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
-D BATTERY_PIN=35
|
||||
-D ARDUINO_USB_MODE=1
|
||||
|
||||
lib_deps =
|
||||
bblanchon/ArduinoJson@^7.4.1
|
||||
esp32async/ESPAsyncWebServer@^3.7.7
|
||||
esp32async/AsyncTCP@^3.4.2
|
||||
mlesniew/PicoMQTT@^1.3.0
|
||||
miguelbalboa/MFRC522@^1.4.12
|
||||
adafruit/RTClib@^2.1.4
|
||||
adafruit/Adafruit PN532@^1.3.4
|
||||
|
||||
[env:um_feathers3_debug]
|
||||
board = um_feathers3
|
||||
board_upload.flash_size = 16MB
|
||||
board_build.partitions = default_16MB.csv
|
||||
board_upload.wait_for_upload_port = false
|
||||
build_flags =
|
||||
-D ARDUINO_USB_CDC_ON_BOOT=1
|
||||
-D BATTERY_PIN=35
|
||||
-D ARDUINO_USB_MODE=0
|
||||
|
||||
build_type = debug
|
||||
debug_speed = 12000
|
||||
debug_tool = esp-builtin
|
||||
upload_port = COM5
|
||||
monitor_speed = 115200
|
||||
monitor_port = COM7
|
||||
|
||||
lib_deps =
|
||||
bblanchon/ArduinoJson@^7.4.1
|
||||
esp32async/ESPAsyncWebServer@^3.7.7
|
||||
esp32async/AsyncTCP@^3.4.2
|
||||
mlesniew/PicoMQTT@^1.3.0
|
||||
adafruit/Adafruit PN532@^1.3.4
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
#include "master.h"
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include <PicoMQTT.h>
|
||||
|
||||
@@ -44,6 +46,20 @@ typedef struct {
|
||||
// MQTT-Server-Instanz
|
||||
PicoMQTT::Server mqtt;
|
||||
|
||||
// Tracking der Quelle für jede Lane
|
||||
bool start1FoundLocally = false;
|
||||
bool start2FoundLocally = false;
|
||||
String start1UID = "";
|
||||
String start2UID = "";
|
||||
|
||||
// Hilfsfunktionen um die Quelle abzufragen
|
||||
bool wasStart1FoundLocally() { return start1FoundLocally; }
|
||||
|
||||
bool wasStart2FoundLocally() { return start2FoundLocally; }
|
||||
|
||||
String getStart1UID() { return start1UID; }
|
||||
String getStart2UID() { return start2UID; }
|
||||
|
||||
/**
|
||||
* Liest eine Button-JSON-Nachricht, extrahiert Typ, MAC und Timestamp,
|
||||
* prüft die Button-Zuordnung und ruft die entsprechende Handler-Funktion auf.
|
||||
@@ -249,35 +265,116 @@ void publishLaneStatus(int lane, String status) {
|
||||
* sendet diese ggf. als JSON an das Frontend.
|
||||
*/
|
||||
void readRFIDfromButton(const char *topic, const char *payload) {
|
||||
loadLicenceFromPrefs();
|
||||
String topicStr(topic);
|
||||
int lastSlash = topicStr.lastIndexOf('/');
|
||||
if (lastSlash < 0)
|
||||
return;
|
||||
String macStr = topicStr.substring(lastSlash + 1);
|
||||
// Create a JSON document to hold the button press data
|
||||
StaticJsonDocument<256> doc;
|
||||
DeserializationError error = deserializeJson(doc, payload);
|
||||
if (!error) {
|
||||
const char *mac = doc["buttonmac"] | "unknown";
|
||||
const char *uid = doc["uid"] | "unknown";
|
||||
|
||||
Serial.printf("RFID Read from Button:\n");
|
||||
Serial.printf(" Button MAC: %s\n", mac);
|
||||
Serial.printf(" Button MAC: %s\n", macStr.c_str());
|
||||
Serial.printf(" UID: %s\n", uid);
|
||||
String debugUpperUid = String(uid);
|
||||
debugUpperUid.toUpperCase();
|
||||
Serial.printf(" UID (Upper): %s\n", debugUpperUid.c_str());
|
||||
|
||||
// Convert buttonmac to byte array for comparison
|
||||
auto macBytes = macStringToBytes(mac);
|
||||
auto macBytes = macStringToBytes(macStr.c_str());
|
||||
|
||||
// Check if the buttonmac matches buttonConfigs.start1.mac
|
||||
if (memcmp(macBytes.data(), buttonConfigs.start1.mac, 6) == 0) {
|
||||
// Fetch user data
|
||||
UserData userData = checkUser(uid);
|
||||
if (userData.exists) {
|
||||
// Log user data
|
||||
Serial.printf("User found for start1: %s %s, Alter: %d\n",
|
||||
userData.firstname.c_str(), userData.lastname.c_str(),
|
||||
userData.alter);
|
||||
// Prüfe ob Lane 1 bereit ist
|
||||
if (timerData1.isRunning || timerData1.isArmed) {
|
||||
Serial.println("Lane 1 läuft - ignoriere RFID: " + String(uid));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create JSON message to send to the frontend
|
||||
// Zuerst lokal suchen (UID in Großbuchstaben konvertieren)
|
||||
String upperUid = String(uid);
|
||||
upperUid.toUpperCase();
|
||||
UserData userData = checkUser(upperUid);
|
||||
start1FoundLocally = userData.exists; // Merken ob lokal gefunden
|
||||
start1UID = upperUid; // UID für später speichern
|
||||
|
||||
if (!userData.exists) {
|
||||
// Nicht lokal gefunden - Online-Server fragen
|
||||
Serial.println("User nicht lokal gefunden, suche online...");
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
HTTPClient http;
|
||||
http.begin(String(BACKEND_SERVER) + "/api/v1/private/users/find");
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
http.addHeader("Authorization", String("Bearer ") + licence);
|
||||
|
||||
Serial.println("Online-Suche mit Token: " + licence);
|
||||
|
||||
StaticJsonDocument<200> requestDoc;
|
||||
String upperUidForRequest = String(uid);
|
||||
upperUidForRequest.toUpperCase();
|
||||
requestDoc["uid"] =
|
||||
upperUidForRequest; // UID in Großbuchstaben konvertieren
|
||||
String requestBody;
|
||||
serializeJson(requestDoc, requestBody);
|
||||
|
||||
Serial.println("Request Body: " + requestBody);
|
||||
|
||||
int httpCode = http.POST(requestBody);
|
||||
|
||||
if (httpCode == HTTP_CODE_OK) {
|
||||
String response = http.getString();
|
||||
Serial.println("Response: " + response);
|
||||
StaticJsonDocument<512> responseDoc;
|
||||
DeserializationError parseError =
|
||||
deserializeJson(responseDoc, response);
|
||||
|
||||
if (!parseError && responseDoc["success"].as<bool>() &&
|
||||
responseDoc["data"]["exists"].as<bool>()) {
|
||||
// Online gefundenen Benutzer verwenden (nicht lokal speichern)
|
||||
String firstName = responseDoc["data"]["firstname"].as<String>();
|
||||
String lastName = responseDoc["data"]["lastname"].as<String>();
|
||||
String fullName = firstName + " " + lastName;
|
||||
|
||||
// UserData für Frontend erstellen
|
||||
userData.uid = upperUid;
|
||||
userData.firstname = firstName;
|
||||
userData.lastname = "";
|
||||
userData.alter = 0;
|
||||
userData.exists = true;
|
||||
|
||||
Serial.println("User online gefunden: " + fullName);
|
||||
} else {
|
||||
Serial.println("User auch online nicht gefunden für UID: " +
|
||||
upperUid);
|
||||
}
|
||||
} else {
|
||||
Serial.printf("Online-Suche fehlgeschlagen: HTTP %d\n", httpCode);
|
||||
}
|
||||
|
||||
http.end();
|
||||
} else {
|
||||
Serial.println("Keine Internetverbindung für Online-Suche");
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn Benutzer gefunden wurde (lokal oder online)
|
||||
if (userData.exists) {
|
||||
// Bestimme ob lokal oder online gefunden (bereits oben gesetzt)
|
||||
String source = start1FoundLocally ? "lokal" : "online";
|
||||
|
||||
// Log user data mit Quelle
|
||||
Serial.printf("User %s gefunden für start1: %s\n", source.c_str(),
|
||||
userData.firstname.c_str());
|
||||
|
||||
// Create JSON message to send to the frontend (ohne source)
|
||||
StaticJsonDocument<128> messageDoc;
|
||||
messageDoc["firstname"] = userData.firstname;
|
||||
messageDoc["lastname"] = userData.lastname;
|
||||
messageDoc["lane"] = "start1"; // Add lane information
|
||||
messageDoc["name"] = userData.firstname;
|
||||
messageDoc["lane"] = "start1";
|
||||
|
||||
String message;
|
||||
serializeJson(messageDoc, message);
|
||||
@@ -287,24 +384,110 @@ void readRFIDfromButton(const char *topic, const char *payload) {
|
||||
Serial.printf("Pushed user data for start1 to frontend: %s\n",
|
||||
message.c_str());
|
||||
} else {
|
||||
Serial.println("User not found for UID: " + String(uid));
|
||||
Serial.println("User nicht gefunden für UID: " + upperUid);
|
||||
|
||||
// Sende UID an Frontend wenn kein User gefunden wurde
|
||||
StaticJsonDocument<128> messageDoc;
|
||||
messageDoc["name"] = upperUid; // UID als Name senden
|
||||
messageDoc["lane"] = "start1";
|
||||
|
||||
String message;
|
||||
serializeJson(messageDoc, message);
|
||||
|
||||
// Push die UID an das Frontend
|
||||
pushUpdateToFrontend(message);
|
||||
Serial.printf("Sende UID an Frontend für start1: %s\n",
|
||||
message.c_str());
|
||||
}
|
||||
}
|
||||
// Check if the buttonmac matches buttonConfigs.start2.mac
|
||||
else if (memcmp(macBytes.data(), buttonConfigs.start2.mac, 6) == 0) {
|
||||
// Fetch user data
|
||||
UserData userData = checkUser(uid);
|
||||
if (userData.exists) {
|
||||
// Log user data
|
||||
Serial.printf("User found for start2: %s %s, Alter: %d\n",
|
||||
userData.firstname.c_str(), userData.lastname.c_str(),
|
||||
userData.alter);
|
||||
// Prüfe ob Lane 2 bereit ist
|
||||
if (timerData2.isRunning || timerData2.isArmed) {
|
||||
Serial.println("Lane 2 nicht bereit - ignoriere RFID: " + String(uid));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create JSON message to send to the frontend
|
||||
// Zuerst lokal suchen (UID in Großbuchstaben konvertieren)
|
||||
String upperUid = String(uid);
|
||||
upperUid.toUpperCase();
|
||||
UserData userData = checkUser(upperUid);
|
||||
start2FoundLocally = userData.exists; // Merken ob lokal gefunden
|
||||
start2UID = upperUid; // UID für später speichern
|
||||
|
||||
if (!userData.exists) {
|
||||
// Nicht lokal gefunden - Online-Server fragen
|
||||
Serial.println("User nicht lokal gefunden, suche online...");
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
HTTPClient http;
|
||||
http.begin(String(BACKEND_SERVER) + "/api/v1/private/users/find");
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
http.addHeader("Authorization", String("Bearer ") + licence);
|
||||
|
||||
Serial.println("Online-Suche mit Token: " + licence);
|
||||
|
||||
StaticJsonDocument<200> requestDoc;
|
||||
String upperUidForRequest2 = String(uid);
|
||||
upperUidForRequest2.toUpperCase();
|
||||
requestDoc["uid"] =
|
||||
upperUidForRequest2; // UID in Großbuchstaben konvertieren
|
||||
String requestBody;
|
||||
serializeJson(requestDoc, requestBody);
|
||||
|
||||
Serial.println("Request Body: " + requestBody);
|
||||
|
||||
int httpCode = http.POST(requestBody);
|
||||
|
||||
if (httpCode == HTTP_CODE_OK) {
|
||||
String response = http.getString();
|
||||
Serial.println("Response: " + response);
|
||||
StaticJsonDocument<512> responseDoc;
|
||||
DeserializationError parseError =
|
||||
deserializeJson(responseDoc, response);
|
||||
|
||||
if (!parseError && responseDoc["success"].as<bool>() &&
|
||||
responseDoc["data"]["exists"].as<bool>()) {
|
||||
// Online gefundenen Benutzer verwenden (nicht lokal speichern)
|
||||
String firstName = responseDoc["data"]["firstname"].as<String>();
|
||||
String lastName = responseDoc["data"]["lastname"].as<String>();
|
||||
String fullName = firstName + " " + lastName;
|
||||
|
||||
// UserData für Frontend erstellen
|
||||
userData.uid = upperUid;
|
||||
userData.firstname = firstName;
|
||||
userData.lastname = "";
|
||||
userData.alter = 0;
|
||||
userData.exists = true;
|
||||
|
||||
Serial.println("User online gefunden: " + fullName);
|
||||
} else {
|
||||
Serial.println("User auch online nicht gefunden für UID: " +
|
||||
upperUid);
|
||||
}
|
||||
} else {
|
||||
Serial.printf("Online-Suche fehlgeschlagen: HTTP %d\n", httpCode);
|
||||
}
|
||||
|
||||
http.end();
|
||||
} else {
|
||||
Serial.println("Keine Internetverbindung für Online-Suche");
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn Benutzer gefunden wurde (lokal oder online)
|
||||
if (userData.exists) {
|
||||
// Bestimme ob lokal oder online gefunden (bereits oben gesetzt)
|
||||
String source = start2FoundLocally ? "lokal" : "online";
|
||||
|
||||
// Log user data mit Quelle
|
||||
Serial.printf("User %s gefunden für start2: %s\n", source.c_str(),
|
||||
userData.firstname.c_str());
|
||||
|
||||
// Create JSON message to send to the frontend (ohne source)
|
||||
StaticJsonDocument<128> messageDoc;
|
||||
messageDoc["firstname"] = userData.firstname;
|
||||
messageDoc["lastname"] = userData.lastname;
|
||||
messageDoc["lane"] = "start2"; // Add lane information
|
||||
messageDoc["name"] = userData.firstname;
|
||||
messageDoc["lane"] = "start2";
|
||||
|
||||
String message;
|
||||
serializeJson(messageDoc, message);
|
||||
@@ -314,7 +497,20 @@ void readRFIDfromButton(const char *topic, const char *payload) {
|
||||
Serial.printf("Pushed user data for start2 to frontend: %s\n",
|
||||
message.c_str());
|
||||
} else {
|
||||
Serial.println("User not found for UID: " + String(uid));
|
||||
Serial.println("User nicht gefunden für UID: " + upperUid);
|
||||
|
||||
// Sende UID an Frontend wenn kein User gefunden wurde
|
||||
StaticJsonDocument<128> messageDoc;
|
||||
messageDoc["name"] = upperUid; // UID als Name senden
|
||||
messageDoc["lane"] = "start2";
|
||||
|
||||
String message;
|
||||
serializeJson(messageDoc, message);
|
||||
|
||||
// Push die UID an das Frontend
|
||||
pushUpdateToFrontend(message);
|
||||
Serial.printf("Sende UID an Frontend für start2: %s\n",
|
||||
message.c_str());
|
||||
}
|
||||
} else {
|
||||
Serial.println("Button MAC does not match start1.mac or start2.mac");
|
||||
@@ -335,11 +531,11 @@ void setupMqttServer() {
|
||||
mqtt.subscribe("#", [](const char *topic, const char *payload) {
|
||||
// Message received callback
|
||||
// Serial.printf("Received message on topic '%s': %s\n", topic, payload);
|
||||
if (strncmp(topic, "aquacross/button/", 17) == 0) {
|
||||
readButtonJSON(topic, payload);
|
||||
} else if (strncmp(topic, "aquacross/button/rfid/", 22) == 0) {
|
||||
if (strncmp(topic, "aquacross/button/rfid/", 22) == 0) {
|
||||
readRFIDfromButton(topic, payload);
|
||||
// Handle RFID read messages
|
||||
} else if (strncmp(topic, "aquacross/button/", 17) == 0) {
|
||||
readButtonJSON(topic, payload);
|
||||
} else if (strncmp(topic, "aquacross/battery/", 17) == 0) {
|
||||
handleBatteryTopic(topic, payload);
|
||||
} else if (strncmp(topic, "heartbeat/alive/", 16) == 0) {
|
||||
|
||||
@@ -3,13 +3,23 @@
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <HTTPClient.h>
|
||||
#include <algorithm>
|
||||
#include <preferencemanager.h>
|
||||
#include <vector>
|
||||
|
||||
const char *BACKEND_SERVER = "https://ninja.reptilfpv.de";
|
||||
extern String
|
||||
licence; // Declare licence as an external variable defined elsewhere
|
||||
String BACKEND_TOKEN =
|
||||
licence; // Use the licence as the token for authentication
|
||||
|
||||
// Lokale Benutzer-Struktur
|
||||
struct LocalUser {
|
||||
String uid;
|
||||
String name;
|
||||
unsigned long timestamp; // Zeitstempel der Erstellung
|
||||
};
|
||||
|
||||
// Lokale Benutzer-Speicherung (geht bei Neustart verloren)
|
||||
std::vector<LocalUser> localUsers;
|
||||
|
||||
bool backendOnline() {
|
||||
|
||||
@@ -21,8 +31,8 @@ bool backendOnline() {
|
||||
}
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(String(BACKEND_SERVER) + "/v1/private/health");
|
||||
http.addHeader("Authorization", String("Bearer ") + BACKEND_TOKEN);
|
||||
http.begin(String(BACKEND_SERVER) + "/api/v1/private/health");
|
||||
http.addHeader("Authorization", String("Bearer ") + licence);
|
||||
|
||||
int httpCode = http.GET();
|
||||
bool isOnline = (httpCode == HTTP_CODE_OK);
|
||||
@@ -45,47 +55,35 @@ struct UserData {
|
||||
bool exists;
|
||||
};
|
||||
|
||||
// Forward declarations für Leaderboard-Funktionen
|
||||
void addLocalTime(String uid, String name, unsigned long timeMs);
|
||||
|
||||
// Prüft, ob ein Benutzer mit der angegebenen UID in der Datenbank existiert und
|
||||
// gibt dessen Daten zurück.
|
||||
UserData checkUser(const String &uid) {
|
||||
|
||||
UserData userData = {"", "", "", 0, false};
|
||||
String upperUid = uid;
|
||||
upperUid.toUpperCase(); // UID in Großbuchstaben konvertieren
|
||||
|
||||
if (!backendOnline()) {
|
||||
Serial.println("No internet connection, cannot check user.");
|
||||
return userData;
|
||||
}
|
||||
// Lokale Benutzer durchsuchen
|
||||
for (const auto &user : localUsers) {
|
||||
String userUpperUid = user.uid;
|
||||
userUpperUid.toUpperCase();
|
||||
if (userUpperUid == upperUid) {
|
||||
userData.uid = user.uid;
|
||||
userData.firstname = user.name;
|
||||
userData.lastname = ""; // Nicht mehr verwendet
|
||||
userData.alter = 0; // Nicht mehr verwendet
|
||||
userData.exists = true;
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(String(BACKEND_SERVER) + "/v1/private/users/find");
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
http.addHeader("Authorization", String("Bearer ") + BACKEND_TOKEN);
|
||||
|
||||
// Create JSON payload
|
||||
StaticJsonDocument<200> requestDoc;
|
||||
requestDoc["uid"] = uid;
|
||||
String requestBody;
|
||||
serializeJson(requestDoc, requestBody);
|
||||
|
||||
int httpCode = http.POST(requestBody);
|
||||
|
||||
if (httpCode == HTTP_CODE_OK) {
|
||||
String payload = http.getString();
|
||||
StaticJsonDocument<512> responseDoc;
|
||||
DeserializationError error = deserializeJson(responseDoc, payload);
|
||||
|
||||
if (!error) {
|
||||
userData.uid = responseDoc["uid"].as<String>();
|
||||
userData.firstname = responseDoc["firstname"].as<String>();
|
||||
userData.lastname = responseDoc["lastname"].as<String>();
|
||||
userData.alter = responseDoc["alter"] | 0;
|
||||
userData.exists = responseDoc["exists"] | false;
|
||||
Serial.println("Lokaler Benutzer gefunden: " + user.name);
|
||||
return userData;
|
||||
}
|
||||
} else {
|
||||
Serial.printf("User check failed, HTTP code: %d\n", httpCode);
|
||||
}
|
||||
|
||||
http.end();
|
||||
Serial.println("Benutzer mit UID " + uid +
|
||||
" nicht in lokaler Datenbank gefunden");
|
||||
return userData;
|
||||
}
|
||||
|
||||
@@ -100,7 +98,7 @@ JsonDocument getAllLocations() {
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(String(BACKEND_SERVER) + "/v1/private/locations");
|
||||
http.addHeader("Authorization", String("Bearer ") + BACKEND_TOKEN);
|
||||
http.addHeader("Authorization", String("Bearer ") + licence);
|
||||
|
||||
int httpCode = http.GET();
|
||||
|
||||
@@ -124,42 +122,47 @@ JsonDocument getAllLocations() {
|
||||
bool userExists(const String &uid) { return checkUser(uid).exists; }
|
||||
|
||||
// Fügt einen neuen Benutzer in die Datenbank ein
|
||||
bool enterUserData(const String &uid, const String &vorname,
|
||||
const String &nachname, const String &geburtsdatum,
|
||||
int alter) {
|
||||
if (!backendOnline()) {
|
||||
Serial.println("No internet connection, cannot enter user data.");
|
||||
return false;
|
||||
bool enterUserData(const String &uid, const String &name) {
|
||||
String upperUid = uid;
|
||||
upperUid.toUpperCase(); // UID in Großbuchstaben konvertieren
|
||||
|
||||
// Prüfen ob Benutzer bereits existiert
|
||||
for (const auto &user : localUsers) {
|
||||
String userUpperUid = user.uid;
|
||||
userUpperUid.toUpperCase();
|
||||
if (userUpperUid == upperUid) {
|
||||
Serial.println("Benutzer mit UID " + upperUid + " existiert bereits!");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(String(BACKEND_SERVER) + "/v1/private/users/insert");
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
http.addHeader("Authorization", String("Bearer ") + BACKEND_TOKEN);
|
||||
// Neuen Benutzer erstellen
|
||||
LocalUser newUser;
|
||||
newUser.uid = upperUid; // UID in Großbuchstaben speichern
|
||||
newUser.name = name;
|
||||
newUser.timestamp = millis();
|
||||
|
||||
// Create JSON payload
|
||||
StaticJsonDocument<512> requestDoc;
|
||||
requestDoc["uid"] = uid;
|
||||
requestDoc["firstname"] = vorname;
|
||||
requestDoc["lastname"] = nachname;
|
||||
requestDoc["geburtsdatum"] = geburtsdatum;
|
||||
requestDoc["alter"] = alter;
|
||||
// Benutzer zum lokalen Array hinzufügen
|
||||
localUsers.push_back(newUser);
|
||||
|
||||
String requestBody;
|
||||
serializeJson(requestDoc, requestBody);
|
||||
Serial.println("Benutzer lokal gespeichert:");
|
||||
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) {
|
||||
Serial.println("User data successfully entered into database");
|
||||
} else {
|
||||
Serial.printf("Failed to enter user data, HTTP code: %d\n", httpCode);
|
||||
for (const auto &user : localUsers) {
|
||||
result += "- UID: " + user.uid + ", Name: " + user.name +
|
||||
", Erstellt: " + String(user.timestamp) + "\n";
|
||||
}
|
||||
|
||||
http.end();
|
||||
return success;
|
||||
return result;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (!backendOnline()) {
|
||||
request->send(503, "application/json",
|
||||
"{\"error\":\"Database not connected\"}");
|
||||
return;
|
||||
// Lokale Benutzer als JSON zurückgeben
|
||||
DynamicJsonDocument doc(2048);
|
||||
JsonArray usersArray = doc.createNestedArray("users");
|
||||
|
||||
for (const auto &user : localUsers) {
|
||||
JsonObject userObj = usersArray.createNestedObject();
|
||||
userObj["uid"] = user.uid;
|
||||
userObj["name"] = user.name;
|
||||
userObj["timestamp"] = user.timestamp;
|
||||
}
|
||||
|
||||
// Handle user retrieval logic here
|
||||
doc["count"] = localUsers.size();
|
||||
|
||||
String response;
|
||||
serializeJson(doc, response);
|
||||
request->send(200, "application/json", response);
|
||||
});
|
||||
|
||||
// Route zum Erstellen eines neuen Benutzers
|
||||
server.on(
|
||||
"/api/users/insert", HTTP_POST,
|
||||
[](AsyncWebServerRequest *request) {
|
||||
Serial.println("API: /api/users/insert aufgerufen");
|
||||
},
|
||||
NULL,
|
||||
[](AsyncWebServerRequest *request, uint8_t *data, size_t len,
|
||||
size_t index, size_t total) {
|
||||
// Diese Funktion wird für den Body aufgerufen
|
||||
static String bodyBuffer = "";
|
||||
|
||||
// Daten anhängen
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
bodyBuffer += (char)data[i];
|
||||
}
|
||||
|
||||
// Wenn alle Daten empfangen wurden
|
||||
if (index + len == total) {
|
||||
Serial.println("Request Body empfangen: '" + bodyBuffer + "'");
|
||||
|
||||
if (bodyBuffer.length() == 0) {
|
||||
Serial.println("FEHLER: Request Body ist leer!");
|
||||
DynamicJsonDocument response(200);
|
||||
response["success"] = false;
|
||||
response["error"] = "Request Body ist leer";
|
||||
String jsonString;
|
||||
serializeJson(response, jsonString);
|
||||
request->send(400, "application/json", jsonString);
|
||||
bodyBuffer = "";
|
||||
return;
|
||||
}
|
||||
|
||||
DynamicJsonDocument doc(512);
|
||||
DeserializationError error = deserializeJson(doc, bodyBuffer);
|
||||
|
||||
if (error) {
|
||||
Serial.println("JSON Parse Error: " + String(error.c_str()));
|
||||
DynamicJsonDocument response(200);
|
||||
response["success"] = false;
|
||||
response["error"] = "Invalid JSON: " + String(error.c_str());
|
||||
String jsonString;
|
||||
serializeJson(response, jsonString);
|
||||
request->send(400, "application/json", jsonString);
|
||||
bodyBuffer = "";
|
||||
return;
|
||||
}
|
||||
|
||||
String uid = doc["uid"].as<String>();
|
||||
String name = doc["name"].as<String>();
|
||||
|
||||
Serial.println("Extrahierte UID: " + uid);
|
||||
Serial.println("Extrahierter Name: " + name);
|
||||
|
||||
if (uid.length() == 0 || name.length() == 0) {
|
||||
DynamicJsonDocument response(200);
|
||||
response["success"] = false;
|
||||
response["error"] = "UID und Name sind erforderlich";
|
||||
String jsonString;
|
||||
serializeJson(response, jsonString);
|
||||
request->send(400, "application/json", jsonString);
|
||||
bodyBuffer = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfen ob Benutzer bereits existiert
|
||||
bool userExists = false;
|
||||
for (const auto &user : localUsers) {
|
||||
if (user.uid == uid) {
|
||||
userExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (userExists) {
|
||||
DynamicJsonDocument response(200);
|
||||
response["success"] = false;
|
||||
response["error"] = "Benutzer bereits vorhanden";
|
||||
String jsonString;
|
||||
serializeJson(response, jsonString);
|
||||
request->send(409, "application/json", jsonString);
|
||||
bodyBuffer = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Neuen Benutzer direkt in das Array einfügen
|
||||
LocalUser newUser;
|
||||
newUser.uid = uid;
|
||||
newUser.name = name;
|
||||
newUser.timestamp = millis();
|
||||
|
||||
localUsers.push_back(newUser);
|
||||
|
||||
Serial.println("Benutzer über API eingefügt:");
|
||||
Serial.println("UID: " + uid);
|
||||
Serial.println("Name: " + name);
|
||||
Serial.println("Gespeicherte Benutzer: " + String(localUsers.size()));
|
||||
|
||||
DynamicJsonDocument response(200);
|
||||
response["success"] = true;
|
||||
response["message"] = "Benutzer erfolgreich erstellt";
|
||||
response["uid"] = uid;
|
||||
response["name"] = name;
|
||||
|
||||
String jsonString;
|
||||
serializeJson(response, jsonString);
|
||||
request->send(200, "application/json", jsonString);
|
||||
|
||||
// Buffer zurücksetzen
|
||||
bodyBuffer = "";
|
||||
}
|
||||
});
|
||||
|
||||
// Debug-Route für lokale Benutzer
|
||||
server.on("/api/debug/users", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
String userList = getLocalUsersList();
|
||||
request->send(200, "text/plain", userList);
|
||||
});
|
||||
|
||||
// Location routes /api/location/
|
||||
server.on("/api/location/", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
String result;
|
||||
@@ -221,5 +353,206 @@ void setupBackendRoutes(AsyncWebServer &server) {
|
||||
// Andere Logik wie in getBestLocs
|
||||
});
|
||||
|
||||
// Lokales Leaderboard API (für Hauptseite - 6 Einträge)
|
||||
server.on("/api/leaderboard", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
// Sortiere nach Zeit (beste zuerst)
|
||||
std::sort(localTimes.begin(), localTimes.end(),
|
||||
[](const LocalTime &a, const LocalTime &b) {
|
||||
return a.timeMs < b.timeMs;
|
||||
});
|
||||
|
||||
DynamicJsonDocument doc(2048);
|
||||
JsonArray leaderboard = doc.createNestedArray("leaderboard");
|
||||
|
||||
// Nimm die besten 6
|
||||
int count = 0;
|
||||
for (const auto &time : localTimes) {
|
||||
if (count >= 6)
|
||||
break;
|
||||
|
||||
JsonObject entry = leaderboard.createNestedObject();
|
||||
entry["rank"] = count + 1;
|
||||
entry["name"] = time.name;
|
||||
entry["uid"] = time.uid;
|
||||
entry["time"] = time.timeMs / 1000.0;
|
||||
|
||||
// Format time inline
|
||||
float seconds = time.timeMs / 1000.0;
|
||||
int totalSeconds = (int)seconds;
|
||||
int minutes = totalSeconds / 60;
|
||||
int remainingSeconds = totalSeconds % 60;
|
||||
int milliseconds = (int)((seconds - totalSeconds) * 100);
|
||||
|
||||
String timeFormatted;
|
||||
if (minutes > 0) {
|
||||
timeFormatted = String(minutes) + ":" +
|
||||
(remainingSeconds < 10 ? "0" : "") +
|
||||
String(remainingSeconds) + "." +
|
||||
(milliseconds < 10 ? "0" : "") + String(milliseconds);
|
||||
} else {
|
||||
timeFormatted = String(remainingSeconds) + "." +
|
||||
(milliseconds < 10 ? "0" : "") + String(milliseconds);
|
||||
}
|
||||
entry["timeFormatted"] = timeFormatted;
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
String result;
|
||||
serializeJson(doc, result);
|
||||
request->send(200, "application/json", result);
|
||||
});
|
||||
|
||||
// Erweiterte Leaderboard API (für Leaderboard-Seite - 10 Einträge)
|
||||
server.on(
|
||||
"/api/leaderboard-full", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
// Sortiere nach Zeit (beste zuerst)
|
||||
std::sort(localTimes.begin(), localTimes.end(),
|
||||
[](const LocalTime &a, const LocalTime &b) {
|
||||
return a.timeMs < b.timeMs;
|
||||
});
|
||||
|
||||
DynamicJsonDocument doc(2048);
|
||||
JsonArray leaderboard = doc.createNestedArray("leaderboard");
|
||||
|
||||
// Nimm die besten 10
|
||||
int count = 0;
|
||||
for (const auto &time : localTimes) {
|
||||
if (count >= 10)
|
||||
break;
|
||||
|
||||
JsonObject entry = leaderboard.createNestedObject();
|
||||
entry["rank"] = count + 1;
|
||||
entry["name"] = time.name;
|
||||
entry["uid"] = time.uid;
|
||||
entry["time"] = time.timeMs / 1000.0;
|
||||
|
||||
// Format time inline
|
||||
float seconds = time.timeMs / 1000.0;
|
||||
int totalSeconds = (int)seconds;
|
||||
int minutes = totalSeconds / 60;
|
||||
int remainingSeconds = totalSeconds % 60;
|
||||
int milliseconds = (int)((seconds - totalSeconds) * 100);
|
||||
|
||||
String timeFormatted;
|
||||
if (minutes > 0) {
|
||||
timeFormatted =
|
||||
String(minutes) + ":" + (remainingSeconds < 10 ? "0" : "") +
|
||||
String(remainingSeconds) + "." +
|
||||
(milliseconds < 10 ? "0" : "") + String(milliseconds);
|
||||
} else {
|
||||
timeFormatted = String(remainingSeconds) + "." +
|
||||
(milliseconds < 10 ? "0" : "") +
|
||||
String(milliseconds);
|
||||
}
|
||||
|
||||
entry["timeFormatted"] = timeFormatted;
|
||||
count++;
|
||||
}
|
||||
|
||||
String result;
|
||||
serializeJson(doc, result);
|
||||
request->send(200, "application/json", result);
|
||||
});
|
||||
|
||||
// Add more routes as needed
|
||||
}
|
||||
|
||||
// Hilfsfunktionen um UID und Status abzufragen (aus communication.h)
|
||||
String getStart1UID();
|
||||
String getStart2UID();
|
||||
bool wasStart1FoundLocally();
|
||||
bool wasStart2FoundLocally();
|
||||
|
||||
// Funktion um Zeit an Online-API zu senden
|
||||
void sendTimeToOnlineAPI(int lane, String uid, float timeInSeconds) {
|
||||
// Nur senden wenn User online gefunden wurde
|
||||
bool wasOnlineFound =
|
||||
(lane == 1) ? !wasStart1FoundLocally() : !wasStart2FoundLocally();
|
||||
|
||||
if (!wasOnlineFound) {
|
||||
Serial.println("Zeit nicht gesendet - User wurde lokal gefunden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
Serial.println("Keine Internetverbindung - Zeit nicht gesendet");
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.println("Sende Zeit an Online-API für Lane " + String(lane));
|
||||
|
||||
HTTPClient http;
|
||||
http.begin(String(BACKEND_SERVER) + "/api/v1/private/create-time");
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
http.addHeader("Authorization", String("Bearer ") + licence);
|
||||
|
||||
// Zeit in M:SS.mmm Format konvertieren (ohne führende Null bei Minuten)
|
||||
int minutes = (int)(timeInSeconds / 60);
|
||||
int seconds = (int)timeInSeconds % 60;
|
||||
int milliseconds = (int)((timeInSeconds - (int)timeInSeconds) * 1000);
|
||||
|
||||
String formattedTime =
|
||||
String(minutes) + ":" + (seconds < 10 ? "0" : "") + String(seconds) +
|
||||
"." + (milliseconds < 10 ? "00" : (milliseconds < 100 ? "0" : "")) +
|
||||
String(milliseconds);
|
||||
|
||||
StaticJsonDocument<200> requestDoc;
|
||||
requestDoc["rfiduid"] = uid;
|
||||
requestDoc["location_name"] =
|
||||
getLocationIdFromPrefs(); // Aus den Einstellungen
|
||||
requestDoc["recorded_time"] = formattedTime;
|
||||
|
||||
String requestBody;
|
||||
serializeJson(requestDoc, requestBody);
|
||||
|
||||
Serial.println("API Request Body: " + requestBody);
|
||||
|
||||
int httpCode = http.POST(requestBody);
|
||||
|
||||
if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_CREATED) {
|
||||
String response = http.getString();
|
||||
Serial.println("Zeit erfolgreich gesendet: " + response);
|
||||
} else {
|
||||
Serial.printf("Fehler beim Senden der Zeit: HTTP %d\n", httpCode);
|
||||
if (httpCode > 0) {
|
||||
String response = http.getString();
|
||||
Serial.println("Response: " + response);
|
||||
}
|
||||
}
|
||||
|
||||
http.end();
|
||||
}
|
||||
|
||||
// Funktionen für lokales Leaderboard
|
||||
void addLocalTime(String uid, String name, unsigned long timeMs) {
|
||||
// Prüfe minimale Zeit für Leaderboard-Eintrag
|
||||
if (timeMs < minTimeForLeaderboard) {
|
||||
Serial.printf(
|
||||
"Zeit zu kurz für Leaderboard: %s (%s) - %.2fs (Minimum: %.2fs)\n",
|
||||
name.c_str(), uid.c_str(), timeMs / 1000.0,
|
||||
minTimeForLeaderboard / 1000.0);
|
||||
return; // Zeit wird nicht ins Leaderboard eingetragen
|
||||
}
|
||||
|
||||
LocalTime newTime;
|
||||
newTime.uid = uid;
|
||||
newTime.name = name;
|
||||
newTime.timeMs = timeMs;
|
||||
newTime.timestamp = millis();
|
||||
|
||||
localTimes.push_back(newTime);
|
||||
|
||||
// Speichere das Leaderboard automatisch
|
||||
saveBestTimes();
|
||||
|
||||
Serial.printf("Lokale Zeit hinzugefügt: %s (%s) - %.2fs\n", name.c_str(),
|
||||
uid.c_str(), timeMs / 1000.0);
|
||||
}
|
||||
|
||||
// Leert das lokale Leaderboard
|
||||
void clearLocalLeaderboard() {
|
||||
localTimes.clear();
|
||||
saveBestTimes(); // Speichere das geleerte Leaderboard
|
||||
Serial.println("Lokales Leaderboard geleert");
|
||||
}
|
||||
@@ -56,6 +56,25 @@ void IndividualMode(const char *action, int press, int lane,
|
||||
publishLaneStatus(1, "stopped");
|
||||
Serial.println("Bahn 1 gestoppt - Zeit: " + String(currentTime / 1000.0) +
|
||||
"s");
|
||||
|
||||
// Speichere Zeit immer lokal
|
||||
if (wasStart1FoundLocally() && getStart1UID().length() > 0) {
|
||||
// Finde den Namen des lokalen Users
|
||||
UserData userData = checkUser(getStart1UID());
|
||||
if (userData.exists) {
|
||||
addLocalTime(getStart1UID(), userData.firstname, currentTime);
|
||||
} else {
|
||||
// User lokal gefunden aber keine Daten - speichere ohne Namen
|
||||
addLocalTime(getStart1UID(), "Unbekannt", currentTime);
|
||||
}
|
||||
} else if (!wasStart1FoundLocally() && getStart1UID().length() > 0) {
|
||||
// Sende Zeit an Online-API wenn User online gefunden wurde
|
||||
sendTimeToOnlineAPI(1, getStart1UID(), currentTime / 1000.0);
|
||||
} else {
|
||||
// Kein User gefunden - speichere Zeit ohne UID und Namen
|
||||
addLocalTime("", "Spieler " + String((localTimes.size() + 1)),
|
||||
currentTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action == "start" && press == 2 && lane == 2) {
|
||||
@@ -84,6 +103,25 @@ void IndividualMode(const char *action, int press, int lane,
|
||||
publishLaneStatus(2, "stopped");
|
||||
Serial.println("Bahn 2 gestoppt - Zeit: " + String(currentTime / 1000.0) +
|
||||
"s");
|
||||
|
||||
// Speichere Zeit immer lokal
|
||||
if (wasStart2FoundLocally() && getStart2UID().length() > 0) {
|
||||
// Finde den Namen des lokalen Users
|
||||
UserData userData = checkUser(getStart2UID());
|
||||
if (userData.exists) {
|
||||
addLocalTime(getStart2UID(), userData.firstname, currentTime);
|
||||
} else {
|
||||
// User lokal gefunden aber keine Daten - speichere ohne Namen
|
||||
addLocalTime(getStart2UID(), "Unbekannt", currentTime);
|
||||
}
|
||||
} else if (!wasStart2FoundLocally() && getStart2UID().length() > 0) {
|
||||
// Sende Zeit an Online-API wenn User online gefunden wurde
|
||||
sendTimeToOnlineAPI(2, getStart2UID(), currentTime / 1000.0);
|
||||
} else {
|
||||
// Kein User gefunden - speichere Zeit ohne UID und Namen
|
||||
addLocalTime("", "Spieler " + String((localTimes.size() + 1)),
|
||||
currentTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,4 +368,4 @@ String getTimerDataJSON() {
|
||||
String result;
|
||||
serializeJson(doc, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,18 +18,16 @@
|
||||
#include <debug.h>
|
||||
#include <gamemodes.h>
|
||||
#include <licenceing.h>
|
||||
#include <preferencemanager.h>
|
||||
#include <rfid.h>
|
||||
#include <timesync.h>
|
||||
#include <webserverrouter.h>
|
||||
#include <wificlass.h>
|
||||
#include <preferencemanager.h>
|
||||
|
||||
const char *firmwareversion = "1.0.0"; // Version der Firmware
|
||||
|
||||
// moved to preferencemanager.h
|
||||
|
||||
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
@@ -52,7 +50,6 @@ void setup() {
|
||||
loadWifiSettings();
|
||||
loadLocationSettings();
|
||||
|
||||
|
||||
setupWifi(); // WiFi initialisieren
|
||||
setupOTA(&server);
|
||||
|
||||
@@ -61,13 +58,24 @@ void setup() {
|
||||
setupLED();
|
||||
setupMqttServer(); // MQTT Server initialisieren
|
||||
// setupBattery();
|
||||
// setupRFID();
|
||||
|
||||
setupRFID(); // RFID initialisieren (ganz einfach)
|
||||
}
|
||||
|
||||
void loop() {
|
||||
checkAutoReset();
|
||||
loopMqttServer(); // MQTT Server in der Loop aufrufen
|
||||
|
||||
// MQTT hat höchste Priorität (wird zuerst verarbeitet)
|
||||
loopMqttServer();
|
||||
|
||||
// WebSocket verarbeiten
|
||||
loopWebSocket();
|
||||
// loopBattery(); // Batterie-Loop aufrufen
|
||||
// loopRFID(); // RFID Loop aufrufen
|
||||
|
||||
// RFID Loop nur wenn aktiv (spart CPU-Zyklen)
|
||||
if (isRFIDReadingActive()) {
|
||||
loopRFID();
|
||||
}
|
||||
|
||||
// Kurze Pause um anderen Tasks Zeit zu geben
|
||||
delay(1);
|
||||
}
|
||||
|
||||
17
src/master.h
17
src/master.h
@@ -4,6 +4,7 @@
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <sys/time.h>
|
||||
#include <time.h>
|
||||
#include <vector>
|
||||
|
||||
const char *ssidAP;
|
||||
const char *passwordAP = nullptr;
|
||||
@@ -24,6 +25,14 @@ struct TimerData1 {
|
||||
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
|
||||
struct TimerData2 {
|
||||
unsigned long startTime = 0;
|
||||
@@ -63,7 +72,9 @@ bool learningMode = false;
|
||||
int learningStep = 0; // 0=Start1, 1=Stop1, 2=Start2, 3=Stop2
|
||||
unsigned long maxTimeBeforeReset = 300000; // 5 Minuten default
|
||||
unsigned long maxTimeDisplay = 20000; // 20 Sekunden Standard (in ms)
|
||||
bool wifimodeAP = false; // AP-Modus deaktiviert
|
||||
unsigned long minTimeForLeaderboard =
|
||||
5000; // 5 Sekunden minimum für Leaderboard (in ms)
|
||||
bool wifimodeAP = false; // AP-Modus deaktiviert
|
||||
String masterlocation;
|
||||
int gamemode; // 0=Individual, 1=Wettkampf
|
||||
bool startCompetition = false; // Flag, ob der Timer gestartet wurde
|
||||
@@ -73,6 +84,9 @@ int laneConfigType = 0; // 0=Identical, 1=Different
|
||||
int lane1DifficultyType = 0; // 0=Light, 1=Heavy (difficulty)
|
||||
int lane2DifficultyType = 0; // 0=Light, 1=Heavy (difficulty)
|
||||
|
||||
// Lokales Leaderboard
|
||||
std::vector<LocalTime> localTimes;
|
||||
|
||||
// Function Declarations
|
||||
void OnDataRecv(const uint8_t *mac, const uint8_t *incomingData, int len);
|
||||
void handleLearningMode(const uint8_t *mac);
|
||||
@@ -85,6 +99,7 @@ void loadBestTimes();
|
||||
void saveSettings();
|
||||
void loadSettings();
|
||||
void loadWifiSettings();
|
||||
void clearLocalLeaderboard();
|
||||
void saveWifiSettings();
|
||||
void loadLocationSettings();
|
||||
void saveLocationSettings();
|
||||
|
||||
@@ -21,19 +21,60 @@ void loadButtonConfig() {
|
||||
preferences.end();
|
||||
}
|
||||
|
||||
// Persist and load best times
|
||||
// Persist and load local leaderboard
|
||||
void saveBestTimes() {
|
||||
preferences.begin("times", false);
|
||||
preferences.putULong("best1", timerData1.bestTime);
|
||||
preferences.putULong("best2", timerData2.bestTime);
|
||||
preferences.begin("leaderboard", false);
|
||||
|
||||
// Speichere Anzahl der Einträge
|
||||
preferences.putUInt("count", localTimes.size());
|
||||
|
||||
// Speichere jeden Eintrag (kurze Schlüssel für NVS)
|
||||
for (size_t i = 0; i < localTimes.size(); i++) {
|
||||
String key = "e" + String(i); // e0, e1, e2, etc.
|
||||
preferences.putString((key + "u").c_str(),
|
||||
localTimes[i].uid); // e0u, e1u, etc.
|
||||
preferences.putString((key + "n").c_str(),
|
||||
localTimes[i].name); // e0n, e1n, etc.
|
||||
preferences.putULong((key + "t").c_str(),
|
||||
localTimes[i].timeMs); // e0t, e1t, etc.
|
||||
preferences.putULong((key + "s").c_str(),
|
||||
localTimes[i].timestamp); // e0s, e1s, etc.
|
||||
}
|
||||
|
||||
preferences.end();
|
||||
Serial.println("Lokales Leaderboard gespeichert: " +
|
||||
String(localTimes.size()) + " Einträge");
|
||||
}
|
||||
|
||||
void loadBestTimes() {
|
||||
preferences.begin("times", true);
|
||||
timerData1.bestTime = preferences.getULong("best1", 0);
|
||||
timerData2.bestTime = preferences.getULong("best2", 0);
|
||||
preferences.begin("leaderboard", true);
|
||||
|
||||
// Leere das aktuelle Leaderboard
|
||||
localTimes.clear();
|
||||
|
||||
// Lade Anzahl der Einträge
|
||||
uint32_t count = preferences.getUInt("count", 0);
|
||||
|
||||
// Lade jeden Eintrag (kurze Schlüssel für NVS)
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
LocalTime entry;
|
||||
String key = "e" + String(i); // e0, e1, e2, etc.
|
||||
|
||||
entry.uid =
|
||||
preferences.getString((key + "u").c_str(), ""); // e0u, e1u, etc.
|
||||
entry.name =
|
||||
preferences.getString((key + "n").c_str(), ""); // e0n, e1n, etc.
|
||||
entry.timeMs =
|
||||
preferences.getULong((key + "t").c_str(), 0); // e0t, e1t, etc.
|
||||
entry.timestamp =
|
||||
preferences.getULong((key + "s").c_str(), 0); // e0s, e1s, etc.
|
||||
|
||||
localTimes.push_back(entry);
|
||||
}
|
||||
|
||||
preferences.end();
|
||||
Serial.println("Lokales Leaderboard geladen: " + String(localTimes.size()) +
|
||||
" Einträge");
|
||||
}
|
||||
|
||||
// Persist and load general settings
|
||||
@@ -41,6 +82,7 @@ void saveSettings() {
|
||||
preferences.begin("settings", false);
|
||||
preferences.putULong("maxTime", maxTimeBeforeReset);
|
||||
preferences.putULong("maxTimeDisplay", maxTimeDisplay);
|
||||
preferences.putULong("minTime", minTimeForLeaderboard);
|
||||
preferences.putUInt("gamemode", gamemode);
|
||||
preferences.putUInt("laneConfigType", laneConfigType);
|
||||
preferences.putUInt("lane1Diff", lane1DifficultyType);
|
||||
@@ -52,6 +94,7 @@ void loadSettings() {
|
||||
preferences.begin("settings", true);
|
||||
maxTimeBeforeReset = preferences.getULong("maxTime", 300000);
|
||||
maxTimeDisplay = preferences.getULong("maxTimeDisplay", 20000);
|
||||
minTimeForLeaderboard = preferences.getULong("minTime", 5000);
|
||||
gamemode = preferences.getUInt("gamemode", 0);
|
||||
laneConfigType = preferences.getUInt("laneConfigType", 0);
|
||||
lane1DifficultyType = preferences.getUInt("lane1Diff", 0);
|
||||
|
||||
398
src/rfid.h
398
src/rfid.h
@@ -1,189 +1,150 @@
|
||||
#pragma once
|
||||
#include "databasebackend.h"
|
||||
#include <Adafruit_PN532.h>
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <MFRC522.h>
|
||||
#include <SPI.h>
|
||||
#include <Wire.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
|
||||
#define RST_PIN 21 // Configurable, see typical pin layout above
|
||||
#define SS_PIN 5 // Configurable, see typical pin layout above
|
||||
// PN532 RFID Reader (mit IRQ und Reset-Pin)
|
||||
Adafruit_PN532 nfc(IRQ_PIN, RST_PIN);
|
||||
|
||||
MFRC522 mfrc522(SS_PIN, RST_PIN); // Create MFRC522 instance
|
||||
std::map<String, unsigned long>
|
||||
blockedUIDs; // Map to store blocked UIDs and their timestamps
|
||||
const unsigned long BLOCK_DURATION = 10 * 1000; // 10 Seconds in milliseconds
|
||||
|
||||
// Neue Variablen für API-basiertes Lesen
|
||||
bool rfidReadRequested = false;
|
||||
// RFID Variablen
|
||||
bool rfidInitialized = false;
|
||||
bool readingMode = false;
|
||||
String lastReadUID = "";
|
||||
bool rfidReadSuccess = false;
|
||||
unsigned long rfidReadStartTime = 0;
|
||||
const unsigned long RFID_READ_TIMEOUT =
|
||||
10000; // 10 Sekunden Timeout für API Requests
|
||||
unsigned long lastReadTime = 0;
|
||||
|
||||
// Initialisiert den RFID-Reader und das SPI-Interface.
|
||||
// Hilfsfunktion um Reading-Mode zu prüfen
|
||||
bool isRFIDReadingActive() { return readingMode; }
|
||||
|
||||
// Initialisiert den RFID-Reader
|
||||
void setupRFID() {
|
||||
// I2C starten mit korrekten Pins
|
||||
Wire.begin(SDA_PIN, SCL_PIN, 100000);
|
||||
delay(100);
|
||||
|
||||
// SPI und RFID initialisieren
|
||||
SPI.begin(); // Init SPI bus
|
||||
mfrc522.PCD_Init(); // Init MFRC522
|
||||
delay(4); // Optional delay. Some boards need more time after init to be ready
|
||||
mfrc522.PCD_DumpVersionToSerial(); // Show details of PCD - MFRC522 Card
|
||||
// Reader details
|
||||
// PN532 initialisieren
|
||||
if (!nfc.begin()) {
|
||||
Serial.println("RFID: PN532 nicht gefunden!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Firmware prüfen
|
||||
uint32_t versiondata = nfc.getFirmwareVersion();
|
||||
if (!versiondata) {
|
||||
Serial.println("RFID: Firmware nicht lesbar!");
|
||||
return;
|
||||
}
|
||||
|
||||
// SAM Config
|
||||
nfc.SAMConfig();
|
||||
|
||||
rfidInitialized = true;
|
||||
Serial.println("RFID: Setup erfolgreich!");
|
||||
}
|
||||
|
||||
// Liest automatisch eine RFID-Karte ein und blockiert die UID für eine
|
||||
// bestimmte Zeit.
|
||||
void handleAutomaticRFID() {
|
||||
if (!mfrc522.PICC_IsNewCardPresent()) {
|
||||
return;
|
||||
// Prüft ob RFID funktioniert
|
||||
bool checkRFID() {
|
||||
if (!rfidInitialized) {
|
||||
return false;
|
||||
}
|
||||
uint32_t versiondata = nfc.getFirmwareVersion();
|
||||
return (versiondata != 0);
|
||||
}
|
||||
|
||||
// Liest RFID-Karte - NICHT BLOCKIEREND
|
||||
String readRFIDCard() {
|
||||
if (!checkRFID()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Select one of the cards
|
||||
if (!mfrc522.PICC_ReadCardSerial()) {
|
||||
return;
|
||||
uint8_t uid[] = {0, 0, 0, 0, 0, 0, 0};
|
||||
uint8_t uidLength;
|
||||
|
||||
// Nicht-blockierender Aufruf mit sehr kurzer Timeout
|
||||
uint8_t success =
|
||||
nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLength,
|
||||
50); // 50ms Timeout statt Standard 100ms
|
||||
|
||||
if (!success) {
|
||||
return ""; // Keine Karte oder Timeout
|
||||
}
|
||||
|
||||
// Read the UID
|
||||
String uid = "";
|
||||
for (byte i = 0; i < mfrc522.uid.size; i++) {
|
||||
// UID zu String
|
||||
String uidString = "";
|
||||
for (uint8_t i = 0; i < uidLength; i++) {
|
||||
if (i > 0)
|
||||
uid += ":";
|
||||
if (mfrc522.uid.uidByte[i] < 0x10)
|
||||
uid += "0";
|
||||
uid += String(mfrc522.uid.uidByte[i], HEX);
|
||||
uidString += ":";
|
||||
if (uid[i] < 0x10)
|
||||
uidString += "0";
|
||||
uidString += String(uid[i], HEX);
|
||||
}
|
||||
uidString.toUpperCase();
|
||||
|
||||
Serial.println("RFID: " + uidString);
|
||||
return uidString;
|
||||
}
|
||||
|
||||
// RFID Loop - kontinuierliches Lesen wenn aktiviert (MQTT-optimiert)
|
||||
void loopRFID() {
|
||||
if (!readingMode) {
|
||||
return; // Lesen nicht aktiviert
|
||||
}
|
||||
|
||||
// Check if the UID is blocked
|
||||
unsigned long currentTime = millis();
|
||||
if (blockedUIDs.find(uid) != blockedUIDs.end()) {
|
||||
if (currentTime - blockedUIDs[uid] < BLOCK_DURATION) {
|
||||
Serial.print(F("UID blocked for 10 seconds. Remaining time: "));
|
||||
Serial.print((BLOCK_DURATION - (currentTime - blockedUIDs[uid])) / 1000);
|
||||
Serial.println(F(" seconds."));
|
||||
Serial.println(uid);
|
||||
return;
|
||||
} else {
|
||||
// Remove the UID from the blocked list if the block duration has passed
|
||||
blockedUIDs.erase(uid);
|
||||
static unsigned long lastCheck = 0;
|
||||
|
||||
// Nur alle 300ms prüfen (weniger belastend für MQTT)
|
||||
if (millis() - lastCheck < 300) {
|
||||
return;
|
||||
}
|
||||
lastCheck = millis();
|
||||
|
||||
// Versuchen zu lesen (mit kurzer Timeout)
|
||||
String uid = readRFIDCard();
|
||||
if (uid.length() > 0) {
|
||||
// Nur neue UIDs oder nach 2 Sekunden Pause
|
||||
if (uid != lastReadUID || millis() - lastReadTime > 2000) {
|
||||
lastReadUID = uid;
|
||||
lastReadTime = millis();
|
||||
Serial.println("RFID gelesen: " + uid);
|
||||
}
|
||||
}
|
||||
|
||||
// Process the UID
|
||||
Serial.print(F("UID: "));
|
||||
Serial.println(uid);
|
||||
|
||||
// Block the UID for 10 seconds
|
||||
blockedUIDs[uid] = currentTime;
|
||||
// show the remaining time for the block
|
||||
Serial.print(F("UID blocked for 10 seconds. Remaining time: "));
|
||||
Serial.print((BLOCK_DURATION - (currentTime - blockedUIDs[uid])) / 1000);
|
||||
Serial.println(F(" seconds."));
|
||||
|
||||
// Halt the card
|
||||
mfrc522.PICC_HaltA();
|
||||
}
|
||||
|
||||
// Neue Funktion für API-basiertes RFID Lesen
|
||||
|
||||
// Liest eine RFID-Karte im API-Modus ein (für Web-Requests).
|
||||
void handleAPIRFIDRead() {
|
||||
unsigned long currentTime = millis();
|
||||
|
||||
// Timeout prüfen
|
||||
if (currentTime - rfidReadStartTime > RFID_READ_TIMEOUT) {
|
||||
Serial.println("RFID API Timeout - keine Karte erkannt");
|
||||
rfidReadRequested = false;
|
||||
rfidReadSuccess = false;
|
||||
lastReadUID = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfen ob neue Karte vorhanden ist
|
||||
if (!mfrc522.PICC_IsNewCardPresent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Karte auswählen
|
||||
if (!mfrc522.PICC_ReadCardSerial()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// UID für API lesen (ohne Doppelpunkt-Trenner, Großbuchstaben)
|
||||
String uid = "";
|
||||
for (byte i = 0; i < mfrc522.uid.size; i++) {
|
||||
if (mfrc522.uid.uidByte[i] < 0x10) {
|
||||
uid += "0"; // Leading Zero für einstellige Hex-Werte
|
||||
}
|
||||
uid += String(mfrc522.uid.uidByte[i], HEX);
|
||||
}
|
||||
|
||||
// UID in Großbuchstaben konvertieren
|
||||
uid.toUpperCase();
|
||||
|
||||
Serial.println("RFID API UID gelesen: " + uid);
|
||||
|
||||
// Ergebnis speichern
|
||||
lastReadUID = uid;
|
||||
rfidReadSuccess = true;
|
||||
rfidReadRequested = false;
|
||||
|
||||
// Karte "halt" setzen
|
||||
mfrc522.PICC_HaltA();
|
||||
mfrc522.PCD_StopCrypto1();
|
||||
}
|
||||
|
||||
// API Funktion: RFID Lesevorgang starten
|
||||
|
||||
// Startet einen neuen RFID-Lesevorgang über die API.
|
||||
void startRFIDRead() {
|
||||
Serial.println("RFID API Lesevorgang gestartet...");
|
||||
rfidReadRequested = true;
|
||||
rfidReadSuccess = false;
|
||||
lastReadUID = "";
|
||||
rfidReadStartTime = millis();
|
||||
}
|
||||
|
||||
// API Funktion: Prüfen ob Lesevorgang abgeschlossen
|
||||
|
||||
// Prüft, ob der aktuelle RFID-Lesevorgang abgeschlossen ist.
|
||||
bool isRFIDReadComplete() { return !rfidReadRequested; }
|
||||
|
||||
// API Funktion: Ergebnis des Lesevorgangs abrufen
|
||||
// Gibt das Ergebnis des letzten RFID-Lesevorgangs zurück.
|
||||
String getRFIDReadResult(bool &success) {
|
||||
success = rfidReadSuccess;
|
||||
return lastReadUID;
|
||||
}
|
||||
|
||||
// Richtet die HTTP-API-Routen für RFID-Operationen ein.
|
||||
// API Routes
|
||||
void setupRFIDRoute(AsyncWebServer &server) {
|
||||
server.on("/api/rfid/read", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
Serial.println("api/rfid/read");
|
||||
|
||||
// Start RFID-Lesevorgang
|
||||
startRFIDRead();
|
||||
unsigned long startTime = millis();
|
||||
|
||||
// Warten, bis eine UID gelesen wird oder Timeout eintritt
|
||||
while (!isRFIDReadComplete()) {
|
||||
if (millis() - startTime > RFID_READ_TIMEOUT) {
|
||||
break;
|
||||
}
|
||||
delay(10); // Kurze Pause, um die CPU nicht zu blockieren
|
||||
}
|
||||
// Toggle RFID Reading Mode
|
||||
server.on("/api/rfid/toggle", HTTP_POST, [](AsyncWebServerRequest *request) {
|
||||
readingMode = !readingMode;
|
||||
|
||||
DynamicJsonDocument response(200);
|
||||
response["success"] = true;
|
||||
response["reading_mode"] = readingMode;
|
||||
response["message"] =
|
||||
readingMode ? "RFID Lesen gestartet" : "RFID Lesen gestoppt";
|
||||
|
||||
if (rfidReadSuccess && lastReadUID.length() > 0) {
|
||||
String jsonString;
|
||||
serializeJson(response, jsonString);
|
||||
request->send(200, "application/json", jsonString);
|
||||
});
|
||||
|
||||
// Einzelnes Lesen (wie vorher)
|
||||
server.on("/api/rfid/read", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
String uid = readRFIDCard();
|
||||
|
||||
DynamicJsonDocument response(200);
|
||||
if (uid.length() > 0) {
|
||||
response["success"] = true;
|
||||
response["uid"] = lastReadUID;
|
||||
response["message"] = "UID erfolgreich gelesen";
|
||||
response["uid"] = uid;
|
||||
response["message"] = "Karte gelesen";
|
||||
} else {
|
||||
response["success"] = false;
|
||||
response["error"] = "Keine RFID Karte erkannt oder Timeout";
|
||||
response["error"] = "Keine Karte gefunden";
|
||||
response["uid"] = "";
|
||||
}
|
||||
|
||||
@@ -192,107 +153,32 @@ void setupRFIDRoute(AsyncWebServer &server) {
|
||||
request->send(200, "application/json", jsonString);
|
||||
});
|
||||
|
||||
server.on(
|
||||
"/api/users/insert", HTTP_POST, [](AsyncWebServerRequest *request) {},
|
||||
NULL,
|
||||
[](AsyncWebServerRequest *request, uint8_t *data, size_t len,
|
||||
size_t index, size_t total) {
|
||||
Serial.println("/api/users/insert");
|
||||
// Status und letzte gelesene UID
|
||||
server.on("/api/rfid/status", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
DynamicJsonDocument response(300);
|
||||
response["success"] = true;
|
||||
response["rfid_initialized"] = rfidInitialized;
|
||||
response["reading_mode"] = readingMode;
|
||||
response["last_uid"] = lastReadUID;
|
||||
response["message"] =
|
||||
readingMode ? "RFID Lesen aktiv" : "RFID Lesen inaktiv";
|
||||
|
||||
// Parse the incoming JSON payload
|
||||
DynamicJsonDocument doc(512);
|
||||
DeserializationError error = deserializeJson(doc, data, len);
|
||||
String jsonString;
|
||||
serializeJson(response, jsonString);
|
||||
request->send(200, "application/json", jsonString);
|
||||
});
|
||||
|
||||
DynamicJsonDocument response(200);
|
||||
// UID zurücksetzen (nach erfolgreichem Lesen)
|
||||
server.on("/api/rfid/clear", HTTP_POST, [](AsyncWebServerRequest *request) {
|
||||
lastReadUID = ""; // UID zurücksetzen
|
||||
lastReadTime = 0; // Zeit auch zurücksetzen
|
||||
|
||||
if (error) {
|
||||
Serial.println("Fehler beim Parsen der JSON-Daten");
|
||||
response["success"] = false;
|
||||
response["error"] = "Ungültige JSON-Daten";
|
||||
} else {
|
||||
// Extract user data from the JSON payload
|
||||
String uid = doc["uid"] | "";
|
||||
String vorname = doc["vorname"] | "";
|
||||
String nachname = doc["nachname"] | "";
|
||||
String geburtsdatum = doc["geburtsdatum"] | "";
|
||||
int alter = doc["alter"] | 0;
|
||||
DynamicJsonDocument response(200);
|
||||
response["success"] = true;
|
||||
response["message"] = "UID zurückgesetzt";
|
||||
|
||||
// Validate the data
|
||||
if (uid.isEmpty() || vorname.isEmpty() || nachname.isEmpty() ||
|
||||
geburtsdatum.isEmpty() || alter <= 0) {
|
||||
Serial.println("Ungültige Eingabedaten");
|
||||
response["success"] = false;
|
||||
response["error"] = "Ungültige Eingabedaten";
|
||||
} else {
|
||||
// Process the data using the enterUserData function
|
||||
Serial.println("Benutzerdaten empfangen:");
|
||||
Serial.println("UID: " + uid);
|
||||
Serial.println("Vorname: " + vorname);
|
||||
Serial.println("Nachname: " + nachname);
|
||||
Serial.println("Alter: " + String(alter));
|
||||
|
||||
bool dbSuccess =
|
||||
enterUserData(uid, vorname, nachname, geburtsdatum, alter);
|
||||
|
||||
if (dbSuccess) {
|
||||
response["success"] = true;
|
||||
response["message"] = "Benutzer erfolgreich gespeichert";
|
||||
} else {
|
||||
response["success"] = false;
|
||||
response["error"] = "Fehler beim Speichern in der Datenbank";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send the response back to the client
|
||||
String jsonString;
|
||||
serializeJson(response, jsonString);
|
||||
request->send(200, "application/json", jsonString);
|
||||
});
|
||||
}
|
||||
|
||||
// API Funktion: RFID Reader Status prüfen
|
||||
|
||||
// Prüft, ob der RFID-Reader korrekt funktioniert und gibt den Status zurück.
|
||||
bool checkRFIDReaderStatus() {
|
||||
byte version = mfrc522.PCD_ReadRegister(mfrc522.VersionReg);
|
||||
|
||||
// Bekannte MFRC522 Versionen: 0x91, 0x92
|
||||
if (version == 0x91 || version == 0x92) {
|
||||
Serial.println("RFID Reader OK (Version: 0x" + String(version, HEX) + ")");
|
||||
return true;
|
||||
} else {
|
||||
Serial.println("RFID Reader Fehler (Version: 0x" + String(version, HEX) +
|
||||
")");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hilfsfunktion: Blockierte UIDs aufräumen
|
||||
|
||||
// Entfernt UIDs aus der Blockliste, deren Blockdauer abgelaufen ist.
|
||||
void cleanupBlockedUIDs() {
|
||||
unsigned long currentTime = millis();
|
||||
|
||||
// Iterator für sicheres Löschen während der Iteration
|
||||
for (auto it = blockedUIDs.begin(); it != blockedUIDs.end();) {
|
||||
if (currentTime - it->second >= BLOCK_DURATION) {
|
||||
it = blockedUIDs.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hauptschleife für das RFID-Handling (automatisch und API-basiert).
|
||||
void loopRFID() {
|
||||
// Originale Funktionalität für automatisches Lesen
|
||||
if (!rfidReadRequested) {
|
||||
handleAutomaticRFID();
|
||||
}
|
||||
|
||||
// API-basiertes Lesen verarbeiten
|
||||
if (rfidReadRequested) {
|
||||
handleAPIRFIDRead();
|
||||
}
|
||||
}
|
||||
String jsonString;
|
||||
serializeJson(response, jsonString);
|
||||
request->send(200, "application/json", jsonString);
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
// Zeit-bezogene Variablen und Includes
|
||||
#pragma once
|
||||
#include "RTClib.h"
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <Wire.h>
|
||||
#include <sys/time.h>
|
||||
#include <time.h>
|
||||
|
||||
RTC_PCF8523 rtc;
|
||||
|
||||
// Globale Zeitvariablen
|
||||
struct timeval tv;
|
||||
struct timezone tz;
|
||||
@@ -90,8 +86,6 @@ bool setSystemTime(long timestamp) {
|
||||
// Initialisiert die Zeit-API und richtet die HTTP-Endpunkte ein.
|
||||
void setupTimeAPI(AsyncWebServer &server) {
|
||||
|
||||
// setupRTC();
|
||||
|
||||
// API-Endpunkt: Aktuelle Zeit abrufen
|
||||
server.on("/api/time", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
String response = getCurrentTimeJSON();
|
||||
|
||||
@@ -33,6 +33,10 @@ void setupRoutes() {
|
||||
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) {
|
||||
if (SPIFFS.exists("/firmware.bin")) {
|
||||
request->send(SPIFFS, "/firmware.bin", "application/octet-stream");
|
||||
@@ -52,6 +56,7 @@ void setupRoutes() {
|
||||
timerData1.bestTime = 0;
|
||||
timerData2.bestTime = 0;
|
||||
saveBestTimes();
|
||||
clearLocalLeaderboard(); // Leere auch das lokale Leaderboard
|
||||
DynamicJsonDocument doc(64);
|
||||
doc["success"] = true;
|
||||
String result;
|
||||
@@ -79,6 +84,12 @@ void setupRoutes() {
|
||||
request->getParam("maxTimeDisplay", true)->value().toInt() * 1000;
|
||||
changed = true;
|
||||
}
|
||||
if (request->hasParam("minTimeForLeaderboard", true)) {
|
||||
minTimeForLeaderboard =
|
||||
request->getParam("minTimeForLeaderboard", true)->value().toInt() *
|
||||
1000;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
saveSettings();
|
||||
DynamicJsonDocument doc(32);
|
||||
@@ -96,6 +107,7 @@ void setupRoutes() {
|
||||
DynamicJsonDocument doc(256);
|
||||
doc["maxTime"] = maxTimeBeforeReset / 1000;
|
||||
doc["maxTimeDisplay"] = maxTimeDisplay / 1000;
|
||||
doc["minTimeForLeaderboard"] = minTimeForLeaderboard / 1000;
|
||||
String result;
|
||||
serializeJson(doc, result);
|
||||
request->send(200, "application/json", result);
|
||||
|
||||
Reference in New Issue
Block a user