commit 2d2ee0a41a071a08b0265e0e1ea196ab94497eb8 Author: Carsten Graf Date: Sun Jun 1 11:51:02 2025 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..981728e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "files.associations": { + "functional": "cpp", + "array": "cpp", + "deque": "cpp", + "list": "cpp", + "string": "cpp", + "unordered_map": "cpp", + "unordered_set": "cpp", + "vector": "cpp", + "string_view": "cpp", + "initializer_list": "cpp", + "regex": "cpp" + } +} \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..a18f71c --- /dev/null +++ b/TODO.md @@ -0,0 +1,18 @@ +- KOMPLETTES Messageing System auf MQTT Server und Broker umstellen. +- Testen wie zuverlässig das ist + + +- message system überarbeiten. Sehr unzuverlässig mit dem peering +- Uhrzeit abfragen (Eingabe der Zeitzone) DONE +- Sync der Buttons mit echtzeit +- implementierung einer RTC + +v2.0 +- ADD Hotspot Manager to connect to a Wifi +- ADD Licence Management (generate a programm where i can generate keys that get checked agains a private seed in the firmware) +enables the Wifimanager to connect DONE + +- ADD option point for location (read from online table and select the location via dropdown) +- ADD option to enter a name, age +- ADD upload to a Online Database () + diff --git a/apientpoints b/apientpoints new file mode 100644 index 0000000..9e32638 --- /dev/null +++ b/apientpoints @@ -0,0 +1,47 @@ +API-Routen Übersicht +GET /api/data +→ Gibt den aktuellen Timer-Status und Zeiten zurück + +POST /api/reset-best +→ Setzt die besten Zeiten zurück + +POST /api/unlearn-button +→ Verlernt alle Button-Zuordnungen + +POST /api/set-max-time +→ Setzt die maximale Zeit und maxTimeDisplay + +GET /api/get-settings +→ Gibt die aktuellen Einstellungen zurück + +POST /api/start-learning +→ Startet den Anlernmodus + +POST /api/stop-learning +→ Beendet den Anlernmodus + +GET /api/learn/status +→ Gibt den Status des Anlernmodus zurück + +GET /api/buttons/status +→ Gibt den Status der Button-Zuordnungen zurück + +GET /api/info +→ Systeminformationen (IP, MAC, Speicher, verbundene Buttons) + +(aus timesync.h) + +GET /api/time +→ Gibt die aktuelle Systemzeit zurück +POST /api/set-time +→ Setzt die Systemzeit +(aus licenceing.h) + +GET /api/get-licence +→ Gibt den gespeicherten Lizenzschlüssel zurück +POST /api/set-licence +→ Speichert einen neuen Lizenzschlüssel +Statische Dateien: + +/ → index.html +/settings → settings.html \ No newline at end of file diff --git a/data/about.html b/data/about.html new file mode 100644 index 0000000..7b49b14 --- /dev/null +++ b/data/about.html @@ -0,0 +1,480 @@ + + + + + + Über NinjaCross Timer + + + + + + ← Zurück zum Timer + + +
+

🏊‍♀️ Über NinjaCross Timer

+

Der professionelle Zeitmesser für Ninjacross Wettkämpfe

+
+ +
+
+

🎯 Was ist NinjaCross?

+

+ NinjaCross ist ein aufregender Wassersport, der Geschwindigkeit, Technik und Athletik kombiniert. + Teilnehmer durchqueren Schwimmbahnen mit verschiedenen Hindernissen und Herausforderungen, + wobei Zeit und Präzision entscheidend sind. +

+

+ Unser Timer-System wurde speziell entwickelt, um professionelle Wettkämpfe zu unterstützen + und präzise Zeitmessungen für bis zu zwei Bahnen gleichzeitig zu ermöglichen. +

+
+ +
+

⚡ Funktionen

+
+
+

🎲 Dual-Timer

+

Gleichzeitige Zeitmessung für zwei Bahnen mit präziser Synchronisation

+
+
+

📱 Responsive Design

+

Optimiert für alle Geräte - Desktop, Tablet und Smartphone

+
+
+

🏆 Bestzeiten

+

Automatische Verfolgung und Anzeige der besten Tageszeiten

+
+
+

📚 Lernmodus

+

Interaktiver Modus für Training und Schulungszwecke

+
+
+

⚙️ Einfache Bedienung

+

Intuitive Benutzeroberfläche für schnelle und fehlerfreie Bedienung

+
+
+

🔄 Live-Sync

+

Echtzeitaktualisierung aller Timer-Daten über Backend-Integration

+
+
+
+ +
+

📊 Technische Spezifikationen

+
+
+ 0.01s + Präzision +
+
+ 2 + Bahnen +
+
+ 50ms + Update-Rate +
+
+ 100% + Responsive +
+
+ +

🔧 Technologie-Stack

+
    +
  • Frontend: HTML5, CSS3, Vanilla JavaScript
  • +
  • Design: Responsive Grid Layout, Glassmorphism
  • +
  • Performance: Optimierte Render-Zyklen, Smooth Animations
  • +
  • Kompatibilität: Alle modernen Browser, Mobile-First
  • +
