From 9de327bfb31209fb0cdb4735aea2b616b34b3649 Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Sat, 20 Sep 2025 19:14:41 +0200 Subject: [PATCH] Lokal Leaderboard --- data/index.css | 57 +++++++++++++++++- data/index.html | 107 +++++++++++++++++++++++++++++---- src/communication.h | 84 ++++++++++++++++++++++---- src/databasebackend.h | 134 ++++++++++++++++++++++++++++++++++++++++++ src/gamemodes.h | 26 +++++++- src/master.cpp | 16 ++++- src/master.h | 12 ++++ src/rfid.h | 19 +++--- 8 files changed, 419 insertions(+), 36 deletions(-) diff --git a/data/index.css b/data/index.css index 51ee23f..0397b27 100644 --- a/data/index.css +++ b/data/index.css @@ -353,7 +353,7 @@ body { } .status { - font-size: clamp(3rem, 1.8vw, 1.2rem); + font-size: clamp(1.5rem, 3vw, 3rem); margin: clamp(8px, 1vh, 12px) 0; padding: clamp(6px, 1vh, 10px) clamp(12px, 2vw, 18px); border-radius: 20px; @@ -455,6 +455,61 @@ body { border-radius: 8px; } +/* Leaderboard Styles */ +#leaderboard-container { + text-align: left; +} + +.leaderboard-entry { + display: flex; + justify-content: space-between; + align-items: center; + margin: clamp(8px, 1vh, 12px) 0; + font-size: clamp(1.1rem, 2.2vw, 1.4rem); + font-weight: 600; + background: rgba(255, 255, 255, 0.15); + padding: clamp(8px, 1.5vh, 12px) clamp(12px, 2vw, 16px); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.3); + transition: all 0.3s ease; +} + +.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; +} + +.no-times { + text-align: center; + color: rgba(255, 255, 255, 0.7); + font-style: italic; + font-size: clamp(0.9rem, 1.8vw, 1.1rem); + padding: 20px; +} + .learning-mode { background: rgba(245, 157, 15, 0.2); border: 2px solid #f59d0f; diff --git a/data/index.html b/data/index.html index f029353..1856b16 100644 --- a/data/index.html +++ b/data/index.html @@ -72,14 +72,33 @@
-

🏆 Deine Bestzeiten heute

-
- Bahn 1: - --.- -
-
- Bahn 2: - --.- +

🏆 Lokales Leaderboard

+
+
+ 1. + Max Mustermann + 23.45 +
+
+ 2. + Anna Schmidt + 24.67 +
+
+ 3. + Tom Weber + 25.89 +
+
+ 4. + Lisa Müller + 26.12 +
+
+ 5. + Paul Fischer + 27.34 +
@@ -96,6 +115,7 @@ let learningButton = ""; let name1 = ""; let name2 = ""; + let leaderboardData = []; // Lane Configuration let laneConfigType = 0; // 0=Identical, 1=Different @@ -336,7 +356,67 @@ function formatTime(seconds) { if (seconds === 0) return "00.00"; - return seconds.toFixed(2); + + const totalSeconds = Math.floor(seconds); + const minutes = Math.floor(totalSeconds / 60); + const remainingSeconds = totalSeconds % 60; + const milliseconds = Math.floor((seconds - totalSeconds) * 100); + + // Zeige Minuten nur wenn über 60 Sekunden + if (totalSeconds >= 60) { + return `${minutes.toString().padStart(2, "0")}:${remainingSeconds + .toString() + .padStart(2, "0")}.${milliseconds.toString().padStart(2, "0")}`; + } else { + return `${remainingSeconds.toString().padStart(2, "0")}.${milliseconds + .toString() + .padStart(2, "0")}`; + } + } + + // Leaderboard Funktionen + async function loadLeaderboard() { + try { + const response = await fetch("/api/leaderboard"); + const data = await response.json(); + leaderboardData = data.leaderboard || []; + updateLeaderboardDisplay(); + } catch (error) { + console.error("Fehler beim Laden des Leaderboards:", error); + } + } + + function updateLeaderboardDisplay() { + const container = document.getElementById("leaderboard-container"); + container.innerHTML = ""; + + if (leaderboardData.length === 0) { + container.innerHTML = + '
Noch keine Zeiten erfasst
'; + return; + } + + leaderboardData.forEach((entry, index) => { + const entryDiv = document.createElement("div"); + entryDiv.className = "leaderboard-entry"; + + 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); + container.appendChild(entryDiv); + }); } function updateDisplay() { @@ -411,10 +491,7 @@ } } - document.getElementById("best1").textContent = - best1 > 0 ? formatTime(best1) + "s" : "--.-"; - document.getElementById("best2").textContent = - best2 > 0 ? formatTime(best2) + "s" : "--.-"; + // Leaderboard wird separat geladen // Namen anzeigen/verstecken const name1Element = document.getElementById("name1"); @@ -528,6 +605,10 @@ // Initial load syncFromBackend(); loadLaneConfig(); + loadLeaderboard(); + + // Leaderboard alle 5 Sekunden aktualisieren + setInterval(loadLeaderboard, 5000); diff --git a/src/communication.h b/src/communication.h index 8d3f45e..9475751 100644 --- a/src/communication.h +++ b/src/communication.h @@ -46,6 +46,20 @@ typedef struct { // MQTT-Server-Instanz PicoMQTT::Server mqtt; +// Tracking der Quelle für jede Lane +bool start1FoundLocally = false; +bool start2FoundLocally = false; +String start1UID = ""; +String start2UID = ""; + +// Hilfsfunktionen um die Quelle abzufragen +bool wasStart1FoundLocally() { return start1FoundLocally; } + +bool wasStart2FoundLocally() { return start2FoundLocally; } + +String getStart1UID() { return start1UID; } +String getStart2UID() { return start2UID; } + /** * Liest eine Button-JSON-Nachricht, extrahiert Typ, MAC und Timestamp, * prüft die Button-Zuordnung und ruft die entsprechende Handler-Funktion auf. @@ -275,10 +289,18 @@ void readRFIDfromButton(const char *topic, const char *payload) { // Check if the buttonmac matches buttonConfigs.start1.mac if (memcmp(macBytes.data(), buttonConfigs.start1.mac, 6) == 0) { + // Prüfe ob Lane 1 bereit ist + if (timerData1.isRunning || timerData1.isArmed) { + Serial.println("Lane 1 läuft - ignoriere RFID: " + String(uid)); + return; + } + // 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 @@ -342,15 +364,17 @@ void readRFIDfromButton(const char *topic, const char *payload) { // Wenn Benutzer gefunden wurde (lokal oder online) if (userData.exists) { - // Log user data - Serial.printf("User found for start1: %s\n", + // 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 + // Create JSON message to send to the frontend (ohne source) StaticJsonDocument<128> messageDoc; - messageDoc["name"] = - userData.firstname; // Verwende name statt firstname/lastname - messageDoc["lane"] = "start1"; // Add lane information + messageDoc["name"] = userData.firstname; + messageDoc["lane"] = "start1"; String message; serializeJson(messageDoc, message); @@ -361,14 +385,35 @@ void readRFIDfromButton(const char *topic, const char *payload) { message.c_str()); } else { Serial.println("User nicht gefunden für UID: " + upperUid); + + // Sende UID an Frontend wenn kein User gefunden wurde + StaticJsonDocument<128> messageDoc; + messageDoc["name"] = upperUid; // UID als Name senden + messageDoc["lane"] = "start1"; + + String message; + serializeJson(messageDoc, message); + + // Push die UID an das Frontend + pushUpdateToFrontend(message); + Serial.printf("Sende UID an Frontend für start1: %s\n", + message.c_str()); } } // Check if the buttonmac matches buttonConfigs.start2.mac else if (memcmp(macBytes.data(), buttonConfigs.start2.mac, 6) == 0) { + // Prüfe ob Lane 2 bereit ist + if (timerData2.isRunning || timerData2.isArmed) { + Serial.println("Lane 2 nicht bereit - ignoriere RFID: " + String(uid)); + return; + } + // 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 @@ -432,15 +477,17 @@ void readRFIDfromButton(const char *topic, const char *payload) { // Wenn Benutzer gefunden wurde (lokal oder online) if (userData.exists) { - // Log user data - Serial.printf("User found for start2: %s\n", + // 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 + // Create JSON message to send to the frontend (ohne source) StaticJsonDocument<128> messageDoc; - messageDoc["name"] = - userData.firstname; // Verwende name statt firstname/lastname - messageDoc["lane"] = "start2"; // Add lane information + messageDoc["name"] = userData.firstname; + messageDoc["lane"] = "start2"; String message; serializeJson(messageDoc, message); @@ -451,6 +498,19 @@ void readRFIDfromButton(const char *topic, const char *payload) { message.c_str()); } else { Serial.println("User nicht gefunden für UID: " + upperUid); + + // Sende UID an Frontend wenn kein User gefunden wurde + StaticJsonDocument<128> messageDoc; + messageDoc["name"] = upperUid; // UID als Name senden + messageDoc["lane"] = "start2"; + + String message; + serializeJson(messageDoc, message); + + // Push die UID an das Frontend + pushUpdateToFrontend(message); + Serial.printf("Sende UID an Frontend für start2: %s\n", + message.c_str()); } } else { Serial.println("Button MAC does not match start1.mac or start2.mac"); diff --git a/src/databasebackend.h b/src/databasebackend.h index 79b5f3f..037a465 100644 --- a/src/databasebackend.h +++ b/src/databasebackend.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -54,6 +55,9 @@ struct UserData { bool exists; }; +// Forward declarations für Leaderboard-Funktionen +void addLocalTime(String uid, String name, unsigned long timeMs); + // Prüft, ob ein Benutzer mit der angegebenen UID in der Datenbank existiert und // gibt dessen Daten zurück. UserData checkUser(const String &uid) { @@ -349,5 +353,135 @@ void setupBackendRoutes(AsyncWebServer &server) { // Andere Logik wie in getBestLocs }); + // Lokales Leaderboard API + 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 5 + int count = 0; + for (const auto &time : localTimes) { + if (count >= 5) + break; + + JsonObject entry = leaderboard.createNestedObject(); + entry["rank"] = count + 1; + entry["name"] = time.name; + entry["uid"] = time.uid; + entry["time"] = time.timeMs / 1000.0; + + // Format time inline + float seconds = time.timeMs / 1000.0; + int totalSeconds = (int)seconds; + int minutes = totalSeconds / 60; + int remainingSeconds = totalSeconds % 60; + int milliseconds = (int)((seconds - totalSeconds) * 100); + + String timeFormatted; + if (minutes > 0) { + timeFormatted = String(minutes) + ":" + + (remainingSeconds < 10 ? "0" : "") + + String(remainingSeconds) + "." + + (milliseconds < 10 ? "0" : "") + String(milliseconds); + } else { + timeFormatted = String(remainingSeconds) + "." + + (milliseconds < 10 ? "0" : "") + String(milliseconds); + } + entry["timeFormatted"] = timeFormatted; + + count++; + } + + String result; + serializeJson(doc, result); + request->send(200, "application/json", result); + }); + // Add more routes as needed } + +// Hilfsfunktionen um UID und Status abzufragen (aus communication.h) +String getStart1UID(); +String getStart2UID(); +bool wasStart1FoundLocally(); +bool wasStart2FoundLocally(); + +// Funktion um Zeit an Online-API zu senden +void sendTimeToOnlineAPI(int lane, String uid, float timeInSeconds) { + // Nur senden wenn User online gefunden wurde + bool wasOnlineFound = + (lane == 1) ? !wasStart1FoundLocally() : !wasStart2FoundLocally(); + + if (!wasOnlineFound) { + Serial.println("Zeit nicht gesendet - User wurde lokal gefunden"); + return; + } + + if (WiFi.status() != WL_CONNECTED) { + Serial.println("Keine Internetverbindung - Zeit nicht gesendet"); + return; + } + + Serial.println("Sende Zeit an Online-API für Lane " + String(lane)); + + HTTPClient http; + http.begin(String(BACKEND_SERVER) + "/api/v1/private/create-time"); + http.addHeader("Content-Type", "application/json"); + http.addHeader("Authorization", String("Bearer ") + licence); + + // Zeit in M:SS.mmm Format konvertieren (ohne führende Null bei Minuten) + int minutes = (int)(timeInSeconds / 60); + int seconds = (int)timeInSeconds % 60; + int milliseconds = (int)((timeInSeconds - (int)timeInSeconds) * 1000); + + String formattedTime = + String(minutes) + ":" + (seconds < 10 ? "0" : "") + String(seconds) + + "." + (milliseconds < 10 ? "00" : (milliseconds < 100 ? "0" : "")) + + String(milliseconds); + + StaticJsonDocument<200> requestDoc; + requestDoc["rfiduid"] = uid; + requestDoc["location_name"] = + getLocationIdFromPrefs(); // Aus den Einstellungen + requestDoc["recorded_time"] = formattedTime; + + String requestBody; + serializeJson(requestDoc, requestBody); + + Serial.println("API Request Body: " + requestBody); + + int httpCode = http.POST(requestBody); + + if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_CREATED) { + String response = http.getString(); + Serial.println("Zeit erfolgreich gesendet: " + response); + } else { + Serial.printf("Fehler beim Senden der Zeit: HTTP %d\n", httpCode); + if (httpCode > 0) { + String response = http.getString(); + Serial.println("Response: " + response); + } + } + + http.end(); +} + +// Funktionen für lokales Leaderboard +void addLocalTime(String uid, String name, unsigned long timeMs) { + LocalTime newTime; + newTime.uid = uid; + newTime.name = name; + newTime.timeMs = timeMs; + newTime.timestamp = millis(); + + localTimes.push_back(newTime); + + Serial.printf("Lokale Zeit hinzugefügt: %s (%s) - %.2fs\n", name.c_str(), + uid.c_str(), timeMs / 1000.0); +} diff --git a/src/gamemodes.h b/src/gamemodes.h index cbafae0..dce7088 100644 --- a/src/gamemodes.h +++ b/src/gamemodes.h @@ -56,6 +56,18 @@ void IndividualMode(const char *action, int press, int lane, publishLaneStatus(1, "stopped"); Serial.println("Bahn 1 gestoppt - Zeit: " + String(currentTime / 1000.0) + "s"); + + // Speichere Zeit lokal wenn User lokal gefunden wurde + if (wasStart1FoundLocally() && getStart1UID().length() > 0) { + // Finde den Namen des lokalen Users + UserData userData = checkUser(getStart1UID()); + if (userData.exists) { + addLocalTime(getStart1UID(), userData.firstname, currentTime); + } + } else if (!wasStart1FoundLocally() && getStart1UID().length() > 0) { + // Sende Zeit an Online-API wenn User online gefunden wurde + sendTimeToOnlineAPI(1, getStart1UID(), currentTime / 1000.0); + } } } if (action == "start" && press == 2 && lane == 2) { @@ -84,6 +96,18 @@ void IndividualMode(const char *action, int press, int lane, publishLaneStatus(2, "stopped"); Serial.println("Bahn 2 gestoppt - Zeit: " + String(currentTime / 1000.0) + "s"); + + // Speichere Zeit lokal wenn User lokal gefunden wurde + if (wasStart2FoundLocally() && getStart2UID().length() > 0) { + // Finde den Namen des lokalen Users + UserData userData = checkUser(getStart2UID()); + if (userData.exists) { + addLocalTime(getStart2UID(), userData.firstname, currentTime); + } + } else if (!wasStart2FoundLocally() && getStart2UID().length() > 0) { + // Sende Zeit an Online-API wenn User online gefunden wurde + sendTimeToOnlineAPI(2, getStart2UID(), currentTime / 1000.0); + } } } @@ -330,4 +354,4 @@ String getTimerDataJSON() { String result; serializeJson(doc, result); return result; -} \ No newline at end of file +} diff --git a/src/master.cpp b/src/master.cpp index 0bc9f86..6ee5fbf 100644 --- a/src/master.cpp +++ b/src/master.cpp @@ -64,8 +64,20 @@ void setup() { void loop() { checkAutoReset(); - loopMqttServer(); // MQTT Server in der Loop aufrufen + + // MQTT hat höchste Priorität (wird zuerst verarbeitet) + loopMqttServer(); + + // WebSocket verarbeiten loopWebSocket(); + + // RFID Loop nur wenn aktiv (spart CPU-Zyklen) + if (isRFIDReadingActive()) { + loopRFID(); + } + // loopBattery(); // Batterie-Loop aufrufen - loopRFID(); // RFID Loop aufrufen + + // Kurze Pause um anderen Tasks Zeit zu geben + delay(1); } diff --git a/src/master.h b/src/master.h index 71fffe7..8924050 100644 --- a/src/master.h +++ b/src/master.h @@ -4,6 +4,7 @@ #include #include #include +#include const char *ssidAP; const char *passwordAP = nullptr; @@ -24,6 +25,14 @@ struct TimerData1 { char RFIDUID[32] = ""; }; +// Struktur für lokale Zeiten (Leaderboard) +struct LocalTime { + String uid; + String name; + unsigned long timeMs; + unsigned long timestamp; +}; + // Timer Struktur für Bahn 2 struct TimerData2 { unsigned long startTime = 0; @@ -73,6 +82,9 @@ int laneConfigType = 0; // 0=Identical, 1=Different int lane1DifficultyType = 0; // 0=Light, 1=Heavy (difficulty) int lane2DifficultyType = 0; // 0=Light, 1=Heavy (difficulty) +// Lokales Leaderboard +std::vector localTimes; + // Function Declarations void OnDataRecv(const uint8_t *mac, const uint8_t *incomingData, int len); void handleLearningMode(const uint8_t *mac); diff --git a/src/rfid.h b/src/rfid.h index b19cb6f..9887776 100644 --- a/src/rfid.h +++ b/src/rfid.h @@ -19,6 +19,9 @@ bool readingMode = false; String lastReadUID = ""; unsigned long lastReadTime = 0; +// Hilfsfunktion um Reading-Mode zu prüfen +bool isRFIDReadingActive() { return readingMode; } + // Initialisiert den RFID-Reader void setupRFID() { // I2C starten mit korrekten Pins @@ -54,7 +57,7 @@ bool checkRFID() { return (versiondata != 0); } -// Liest RFID-Karte - GANZ EINFACH +// Liest RFID-Karte - NICHT BLOCKIEREND String readRFIDCard() { if (!checkRFID()) { return ""; @@ -63,11 +66,13 @@ String readRFIDCard() { 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); + nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLength, + 50); // 50ms Timeout statt Standard 100ms if (!success) { - return ""; // Keine Karte + return ""; // Keine Karte oder Timeout } // UID zu String @@ -85,7 +90,7 @@ String readRFIDCard() { return uidString; } -// RFID Loop - kontinuierliches Lesen wenn aktiviert +// RFID Loop - kontinuierliches Lesen wenn aktiviert (MQTT-optimiert) void loopRFID() { if (!readingMode) { return; // Lesen nicht aktiviert @@ -93,13 +98,13 @@ void loopRFID() { static unsigned long lastCheck = 0; - // Nur alle 200ms prüfen (schneller für bessere Responsivität) - if (millis() - lastCheck < 200) { + // Nur alle 300ms prüfen (weniger belastend für MQTT) + if (millis() - lastCheck < 300) { return; } lastCheck = millis(); - // Versuchen zu lesen + // Versuchen zu lesen (mit kurzer Timeout) String uid = readRFIDCard(); if (uid.length() > 0) { // Nur neue UIDs oder nach 2 Sekunden Pause