From 1e258f6805500c2105756fb9c2da35508edba168 Mon Sep 17 00:00:00 2001 From: Pascal RIGAUD Date: Mon, 9 Feb 2026 07:38:28 +0100 Subject: [PATCH] WIP --- .gitignore | 67 +------ .vscode/extensions.json | 10 + include/README | 37 ++++ lib/README | 46 +++++ platformio.ini | 22 +++ src/lichessboard.cpp | 413 ++++++++++++++++++++++++++++++++++++++++ src/lichessboard.h | 59 ++++++ src/main.cpp | 121 ++++++++++++ test/README | 11 ++ 9 files changed, 724 insertions(+), 62 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 include/README create mode 100644 lib/README create mode 100644 platformio.ini create mode 100644 src/lichessboard.cpp create mode 100644 src/lichessboard.h create mode 100644 src/main.cpp create mode 100644 test/README diff --git a/.gitignore b/.gitignore index ef9edc2..89cc49c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,62 +1,5 @@ -# ---> C -# Prerequisites -*.d - -# Object files -*.o -*.ko -*.obj -*.elf - -# Linker output -*.ilk -*.map -*.exp - -# Precompiled Headers -*.gch -*.pch - -# Libraries -*.lib -*.a -*.la -*.lo - -# Shared objects (inc. Windows DLLs) -*.dll -*.so -*.so.* -*.dylib - -# Executables -*.exe -*.out -*.app -*.i*86 -*.x86_64 -*.hex - -# Debug files -*.dSYM/ -*.su -*.idb -*.pdb - -# Kernel Module Compile Results -*.mod* -*.cmd -.tmp_versions/ -modules.order -Module.symvers -Mkfile.old -dkms.conf - -# ---> esp-idf -# gitignore template for esp-idf, the official development framework for ESP32 -# https://github.com/espressif/esp-idf - -build/ -sdkconfig -sdkconfig.old - +.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/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..3f10b8d --- /dev/null +++ b/platformio.ini @@ -0,0 +1,22 @@ +; 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 + +[env:wemos_d1_mini32] +platform = espressif32 +board = wemos_d1_mini32 +framework = arduino +monitor_speed = 115200 + +lib_deps = + tzapu/WiFiManager@^2.0.16-rc.2 + ayushsharma82/ElegantOTA@^3.1.0 + bblanchon/ArduinoJson@^6.21.3 + +board_build.filesystem = littlefs \ No newline at end of file diff --git a/src/lichessboard.cpp b/src/lichessboard.cpp new file mode 100644 index 0000000..6a221ee --- /dev/null +++ b/src/lichessboard.cpp @@ -0,0 +1,413 @@ +#include "lichessboard.h" + +LichessBoard::LichessBoard() +{ + streaming = false; + lastHeartbeat = 0; + onMoveReceived = nullptr; + onGameStateChanged = nullptr; +} + +void LichessBoard::setToken(String token) +{ + apiToken = token; +} + +void LichessBoard::setMoveCallback(MoveCallback callback) +{ + onMoveReceived = callback; +} + +void LichessBoard::setGameStateCallback(GameStateCallback callback) +{ + onGameStateChanged = callback; +} + +bool LichessBoard::streamGame(String gameId) +{ + if (streaming) + { + stop(); + } + + currentGameId = gameId; + client.setInsecure(); // Pour HTTPS sans certificat + + Serial.println("Connexion à Lichess..."); + if (!client.connect("lichess.org", 443)) + { + Serial.println("Échec connexion"); + return false; + } + + // Requête HTTP pour le stream + String request = "GET /api/board/game/stream/" + gameId + " HTTP/1.1\r\n"; + request += "Host: lichess.org\r\n"; + if (apiToken.length() > 0) + { + request += "Authorization: Bearer " + apiToken + "\r\n"; + } + request += "Accept: application/x-ndjson\r\n"; + request += "Connection: keep-alive\r\n"; + request += "\r\n"; + + client.print(request); + + Serial.println("Requête envoyée, attente réponse..."); + + // Attendre les headers HTTP + unsigned long timeout = millis(); + while (client.connected() && !client.available()) + { + if (millis() - timeout > 5000) + { + Serial.println("Timeout headers"); + client.stop(); + return false; + } + delay(10); + } + + // Lire les headers + bool headersEnded = false; + while (client.available() && !headersEnded) + { + String line = client.readStringUntil('\n'); + if (line == "\r" || line.length() == 0) + { + headersEnded = true; + } + Serial.println("Header: " + line); + } + + streaming = true; + lastHeartbeat = millis(); + Serial.println("Stream démarré!"); + + return true; +} + +bool LichessBoard::streamMyGames() +{ + if (apiToken.length() == 0) + { + Serial.println("Token requis pour streamMyGames"); + return false; + } + + if (streaming) + { + stop(); + } + + client.setInsecure(); + + if (!client.connect("lichess.org", 443)) + { + Serial.println("Échec connexion"); + return false; + } + + String request = "GET /api/board/game/stream HTTP/1.1\r\n"; + request += "Host: lichess.org\r\n"; + request += "Authorization: Bearer " + apiToken + "\r\n"; + request += "Accept: application/x-ndjson\r\n"; + request += "Connection: keep-alive\r\n"; + request += "\r\n"; + + client.print(request); + + // Skip headers + while (client.connected()) + { + String line = client.readStringUntil('\n'); + if (line == "\r" || line.length() == 0) + break; + } + + streaming = true; + lastHeartbeat = millis(); + + return true; +} + +bool LichessBoard::streamEvents() +{ + if (apiToken.length() == 0) + { + Serial.println("Token requis"); + return false; + } + + if (streaming) + { + stop(); + } + + client.setInsecure(); + + if (!client.connect("lichess.org", 443)) + { + Serial.println("Échec connexion"); + return false; + } + + String request = "GET /api/stream/event HTTP/1.1\r\n"; + request += "Host: lichess.org\r\n"; + request += "Authorization: Bearer " + apiToken + "\r\n"; + request += "Accept: application/x-ndjson\r\n"; + request += "Connection: keep-alive\r\n"; + request += "\r\n"; + + client.print(request); + + // Skip headers + while (client.connected()) + { + String line = client.readStringUntil('\n'); + if (line == "\r" || line.length() == 0) + break; + } + + streaming = true; + lastHeartbeat = millis(); + + return true; +} + +void LichessBoard::parseLine(String line) +{ + line.trim(); + + if (line.length() == 0) + { + // Heartbeat (ligne vide) + lastHeartbeat = millis(); + return; + } + + Serial.println("JSON reçu: " + line); + + StaticJsonDocument<256> doc; + DeserializationError error = deserializeJson(doc, line); + + if (error) + { + Serial.print("Erreur JSON: "); + Serial.println(error.c_str()); + return; + } + + // Type d'événement + const char *type = doc["type"]; + + if (strcmp(type, "gameFull") == 0) + { + // État complet de la partie au début + Serial.println("=== Partie complète reçue ==="); + + const char *gameId = doc["id"]; + const char *state = doc["state"]["status"]; + + Serial.print("Game ID: "); + Serial.println(gameId); + Serial.print("État: "); + Serial.println(state); + + if (onGameStateChanged) + { + onGameStateChanged(String(state)); + } + + // Récupérer tous les coups déjà joués + const char *moves = doc["state"]["moves"]; + if (moves && strlen(moves) > 0) + { + Serial.print("Coups joués: "); + Serial.println(moves); + } + + // FEN initial si disponible + const char *initialFen = doc["initialFen"]; + if (initialFen) + { + Serial.print("FEN: "); + Serial.println(initialFen); + } + } + else if (strcmp(type, "gameState") == 0) + { + // Mise à jour de l'état (nouveau coup) + Serial.println("=== Nouveau coup ==="); + + const char *moves = doc["moves"]; + const char *status = doc["status"]; + + Serial.print("Tous les coups: "); + Serial.println(moves); + Serial.print("État: "); + Serial.println(status); + + if (onGameStateChanged) + { + onGameStateChanged(String(status)); + } + + // Extraire le dernier coup + if (moves && strlen(moves) > 0) + { + String allMoves = String(moves); + int lastSpace = allMoves.lastIndexOf(' '); + String lastMove = (lastSpace >= 0) ? allMoves.substring(lastSpace + 1) : allMoves; + + // Format UCI: e2e4 + if (lastMove.length() >= 4) + { + String from = lastMove.substring(0, 2); + String to = lastMove.substring(2, 4); + + Serial.print("Dernier coup: "); + Serial.print(from); + Serial.print(" -> "); + Serial.println(to); + + if (onMoveReceived) + { + onMoveReceived(from, to, lastMove, ""); + } + } + } + } + else if (strcmp(type, "gameStart") == 0) + { + // Nouvelle partie démarrée + JsonObject game = doc["game"]; + const char *gameId = game["id"]; + + Serial.print("Nouvelle partie: "); + Serial.println(gameId); + + if (onGameStateChanged) + { + onGameStateChanged("started"); + } + } + else if (strcmp(type, "gameFinish") == 0) + { + // Partie terminée + JsonObject game = doc["game"]; + const char *gameId = game["id"]; + + Serial.print("Partie terminée: "); + Serial.println(gameId); + + if (onGameStateChanged) + { + onGameStateChanged("finished"); + } + } +} + +void LichessBoard::loop() +{ + if (!streaming) + { + return; + } + + // Vérifier timeout + if (millis() - lastHeartbeat > heartbeatTimeout) + { + Serial.println("Timeout stream!"); + stop(); + return; + } + + // Lire les données disponibles + while (client.available()) + { + String line = client.readStringUntil('\n'); + parseLine(line); + } + + // Vérifier connexion + if (!client.connected()) + { + Serial.println("Connexion perdue"); + stop(); + } +} + +void LichessBoard::stop() +{ + if (client.connected()) + { + client.stop(); + } + streaming = false; + currentGameId = ""; + Serial.println("Stream arrêté"); +} + +bool LichessBoard::isStreaming() +{ + return streaming; +} + +String LichessBoard::getCurrentGameId() +{ + return currentGameId; +} + +bool LichessBoard::getGameState(String gameId, String &fen, String &lastMove) +{ + WiFiClientSecure httpClient; + httpClient.setInsecure(); + + if (!httpClient.connect("lichess.org", 443)) + { + return false; + } + + String request = "GET /game/export/" + gameId + "?moves=true&clocks=false HTTP/1.1\r\n"; + request += "Host: lichess.org\r\n"; + request += "Accept: application/json\r\n"; + request += "Connection: close\r\n"; + request += "\r\n"; + + httpClient.print(request); + + // Skip headers + while (httpClient.connected()) + { + String line = httpClient.readStringUntil('\n'); + if (line == "\r") + break; + } + + // Lire JSON + String json = httpClient.readString(); + httpClient.stop(); + + StaticJsonDocument<4096> doc; + DeserializationError error = deserializeJson(doc, json); + + if (error) + { + return false; + } + + const char *moves = doc["moves"]; + if (moves) + { + lastMove = String(moves); + // Extraire le dernier coup + int lastSpace = lastMove.lastIndexOf(' '); + if (lastSpace >= 0) + { + lastMove = lastMove.substring(lastSpace + 1); + } + } + + return true; +} diff --git a/src/lichessboard.h b/src/lichessboard.h new file mode 100644 index 0000000..04aca9a --- /dev/null +++ b/src/lichessboard.h @@ -0,0 +1,59 @@ +#ifndef LICHESS_BOARD_H +#define LICHESS_BOARD_H + +#include +#include +#include + +// Callback pour chaque coup reçu +typedef void (*MoveCallback)(String from, String to, String san, String fen); +typedef void (*GameStateCallback)(String state); // started, finished, etc. + +class LichessBoard +{ +private: + WiFiClientSecure client; + String apiToken; + String currentGameId; + bool streaming; + String lastLine; + + MoveCallback onMoveReceived; + GameStateCallback onGameStateChanged; + + unsigned long lastHeartbeat; + const unsigned long heartbeatTimeout = 10000; // 10 secondes + + // Parse une ligne JSON du stream + void parseLine(String line); + +public: + LichessBoard(); + + // Configuration + void setToken(String token); + void setMoveCallback(MoveCallback callback); + void setGameStateCallback(GameStateCallback callback); + + // Stream d'une partie spécifique + bool streamGame(String gameId); + + // Stream de vos parties en cours (nécessite token) + bool streamMyGames(); + + // Stream des événements de votre compte + bool streamEvents(); + + // À appeler dans loop() + void loop(); + + // Contrôle + void stop(); + bool isStreaming(); + String getCurrentGameId(); + + // Récupérer l'état actuel d'une partie (non-stream) + bool getGameState(String gameId, String &fen, String &lastMove); +}; + +#endif diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..d7e3ac0 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,121 @@ +#include +#include +#include +#include +#include "LichessBoard.h" + +WebServer server(80); +WiFiManager wm; +LichessBoard lichess; + +String currentGameId = ""; + +// Callback quand un coup est joué +void onMove(String from, String to, String san, String fen) +{ + Serial.println("\n🎯 NOUVEAU COUP:"); + Serial.println(" De: " + from); + Serial.println(" À: " + to); + Serial.println(" Notation: " + san); + + // ICI: Allumer des LEDs, bouger des servos, etc. +} + +// Callback changement d'état +void onGameState(String state) +{ + Serial.println("\n📢 État partie: " + state); +} + +void setup() +{ + Serial.begin(115200); + + // WiFiManager - Portail captif automatique + // Si pas de WiFi configuré, crée un AP "ESP32-Setup" + wm.setConfigPortalTimeout(180); // 3 min timeout + + if (!wm.autoConnect("ESP32-Setup", "12345678")) + { + Serial.println("Échec connexion WiFi"); + ESP.restart(); + } + + Serial.println("WiFi connecté!"); + Serial.print("IP: "); + Serial.println(WiFi.localIP()); + + // Configuration Lichess + lichess.setMoveCallback(onMove); + lichess.setGameStateCallback(onGameState); + + // OPTIONNEL: Token API pour vos parties privées + // Générer sur: https://lichess.org/account/oauth/token + lichess.setToken("lip_ccje7pt8qzmrVT48d1V8"); + + // Interface web + server.on("/", []() + { + String html = ""; + html += "