+
+ +
+

👥 Entwicklung

+
+

🚀 Entwickelt mit ❤️ von Carsten Graf

+

+ Dieses Projekt wurde mit Leidenschaft für den NinjaCross-Sport entwickelt, + um Wettkämpfe professioneller und spannender zu gestalten. +

+
+
+ +
+

🎮 Bedienung

+

Grundfunktionen

+
    +
  • Timer starten: Automatische Synchronisation mit Backend-System
  • +
  • Live-Anzeige: Echtzeitaktualisierung aller Zeiten und Status
  • +
  • Bestzeiten: Automatische Speicherung der Tagesrekorde
  • +
  • Lernmodus: Interaktive Anweisungen für neue Benutzer
  • +
+ +

Status-Anzeigen

+
    +
  • Bereit (Blau): Timer ist startbereit
  • +
  • Läuft (Grün): Aktive Zeitmessung mit Pulsation
  • +
  • Beendet (Rot): Zeitmessung abgeschlossen
  • +
+
+ +
+
+

🏁 Bereit für den Wettkampf?

+

+ Starten Sie jetzt mit dem professionellen NinjaCross Timer + und erleben Sie präzise Zeitmessung auf höchstem Niveau! +

+ Timer starten 🚀 +
+
+
+ + + + \ No newline at end of file diff --git a/data/index.html b/data/index.html new file mode 100644 index 0000000..172b663 --- /dev/null +++ b/data/index.html @@ -0,0 +1,453 @@ + + + + + + NinjaCross Timer + + + + + + ⚙️ + +
+

🏊‍♀️ NinjaCross Timer

+

Professioneller Zeitmesser für Ninjacross Wettkämpfe

+
+ + + +
+
+

🏊‍♀️ Bahn 1

+
00.00
+
Bereit
+
+ +
+

🏊‍♂️ Bahn 2

+
00.00
+
Bereit
+
+
+ +
+

🏆 Beste Zeiten des Tages

+
+ Bahn 1: + --.- +
+
+ Bahn 2: + --.- +
+
+ + + + \ No newline at end of file diff --git a/data/logo.png b/data/logo.png new file mode 100644 index 0000000..b96bc2a Binary files /dev/null and b/data/logo.png differ diff --git a/data/settings.html b/data/settings.html new file mode 100644 index 0000000..90498da --- /dev/null +++ b/data/settings.html @@ -0,0 +1,998 @@ + + + + + + + Ninjacross Timer - Einstellungen + + + +
+
+

⏱️ Ninjacross Timer

+

Einstellungen & Konfiguration

+
+ +
+ + +
+ +
+

🕐 Datum & Uhrzeit

+
+ Aktuelle Zeit: Laden... +
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+

🔧 Grundeinstellungen

+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

🏆 Zeiten verwalten

+
+ +
+
+ +
+

📡 Button-Konfiguration

+
+ + + +
+ +
+

🎯 Anlernmodus aktiv

+

+ Drücken Sie jetzt den Button für: Bahn 1 Start +

+ +
+
+ +
+

🔄 OTA Update

+ +
+ +
+
+ +
+

ℹ️ System-Information

+
+
IP-Adresse: Laden...
+
Kanal: Laden...
+
MAC-Adresse: Laden...
+
Freier Speicher: Laden...
+
Verbundene Buttons: Laden...
+
Lizenz gültig: Laden...
+
Lizenz Level: Laden...
+
+
+ +
+

🔧 Lizenz

