Compare commits
30 Commits
0166e1a695
...
esp32thing
| Author | SHA1 | Date | |
|---|---|---|---|
| 05166b443b | |||
|
|
76b492606e | ||
| d9edd47a31 | |||
|
|
a67e29b9e4 | ||
|
|
5ef5e6d636 | ||
|
|
77f1ebc1f1 | ||
|
|
2a832257ba | ||
|
|
5ca67d8804 | ||
|
|
8fac847a75 | ||
|
|
36c35ba161 | ||
|
|
e383e54e41 | ||
|
|
9de327bfb3 | ||
|
|
7e9705902e | ||
|
|
1ed3a30340 | ||
|
|
02a60d84cf | ||
|
|
4f0fc68d41 | ||
|
|
3aac843736 | ||
|
|
ed9e8994a9 | ||
|
|
86b0407f82 | ||
|
|
a400ca00ff | ||
|
|
173b13fcfc | ||
|
|
55eb062d2c | ||
|
|
a768783640 | ||
|
|
2b9cc7283c | ||
|
|
ba1b86a053 | ||
|
|
4a04565878 | ||
|
|
6793a54103 | ||
|
|
60d4393bd2 | ||
|
|
a1c68791bf | ||
| e6a089fd61 |
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -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
106
.gitignore
vendored
@@ -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
140
API.md
@@ -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).**
|
||||||
|
|||||||
674
Bedienungsanleitung_NinjaCross_Timer.html
Normal file
674
Bedienungsanleitung_NinjaCross_Timer.html
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>NinjaCross Timer - Bedienungsanleitung</title>
|
||||||
|
<!--[if gte mso 9]>
|
||||||
|
<xml>
|
||||||
|
<w:WordDocument>
|
||||||
|
<w:View>Print</w:View>
|
||||||
|
<w:Zoom>90</w:Zoom>
|
||||||
|
<w:DoNotOptimizeForBrowser/>
|
||||||
|
</w:WordDocument>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; }
|
||||||
|
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
|
||||||
|
h2 { color: #34495e; margin-top: 30px; }
|
||||||
|
h3 { color: #555; }
|
||||||
|
ul { line-height: 1.8; }
|
||||||
|
ol { line-height: 1.8; }
|
||||||
|
code { background-color: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
|
||||||
|
.warning { background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 15px 0; }
|
||||||
|
.info { background-color: #d1ecf1; border-left: 4px solid #0dcaf0; padding: 15px; margin: 15px 0; }
|
||||||
|
.success { background-color: #d4edda; border-left: 4px solid #28a745; padding: 15px; margin: 15px 0; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin: 15px 0; }
|
||||||
|
table th, table td { border: 1px solid #ddd; padding: 12px; text-align: left; }
|
||||||
|
table th { background-color: #3498db; color: white; }
|
||||||
|
table tr:nth-child(even) { background-color: #f2f2f2; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>NinjaCross Timer - Bedienungsanleitung</h1>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<p><strong>Version:</strong> 1.0</p>
|
||||||
|
<p><strong>Hersteller:</strong> AquaMaster MQTT</p>
|
||||||
|
<p><strong>Datum:</strong> 2024</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>1. Einleitung</h2>
|
||||||
|
|
||||||
|
<p>Der NinjaCross Timer ist ein professionelles Zeitmessgerät für Ninjacross-Wettkämpfe. Das System ermöglicht die präzise Zeitmessung für bis zu zwei Bahnen gleichzeitig und bietet zahlreiche Features wie RFID-Erkennung, lokales Leaderboard und Internet-Konnektivität über WiFi und MQTT.</p>
|
||||||
|
|
||||||
|
<h2>2. Systemübersicht</h2>
|
||||||
|
|
||||||
|
<h3>2.1 Komponenten</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>ESP32 Master</strong>: Hauptprozessor mit Web-Interface</li>
|
||||||
|
<li><strong>4 Wireless-Buttons</strong>: Start/Stop Buttons für 2 Bahnen</li>
|
||||||
|
<li><strong>RFID-Reader</strong>: Optional - für Nutzeridentifikation</li>
|
||||||
|
<li><strong>Internet-Verbindung</strong>: Über WiFi für Cloud-Synchronisation</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>2.2 Anzeigen und Status</h3>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Komponente</th>
|
||||||
|
<th>Beschreibung</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Heartbeat-Indikatoren</td>
|
||||||
|
<td>4 grüne/rote Punkte zeigen die Verbindung der Buttons an (Start1, Stop1, Start2, Stop2)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Timer-Anzeige</td>
|
||||||
|
<td>Live-Zeit für beide Bahnen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Status-Anzeige</td>
|
||||||
|
<td>Bereit, Läuft, Geschafft, Standby</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Leaderboard</td>
|
||||||
|
<td>Top 6 Zeiten lokal gespeichert</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Batterie-Warnung</td>
|
||||||
|
<td>Banner bei niedriger Batterie der Buttons</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>3. Erste Inbetriebnahme</h2>
|
||||||
|
|
||||||
|
<h3>3.1 Einschalten und Netzwerkverbindung</h3>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li><strong>Einschalten</strong>: Master einschalten</li>
|
||||||
|
<li><strong>Access Point finden</strong>: Suchen Sie nach dem WiFi-Netzwerk mit dem Namen <code>NinjaCross-XXXXX</code> (die letzten Zeichen sind eindeutig für Ihr Gerät)</li>
|
||||||
|
<li><strong>Verbinden</strong>: Das Netzwerk ist standardmäßig ohne Passwort</li>
|
||||||
|
<li><strong>IP-Adresse</strong>: Das Gerät hat die feste IP <code>192.168.10.1</code></li>
|
||||||
|
<li><strong>Alternative</strong>: Sie können auch <code>ninjacross.local</code> im Browser verwenden (mDNS)</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<p><strong>Wichtig:</strong> Der Access Point benötigt kein Passwort.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>3.2 Web-Interface öffnen</h3>
|
||||||
|
|
||||||
|
<p>Öffnen Sie Ihren Webbrowser und geben Sie eine der folgenden Adressen ein:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>http://192.168.10.1</code> (direkte IP)</li>
|
||||||
|
<li><code>http://ninjacross.local</code> (falls mDNS unterstützt wird)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>4. Hauptoberfläche</h2>
|
||||||
|
|
||||||
|
<h3>4.1 Timer-Ansicht</h3>
|
||||||
|
|
||||||
|
<p>Die Hauptseite zeigt:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Bahn 1</strong>: Links - Timer und Status</li>
|
||||||
|
<li><strong>Bahn 2</strong>: Rechts - Timer und Status</li>
|
||||||
|
<li><strong>Heartbeat-Indikatoren</strong>: Oben - Verbindungsstatus der Buttons</li>
|
||||||
|
<li><strong>Leaderboard</strong>: Unten - Top 6 lokale Zeiten</li>
|
||||||
|
<li><strong>Navigation</strong>:
|
||||||
|
<ul>
|
||||||
|
<li>🏆 = Leaderboard (Volansicht)</li>
|
||||||
|
<li>⚙️ = Einstellungen</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>4.2 Timer-Bedienung</h3>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li><strong>Standby</strong>: "Drücke beide Buttons einmal" - Buttons initialisieren</li>
|
||||||
|
<li><strong>Bereit</strong>: Beide Buttons sind verbunden (grüne Heartbeats)</li>
|
||||||
|
<li><strong>Armiert</strong>: Startbutton gedrückt - Timer startet bei freigegebenem Button</li>
|
||||||
|
<li><strong>Läuft</strong>: Timer läuft - Zeit wird live angezeigt</li>
|
||||||
|
<li><strong>Geschafft</strong>: Stop-Button gedrückt - Zeit wird gespeichert</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<p><strong>Tipp:</strong> Die Anzeige blendet automatisch die Schwimmer-Namen ein, wenn sie via RFID erkannt werden.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>5. Button-Konfiguration</h2>
|
||||||
|
|
||||||
|
<h3>5.1 Anlernmodus</h3>
|
||||||
|
|
||||||
|
<p>Der erste Schritt ist das Anlernen Ihrer Wireless-Buttons:</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Öffnen Sie die <strong>Einstellungen</strong> (⚙️)</li>
|
||||||
|
<li>Scrollen Sie zu <strong>"Button-Konfiguration"</strong></li>
|
||||||
|
<li>Klicken Sie auf <strong>"🎯 Anlernmodus starten"</strong></li>
|
||||||
|
<li>Folgen Sie den Anweisungen:
|
||||||
|
<ol>
|
||||||
|
<li>Drücken Sie den Button für <strong>Bahn 1 Start</strong></li>
|
||||||
|
<li>Drücken Sie den Button für <strong>Bahn 1 Stop</strong></li>
|
||||||
|
<li>Drücken Sie den Button für <strong>Bahn 2 Start</strong></li>
|
||||||
|
<li>Drücken Sie den Button für <strong>Bahn 2 Stop</strong></li>
|
||||||
|
</ol>
|
||||||
|
</li>
|
||||||
|
<li>Die Anzeige zeigt automatisch an, welchen Button Sie drücken müssen</li>
|
||||||
|
<li>Nach erfolgreicher Konfiguration erhalten Sie eine Bestätigung</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="success">
|
||||||
|
<p><strong>Erfolg:</strong> Nach dem Anlernen sollten alle 4 Heartbeat-Indikatoren grün leuchten.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>5.2 Buttons verlernen</h3>
|
||||||
|
|
||||||
|
<p>Um alle Button-Zuweisungen zu löschen:</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Einstellungen öffnen</li>
|
||||||
|
<li>"❌ Buttons verlernen" klicken</li>
|
||||||
|
<li>Bestätigung erfordert</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>5.3 Button-Status anzeigen</h3>
|
||||||
|
|
||||||
|
<p>Klicken Sie auf <strong>"📊 Button-Status anzeigen"</strong> um zu sehen:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Welche Buttons konfiguriert sind</li>
|
||||||
|
<li>Batteriestand jedes Buttons in Prozent</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>6. RFID-Benutzerverwaltung</h2>
|
||||||
|
|
||||||
|
<h3>6.1 RFID-Karte registrieren</h3>
|
||||||
|
|
||||||
|
<p>Die RFID-Funktion ermöglicht die automatische Zuordnung von Zeiten zu Nutzern:</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Öffnen Sie <strong>"RFID"</strong> (🏷️) aus dem Einstellungsmenü</li>
|
||||||
|
<li>Klicken Sie auf <strong>"📡 Read Chip"</strong></li>
|
||||||
|
<li>Halten Sie die RFID-Karte an den Reader des Masters</li>
|
||||||
|
<li>Die UID wird automatisch eingefügt</li>
|
||||||
|
<li>Geben Sie den <strong>Namen</strong> ein</li>
|
||||||
|
<li>Klicken Sie auf <strong>"💾 Speichern"</strong></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<p><strong>Funktionsweise:</strong> Beim nächsten Scannen der RFID-Karte an einem Button wird automatisch der Name angezeigt und die Zeit diesem Nutzer zugeordnet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>6.2 Kontinuierliches Lesen</h3>
|
||||||
|
|
||||||
|
<p>Der "Read Chip" Button startet einen kontinuierlichen Lesemodus:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Statusleiste zeigt: "RFID Lesen gestartet - Karte auflegen!"</li>
|
||||||
|
<li>Alle erkannten Karten werden automatisch übernommen</li>
|
||||||
|
<li>Nach erfolgreichem Lesen wird die Eingabe fokussiert</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>7. Einstellungen</h2>
|
||||||
|
|
||||||
|
<h3>7.1 Datum & Uhrzeit</h3>
|
||||||
|
|
||||||
|
<p>Die Uhrzeit kann manuell oder automatisch gesetzt werden:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Manuell</strong>: Datum und Uhrzeit eingeben, dann "🕐 Uhrzeit setzen"</li>
|
||||||
|
<li><strong>Automatisch</strong>: "💻 Browser-Zeit übernehmen" verwendet die Zeit Ihres Computers</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>7.2 Modus</h3>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Modus</th>
|
||||||
|
<th>Beschreibung</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>👤 Individual</td>
|
||||||
|
<td>Beide Bahnen arbeiten unabhängig - ideale für Training</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>🏆 Wettkampf</td>
|
||||||
|
<td>Beide Bahnen starten synchron - für Wettkämpfe</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>7.3 Lane-Konfiguration</h3>
|
||||||
|
|
||||||
|
<p>Die Bahnen können identisch oder unterschiedlich konfiguriert werden:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>⚖️ Identische Lanes</strong>: Beide Bahnen sind gleich</li>
|
||||||
|
<li><strong>⚡ Unterschiedliche Lanes</strong>: Bahnen mit unterschiedlichen Schwierigkeiten
|
||||||
|
<ul>
|
||||||
|
<li>🟢 Leicht: Standard-Konfiguration</li>
|
||||||
|
<li>🔴 Schwer: Anspruchsvollere Hindernisse</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>7.4 Grundeinstellungen</h3>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Einstellung</th>
|
||||||
|
<th>Standard</th>
|
||||||
|
<th>Beschreibung</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Maximale Zeit</td>
|
||||||
|
<td>300 Sekunden</td>
|
||||||
|
<td>Nach dieser Zeit wird eine Bahn automatisch zurückgesetzt</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Anzeigedauer</td>
|
||||||
|
<td>20 Sekunden</td>
|
||||||
|
<td>Wie lange die letzte Zeit angezeigt bleibt</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Min. Zeit Leaderboard</td>
|
||||||
|
<td>5 Sekunden</td>
|
||||||
|
<td>Zeiten unter diesem Wert werden nicht gespeichert (Missbrauchsschutz)</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>7.5 WLAN-Konfiguration (Lizenz Level 3 erforderlich)</h3>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<p><strong>Wichtig:</strong> Um das System mit einem bestehenden WLAN zu verbinden wird eine Lizenz Level 3 oder höher.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Zur Konfiguration:</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>WLAN Name (SSID) eingeben</li>
|
||||||
|
<li>WLAN Passwort eingeben</li>
|
||||||
|
<li>Aktueller STA IP-Status wird angezeigt</li>
|
||||||
|
<li>Nach dem Speichern startet das Gerät neu</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<p><strong>Dual-Mode:</strong> Das Gerät kann gleichzeitig Access Point (für direkte Verbindung) und WiFi Station (für Internet) betreiben.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>7.6 Standort (Lizenz Level 3 erforderlich)</h3>
|
||||||
|
|
||||||
|
<p>Wählen Sie Ihren Standort aus einem Dropdown-Menü:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Beim Eingeben einer gültigen Lizenz werden verfügbare Standorte aus der API geladen</li>
|
||||||
|
<li>Ohne Lizenz werden Fallback-Standorte angezeigt</li>
|
||||||
|
<li>Der gewählte Standort wird lokal gespeichert</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>7.7 OTA Update (Lizenz Level 2 erforderlich)</h3>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<p><strong>Lizenz erforderlich:</strong> OTA-Updates benötigen Lizenz Level 2 oder höher.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Klicken Sie auf <strong>"🔄 Update durchführen"</strong></li>
|
||||||
|
<li>Bestätigen Sie die Abfrage</li>
|
||||||
|
<li>Das Gerät lädt die neueste Firmware herunter und installiert sie automatisch</li>
|
||||||
|
<li>Während des Updates darf der Strom nicht unterbrochen werden!</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>7.8 Buttons Updaten</h3>
|
||||||
|
|
||||||
|
<p>Sendet eine Update-Nachricht über MQTT an alle konfigurierten Buttons:</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Klicken Sie auf <strong>"📡 Buttons Updaten"</strong></li>
|
||||||
|
<li>Die Buttons erhalten die aktuelle Konfiguration</li>
|
||||||
|
<li>Nutzen Sie dies nach Button-Wartung oder Konfigurationsänderungen</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>8. Leaderboard</h2>
|
||||||
|
|
||||||
|
<h3>8.1 Lokales Leaderboard</h3>
|
||||||
|
|
||||||
|
<p>Die Hauptseite zeigt die Top 6 Zeiten:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>🏆 Gold für Platz 1</li>
|
||||||
|
<li>🥈 Silber für Platz 2</li>
|
||||||
|
<li>🥉 Bronze für Platz 3</li>
|
||||||
|
<li>Platz 4-6 in Standard-Darstellung</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>8.2 Volle Leaderboard-Ansicht</h3>
|
||||||
|
|
||||||
|
<p>Öffnen Sie die Leaderboard-Seite (🏆):</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Zeigt alle erfassten Zeiten</li>
|
||||||
|
<li>Gruppiert in 2 Zeilen zu je 5 Einträgen</li>
|
||||||
|
<li>Wird alle 5 Sekunden automatisch aktualisiert</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>8.3 Beste Zeiten zurücksetzen</h3>
|
||||||
|
|
||||||
|
<p>Einstellungen → "🏆 Zeiten verwalten" → "🔄 Beste Zeiten zurücksetzen"</p>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<p><strong>Achtung:</strong> Diese Aktion kann nicht rückgängig gemacht werden!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>9. System-Information</h2>
|
||||||
|
|
||||||
|
<p>Die Einstellungsseite zeigt folgende Systemdaten:</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Information</th>
|
||||||
|
<th>Beschreibung</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>IP-Adresse</td>
|
||||||
|
<td>Access Point IP (meist 192.168.10.1)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Kanal</td>
|
||||||
|
<td>WiFi-Kanal</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>MAC-Adresse</td>
|
||||||
|
<td>Eindeutige Geräte-ID</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Internet</td>
|
||||||
|
<td>Ja/Nein - Verbindung zum Internet</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Freier Speicher</td>
|
||||||
|
<td>Verfügbarer RAM in Bytes</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Verbundene Buttons</td>
|
||||||
|
<td>Anzahl konfigurierter Buttons (0-4)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Lizenz gültig</td>
|
||||||
|
<td>Status der Lizenz</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Lizenz Level</td>
|
||||||
|
<td>0-3 - Bestimmt verfügbare Features</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>10. Lizenz-System</h2>
|
||||||
|
|
||||||
|
<h3>10.1 Lizenz-Level</h3>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Level</th>
|
||||||
|
<th>Features</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>0 (Basis)</td>
|
||||||
|
<td>Standard-Timer, lokales Leaderboard, RFID</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td>Alle Level 0 Features</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2</td>
|
||||||
|
<td>Level 1 + OTA Updates</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>3</td>
|
||||||
|
<td>Level 2 + WLAN-Station Mode, Standort-Konfiguration</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>10.2 Lizenz eingeben</h3>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Einstellungen → "🔧 Lizenz"</li>
|
||||||
|
<li>Lizenzschlüssel eingeben</li>
|
||||||
|
<li>"💾 Lizenz speichern" klicken</li>
|
||||||
|
<li>System-Information aktualisiert sich automatisch</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>11. Batterie-Überwachung</h2>
|
||||||
|
|
||||||
|
<p>Das System überwacht kontinuierlich die Batteriestände der Wireless-Buttons:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Warnung</strong>: Bei Batteriestand ≤ 15% erscheint ein Banner</li>
|
||||||
|
<li><strong>Anzeige</strong>: "⚠️ Niedrige Batterie erkannt!" mit Geräteliste</li>
|
||||||
|
<li><strong>Detailliert</strong>: Über Button-Status-Anzeige werden alle Batteriestände angezeigt</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<p><strong>Tipp:</strong> Der Banner blendet automatisch aus, sobald alle Batterien wieder über 15% sind.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>12. API & Technische Details</h2>
|
||||||
|
|
||||||
|
<h3>12.1 API-Endpunkte</h3>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Endpoint</th>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Funktion</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>/api/data</td>
|
||||||
|
<td>GET</td>
|
||||||
|
<td>Timer und Status abrufen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>/api/reset-best</td>
|
||||||
|
<td>POST</td>
|
||||||
|
<td>Beste Zeiten zurücksetzen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>/api/start-learning</td>
|
||||||
|
<td>POST</td>
|
||||||
|
<td>Anlernmodus starten</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>/api/learn/status</td>
|
||||||
|
<td>GET</td>
|
||||||
|
<td>Anlern-Status abrufen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>/api/buttons/status</td>
|
||||||
|
<td>GET</td>
|
||||||
|
<td>Button-Konfiguration und Batterie</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>/api/set-max-time</td>
|
||||||
|
<td>POST</td>
|
||||||
|
<td>Timer-Einstellungen setzen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>/api/get-settings</td>
|
||||||
|
<td>GET</td>
|
||||||
|
<td>Einstellungen abrufen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>/api/set-wifi</td>
|
||||||
|
<td>POST</td>
|
||||||
|
<td>WiFi konfigurieren</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>/api/set-mode</td>
|
||||||
|
<td>POST</td>
|
||||||
|
<td>Modus setzen (Individual/Wettkampf)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>/api/info</td>
|
||||||
|
<td>GET</td>
|
||||||
|
<td>System-Informationen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>/ws</td>
|
||||||
|
<td>WebSocket</td>
|
||||||
|
<td>Live-Updates für Timer</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>12.2 WebSocket-Daten</h3>
|
||||||
|
|
||||||
|
<p>Der WebSocket liefert Echtzeit-Updates:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Button-Status und Heartbeats</li>
|
||||||
|
<li>Timer-Daten (live)</li>
|
||||||
|
<li>RFID-Erkennung</li>
|
||||||
|
<li>Batterie-Status</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>13. Troubleshooting</h2>
|
||||||
|
|
||||||
|
<h3>13.1 Buttons verbinden sich nicht</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Heartbeat rot</strong>: Button außerhalb der Reichweite oder Batterie leer</li>
|
||||||
|
<li><strong>Lösung</strong>: Batterien prüfen, Button näher zum Master bringen</li>
|
||||||
|
<li><strong>Neu anlernen</strong>: Einstellungen → Buttons verlernen → Anlernmodus starten</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>13.2 WiFi-Verbindung funktioniert nicht</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Standard: Nutzen Sie den Access Point <code>NinjaCross-XXXXX</code></li>
|
||||||
|
<li>Mit Lizenz Level 3: Konfigurieren Sie das WLAN in den Einstellungen</li>
|
||||||
|
<li>Falls Netzwerk nicht gefunden wird: Gerät neustarten</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>13.3 IP-Adresse unbekannt</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>192.168.10.1</code> ist die Standard IP</li>
|
||||||
|
<li>Alternative: <code>ninjacross.local</code></li>
|
||||||
|
<li>Router-Konfiguration: DHCP-Range darf 192.168.10.1 nicht blocken</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>13.4 Timer startet nicht</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Prüfen Sie alle 4 Heartbeat-Indikatoren (müssen grün sein)</li>
|
||||||
|
<li>Start-Button muss vor dem Drücken des Stop-Buttons gedrückt werden</li>
|
||||||
|
<li>Bahn muss "Bereit" Status zeigen</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>13.5 RFID wird nicht erkannt</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>RFID-Lesemodus aktivieren: "📡 Read Chip" klicken</li>
|
||||||
|
<li>Karte langsam über den Reader führen</li>
|
||||||
|
<li>Neu versuchen wenn nach 5 Sekunden nichts passiert</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<p><strong>Wichtig:</strong> Bei andauernden Problemen Gerät neustarten oder Support kontaktieren.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>14. Wartung</h2>
|
||||||
|
|
||||||
|
<h3>14.1 Regelmäßige Wartung</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Täglich</strong>: Batteriestände prüfen</li>
|
||||||
|
<li><strong>Wöchentlich</strong>: Leaderboard zurücksetzen (falls gewünscht)</li>
|
||||||
|
<li><strong>Monatlich</strong>: OTA Update prüfen</li>
|
||||||
|
<li><strong>Jährlich</strong>: Firmware aktualisieren</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>14.2 Firmware-Updates</h3>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Lizenz Level 2+ erforderlich</li>
|
||||||
|
<li>Einstellungen → OTA Update</li>
|
||||||
|
<li>Keine Unterbrechung während des Updates</li>
|
||||||
|
<li>Update dauert ca. 1-2 Minuten</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>15. Support & Kontakt</h2>
|
||||||
|
|
||||||
|
<p>Bei Fragen oder Problemen:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Dokumentation prüfen</li>
|
||||||
|
<li>Troubleshooting-Abschnitt beachten</li>
|
||||||
|
<li>System-Informationen für Support bereitstellen</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<p><strong>Hinweis:</strong> Diese Anleitung basiert auf der aktuellen Firmware-Version. Neuere Versionen könnten abweichende Features haben.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>16. Anhang</h2>
|
||||||
|
|
||||||
|
<h3>16.1 Tastenkombinationen im Web-Interface</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code>Enter</code> in UID-Feld: Sprung zum Namensfeld</li>
|
||||||
|
<li>Browser-Refresh: Aktualisiert alle Daten</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>16.2 Unterstützte Browser</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Chrome/Edge (empfohlen)</li>
|
||||||
|
<li>Firefox</li>
|
||||||
|
<li>Safari</li>
|
||||||
|
<li>Mobile Browser (iOS/Android)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>16.3 Technische Spezifikationen</h3>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Komponente</th>
|
||||||
|
<th>Spezifikation</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>ESP32 Version</td>
|
||||||
|
<td>ESP32-WROOM oder kompatibel</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>WiFi</td>
|
||||||
|
<td>2.4 GHz, WPA2</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Protokoll</td>
|
||||||
|
<td>MQTT für Kommunikation</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>RFID</td>
|
||||||
|
<td>13.56 MHz, NFC-kompatibel</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Timer-Genauigkeit</td>
|
||||||
|
<td>Millisekunden</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<p style="text-align: center; color: #888; margin-top: 50px;">
|
||||||
|
<strong>Ende der Bedienungsanleitung</strong><br>
|
||||||
|
NinjaCross Timer v1.0
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
245
README.md
@@ -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
|
[](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}}) [](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
78
THIRD_PARTY_LICENSES.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
## Third-Party Licenses and Notices
|
||||||
|
|
||||||
|
This project uses third‑party 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 LGPL‑licensed 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 | LGPL‑3.0 | [esp32async/ESPAsyncWebServer](https://github.com/esp32async/ESPAsyncWebServer) | [LICENSE](https://github.com/esp32async/ESPAsyncWebServer/blob/master/LICENSE) |
|
||||||
|
| AsyncTCP (esp32async) | ^3.4.2 | LGPL‑3.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 LGPL‑3.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 LGPL‑3.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 LGPL‑3.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).
|
||||||
|
|
||||||
|
|
||||||
3
TODO.md
3
TODO.md
@@ -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!
|
||||||
|
|||||||
@@ -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.
324
data/index.css
324
data/index.css
@@ -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 {
|
||||||
|
|||||||
408
data/index.html
408
data/index.html
@@ -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()">×</button>
|
<button class="close-btn" onclick="closeBatteryBanner()">×</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
367
data/leaderboard.css
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Segoe UI", Arial, sans-serif;
|
||||||
|
background: linear-gradient(0deg, #0d1733 0%, #223c83 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: visible;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #49bae4 0%, #223c83 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2.5em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: "Segoe UI", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
min-height: 150px;
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.leaderboard-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
grid-auto-rows: min-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-row {
|
||||||
|
margin-bottom: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin: 15px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry.gold {
|
||||||
|
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
|
||||||
|
border-color: #ffd700;
|
||||||
|
color: #b8860b;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry.silver {
|
||||||
|
background: linear-gradient(135deg, #c0c0c0 0%, #e8e8e8 100%);
|
||||||
|
border-color: #c0c0c0;
|
||||||
|
color: #696969;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 4px 15px rgba(192, 192, 192, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry.bronze {
|
||||||
|
background: linear-gradient(135deg, #cd7f32 0%, #e6a85c 100%);
|
||||||
|
border-color: #cd7f32;
|
||||||
|
color: #8b4513;
|
||||||
|
font-weight: bold;
|
||||||
|
box-shadow: 0 4px 15px rgba(205, 127, 50, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry .rank {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 40px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry .name {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry .time {
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
min-width: 100px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-entries {
|
||||||
|
text-align: center;
|
||||||
|
color: #6c757d;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.1em;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
color: #49bae4;
|
||||||
|
font-size: 1.1em;
|
||||||
|
padding: 40px;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Notification Toast */
|
||||||
|
.notification-toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 400px;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow:
|
||||||
|
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.04),
|
||||||
|
0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
z-index: 99999;
|
||||||
|
display: none;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
pointer-events: auto;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toast.show {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-top: -4px;
|
||||||
|
margin-right: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast Types */
|
||||||
|
.notification-toast.success .notification-icon {
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toast.error .notification-icon {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toast.info .notification-icon {
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toast.warning .notification-icon {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsiveness */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
margin: 10px;
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry .name {
|
||||||
|
margin: 0;
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry .rank {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry .time {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile notification adjustments */
|
||||||
|
.notification-toast {
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
left: 10px;
|
||||||
|
max-width: none;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry {
|
||||||
|
padding: 12px 15px;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry .rank {
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-entry .time {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
227
data/leaderboard.html
Normal file
227
data/leaderboard.html
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<!-- Meta Tags -->
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico" />
|
||||||
|
|
||||||
|
<!-- Stylesheets -->
|
||||||
|
<link rel="stylesheet" href="leaderboard.css" />
|
||||||
|
<title>Ninjacross Timer - Leaderboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Modern Notification Toast -->
|
||||||
|
<div
|
||||||
|
id="notificationBubble"
|
||||||
|
class="notification-toast"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
<div class="notification-icon">
|
||||||
|
<span id="notificationIcon">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="notification-body">
|
||||||
|
<div class="notification-title" id="notificationTitle">Erfolg</div>
|
||||||
|
<div class="notification-message" id="notificationText">Bereit</div>
|
||||||
|
</div>
|
||||||
|
<button class="notification-close" onclick="hideNotification()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zurück Button -->
|
||||||
|
<a href="/" class="back-btn">🏠</a>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<div class="header">
|
||||||
|
<h1>🏆 Leaderboard</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- Leaderboard Section -->
|
||||||
|
<div id="leaderboard-container" class="leaderboard-container">
|
||||||
|
<div class="loading">Lade Leaderboard...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript Code -->
|
||||||
|
<script>
|
||||||
|
let leaderboardData = [];
|
||||||
|
let lastUpdateTime = null;
|
||||||
|
|
||||||
|
// Seite laden
|
||||||
|
window.onload = function () {
|
||||||
|
loadLeaderboard();
|
||||||
|
// Leaderboard alle 5 Sekunden aktualisieren
|
||||||
|
setInterval(loadLeaderboard, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Leaderboard laden
|
||||||
|
async function loadLeaderboard() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/leaderboard-full");
|
||||||
|
const data = await response.json();
|
||||||
|
leaderboardData = data.leaderboard || [];
|
||||||
|
lastUpdateTime = new Date();
|
||||||
|
updateLeaderboardDisplay();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Laden des Leaderboards:", error);
|
||||||
|
showMessage("Fehler beim Laden des Leaderboards", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaderboard anzeigen
|
||||||
|
function updateLeaderboardDisplay() {
|
||||||
|
const container = document.getElementById("leaderboard-container");
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
if (leaderboardData.length === 0) {
|
||||||
|
container.innerHTML =
|
||||||
|
'<div class="no-entries">Noch keine Zeiten erfasst</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Einträge anzeigen
|
||||||
|
const displayData = leaderboardData;
|
||||||
|
|
||||||
|
// Erstelle zwei Reihen
|
||||||
|
const row1 = document.createElement("div");
|
||||||
|
row1.className = "leaderboard-row";
|
||||||
|
const row2 = document.createElement("div");
|
||||||
|
row2.className = "leaderboard-row";
|
||||||
|
|
||||||
|
displayData.forEach((entry, index) => {
|
||||||
|
const entryDiv = document.createElement("div");
|
||||||
|
entryDiv.className = "leaderboard-entry";
|
||||||
|
|
||||||
|
// Podium-Plätze hervorheben
|
||||||
|
if (index === 0) {
|
||||||
|
entryDiv.classList.add("gold");
|
||||||
|
} else if (index === 1) {
|
||||||
|
entryDiv.classList.add("silver");
|
||||||
|
} else if (index === 2) {
|
||||||
|
entryDiv.classList.add("bronze");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankSpan = document.createElement("span");
|
||||||
|
rankSpan.className = "rank";
|
||||||
|
rankSpan.textContent = entry.rank + ".";
|
||||||
|
|
||||||
|
const nameSpan = document.createElement("span");
|
||||||
|
nameSpan.className = "name";
|
||||||
|
nameSpan.textContent = entry.name;
|
||||||
|
|
||||||
|
const timeSpan = document.createElement("span");
|
||||||
|
timeSpan.className = "time";
|
||||||
|
timeSpan.textContent = entry.timeFormatted;
|
||||||
|
|
||||||
|
entryDiv.appendChild(rankSpan);
|
||||||
|
entryDiv.appendChild(nameSpan);
|
||||||
|
entryDiv.appendChild(timeSpan);
|
||||||
|
|
||||||
|
// Erste 5 Einträge in die erste Reihe, nächste 5 in die zweite Reihe
|
||||||
|
if (index < 5) {
|
||||||
|
row1.appendChild(entryDiv);
|
||||||
|
} else {
|
||||||
|
row2.appendChild(entryDiv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(row1);
|
||||||
|
if (displayData.length > 5) {
|
||||||
|
container.appendChild(row2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moderne Notification anzeigen
|
||||||
|
function showMessage(message, type = "info") {
|
||||||
|
console.log("showMessage called:", message, type);
|
||||||
|
const toast = document.getElementById("notificationBubble");
|
||||||
|
const icon = document.getElementById("notificationIcon");
|
||||||
|
const title = document.getElementById("notificationTitle");
|
||||||
|
const text = document.getElementById("notificationText");
|
||||||
|
|
||||||
|
if (!toast || !icon || !title || !text) {
|
||||||
|
console.error("Notification elements not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (window.notificationTimeout) {
|
||||||
|
clearTimeout(window.notificationTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set content
|
||||||
|
text.textContent = message;
|
||||||
|
|
||||||
|
// Set type-specific styling and content
|
||||||
|
toast.className = "notification-toast";
|
||||||
|
switch (type) {
|
||||||
|
case "success":
|
||||||
|
toast.classList.add("success");
|
||||||
|
icon.textContent = "✓";
|
||||||
|
title.textContent = "Erfolg";
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
toast.classList.add("error");
|
||||||
|
icon.textContent = "✕";
|
||||||
|
title.textContent = "Fehler";
|
||||||
|
break;
|
||||||
|
case "info":
|
||||||
|
toast.classList.add("info");
|
||||||
|
icon.textContent = "ℹ";
|
||||||
|
title.textContent = "Information";
|
||||||
|
break;
|
||||||
|
case "warning":
|
||||||
|
toast.classList.add("warning");
|
||||||
|
icon.textContent = "⚠";
|
||||||
|
title.textContent = "Warnung";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
toast.classList.add("info");
|
||||||
|
icon.textContent = "ℹ";
|
||||||
|
title.textContent = "Information";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show toast with animation
|
||||||
|
toast.style.display = "flex";
|
||||||
|
// Force reflow
|
||||||
|
toast.offsetHeight;
|
||||||
|
// Add show class after a small delay to ensure display is set
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add("show");
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Auto-hide after 5 seconds
|
||||||
|
window.notificationTimeout = setTimeout(() => {
|
||||||
|
hideNotification();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification verstecken mit Animation
|
||||||
|
function hideNotification() {
|
||||||
|
const toast = document.getElementById("notificationBubble");
|
||||||
|
if (!toast) return;
|
||||||
|
|
||||||
|
// Clear timeout if exists
|
||||||
|
if (window.notificationTimeout) {
|
||||||
|
clearTimeout(window.notificationTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove show class for animation
|
||||||
|
toast.classList.remove("show");
|
||||||
|
|
||||||
|
// Hide after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.display = "none";
|
||||||
|
}, 400); // Match CSS transition duration
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
23
data/pictures/logo.svg
Normal file
23
data/pictures/logo.svg
Normal 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 |
358
data/rfid.html
358
data/rfid.html
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
BIN
lib/PrettyOTA/examples/.DS_Store
vendored
BIN
lib/PrettyOTA/examples/.DS_Store
vendored
Binary file not shown.
BIN
lib/PrettyOTA/examples/callbacks/.DS_Store
vendored
BIN
lib/PrettyOTA/examples/callbacks/.DS_Store
vendored
Binary file not shown.
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
14
src/debug.h
14
src/debug.h
@@ -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
371
src/gamemodes.h
Normal 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;
|
||||||
|
}
|
||||||
273
src/master.cpp
273
src/master.cpp
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
69
src/master.h
69
src/master.h
@@ -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
156
src/preferencemanager.h
Normal 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;
|
||||||
|
}
|
||||||
376
src/rfid.h
376
src/rfid.h
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user