Compare commits

..

30 Commits

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

View File

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

106
.gitignore vendored
View File

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

140
API.md
View File

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

View File

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

21
LICENSE Normal file
View File

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

245
README.md
View File

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

78
THIRD_PARTY_LICENSES.md Normal file
View File

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

View File

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

View File

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

Binary file not shown.

View File

@@ -11,8 +11,8 @@ html {
} }
body { body {
font-family: "Arial", sans-serif; font-family: "Segoe UI", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(0deg, #0d1733 0%, #223c83 100%);
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
display: flex; display: flex;
@@ -38,8 +38,8 @@ body {
text-decoration: none; text-decoration: none;
display: block; display: block;
cursor: pointer; cursor: pointer;
padding-left: 5px; padding: 5px;
padding-right: 5px; background:rgba(255, 255, 255, 0.6);
} }
.logo:hover { .logo:hover {
@@ -53,6 +53,32 @@ body {
border-radius: 10px; border-radius: 10px;
} }
.leaderboard-btn {
position: fixed;
top: 20px;
right: 90px;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 15px;
border-radius: 50%;
text-decoration: none;
font-size: 1.5rem;
transition: all 0.3s ease;
z-index: 1000;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.leaderboard-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
transform: scale(1.1);
}
.settings-btn { .settings-btn {
position: fixed; position: fixed;
top: 20px; top: 20px;
@@ -82,7 +108,7 @@ body {
.heartbeat-indicators { .heartbeat-indicators {
position: fixed; position: fixed;
top: 20px; top: 20px;
right: 90px; right: 160px;
display: flex; display: flex;
gap: 15px; gap: 15px;
z-index: 1000; z-index: 1000;
@@ -93,11 +119,61 @@ body {
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
} }
@media (max-width: 768px) {
.logo {
width: 40px;
height: 40px;
top: 15px;
left: 15px;
padding: 3px;
}
.leaderboard-btn {
top: 15px;
right: 60px;
padding: 10px;
font-size: 1.2rem;
}
.settings-btn {
top: 15px;
right: 15px;
padding: 10px;
font-size: 1.2rem;
}
.heartbeat-indicators {
top: 15px;
right: 90px;
gap: 8px;
padding: 8px 12px;
font-size: 0.8rem;
}
.heartbeat-indicator {
width: 12px;
height: 12px;
}
.heartbeat-indicator::before {
font-size: 8px;
top: -20px;
}
.header h1 {
font-size: clamp(1.2rem, 3vw, 1.8rem);
}
.header p {
font-size: clamp(0.7rem, 1.5vw, 0.9rem);
}
}
.heartbeat-indicator { .heartbeat-indicator {
width: 20px; width: 20px;
height: 20px; height: 20px;
border-radius: 50%; border-radius: 50%;
background: #e74c3c; background: #f50f0f;
transition: all 0.3s ease; transition: all 0.3s ease;
position: relative; position: relative;
} }
@@ -115,8 +191,8 @@ body {
} }
.heartbeat-indicator.active { .heartbeat-indicator.active {
background: #2ecc71; background: #00ff15;
box-shadow: 0 0 10px rgba(46, 204, 113, 0.5); box-shadow: 0 0 10px rgba(73, 186, 228, 0.5);
} }
/* Batterie-Banner Styling */ /* Batterie-Banner Styling */
@@ -125,7 +201,7 @@ body {
top: -100px; top: -100px;
left: 0; left: 0;
width: 100%; width: 100%;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%); background: linear-gradient(135deg, #f59d0f 0%, #e67e22 100%);
color: white; color: white;
padding: 15px 20px; padding: 15px 20px;
text-align: center; text-align: center;
@@ -261,6 +337,9 @@ body {
font-size: clamp(1.8rem, 4vw, 2.5rem); font-size: clamp(1.8rem, 4vw, 2.5rem);
margin-bottom: 0.5vh; margin-bottom: 0.5vh;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
} }
.header p { .header p {
@@ -297,15 +376,20 @@ body {
transition: transform 0.3s ease; transition: transform 0.3s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: flex-start;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
position: relative;
} }
.lane h2 { .lane h2 {
font-size: clamp(1.2rem, 2.5vw, 1.8rem); font-size: clamp(1.2rem, 2.5vw, 1.8rem);
margin-bottom: clamp(10px, 1vh, 15px); margin-bottom: clamp(10px, 1vh, 15px);
color: #fff; color: #fff;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
flex-shrink: 0;
} }
.swimmer-name { .swimmer-name {
@@ -338,37 +422,81 @@ body {
} }
.time-display { .time-display {
font-size: clamp(3rem, 9vw, 10rem); font-size: clamp(3rem, 13vw, 13rem);
font-weight: bold; font-weight: bold;
margin: clamp(10px, 1vh, 15px) 0; margin: clamp(10px, 1vh, 15px) 0;
font-family: "Courier New", monospace; font-family: "Courier New", monospace;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
line-height: 1; line-height: 1;
position: relative;
z-index: 1;
flex-shrink: 0;
order: 1;
} }
.status { .status {
font-size: clamp(3rem, 1.8vw, 1.2rem); font-size: clamp(1.5rem, 4vw, 5rem);
margin: clamp(8px, 1vh, 12px) 0; margin: clamp(8px, 1vh, 12px) 0;
padding: clamp(6px, 1vh, 10px) clamp(12px, 2vw, 18px); padding: clamp(6px, 1vh, 10px) clamp(12px, 2vw, 18px);
border-radius: 20px; border-radius: 20px;
display: inline-block; display: inline-block;
font-weight: 600; font-weight: 600;
position: relative;
z-index: 2;
} }
.status.ready { .status:not(.large-status) {
background-color: rgba(52, 152, 219, 0.3); position: relative;
border: 2px solid #3498db; order: 2;
margin-top: auto;
} }
.status.running { .status.large-status {
background-color: rgba(46, 204, 113, 0.3); font-size: clamp(1.8rem, 5vw, 5rem);
border: 2px solid #2ecc71; position: absolute;
animation: pulse 1s infinite; left: 50%;
transform: translateX(-50%);
z-index: 10;
margin: 0 !important;
padding: clamp(8px, 1.5vh, 15px) clamp(15px, 3vw, 30px);
white-space: normal;
pointer-events: none;
text-align: center;
background-color: rgba(0, 0, 0, 0.85) !important;
backdrop-filter: blur(5px);
width: calc(100% - 40px);
max-width: calc(100% - 40px);
word-wrap: break-word;
line-height: 1.3;
overflow: visible;
}
.status.large-status.ready {
font-size: clamp(2rem, 8vw, 8rem) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
} }
.status.finished { .status.finished {
background-color: rgba(231, 76, 60, 0.3); background-color: rgba(73, 186, 228, 0.3);
border: 2px solid #e74c3c; border: 2px solid #49bae4;
}
.status.ready {
background-color: rgb(0 165 3 / 54%);
border: 2px solid #06ff00;
}
.status.armed {
background-color: rgba(245, 157, 15, 0.3);
border: 2px solid #f59d0f;
animation: pulse 1s infinite;
}
.status.running {
background-color: rgb(255 91 0 / 65%);
border: 2px solid #f59d0f;
} }
@keyframes pulse { @keyframes pulse {
@@ -384,8 +512,8 @@ body {
} }
.status.standby { .status.standby {
background-color: rgba(255, 193, 7, 0.3); background-color: rgba(220, 242, 250, 0.3);
border: 2px solid #ffc107; border: 2px solid #DCF2FA;
animation: standbyBlink 2s infinite; animation: standbyBlink 2s infinite;
} }
@@ -416,17 +544,40 @@ body {
border-radius: 15px; border-radius: 15px;
padding: clamp(10px, 1.5vh, 15px); padding: clamp(10px, 1.5vh, 15px);
margin: 1vh 0 0 0; margin: 1vh 0 0 0;
width: 50%; width: clamp(320px, 80vw, 960px);
max-width: 50%; max-width: 960px;
text-align: center; text-align: center;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
flex-shrink: 0; flex-shrink: 0;
align-self: center; align-self: center;
display: flex;
flex-direction: column;
align-items: stretch;
gap: clamp(12px, 2vh, 20px);
box-sizing: border-box;
}
#leaderboard-container {
text-align: left;
display: grid;
grid-template-columns: 1fr;
gap: clamp(12px, 2vh, 20px);
width: 100%;
}
@media (min-width: 768px) {
#leaderboard-container {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
.best-times h3 { .best-times h3 {
font-size: clamp(0.9rem, 1.8vw, 1.1rem); font-size: clamp(0.9rem, 1.8vw, 1.1rem);
margin-bottom: clamp(5px, 0.5vh, 8px); margin: 0 auto;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
text-align: center;
} }
.best-time-row { .best-time-row {
@@ -440,9 +591,121 @@ body {
border-radius: 8px; border-radius: 8px;
} }
/* Leaderboard Styles */
#leaderboard-container {
text-align: left;
}
.leaderboard-entry {
display: flex;
justify-content: space-between;
align-items: center;
margin: clamp(8px, 1vh, 12px) 0;
font-size: clamp(1.1rem, 2.2vw, 1.4rem);
font-weight: 600;
background: rgba(255, 255, 255, 0.15);
padding: clamp(12px, 2vh, 16px) clamp(16px, 3vw, 24px);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
min-height: 50px;
width: 100%;
box-sizing: border-box;
}
.leaderboard-entry:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.leaderboard-entry .rank {
color: #ffd700;
font-weight: bold;
min-width: 30px;
font-size: clamp(1.2rem, 2.4vw, 1.5rem);
}
.leaderboard-entry .name {
flex: 1;
margin: 0 15px;
color: #ffffff;
font-weight: 600;
}
.leaderboard-entry .time {
color: #00ff88;
font-weight: bold;
font-family: 'Courier New', monospace;
min-width: 80px;
text-align: right;
}
.leaderboard-entry.gold {
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
border-color: #ffd700;
color: #b8860b;
font-weight: bold;
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3);
}
.leaderboard-entry.gold .rank {
color: #7a4d00;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
}
.leaderboard-entry.gold .time {
color: #0f5132;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
}
.leaderboard-entry.silver {
background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%);
border-color: #c0c0c0;
color: #696969;
font-weight: bold;
box-shadow: 0 4px 15px rgba(192, 192, 192, 0.3);
}
.leaderboard-entry.silver .rank {
color: #4b5563;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
}
.leaderboard-entry.silver .time {
color: #0f5132;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
}
.leaderboard-entry.bronze {
background: linear-gradient(135deg, #cd7f32 0%, #e6a85c 100%);
border-color: #cd7f32;
color: #8b4513;
font-weight: bold;
box-shadow: 0 4px 15px rgba(205, 127, 50, 0.3);
}
.leaderboard-entry.bronze .rank {
color: #7a3410;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
}
.leaderboard-entry.bronze .time {
color: #0f5132;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
}
.no-times {
text-align: center;
color: rgba(255, 255, 255, 0.7);
font-style: italic;
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
padding: 20px;
}
.learning-mode { .learning-mode {
background: rgba(255, 193, 7, 0.2); background: rgba(245, 157, 15, 0.2);
border: 2px solid #ffc107; border: 2px solid #f59d0f;
border-radius: 15px; border-radius: 15px;
padding: clamp(15px, 2vh, 20px); padding: clamp(15px, 2vh, 20px);
margin: 2vh 0; margin: 2vh 0;
@@ -457,9 +720,12 @@ body {
} }
.learning-mode h3 { .learning-mode h3 {
color: #ffc107; color: #f59d0f;
margin-bottom: 10px; margin-bottom: 10px;
font-size: clamp(1rem, 2vw, 1.3rem); font-size: clamp(1rem, 2vw, 1.3rem);
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
} }
.learning-mode p { .learning-mode p {

View File

@@ -15,14 +15,16 @@
<div> <div>
<div class="banner-text">⚠️ Niedrige Batterie erkannt!</div> <div class="banner-text">⚠️ Niedrige Batterie erkannt!</div>
<div class="banner-devices" id="battery-devices"> <div class="banner-devices" id="battery-devices">
Geräte mit niedriger Batterie: <span id="low-battery-list"></span> Deine Geräte mit niedriger Batterie:
<span id="low-battery-list"></span>
</div> </div>
</div> </div>
</div> </div>
<button class="close-btn" onclick="closeBatteryBanner()">&times;</button> <button class="close-btn" onclick="closeBatteryBanner()">&times;</button>
</div> </div>
<img src="/pictures/logo.png" class="logo" alt="NinjaCross Logo" /> <img src="/pictures/erlebniss.png" class="logo" alt="NinjaCross Logo" />
<a href="/leaderboard.html" class="leaderboard-btn">🏆</a>
<a href="/settings" class="settings-btn">⚙️</a> <a href="/settings" class="settings-btn">⚙️</a>
<div class="heartbeat-indicators"> <div class="heartbeat-indicators">
@@ -42,46 +44,37 @@
<div class="header"> <div class="header">
<h1>🏊‍♀️ NinjaCross Timer</h1> <h1>🏊‍♀️ NinjaCross Timer</h1>
<p>Professioneller Zeitmesser für Ninjacross Wettkämpfe</p> <p>Dein professioneller Zeitmesser für Ninjacross Wettkämpfe</p>
</div> </div>
<div id="learning-display" class="learning-mode" style="display: none"> <div id="learning-display" class="learning-mode" style="display: none">
<h3>📚 Lernmodus aktiv</h3> <h3>📚 Lernmodus aktiv</h3>
<p> <p>Drücke jetzt den Button für: <span id="learning-button"></span></p>
Bitte drücken Sie den Button für: <span id="learning-button"></span>
</p>
</div> </div>
<div class="timer-container"> <div class="timer-container">
<div class="lane"> <div class="lane">
<div id="name1" class="swimmer-name" style="display: none"></div> <div id="name1" class="swimmer-name" style="display: none"></div>
<h2>🏊‍♀️ Bahn 1</h2> <h2>🏊‍♀️ Bahn 1</h2>
<div id="time1" class="time-display">00.00</div>
<div id="status1" class="status standby"> <div id="status1" class="status standby">
Standby: Bitte beide 1x betätigen Standby: Drücke beide Buttons einmal
</div> </div>
<div id="time1" class="time-display">00.00</div>
</div> </div>
<div class="lane"> <div class="lane">
<div id="name2" class="swimmer-name" style="display: none"></div> <div id="name2" class="swimmer-name" style="display: none"></div>
<h2>🏊‍♂️ Bahn 2</h2> <h2>🏊‍♂️ Bahn 2</h2>
<div id="time2" class="time-display">00.00</div>
<div id="status2" class="status standby"> <div id="status2" class="status standby">
Standby: Bitte beide 1x betätigen Standby: Drücke beide Buttons einmal
</div> </div>
<div id="time2" class="time-display">00.00</div>
</div> </div>
</div> </div>
<div class="best-times"> <div class="best-times">
<h3>🏆 Beste Zeiten des Tages</h3> <h3>🏆 Lokales Leaderboard</h3>
<div class="best-time-row"> <div id="leaderboard-container"></div>
<span>Bahn 1:</span>
<span id="best1">--.-</span>
</div>
<div class="best-time-row">
<span>Bahn 2:</span>
<span id="best2">--.-</span>
</div>
</div> </div>
<script> <script>
@@ -97,6 +90,12 @@
let learningButton = ""; let learningButton = "";
let name1 = ""; let name1 = "";
let name2 = ""; let name2 = "";
let leaderboardData = [];
// Lane Configuration
let laneConfigType = 0; // 0=Identical, 1=Different
let lane1DifficultyType = 0; // 0=Light, 1=Heavy
let lane2DifficultyType = 0; // 0=Light, 1=Heavy
// Batterie-Banner State // Batterie-Banner State
let lowBatteryDevices = new Set(); let lowBatteryDevices = new Set();
@@ -188,24 +187,18 @@
} }
// Namen-Handling // Namen-Handling
if ( if ((data.name == "" || !data.name) && data.lane == "start1") {
(data.firstname == "" || data.lastname == "") &&
data.lane == "start1"
) {
name1 = ""; name1 = "";
} }
if ( if ((data.name == "" || !data.name) && data.lane == "start2") {
(data.firstname == "" || data.lastname == "") &&
data.lane == "start2"
) {
name2 = ""; name2 = "";
} }
if (data.firstname && data.lastname && data.lane) { if (data.name && data.lane) {
if (data.lane === "start1") { if (data.lane === "start1") {
name1 = `${data.firstname} ${data.lastname}`; name1 = data.name;
} else if (data.lane === "start2") { } else if (data.lane === "start2") {
name2 = `${data.firstname} ${data.lastname}`; name2 = data.name;
} }
updateDisplay(); updateDisplay();
} }
@@ -324,13 +317,13 @@
function getButtonDisplayName(button) { function getButtonDisplayName(button) {
switch (button) { switch (button) {
case "start1": case "start1":
return "Start Bahn 1"; return "Start Button Bahn 1";
case "stop1": case "stop1":
return "Stop Bahn 1"; return "Stop Button Bahn 1";
case "start2": case "start2":
return "Start Bahn 2"; return "Start Button Bahn 2";
case "stop2": case "stop2":
return "Stop Bahn 2"; return "Stop Button Bahn 2";
default: default:
return button; return button;
} }
@@ -338,7 +331,93 @@
function formatTime(seconds) { function formatTime(seconds) {
if (seconds === 0) return "00.00"; if (seconds === 0) return "00.00";
return seconds.toFixed(2);
const totalSeconds = Math.floor(seconds);
const minutes = Math.floor(totalSeconds / 60);
const remainingSeconds = totalSeconds % 60;
const milliseconds = Math.floor((seconds - totalSeconds) * 100);
// Zeige Minuten nur wenn über 60 Sekunden
if (totalSeconds >= 60) {
return `${minutes.toString().padStart(2, "0")}:${remainingSeconds
.toString()
.padStart(2, "0")}.${milliseconds.toString().padStart(2, "0")}`;
} else {
return `${remainingSeconds.toString().padStart(2, "0")}.${milliseconds
.toString()
.padStart(2, "0")}`;
}
}
// Leaderboard Funktionen
async function loadLeaderboard() {
try {
const response = await fetch("/api/leaderboard");
const data = await response.json();
leaderboardData = data.leaderboard || [];
updateLeaderboardDisplay();
} catch (error) {
console.error("Fehler beim Laden des Leaderboards:", error);
}
}
function updateLeaderboardDisplay() {
const container = document.getElementById("leaderboard-container");
container.innerHTML = "";
if (leaderboardData.length === 0) {
container.innerHTML =
'<div class="no-times">Noch keine Zeiten erfasst</div>';
return;
}
// Erstelle zwei Reihen für 2x3 Layout
const row1 = document.createElement("div");
row1.className = "leaderboard-row";
const row2 = document.createElement("div");
row2.className = "leaderboard-row";
leaderboardData.forEach((entry, index) => {
const entryDiv = document.createElement("div");
entryDiv.className = "leaderboard-entry";
// Podium-Plätze hervorheben
if (index === 0) {
entryDiv.classList.add("gold");
} else if (index === 1) {
entryDiv.classList.add("silver");
} else if (index === 2) {
entryDiv.classList.add("bronze");
}
const rankSpan = document.createElement("span");
rankSpan.className = "rank";
rankSpan.textContent = entry.rank + ".";
const nameSpan = document.createElement("span");
nameSpan.className = "name";
nameSpan.textContent = entry.name;
const timeSpan = document.createElement("span");
timeSpan.className = "time";
timeSpan.textContent = entry.timeFormatted;
entryDiv.appendChild(rankSpan);
entryDiv.appendChild(nameSpan);
entryDiv.appendChild(timeSpan);
// Erste 3 Einträge in die erste Reihe, nächste 3 in die zweite Reihe
if (index < 3) {
row1.appendChild(entryDiv);
} else if (index < 6) {
row2.appendChild(entryDiv);
}
});
container.appendChild(row1);
if (leaderboardData.length > 3) {
container.appendChild(row2);
}
} }
function updateDisplay() { function updateDisplay() {
@@ -363,38 +442,211 @@
document.getElementById("time1").textContent = formatTime(display1); document.getElementById("time1").textContent = formatTime(display1);
const time1Element = document.getElementById("time1");
const lane1Element = time1Element.closest(".lane");
const h2_1 = lane1Element.querySelector("h2");
if (!lane1Connected) { if (!lane1Connected) {
s1.className = "status standby"; s1.className = "status standby large-status";
s1.textContent = "Standby: Bitte beide Buttons 1x betätigen"; s1.textContent = "Standby: Drücke beide Buttons einmal";
time1Element.style.display = "none";
// Position über time-display, aber innerhalb des Containers
if (s1.classList.contains("large-status")) {
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const h2Bottom = h2Rect.bottom - lane1Rect.top;
const startTop = h2Bottom + 10;
s1.style.top = startTop + "px";
s1.style.left = "50%";
s1.style.transform = "translateX(-50%)";
s1.style.bottom = "20px";
s1.style.width = "calc(100% - 40px)";
s1.style.display = "flex";
s1.style.alignItems = "center";
s1.style.justifyContent = "center";
}
} else { } else {
s1.className = `status ${status1}`; s1.className = `status ${status1}`;
s1.textContent =
status1 === "ready" // Wenn status "ready" ist, verstecke Zeit und mache Status groß
? "Bereit" if (status1 === "ready") {
: status1 === "running" s1.classList.add("large-status");
? "Läuft..." time1Element.style.display = "none";
: "Beendet"; const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const h2Bottom = h2Rect.bottom - lane1Rect.top;
const startTop = h2Bottom + 10;
s1.style.top = startTop + "px";
s1.style.left = "50%";
s1.style.transform = "translateX(-50%)";
s1.style.bottom = "20px";
s1.style.width = "calc(100% - 40px)";
s1.style.display = "flex";
s1.style.alignItems = "center";
s1.style.justifyContent = "center";
s1.style.fontSize = "clamp(2rem, 8vw, 8rem)";
} else {
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
time1Element.style.display = "";
if (status1 !== "running" && status1 !== "finished") {
s1.classList.add("large-status");
const time1Rect = time1Element.getBoundingClientRect();
const lane1Rect = lane1Element.getBoundingClientRect();
const h2Rect = h2_1.getBoundingClientRect();
const time1Center = time1Rect.top - lane1Rect.top + time1Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane1Rect.top;
const startTop = h2Bottom + 10;
const statusHeight = s1.offsetHeight || 200;
const targetTop = Math.max(startTop, time1Center - statusHeight / 2);
s1.style.top = targetTop + "px";
s1.style.transform = "translateX(-50%)";
s1.style.height = "";
s1.style.width = "";
s1.style.display = "";
s1.style.alignItems = "";
s1.style.justifyContent = "";
s1.style.fontSize = "";
const maxHeight = lane1Rect.height - targetTop - 30;
s1.style.maxHeight = maxHeight + "px";
s1.style.overflow = "auto";
} else {
s1.classList.remove("large-status");
s1.style.top = "";
s1.style.transform = "";
s1.style.maxHeight = "";
s1.style.height = "";
s1.style.width = "";
s1.style.display = "";
s1.style.alignItems = "";
s1.style.justifyContent = "";
s1.style.fontSize = "";
s1.style.left = "";
s1.style.bottom = "";
}
}
switch (status1) {
case "ready":
s1.textContent = "Bereit für den Start!";
break;
case "running":
s1.textContent = "Läuft - Gib alles!";
break;
case "finished":
s1.textContent = "Geschafft!";
break;
case "armed":
s1.textContent = "Bereit zum Start!";
break;
default:
s1.textContent = "Status unbekannt";
}
} }
document.getElementById("time2").textContent = formatTime(display2); document.getElementById("time2").textContent = formatTime(display2);
const time2Element = document.getElementById("time2");
const lane2Element = time2Element.closest(".lane");
const h2_2 = lane2Element.querySelector("h2");
if (!lane2Connected) { if (!lane2Connected) {
s2.className = "status standby"; s2.className = "status standby large-status";
s2.textContent = "Standby: Bitte beide 1x betätigen"; s2.textContent = "Standby: Drücke beide Buttons einmal";
time2Element.style.display = "none";
// Position über time-display, aber innerhalb des Containers
if (s2.classList.contains("large-status")) {
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
const h2Bottom = h2Rect.bottom - lane2Rect.top;
const startTop = h2Bottom + 10;
s2.style.top = startTop + "px";
s2.style.left = "50%";
s2.style.transform = "translateX(-50%)";
s2.style.bottom = "20px";
s2.style.width = "calc(100% - 40px)";
s2.style.display = "flex";
s2.style.alignItems = "center";
s2.style.justifyContent = "center";
}
} else { } else {
s2.className = `status ${status2}`; s2.className = `status ${status2}`;
s2.textContent =
status2 === "ready" // Wenn status "ready" ist, verstecke Zeit und mache Status groß
? "Bereit" if (status2 === "ready") {
: status2 === "running" s2.classList.add("large-status");
? "Läuft..." time2Element.style.display = "none";
: "Beendet"; const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
const h2Bottom = h2Rect.bottom - lane2Rect.top;
const startTop = h2Bottom + 10;
s2.style.top = startTop + "px";
s2.style.left = "50%";
s2.style.transform = "translateX(-50%)";
s2.style.bottom = "20px";
s2.style.width = "calc(100% - 40px)";
s2.style.display = "flex";
s2.style.alignItems = "center";
s2.style.justifyContent = "center";
s2.style.fontSize = "clamp(2rem, 8vw, 8rem)";
} else {
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
time2Element.style.display = "";
if (status2 !== "running" && status2 !== "finished") {
s2.classList.add("large-status");
const time2Rect = time2Element.getBoundingClientRect();
const lane2Rect = lane2Element.getBoundingClientRect();
const h2Rect = h2_2.getBoundingClientRect();
const time2Center = time2Rect.top - lane2Rect.top + time2Rect.height / 2;
const h2Bottom = h2Rect.bottom - lane2Rect.top;
const startTop = h2Bottom + 10;
const statusHeight = s2.offsetHeight || 200;
const targetTop = Math.max(startTop, time2Center - statusHeight / 2);
s2.style.top = targetTop + "px";
s2.style.transform = "translateX(-50%)";
s2.style.height = "";
s2.style.width = "";
s2.style.display = "";
s2.style.alignItems = "";
s2.style.justifyContent = "";
s2.style.fontSize = "";
const maxHeight = lane2Rect.height - targetTop - 30;
s2.style.maxHeight = maxHeight + "px";
s2.style.overflow = "auto";
} else {
s2.classList.remove("large-status");
s2.style.top = "";
s2.style.transform = "";
s2.style.maxHeight = "";
s2.style.height = "";
s2.style.width = "";
s2.style.display = "";
s2.style.alignItems = "";
s2.style.justifyContent = "";
s2.style.fontSize = "";
s2.style.left = "";
s2.style.bottom = "";
}
} }
document.getElementById("best1").textContent = switch (status2) {
best1 > 0 ? formatTime(best1) + "s" : "--.-"; case "ready":
document.getElementById("best2").textContent = s2.textContent = "Bereit für den Start!";
best2 > 0 ? formatTime(best2) + "s" : "--.-"; break;
case "running":
s2.textContent = "Läuft - Gib alles!";
break;
case "finished":
s2.textContent = "Geschafft!";
break;
case "armed":
s2.textContent = "Bereit zum Start!";
break;
default:
s2.textContent = "Status unbekannt";
}
}
// Leaderboard wird separat geladen
// Namen anzeigen/verstecken // Namen anzeigen/verstecken
const name1Element = document.getElementById("name1"); const name1Element = document.getElementById("name1");
@@ -441,10 +693,49 @@
updateDisplay(); updateDisplay();
}) })
.catch((error) => .catch((error) =>
console.error("Fehler beim Laden der Daten:", error) console.error("Fehler beim Laden deiner Daten:", error)
); );
} }
function loadLaneConfig() {
fetch("/api/get-lane-config")
.then((response) => response.json())
.then((data) => {
laneConfigType = data.type === "different" ? 1 : 0;
lane1DifficultyType = data.lane1Difficulty === "heavy" ? 1 : 0;
lane2DifficultyType = data.lane2Difficulty === "heavy" ? 1 : 0;
updateLaneDisplay();
})
.catch((error) =>
console.error(
"Fehler beim Laden der Lane-Schwierigkeits-Konfiguration:",
error
)
);
}
function updateLaneDisplay() {
const lane1Title = document.querySelector(".lane h2");
const lane2Title = document.querySelectorAll(".lane h2")[1];
if (laneConfigType === 0) {
// Identische Lanes
lane1Title.textContent = "🏊‍♀️ Bahn 1";
lane2Title.textContent = "🏊‍♂️ Bahn 2";
} else {
// Unterschiedliche Lanes
const lane1Icon = lane1DifficultyType === 0 ? "🟢" : "🔴";
const lane2Icon = lane2DifficultyType === 0 ? "🟢" : "🔴";
const lane1Difficulty =
lane1DifficultyType === 0 ? "Leicht" : "Schwer";
const lane2Difficulty =
lane2DifficultyType === 0 ? "Leicht" : "Schwer";
lane1Title.textContent = `${lane1Icon} Bahn 1 (${lane1Difficulty})`;
lane2Title.textContent = `${lane2Icon} Bahn 2 (${lane2Difficulty})`;
}
}
// Sync with backend every 1 second // Sync with backend every 1 second
setInterval(syncFromBackend, 1000); setInterval(syncFromBackend, 1000);
@@ -468,6 +759,11 @@
// Initial load // Initial load
syncFromBackend(); syncFromBackend();
loadLaneConfig();
loadLeaderboard();
// Leaderboard alle 5 Sekunden aktualisieren
setInterval(loadLeaderboard, 5000);
</script> </script>
</body> </body>
</html> </html>

367
data/leaderboard.css Normal file
View File

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

227
data/leaderboard.html Normal file
View File

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

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

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

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

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

View File

@@ -5,8 +5,8 @@
} }
body { body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; font-family: "Segoe UI", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(0deg, #0d1733 0%, #223c83 100%);
min-height: 100vh; min-height: 100vh;
padding: 20px; padding: 20px;
} }
@@ -22,7 +22,7 @@ body {
} }
.header { .header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white; color: white;
padding: 30px; padding: 30px;
text-align: center; text-align: center;
@@ -45,6 +45,9 @@ body {
margin-bottom: 10px; margin-bottom: 10px;
position: relative; position: relative;
z-index: 1; z-index: 1;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
} }
.header p { .header p {
@@ -78,11 +81,11 @@ body {
} }
.nav-button:hover { .nav-button:hover {
background: #667eea; background: #49bae4;
color: white; color: white;
border-color: #667eea; border-color: #49bae4;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3); box-shadow: 0 5px 15px rgba(73, 186, 228, 0.3);
} }
.section { .section {
@@ -100,13 +103,16 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
} }
.section h2::before { .section h2::before {
content: ""; content: "";
width: 4px; width: 4px;
height: 25px; height: 25px;
background: linear-gradient(135deg, #667eea, #764ba2); background: linear-gradient(135deg, #49bae4, #223c83);
border-radius: 2px; border-radius: 2px;
} }
@@ -132,8 +138,8 @@ body {
.form-group input:focus { .form-group input:focus {
outline: none; outline: none;
border-color: #667eea; border-color: #49bae4;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); box-shadow: 0 0 0 3px rgba(73, 186, 228, 0.1);
} }
.time-row { .time-row {
@@ -179,43 +185,43 @@ body {
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white; color: white;
} }
.btn-primary:hover { .btn-primary:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3); box-shadow: 0 10px 25px rgba(73, 186, 228, 0.3);
} }
.btn-secondary { .btn-secondary {
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%); background: linear-gradient(135deg, #DCF2FA 0%, #49bae4 100%);
color: white; color: #223c83;
} }
.btn-secondary:hover { .btn-secondary:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(116, 185, 255, 0.3); box-shadow: 0 10px 25px rgba(220, 242, 250, 0.3);
} }
.btn-warning { .btn-warning {
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); background: linear-gradient(135deg, #f59d0f 0%, #e67e22 100%);
color: #d84315; color: white;
} }
.btn-warning:hover { .btn-warning:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(252, 182, 159, 0.3); box-shadow: 0 10px 25px rgba(245, 157, 15, 0.3);
} }
.btn-danger { .btn-danger {
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%); background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
color: #c62828; color: white;
} }
.btn-danger:hover { .btn-danger:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(255, 154, 158, 0.3); box-shadow: 0 10px 25px rgba(231, 76, 60, 0.3);
} }
.btn-disabled { .btn-disabled {
@@ -231,6 +237,67 @@ body {
box-shadow: none !important; box-shadow: none !important;
} }
/* Toggle Buttons für Modus-Auswahl */
.mode-toggle {
display: flex;
gap: 0;
border: 2px solid #e9ecef;
border-radius: 12px;
overflow: hidden;
background: white;
}
.mode-button {
flex: 1;
padding: 15px 25px;
border: none;
background: white;
color: #495057;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.mode-button.active {
background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
color: white;
}
.mode-button:not(.active):hover {
background: #f8f9fa;
color: #49bae4;
}
.mode-button:first-child {
border-right: 1px solid #e9ecef;
}
/* Lane Difficulty Selection Styles */
.lane-difficulty-selection {
margin-top: 20px;
padding: 20px;
background: rgba(73, 186, 228, 0.1);
border-radius: 12px;
border: 2px solid rgba(73, 186, 228, 0.2);
}
.lane-difficulty-selection .form-group {
margin-bottom: 25px;
}
.lane-difficulty-selection .form-group:last-child {
margin-bottom: 0;
}
.lane-difficulty-selection label {
font-weight: 600;
color: #223c83;
margin-bottom: 12px;
display: block;
}
.restriction-notice { .restriction-notice {
background: #fff3cd; background: #fff3cd;
color: #856404; color: #856404;
@@ -242,50 +309,158 @@ body {
text-align: center; text-align: center;
} }
.status-message { /* Modern Notification Toast */
margin-top: 20px; .notification-toast {
padding: 15px; position: fixed;
border-radius: 10px; top: 24px;
font-weight: 600; right: 24px;
text-align: center; 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; 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);
} }
.status-success { .notification-toast.show {
background: #d4edda; transform: translateX(0);
color: #155724; opacity: 1;
border: 2px solid #c3e6cb;
} }
.status-error { .notification-icon {
background: #f8d7da; flex-shrink: 0;
color: #721c24; width: 40px;
border: 2px solid #f5c6cb; 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);
} }
.status-info { .notification-body {
background: #cce7ff; flex: 1;
color: #004085; min-width: 0;
border: 2px solid #b3d9ff;
} }
.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);
}
/* Animation */
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
.learning-mode { .learning-mode {
display: none; display: none;
text-align: center; text-align: center;
padding: 30px; padding: 30px;
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); background: linear-gradient(135deg, #f59d0f 0%, #e67e22 100%);
border-radius: 15px; border-radius: 15px;
margin-top: 20px; margin-top: 20px;
} }
.learning-mode h3 { .learning-mode h3 {
color: #d84315; color: white;
font-size: 1.5em; font-size: 1.5em;
margin-bottom: 15px; margin-bottom: 15px;
font-weight: bold;
text-transform: uppercase;
font-family: "Segoe UI", Arial, sans-serif;
} }
.learning-mode p { .learning-mode p {
color: #bf360c; color: white;
font-size: 1.2em; font-size: 1.2em;
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -306,34 +481,6 @@ body {
} }
} }
@media (max-width: 600px) {
.container {
margin: 10px;
border-radius: 15px;
}
.content {
padding: 20px;
}
.nav-buttons {
flex-direction: column;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
}
.time-row {
flex-direction: column;
gap: 10px;
}
}
.section select { .section select {
width: 100%; width: 100%;
padding: 12px 16px; padding: 12px 16px;
@@ -372,45 +519,50 @@ body {
border-color: #dee2e6; border-color: #dee2e6;
} }
.section select:disabled:hover { @media (max-width: 600px) {
border-color: #dee2e6; .container {
box-shadow: none; margin: 10px;
border-radius: 15px;
} }
/* Option Styling */ .content {
.section select option { padding: 20px;
padding: 8px;
font-size: 16px;
background-color: white;
color: #333;
} }
.section select option:hover { .nav-buttons {
background-color: #f8f9fa; flex-direction: column;
} }
.section select option:disabled { .button-group {
color: #6c757d; flex-direction: column;
background-color: #f8f9fa;
} }
/* Form Group für bessere Abstände */ .btn {
.section .form-group { width: 100%;
margin-bottom: 20px;
} }
.section .form-group label { .time-row {
display: block; flex-direction: column;
margin-bottom: 8px; gap: 10px;
font-weight: 600; }
color: #333;
.mode-toggle {
flex-direction: column;
gap: 0;
}
.mode-button:first-child {
border-right: none;
border-bottom: 1px solid #e9ecef;
}
/* Mobile notification bubble adjustments */
.notification-bubble {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
font-size: 14px; font-size: 14px;
} padding: 12px 16px;
/* Responsive Design für kleinere Bildschirme */
@media (max-width: 768px) {
.section select {
font-size: 16px; /* Verhindert Zoom auf iOS */
padding: 14px 16px;
} }
} }