+
+
+ + +
+
+ +
+
+
+
+
+ + + + diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..49d0090 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,55 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[platformio] +default_envs = esp32dev + +[env] +platform = https://github.com/platformio/platform-espressif32.git +framework = arduino +lib_deps = esp32async/ESPAsyncWebServer@^3.7.7 +lib_compat_mode = strict + +[env:wemos_d1_mini32] +board = wemos_d1_mini32 +monitor_speed = 115200 +lib_deps = + bblanchon/ArduinoJson@^7.4.1 + esp32async/ESPAsyncWebServer@^3.7.7 + lostincompilation/PrettyOTA@^1.1.3 + esp32async/AsyncTCP@^3.4.2 + mlesniew/PicoMQTT@^1.3.0 + +[env:wemos_d1_mini32_OTA] +board = wemos_d1_mini32 +monitor_speed = 115200 +lib_deps = + bblanchon/ArduinoJson@^7.4.1 + esp32async/ESPAsyncWebServer@^3.7.7 + lostincompilation/PrettyOTA@^1.1.3 + esp32async/AsyncTCP@^3.4.2 + mlesniew/PicoMQTT@^1.3.0 +upload_protocol = espota +upload_port = 192.168.1.94 + +[env:esp32dev] +board = esp32dev +monitor_speed = 115200 +build_flags = + -DBOARD_HAS_PSRAM + -mfix-esp32-psram-cache-issue +targets = uploadfs +board_build.psram = disabled +lib_deps = + bblanchon/ArduinoJson@^7.4.1 + esp32async/ESPAsyncWebServer@^3.7.7 + lostincompilation/PrettyOTA@^1.1.3 + esp32async/AsyncTCP@^3.4.2 + mlesniew/PicoMQTT@^1.3.0 diff --git a/src/communication.h b/src/communication.h new file mode 100644 index 0000000..b631005 --- /dev/null +++ b/src/communication.h @@ -0,0 +1,44 @@ +#include +#include "master.h" + +#include + +#include +#include "timesync.h" + + +// Datenstruktur für ESP-NOW Nachrichten +// Datenstruktur für ESP-NOW Nachrichten +typedef struct { + uint8_t messageType; + uint8_t buttonId; + int buttonPressed; + uint32_t timestamp; + char messageId[33]; // 32 hex chars + null terminator for 128-bit ID +} ButtonMessage; + +PicoMQTT::Server mqtt; + + +void setupMqttServer() { + // Set up the MQTT server with the desired port + // Subscribe to a topic pattern and attach a callback + mqtt.subscribe("#", [](const char * topic, const char * payload) { + Serial.printf("Received message in topic '%s': %s\n", topic, payload); + }); + + // Start the MQTT server + mqtt.begin(); +} + +void loopMqttServer() { + // Handle incoming MQTT messages + mqtt.loop(); + + // Optionally, you can publish a message periodically + static unsigned long lastPublish = 0; + if (millis() - lastPublish > 5000) { // Publish every 5 seconds + mqtt.publish("test/topic", "Hello from ESP32!"); + lastPublish = millis(); + } +} diff --git a/src/debug.h b/src/debug.h new file mode 100644 index 0000000..508aff5 --- /dev/null +++ b/src/debug.h @@ -0,0 +1,41 @@ +// Zeit-bezogene Variablen und Includes +#pragma once +#include +#include +#include +#include +#include +#include + + + +void setupDebugAPI(AsyncWebServer& server); + + +void setupDebugAPI(AsyncWebServer& server) { + +//DEBUG +server.on("/api/debug/start1", HTTP_GET, [](AsyncWebServerRequest *request){ + handleStart1(); + request->send(200, "text/plain", "handleStart1() called"); +}); + +server.on("/api/debug/stop1", HTTP_GET, [](AsyncWebServerRequest *request){ + handleStop1(); + request->send(200, "text/plain", "handleStop1() called"); +}); + +server.on("/api/debug/start2", HTTP_GET, [](AsyncWebServerRequest *request){ + handleStart2(); + request->send(200, "text/plain", "handleStart2() called"); +}); + +server.on("/api/debug/stop2", HTTP_GET, [](AsyncWebServerRequest *request){ + handleStop2(); + request->send(200, "text/plain", "handleStop2() called"); +}); + + +Serial.println("Debug-API initialisiert"); +} +//DEBUG END \ No newline at end of file diff --git a/src/licenceing.h b/src/licenceing.h new file mode 100644 index 0000000..d06f0da --- /dev/null +++ b/src/licenceing.h @@ -0,0 +1,106 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include "mbedtls/md.h" + +const char* secret = "542ff224606c61fb3024e22f76ef9ac8"; + +// Preferences für persistente Speicherung +Preferences preferences; + +String licence; + +//Prototype für Funktionen +String getUniqueDeviceID(); +String hmacSHA256(const String& key, const String& message); +bool checkLicense(const String& deviceID, const String& licenseKey); +void setupLicenceAPI(AsyncWebServer& server); +void saveLicenceToPrefs(); +void loadLicenceFromPrefs(); + + +String getUniqueDeviceID() { + uint8_t mac[6]; + esp_wifi_get_mac(WIFI_IF_STA, mac); // Use STA MAC for uniqueness + char id[13]; + sprintf(id, "%02X%02X%02X%02X%02X%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return String(id); +} + +String hmacSHA256(const String& key, const String& message) { + byte hmacResult[32]; + mbedtls_md_context_t ctx; + mbedtls_md_type_t md_type = MBEDTLS_MD_SHA256; + + mbedtls_md_init(&ctx); + const mbedtls_md_info_t* md_info = mbedtls_md_info_from_type(md_type); + mbedtls_md_setup(&ctx, md_info, 1); + mbedtls_md_hmac_starts(&ctx, (const unsigned char*)key.c_str(), key.length()); + mbedtls_md_hmac_update(&ctx, (const unsigned char*)message.c_str(), message.length()); + mbedtls_md_hmac_finish(&ctx, hmacResult); + mbedtls_md_free(&ctx); + + String result = ""; + for (int i = 0; i < 32; i++) { + char buf[3]; + sprintf(buf, "%02X", hmacResult[i]); + result += buf; + } + return result; +} + +int getLicenseTier(const String& deviceID, const String& licenseKey) { + for (int tier = 1; tier <= 4; ++tier) { + String data = deviceID + ":" + String(tier); + String expected = hmacSHA256(secret, data); + if (licenseKey.equalsIgnoreCase(expected)) { + return tier; // Found matching tier + } + } + return 0; // No valid tier found +} + +void setupLicenceAPI(AsyncWebServer& server) { + + server.on("/api/get-licence", HTTP_GET, [](AsyncWebServerRequest *request){ + Serial.println("Received request to get licence"); + loadLicenceFromPrefs(); + String deviceID = getUniqueDeviceID(); + int tier = getLicenseTier(deviceID, licence); + String json = "{\"licence\":\"" + licence + "\"," + "\"valid\":" + String(tier > 0 ? "true" : "false") + + ",\"tier\":" + String(tier) + "}"; + request->send(200, "application/json", json); + }); + + server.on("/api/set-licence", HTTP_POST, [](AsyncWebServerRequest *request){ + Serial.println("Received request to set licence"); + if (request->hasParam("licence", true)) { + licence = request->getParam("licence", true)->value(); + Serial.println("Received request to set licence " + licence); + saveLicenceToPrefs(); // eigene Funktion + request->send(200, "application/json", "{\"success\":true}"); + } else { + request->send(400, "application/json", "{\"success\":false}"); + } + }); + + Serial.println("Licence API setup complete"); +} + +void saveLicenceToPrefs() { + preferences.begin("key", false); + preferences.putString("key", licence); + preferences.end(); +} + +void loadLicenceFromPrefs() { + preferences.begin("key", true); + licence = preferences.getString("key", ""); + preferences.end(); +} \ No newline at end of file diff --git a/src/master.cpp b/src/master.cpp new file mode 100644 index 0000000..c00f3dd --- /dev/null +++ b/src/master.cpp @@ -0,0 +1,331 @@ +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + +#include +#include "master.h" +// Aquacross Timer - ESP32 Master (Webserver + ESP-NOW + Anlernmodus) +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + + +const char* firmwareversion = "1.0.0"; // Version der Firmware + + + +void handleLearningMode(const uint8_t* mac) { + // Prüfen ob MAC bereits einem anderen Button zugewiesen ist + if (buttonConfigs.start1.isAssigned && memcmp(buttonConfigs.start1.mac, mac, 6) == 0) { + Serial.println("Diese MAC ist bereits zugewiesen - wird ignoriert"); + return; + } + if (buttonConfigs.stop1.isAssigned && memcmp(buttonConfigs.stop1.mac, mac, 6) == 0) { + Serial.println("Diese MAC ist bereits zugewiesen - wird ignoriert"); + return; + } + if (buttonConfigs.start2.isAssigned && memcmp(buttonConfigs.start2.mac, mac, 6) == 0) { + Serial.println("Diese MAC ist bereits zugewiesen - wird ignoriert"); + return; + } + if (buttonConfigs.stop2.isAssigned && memcmp(buttonConfigs.stop2.mac, mac, 6) == 0) { + Serial.println("Diese MAC ist bereits zugewiesen - wird ignoriert"); + return; + } + + // MAC ist noch nicht zugewiesen, normal fortfahren + switch(learningStep) { + case 0: // Start1 + memcpy(buttonConfigs.start1.mac, mac, 6); + buttonConfigs.start1.isAssigned = true; + Serial.println("Start1 Button zugewiesen"); + break; + case 1: // Stop1 + memcpy(buttonConfigs.stop1.mac, mac, 6); + buttonConfigs.stop1.isAssigned = true; + Serial.println("Stop1 Button zugewiesen"); + break; + case 2: // Start2 + memcpy(buttonConfigs.start2.mac, mac, 6); + buttonConfigs.start2.isAssigned = true; + Serial.println("Start2 Button zugewiesen"); + break; + case 3: // Stop2 + memcpy(buttonConfigs.stop2.mac, mac, 6); + buttonConfigs.stop2.isAssigned = true; + Serial.println("Stop2 Button zugewiesen"); + break; + } + + learningStep++; + if (learningStep >= 4) { + learningMode = false; + learningStep = 0; + saveButtonConfig(); + Serial.println("Lernmodus beendet!"); + } +} + +void handleStartLearning() { + learningMode = true; + + // Count assigned buttons and set appropriate learning step + int assignedButtons = 0; + if (buttonConfigs.start1.isAssigned) assignedButtons++; + if (buttonConfigs.stop1.isAssigned) assignedButtons++; + if (buttonConfigs.start2.isAssigned) assignedButtons++; + if (buttonConfigs.stop2.isAssigned) assignedButtons++; + + learningStep = assignedButtons; + + Serial.printf("Learning mode started - %d buttons already assigned, continuing at step %d\n", + assignedButtons, learningStep); +} + +void handleLearningStatus() { + DynamicJsonDocument doc(256); + doc["active"] = learningMode; + doc["step"] = learningStep; + + String response; + serializeJson(doc, response); +} + +void unlearnButton() { + + memset(buttonConfigs.start1.mac, 0, 6); + buttonConfigs.start1.isAssigned = false; + memset(buttonConfigs.stop1.mac, 0, 6); + buttonConfigs.stop1.isAssigned = false; + memset(buttonConfigs.start2.mac, 0, 6); + buttonConfigs.start2.isAssigned = false; + memset(buttonConfigs.stop2.mac, 0, 6); + buttonConfigs.stop2.isAssigned = false; + + saveButtonConfig(); + Serial.println("Buttons wurden verlernt."); +} + +void handleStart1() { + if (!timerData.isRunning1) { + timerData.startTime1 = millis(); + timerData.isRunning1 = true; + timerData.endTime1 = 0; + Serial.println("Bahn 1 gestartet"); + } +} + +void handleStop1() { + if (timerData.isRunning1) { + timerData.endTime1 = millis(); + timerData.isRunning1 = false; + unsigned long currentTime = timerData.endTime1 - timerData.startTime1; + + if (timerData.bestTime1 == 0 || currentTime < timerData.bestTime1) { + timerData.bestTime1 = currentTime; + saveBestTimes(); + } + timerData.finishedSince1 = millis(); + Serial.println("Bahn 1 gestoppt - Zeit: " + String(currentTime/1000.0) + "s"); + } +} + +void handleStart2() { + if (!timerData.isRunning2) { + timerData.startTime2 = millis(); + timerData.isRunning2 = true; + timerData.endTime2 = 0; + Serial.println("Bahn 2 gestartet"); + } +} + +void handleStop2() { + if (timerData.isRunning2) { + timerData.endTime2 = millis(); + timerData.isRunning2 = false; + unsigned long currentTime = timerData.endTime2 - timerData.startTime2; + + if (timerData.bestTime2 == 0 || currentTime < timerData.bestTime2) { + timerData.bestTime2 = currentTime; + saveBestTimes(); + } + timerData.finishedSince2 = millis(); + Serial.println("Bahn 2 gestoppt - Zeit: " + String(currentTime/1000.0) + "s"); + } +} + +void checkAutoReset() { + unsigned long currentTime = millis(); + + if (timerData.isRunning1 && (currentTime - timerData.startTime1 > maxTimeBeforeReset)) { + timerData.isRunning1 = false; + timerData.startTime1 = 0; + Serial.println("Bahn 1 automatisch zurückgesetzt"); + } + + if (timerData.isRunning2 && (currentTime - timerData.startTime2 > maxTimeBeforeReset)) { + timerData.isRunning2 = false; + timerData.startTime2 = 0; + Serial.println("Bahn 2 automatisch zurückgesetzt"); + } + + // Automatischer Reset nach 10 Sekunden "Beendet" + if (!timerData.isRunning1 && timerData.endTime1 > 0 && timerData.finishedSince1 > 0) { + if (currentTime - timerData.finishedSince1 > maxTimeDisplay) { + timerData.startTime1 = 0; + timerData.endTime1 = 0; + timerData.finishedSince1 = 0; + 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; + 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(); +} + +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.startTime1) / 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.startTime2) / 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() { + Serial.begin(115200); + + if (!SPIFFS.begin(true)) { + Serial.println("SPIFFS Mount Failed"); + return; + } + + //setup external libararies + setupTimeAPI(server); + setupLicenceAPI(server); + setupDebugAPI(server); + + + // Gespeicherte Daten laden + loadButtonConfig(); + loadBestTimes(); + loadSettings(); + + setupWifi(); // WiFi initialisieren + setupOTA(&server); + + setupRoutes(); + + setupMqttServer(); // MQTT Server initialisieren + + +} + +void loop() { + checkAutoReset(); + loopMqttServer(); // MQTT Server in der Loop aufrufen + delay(100); +} diff --git a/src/master.h b/src/master.h new file mode 100644 index 0000000..839788b --- /dev/null +++ b/src/master.h @@ -0,0 +1,64 @@ +#pragma once +#include +#include +#include +#include +#include + + +// Timer Struktur +struct TimerData { + unsigned long startTime1 = 0; + unsigned long startTime2 = 0; + unsigned long endTime1 = 0; + unsigned long endTime2 = 0; + unsigned long bestTime1 = 0; + unsigned long bestTime2 = 0; + bool isRunning1 = false; + bool isRunning2 = false; + unsigned long finishedSince1 = 0; + unsigned long finishedSince2 = 0; +}; + +// Button Konfiguration +struct ButtonConfig { + uint8_t mac[6]; + bool isAssigned = false; +}; + +struct ButtonConfigs { + ButtonConfig start1; + ButtonConfig stop1; + ButtonConfig start2; + ButtonConfig stop2; +}; + +extern const char* firmwareversion; + +// Globale Variablen +TimerData timerData; +ButtonConfigs buttonConfigs; +bool learningMode = false; +int learningStep = 0; // 0=Start1, 1=Stop1, 2=Start2, 3=Stop2 +unsigned long maxTimeBeforeReset = 300000; // 5 Minuten default +unsigned long maxTimeDisplay = 20000; // 20 Sekunden Standard (in ms) +bool wifimodeAP = false; // AP-Modus deaktiviert + +//Function Declarations +void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len); +void handleLearningMode(const uint8_t* mac); +void handleStartLearning(); +void handleStart1(); +void handleStop1(); +void handleStart2(); +void handleStop2(); +void checkAutoReset(); +void saveButtonConfig(); +void loadButtonConfig(); +void saveBestTimes(); +void loadBestTimes(); +void saveSettings(); +void loadSettings(); +void unlearnButton(); +int checkLicence(); +String getTimerDataJSON(); \ No newline at end of file diff --git a/src/statusled.h b/src/statusled.h new file mode 100644 index 0000000..751a2e5 --- /dev/null +++ b/src/statusled.h @@ -0,0 +1,54 @@ +#include + + +#define LED_PIN LED_BUILTIN + +// Status LED +unsigned long lastLedBlink = 0; +bool ledState = false; + + +void updateStatusLED(int blinkPattern) { + unsigned long currentTime = millis(); + + switch (blinkPattern) { + case 0: // Suche Master - Langsames Blinken + if (currentTime - lastLedBlink > 1000) { + ledState = !ledState; + digitalWrite(LED_PIN, ledState); + lastLedBlink = currentTime; + } + break; + + case 1: // Verbunden - Kurzes Blinken alle 3 Sekunden + if (currentTime - lastLedBlink > 3000) { + digitalWrite(LED_PIN, HIGH); + delay(100); + digitalWrite(LED_PIN, LOW); + lastLedBlink = currentTime; + } + break; + + case 2: // Button gedrückt - Schnelles Blinken 3x + static int blinkCount = 0; + if (currentTime - lastLedBlink > 100) { + ledState = !ledState; + digitalWrite(LED_PIN, ledState); + lastLedBlink = currentTime; + blinkCount++; + + if (blinkCount >= 6) { // 3 komplette Blinks + blinkCount = 0; + blinkPattern = 1; // Zurück zu verbunden + } + } + + case 3: // Flash bei Empfang - Einmaliges kurzes Blinken + { + digitalWrite(LED_PIN, HIGH); + delay(100); + digitalWrite(LED_PIN, LOW); + } + break; + } +} diff --git a/src/timesync.h b/src/timesync.h new file mode 100644 index 0000000..8b3c22f --- /dev/null +++ b/src/timesync.h @@ -0,0 +1,198 @@ +// Zeit-bezogene Variablen und Includes +#pragma once +#include +#include +#include +#include +#include + +// Globale Zeitvariablen +struct timeval tv; +struct timezone tz; +time_t now; +struct tm timeinfo; + +void setupTimeAPI(AsyncWebServer& server); +String getCurrentTimeJSON(); +bool setSystemTime(long timestamp); + +// Hilfsfunktionen für Zeit-Management +String getCurrentTimeJSON() { + gettimeofday(&tv, &tz); + now = tv.tv_sec; + + StaticJsonDocument<200> doc; + doc["timestamp"] = (long)now; + doc["success"] = true; + + // Zusätzliche Zeitinformationen + gmtime_r(&now, &timeinfo); + char timeStr[64]; + strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo); + doc["formatted"] = String(timeStr); + doc["year"] = timeinfo.tm_year + 1900; + doc["month"] = timeinfo.tm_mon + 1; + doc["day"] = timeinfo.tm_mday; + doc["hour"] = timeinfo.tm_hour; + doc["minute"] = timeinfo.tm_min; + doc["second"] = timeinfo.tm_sec; + + String response; + serializeJson(doc, response); + return response; +} + +bool setSystemTime(long timestamp) { + struct timeval tv; + tv.tv_sec = timestamp; + tv.tv_usec = 0; + + if (settimeofday(&tv, NULL) == 0) { + Serial.println("Zeit erfolgreich gesetzt: " + String(timestamp)); + return true; + } else { + Serial.println("Fehler beim Setzen der Zeit"); + return false; + } +} + +void setupTimeAPI(AsyncWebServer& server) { + +// API-Endpunkt: Aktuelle Zeit abrufen +server.on("/api/time", HTTP_GET, [](AsyncWebServerRequest *request){ + String response = getCurrentTimeJSON(); + request->send(200, "application/json", response); +}); + +// API-Endpunkt: Zeit setzen +server.on("/api/set-time", HTTP_POST, [](AsyncWebServerRequest *request){ + StaticJsonDocument<100> doc; + + if (request->hasParam("timestamp", true)) { + String timestampStr = request->getParam("timestamp", true)->value(); + long timestamp = timestampStr.toInt(); + + if (timestamp > 0) { + bool success = setSystemTime(timestamp); + + doc["success"] = success; + if (success) { + doc["message"] = "Zeit erfolgreich gesetzt"; + doc["timestamp"] = timestamp; + } else { + doc["message"] = "Fehler beim Setzen der Zeit"; + } + } else { + doc["success"] = false; + doc["message"] = "Ungültiger Timestamp"; + } + } else { + doc["success"] = false; + doc["message"] = "Timestamp-Parameter fehlt"; + } + + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); +}); + +// Alternative Implementierung für manuelle Datum/Zeit-Eingabe +server.on("/api/set-datetime", HTTP_POST, [](AsyncWebServerRequest *request){ + StaticJsonDocument<150> doc; + + if (request->hasParam("year", true) && + request->hasParam("month", true) && + request->hasParam("day", true) && + request->hasParam("hour", true) && + request->hasParam("minute", true) && + request->hasParam("second", true)) { + + struct tm timeinfo; + timeinfo.tm_year = request->getParam("year", true)->value().toInt() - 1900; + timeinfo.tm_mon = request->getParam("month", true)->value().toInt() - 1; + timeinfo.tm_mday = request->getParam("day", true)->value().toInt(); + timeinfo.tm_hour = request->getParam("hour", true)->value().toInt(); + timeinfo.tm_min = request->getParam("minute", true)->value().toInt(); + timeinfo.tm_sec = request->getParam("second", true)->value().toInt(); + + time_t timestamp = mktime(&timeinfo); + + if (timestamp != -1) { + bool success = setSystemTime(timestamp); + + doc["success"] = success; + if (success) { + doc["message"] = "Zeit erfolgreich gesetzt"; + doc["timestamp"] = (long)timestamp; + } else { + doc["message"] = "Fehler beim Setzen der Zeit"; + } + } else { + doc["success"] = false; + doc["message"] = "Ungültiges Datum/Zeit"; + } + } else { + doc["success"] = false; + doc["message"] = "Datum/Zeit-Parameter fehlen"; + } + + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); +}); + +// Erweiterte Zeit-Informationen (optional) +server.on("/api/time/info", HTTP_GET, [](AsyncWebServerRequest *request){ + gettimeofday(&tv, &tz); + now = tv.tv_sec; + gmtime_r(&now, &timeinfo); + + StaticJsonDocument<400> doc; + doc["timestamp"] = (long)now; + doc["uptime"] = millis(); + + // Formatierte Zeitstrings + char buffer[64]; + strftime(buffer, sizeof(buffer), "%Y-%m-%d", &timeinfo); + doc["date"] = String(buffer); + + strftime(buffer, sizeof(buffer), "%H:%M:%S", &timeinfo); + doc["time"] = String(buffer); + + strftime(buffer, sizeof(buffer), "%A", &timeinfo); + doc["weekday"] = String(buffer); + + strftime(buffer, sizeof(buffer), "%B", &timeinfo); + doc["month_name"] = String(buffer); + + // Zusätzliche Infos + doc["day_of_year"] = timeinfo.tm_yday + 1; + doc["week_of_year"] = (timeinfo.tm_yday + 7 - timeinfo.tm_wday) / 7; + doc["is_dst"] = timeinfo.tm_isdst; + + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); +}); +Serial.println("Zeit-API initialisiert"); +} + +// Hilfsfunktion: Zeit-Validierung +bool isValidDateTime(int year, int month, int day, int hour, int minute, int second) { + if (year < 2020 || year > 2099) return false; + if (month < 1 || month > 12) return false; + if (day < 1 || day > 31) return false; + if (hour < 0 || hour > 23) return false; + if (minute < 0 || minute > 59) return false; + if (second < 0 || second > 59) return false; + + // Erweiterte Validierung für Monatstage + int daysInMonth[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + + // Schaltjahr-Prüfung + if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0))) { + daysInMonth[1] = 29; + } + + return day <= daysInMonth[month - 1]; +} diff --git a/src/webserverrouter.h b/src/webserverrouter.h new file mode 100644 index 0000000..bda2ed9 --- /dev/null +++ b/src/webserverrouter.h @@ -0,0 +1,169 @@ +#include +#include "master.h" +#include +#include +#include +#include + +AsyncWebServer server(80); + +void setupRoutes(){ + // Web Server Routes + + // SPIFFS initialisieren + + + server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){ + request->send(SPIFFS, "/index.html", "text/html"); + }); + + server.on("/settings", HTTP_GET, [](AsyncWebServerRequest *request){ + request->send(SPIFFS, "/settings.html", "text/html"); + }); + + server.on("/about", HTTP_GET, [](AsyncWebServerRequest *request){ + request->send(SPIFFS, "/about.html", "text/html"); + }); + + server.on("/api/data", HTTP_GET, [](AsyncWebServerRequest *request){ + request->send(200, "application/json", getTimerDataJSON()); + }); + + server.on("/api/reset-best", HTTP_POST, [](AsyncWebServerRequest *request){ + Serial.println("/api/reset-best called"); + timerData.bestTime1 = 0; + timerData.bestTime2 = 0; + saveBestTimes(); + DynamicJsonDocument doc(64); + doc["success"] = true; + String result; + serializeJson(doc, result); + request->send(200, "application/json", result); + }); + + server.on("/api/unlearn-button", HTTP_POST, [](AsyncWebServerRequest *request){ + Serial.println("/api/unlearn-button called"); + unlearnButton(); + request->send(200, "application/json", "{\"success\":true}"); + +}); + + + server.on("/api/set-max-time", HTTP_POST, [](AsyncWebServerRequest *request){ + Serial.println("/api/set-max-time called"); + bool changed = false; + if (request->hasParam("maxTime", true)) { + maxTimeBeforeReset = request->getParam("maxTime", true)->value().toInt() * 1000; + changed = true; + } + if (request->hasParam("maxTimeDisplay", true)) { + maxTimeDisplay = request->getParam("maxTimeDisplay", true)->value().toInt() * 1000; + changed = true; + } + if (changed) { + saveSettings(); + DynamicJsonDocument doc(32); + 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-settings", HTTP_GET, [](AsyncWebServerRequest *request){ + Serial.println("/api/get-settings called"); + DynamicJsonDocument doc(256); + doc["maxTime"] = maxTimeBeforeReset / 1000; + doc["maxTimeDisplay"] = maxTimeDisplay / 1000; + String result; + serializeJson(doc, result); + request->send(200, "application/json", result); + }); + + server.on("/api/start-learning", HTTP_POST, [](AsyncWebServerRequest *request){ + Serial.println("/api/start-learning called"); + learningMode = true; + learningStep = 0; + DynamicJsonDocument doc(64); + doc["success"] = true; + String result; + serializeJson(doc, result); + Serial.println("Learning mode started"); + request->send(200, "application/json", result); +}); + + server.on("/api/stop-learning", HTTP_POST, [](AsyncWebServerRequest *request){ + Serial.println("/api/stop-learning called"); + learningMode = false; + learningStep = 0; + DynamicJsonDocument doc(64); + doc["success"] = true; + String result; + serializeJson(doc, result); + Serial.println("Learning mode stopped"); + request->send(200, "application/json", result); + }); + + server.on("/api/learn/status", HTTP_GET, [](AsyncWebServerRequest *request){ + DynamicJsonDocument doc(256); + doc["active"] = learningMode; + doc["step"] = learningStep; + String response; + serializeJson(doc, response); + request->send(200, "application/json", response); + }); + + server.on("/api/buttons/status", HTTP_GET, [](AsyncWebServerRequest *request){ + DynamicJsonDocument doc(128); + doc["lane1Start"] = buttonConfigs.start1.isAssigned; + doc["lane1Stop"] = buttonConfigs.stop1.isAssigned; + doc["lane2Start"] = buttonConfigs.start2.isAssigned; + doc["lane2Stop"] = buttonConfigs.stop2.isAssigned; + String result; + serializeJson(doc, result); + request->send(200, "application/json", result); + }); + + server.on("/api/info", HTTP_GET, [](AsyncWebServerRequest *request){ + DynamicJsonDocument doc(256); + + // IP address + IPAddress ip = WiFi.softAPIP(); + doc["ip"] = ip.toString(); + doc["channel"] = WiFi.channel(); + + // MAC address + uint8_t mac[6]; + esp_wifi_get_mac(WIFI_IF_STA, mac); + char macStr[18]; + sprintf(macStr, "%02X:%02X:%02X:%02X:%02X:%02X", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + doc["mac"] = macStr; + + // Free memory + doc["freeMemory"] = ESP.getFreeHeap(); + + // Connected buttons (count assigned) + int connected = 0; + if (buttonConfigs.start1.isAssigned) connected++; + if (buttonConfigs.stop1.isAssigned) connected++; + if (buttonConfigs.start2.isAssigned) connected++; + if (buttonConfigs.stop2.isAssigned) connected++; + doc["connectedButtons"] = connected; + + doc["valid"] = checkLicence() > 0 ? "Ja" : "Nein"; + doc["tier"] = checkLicence() ; + + + String result; + serializeJson(doc, result); + request->send(200, "application/json", result); +}); + +// Statische Dateien + server.serveStatic("/", SPIFFS, "/"); + server.begin(); + Serial.println("Web Server gestartet"); + +} \ No newline at end of file diff --git a/src/wificlass.h b/src/wificlass.h new file mode 100644 index 0000000..24c0d2c --- /dev/null +++ b/src/wificlass.h @@ -0,0 +1,59 @@ +#pragma once +#include +#include +#include +#include +#include + +#include "master.h" +#include "licenceing.h" + +const char* ssidAP = "AquaCross-Timer"; +const char* passwordAP = "aquacross123"; + +const char* ssidSTA = "Obiwlankenobi"; +const char* passwordSTA = "Delfine1!"; + +PrettyOTA OTAUpdates; + + +void setupWifi() { + + WiFi.mode(WIFI_MODE_APSTA); + WiFi.softAP(ssidAP, passwordAP); + WiFi.begin(ssidSTA, passwordSTA); + + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print("."); + } + Serial.println(""); + Serial.println("Verbunden mit WLAN!"); + Serial.print("IP-Adresse: "); + Serial.println(WiFi.localIP()); + + Serial.println("WiFi AP gestartet"); + Serial.print("IP Adresse: "); + Serial.println(WiFi.softAPIP()); + Serial.println("PrettyOTA can be accessed at: http://" + WiFi.softAPIP().toString() + "/update"); + +} + +void setupOTA(AsyncWebServer *server) { + // Initialize PrettyOTA + OTAUpdates.Begin(server); + + // Set unique Hardware-ID for your hardware/board + OTAUpdates.SetHardwareID("AquaCross-Master"); + + // Set firmware version to 1.0.0 + OTAUpdates.SetAppVersion(firmwareversion); + + // Set current build time and date + PRETTY_OTA_SET_CURRENT_BUILD_TIME_AND_DATE(); +} + + + +// WiFi als Access Point + \ No newline at end of file diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html