🎮 ESP32 Lichess Board

"; + + if (lichess.isStreaming()) { + html += "
✅ Stream actif: " + lichess.getCurrentGameId() + "
"; + html += ""; + } else { + html += "
⭕ Aucun stream actif
"; + html += "
"; + html += ""; + html += ""; + html += "
"; + } + + html += "

Exemples d'ID:

"; + html += "
  • Partie TV Lichess: https://lichess.org/abc12345
"; + html += "🔄 OTA Update"; + html += ""; + + server.send(200, "text/html", html); }); + + server.on("/stream", HTTP_POST, []() + { + if (server.hasArg("gameid")) { + currentGameId = server.arg("gameid"); + currentGameId.trim(); + + if (lichess.streamGame(currentGameId)) { + server.sendHeader("Location", "/"); + server.send(303); + } else { + server.send(500, "text/plain", "Erreur démarrage stream"); + } + } else { + server.send(400, "text/plain", "ID manquant"); + } }); + + server.on("/stop", []() + { + lichess.stop(); + server.sendHeader("Location", "/"); + server.send(303); }); + + // ElegantOTA - Interface web élégante pour OTA + ElegantOTA.begin(&server); + server.begin(); + Serial.println("HTTP server + OTA démarrés"); + Serial.println("Ouvrez: http://" + WiFi.localIP().toString()); + lichess.streamMyGames(); +} + +void loop() +{ + server.handleClient(); + ElegantOTA.loop(); + lichess.loop(); // ⚠️ IMPORTANT: appeler dans loop() +} 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