View File

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

Binary file not shown.

Binary file not shown.

View File

@@ -22,6 +22,9 @@ monitor_speed = 115200
build_flags = build_flags =
-DBOARD_HAS_PSRAM -DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue -mfix-esp32-psram-cache-issue
-DBATTERY_PIN=16
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
targets = uploadfs targets = uploadfs
board_build.psram = disabled board_build.psram = disabled
lib_deps = lib_deps =
@@ -29,8 +32,7 @@ lib_deps =
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing_OTA] [env:esp32thing_OTA]
board = esp32thing board = esp32thing
@@ -50,8 +52,9 @@ lib_deps =
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing] [env:esp32thing]
board = esp32thing_plus board = esp32thing_plus
@@ -69,8 +72,7 @@ lib_deps =
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32thing_CI] [env:esp32thing_CI]
platform = espressif32 platform = espressif32
@@ -87,21 +89,47 @@ lib_deps =
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:esp32-s3-devkitc-1] [env:um_feathers3]
board = esp32-s3-devkitc-1 board = um_feathers3
monitor_speed = 115200 monitor_speed = 115200
board_upload.flash_size = 16MB board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv board_build.partitions = default_16MB.csv
board_upload.wait_for_upload_port = false
build_flags = build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1 -D ARDUINO_USB_CDC_ON_BOOT=1
-D BATTERY_PIN=35 -D BATTERY_PIN=35
-D ARDUINO_USB_MODE=1
lib_deps = lib_deps =
bblanchon/ArduinoJson@^7.4.1 bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7 esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2 esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0 mlesniew/PicoMQTT@^1.3.0
miguelbalboa/MFRC522@^1.4.12 adafruit/Adafruit PN532@^1.3.4
adafruit/RTClib@^2.1.4
[env:um_feathers3_debug]
board = um_feathers3
board_upload.flash_size = 16MB
board_build.partitions = default_16MB.csv
board_upload.wait_for_upload_port = false
build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1
-D BATTERY_PIN=35
-D ARDUINO_USB_MODE=0
build_type = debug
debug_speed = 12000
debug_tool = esp-builtin
upload_port = COM5
monitor_speed = 115200
monitor_port = COM7
lib_deps =
bblanchon/ArduinoJson@^7.4.1
esp32async/ESPAsyncWebServer@^3.7.7
esp32async/AsyncTCP@^3.4.2
mlesniew/PicoMQTT@^1.3.0
adafruit/Adafruit PN532@^1.3.4

View File

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

View File

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

View File

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

371
src/gamemodes.h Normal file
View File

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

View File

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

View File

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

156
src/preferencemanager.h Normal file
View File

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

View File

@@ -1,187 +1,150 @@
#pragma once #pragma once
#include <Adafruit_PN532.h>
#include <Arduino.h> #include <Arduino.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <MFRC522.h> #include <Wire.h>
#include <SPI.h>
// RFID Konfiguration // RFID Konfiguration - KORREKTE ESP32 Thing Plus Pins
#define RST_PIN 21 // Configurable, see typical pin layout above #define SDA_PIN 23 // ESP32 Thing Plus SDA
#define SS_PIN 5 // Configurable, see typical pin layout above #define SCL_PIN 22 // ESP32 Thing Plus SCL
#define IRQ_PIN 14
#define RST_PIN 15
MFRC522 mfrc522(SS_PIN, RST_PIN); // Create MFRC522 instance // PN532 RFID Reader (mit IRQ und Reset-Pin)
std::map<String, unsigned long> Adafruit_PN532 nfc(IRQ_PIN, RST_PIN);
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 // RFID Variablen
bool rfidReadRequested = false; bool rfidInitialized = false;
bool readingMode = false;
String lastReadUID = ""; String lastReadUID = "";
bool rfidReadSuccess = false; unsigned long lastReadTime = 0;
unsigned long rfidReadStartTime = 0;
const unsigned long RFID_READ_TIMEOUT =
10000; // 10 Sekunden Timeout für API Requests
// Initialisiert den RFID-Reader und das SPI-Interface. // Hilfsfunktion um Reading-Mode zu prüfen
bool isRFIDReadingActive() { return readingMode; }
// Initialisiert den RFID-Reader
void setupRFID() { void setupRFID() {
// I2C starten mit korrekten Pins
Wire.begin(SDA_PIN, SCL_PIN, 100000);
delay(100);
// SPI und RFID initialisieren // PN532 initialisieren
SPI.begin(); // Init SPI bus if (!nfc.begin()) {
mfrc522.PCD_Init(); // Init MFRC522 Serial.println("RFID: PN532 nicht gefunden!");
delay(4); // Optional delay. Some boards need more time after init to be ready
mfrc522.PCD_DumpVersionToSerial(); // Show details of PCD - MFRC522 Card
// Reader details
}
// Liest automatisch eine RFID-Karte ein und blockiert die UID für eine
// bestimmte Zeit.
void handleAutomaticRFID() {
if (!mfrc522.PICC_IsNewCardPresent()) {
return; return;
} }
// Select one of the cards // Firmware prüfen
if (!mfrc522.PICC_ReadCardSerial()) { uint32_t versiondata = nfc.getFirmwareVersion();
if (!versiondata) {
Serial.println("RFID: Firmware nicht lesbar!");
return; return;
} }
// Read the UID // SAM Config
String uid = ""; nfc.SAMConfig();
for (byte i = 0; i < mfrc522.uid.size; i++) {
rfidInitialized = true;
Serial.println("RFID: Setup erfolgreich!");
}
// 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 "";
}
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
}
// UID zu String
String uidString = "";
for (uint8_t i = 0; i < uidLength; i++) {
if (i > 0) if (i > 0)
uid += ":"; uidString += ":";
if (mfrc522.uid.uidByte[i] < 0x10) if (uid[i] < 0x10)
uid += "0"; uidString += "0";
uid += String(mfrc522.uid.uidByte[i], HEX); uidString += String(uid[i], HEX);
}
uidString.toUpperCase();
Serial.println("RFID: " + uidString);
return uidString;
} }
// Check if the UID is blocked // RFID Loop - kontinuierliches Lesen wenn aktiviert (MQTT-optimiert)
unsigned long currentTime = millis(); void loopRFID() {
if (blockedUIDs.find(uid) != blockedUIDs.end()) { if (!readingMode) {
if (currentTime - blockedUIDs[uid] < BLOCK_DURATION) { return; // Lesen nicht aktiviert
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);
}
} }
// Process the UID static unsigned long lastCheck = 0;
Serial.print(F("UID: "));
Serial.println(uid);
// Block the UID for 10 seconds // Nur alle 300ms prüfen (weniger belastend für MQTT)
blockedUIDs[uid] = currentTime; if (millis() - lastCheck < 300) {
// 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; return;
} }
lastCheck = millis();
// Prüfen ob neue Karte vorhanden ist // Versuchen zu lesen (mit kurzer Timeout)
if (!mfrc522.PICC_IsNewCardPresent()) { String uid = readRFIDCard();
return; if (uid.length() > 0) {
} // Nur neue UIDs oder nach 2 Sekunden Pause
if (uid != lastReadUID || millis() - lastReadTime > 2000) {
// 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; lastReadUID = uid;
rfidReadSuccess = true; lastReadTime = millis();
rfidReadRequested = false; Serial.println("RFID gelesen: " + uid);
}
// Karte "halt" setzen }
mfrc522.PICC_HaltA();
mfrc522.PCD_StopCrypto1();
} }
// API Funktion: RFID Lesevorgang starten // API Routes
// Startet einen neuen RFID-Lesevorgang über die API.
void startRFIDRead() {
Serial.println("RFID API Lesevorgang gestartet...");
rfidReadRequested = true;
rfidReadSuccess = false;
lastReadUID = "";
rfidReadStartTime = millis();
}
// API Funktion: Prüfen ob Lesevorgang abgeschlossen
// Prüft, ob der aktuelle RFID-Lesevorgang abgeschlossen ist.
bool isRFIDReadComplete() { return !rfidReadRequested; }
// API Funktion: Ergebnis des Lesevorgangs abrufen
// Gibt das Ergebnis des letzten RFID-Lesevorgangs zurück.
String getRFIDReadResult(bool &success) {
success = rfidReadSuccess;
return lastReadUID;
}
// Richtet die HTTP-API-Routen für RFID-Operationen ein.
void setupRFIDRoute(AsyncWebServer &server) { void setupRFIDRoute(AsyncWebServer &server) {
server.on("/api/rfid/read", HTTP_GET, [](AsyncWebServerRequest *request) { // Toggle RFID Reading Mode
Serial.println("api/rfid/read"); server.on("/api/rfid/toggle", HTTP_POST, [](AsyncWebServerRequest *request) {
readingMode = !readingMode;
// Start RFID-Lesevorgang
startRFIDRead();
unsigned long startTime = millis();
// Warten, bis eine UID gelesen wird oder Timeout eintritt
while (!isRFIDReadComplete()) {
if (millis() - startTime > RFID_READ_TIMEOUT) {
break;
}
delay(10); // Kurze Pause, um die CPU nicht zu blockieren
}
DynamicJsonDocument response(200); DynamicJsonDocument response(200);
if (rfidReadSuccess && lastReadUID.length() > 0) {
response["success"] = true; response["success"] = true;
response["uid"] = lastReadUID; response["reading_mode"] = readingMode;
response["message"] = "UID erfolgreich gelesen"; response["message"] =
readingMode ? "RFID Lesen gestartet" : "RFID Lesen gestoppt";
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"] = uid;
response["message"] = "Karte gelesen";
} else { } else {
response["success"] = false; response["success"] = false;
response["error"] = "Keine RFID Karte erkannt oder Timeout"; response["error"] = "Keine Karte gefunden";
response["uid"] = ""; response["uid"] = "";
} }
@@ -190,107 +153,32 @@ void setupRFIDRoute(AsyncWebServer &server) {
request->send(200, "application/json", jsonString); request->send(200, "application/json", jsonString);
}); });
server.on( // Status und letzte gelesene UID
"/api/users/insert", HTTP_POST, [](AsyncWebServerRequest *request) {}, server.on("/api/rfid/status", HTTP_GET, [](AsyncWebServerRequest *request) {
NULL, DynamicJsonDocument response(300);
[](AsyncWebServerRequest *request, uint8_t *data, size_t len, response["success"] = true;
size_t index, size_t total) { response["rfid_initialized"] = rfidInitialized;
Serial.println("/api/users/insert"); response["reading_mode"] = readingMode;
response["last_uid"] = lastReadUID;
response["message"] =
readingMode ? "RFID Lesen aktiv" : "RFID Lesen inaktiv";
// Parse the incoming JSON payload String jsonString;
DynamicJsonDocument doc(512); serializeJson(response, jsonString);
DeserializationError error = deserializeJson(doc, data, len); request->send(200, "application/json", jsonString);
});
// 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
DynamicJsonDocument response(200); DynamicJsonDocument response(200);
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;
// 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["success"] = true;
response["message"] = "Benutzer erfolgreich gespeichert"; response["message"] = "UID zurückgesetzt";
} else {
response["success"] = false;
response["error"] = "Fehler beim Speichern in der Datenbank";
}
}
}
// Send the response back to the client
String jsonString; String jsonString;
serializeJson(response, jsonString); serializeJson(response, jsonString);
request->send(200, "application/json", jsonString); request->send(200, "application/json", jsonString);
}); });
} }
// API Funktion: RFID Reader Status prüfen
// Prüft, ob der RFID-Reader korrekt funktioniert und gibt den Status zurück.
bool checkRFIDReaderStatus() {
byte version = mfrc522.PCD_ReadRegister(mfrc522.VersionReg);
// Bekannte MFRC522 Versionen: 0x91, 0x92
if (version == 0x91 || version == 0x92) {
Serial.println("RFID Reader OK (Version: 0x" + String(version, HEX) + ")");
return true;
} else {
Serial.println("RFID Reader Fehler (Version: 0x" + String(version, HEX) +
")");
return false;
}
}
// Hilfsfunktion: Blockierte UIDs aufräumen
// Entfernt UIDs aus der Blockliste, deren Blockdauer abgelaufen ist.
void cleanupBlockedUIDs() {
unsigned long currentTime = millis();
// Iterator für sicheres Löschen während der Iteration
for (auto it = blockedUIDs.begin(); it != blockedUIDs.end();) {
if (currentTime - it->second >= BLOCK_DURATION) {
it = blockedUIDs.erase(it);
} else {
++it;
}
}
}
// Hauptschleife für das RFID-Handling (automatisch und API-basiert).
void loopRFID() {
// Originale Funktionalität für automatisches Lesen
if (!rfidReadRequested) {
handleAutomaticRFID();
}
// API-basiertes Lesen verarbeiten
if (rfidReadRequested) {
handleAPIRFIDRead();
}
}

View File

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

View File

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