Compare commits

2 Commits
main ... dev

Author SHA1 Message Date
Pascal RIGAUD
6ee40225c0 WIP amélioratuib de la com avec lichess 2026-02-09 22:30:38 +01:00
Pascal RIGAUD
1e258f6805 WIP 2026-02-09 07:38:28 +01:00
9 changed files with 933 additions and 62 deletions

67
.gitignore vendored
View File

@@ -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

10
.vscode/extensions.json vendored Normal file
View File

@@ -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"
]
}

37
include/README Normal file
View File

@@ -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

46
lib/README Normal file
View File

@@ -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 <Foo.h>
#include <Bar.h>
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

22
platformio.ini Normal file
View File

@@ -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

452
src/lichessboard.cpp Normal file
View File

@@ -0,0 +1,452 @@
#include "LichessBoard.h"
LichessBoard::LichessBoard(const char *token)
: apiToken(token), streaming(false), lastHeartbeat(0)
{
client.setInsecure();
}
void LichessBoard::setMoveCallback(std::function<void(String, String, String, String)> callback)
{
onMoveReceived = callback;
}
void LichessBoard::setGameStateCallback(std::function<void(String)> callback)
{
onGameStateChanged = callback;
}
bool LichessBoard::connectToGame(const char *gameId)
{
currentGameId = String(gameId);
Serial.println("Connexion à Lichess...");
if (!client.connect("lichess.org", 443))
{
Serial.println("❌ Échec connexion SSL");
return false;
}
String request = String("GET /api/board/game/stream/") + gameId + " HTTP/1.1\r\n" +
"Host: lichess.org\r\n" +
"Authorization: Bearer " + apiToken + "\r\n" +
"Connection: keep-alive\r\n\r\n";
client.print(request);
Serial.println("Requête envoyée, attente réponse...");
// Lire les headers HTTP
while (client.connected())
{
String line = client.readStringUntil('\n');
Serial.println("Header: " + line);
if (line == "\r" || line.length() == 0)
{
break;
}
}
streaming = true;
lastHeartbeat = millis();
Serial.println("Stream démarré!");
return true;
}
void LichessBoard::loop()
{
if (!streaming)
{
return;
}
if (millis() - lastHeartbeat > heartbeatTimeout)
{
Serial.println("⏱️ Timeout stream!");
stop();
return;
}
while (client.available())
{
String line = client.readStringUntil('\n');
line.trim();
// ✅ Ligne vide = heartbeat
if (line.length() == 0)
{
lastHeartbeat = millis();
Serial.println("💓 Heartbeat");
continue;
}
// ✅ Ignorer chunks HTTP
if (isHexString(line))
{
Serial.println("🔸 Chunk HTTP ignoré: " + line);
continue;
}
// ✅ Traiter JSON
if (line[0] == '{')
{
parseLine(line);
}
else
{
Serial.println("⚠️ Ligne ignorée: " + line);
}
}
if (!client.connected())
{
Serial.println("❌ Connexion perdue");
stop();
}
}
void LichessBoard::stop()
{
if (streaming)
{
client.stop();
streaming = false;
currentGameId = "";
Serial.println("⏹️ Stream arrêté");
}
}
LichessBoard::Move LichessBoard::parseMove(const String &uci)
{
Move move;
move.uci = uci;
move.from = uci.substring(0, 2);
move.to = uci.substring(2, 4);
move.isCastling = false;
move.isKingSide = false;
move.display = uci; // Par défaut, afficher l'UCI
// Détecter les roques
if ((move.from == "e1" || move.from == "e8") &&
(move.to == "g1" || move.to == "g8" ||
move.to == "c1" || move.to == "c8"))
{
move.isCastling = true;
if (move.to[0] == 'g')
{
// Petit roque
move.isKingSide = true;
move.display = "O-O";
Serial.println("🏰 PETIT ROQUE (O-O)");
}
else
{
// Grand roque
move.isKingSide = false;
move.display = "O-O-O";
Serial.println("🏰 GRAND ROQUE (O-O-O)");
}
}
return move;
}
void LichessBoard::parseLine(const String &line)
{
lastHeartbeat = millis();
StaticJsonDocument<2048> doc;
DeserializationError error = deserializeJson(doc, line);
if (error)
{
Serial.print("❌ Erreur JSON: ");
Serial.println(error.c_str());
return;
}
Serial.print("📦 JSON reçu (");
Serial.print(line.length());
Serial.println(" bytes)");
if (!doc.containsKey("type"))
{
Serial.println("⚠️ Pas de champ 'type'");
return;
}
const char *type = doc["type"];
Serial.print("✅ Type: ");
Serial.println(type);
if (strcmp(type, "gameFull") == 0)
{
Serial.println("=== Partie complète reçue ===");
if (doc.containsKey("id"))
{
const char *gameId = doc["id"];
if (gameId)
{
Serial.print("Game ID: ");
Serial.println(gameId);
}
}
if (doc["state"].containsKey("status"))
{
const char *state = doc["state"]["status"];
if (state)
{
Serial.print("État: ");
Serial.println(state);
Serial.println();
if (onGameStateChanged)
{
onGameStateChanged(String(state));
}
}
}
Serial.println("📢 État partie: " + String(doc["state"]["status"].as<const char *>()));
if (doc["state"].containsKey("moves"))
{
String moves = doc["state"]["moves"].as<String>();
Serial.print("Coups joués: ");
Serial.println(moves);
}
if (doc.containsKey("initialFen"))
{
Serial.print("FEN: ");
Serial.println(doc["initialFen"].as<String>());
}
else
{
Serial.println("FEN: startpos");
}
}
else if (strcmp(type, "gameState") == 0)
{
Serial.println("=== Nouveau coup ===");
if (doc.containsKey("moves"))
{
String allMoves = doc["moves"].as<String>();
Serial.print("Tous les coups: ");
Serial.println(allMoves);
if (allMoves.length() > 0)
{
int lastSpace = allMoves.lastIndexOf(' ');
String lastMoveUci = (lastSpace >= 0) ? allMoves.substring(lastSpace + 1) : allMoves;
if (lastMoveUci.length() >= 4)
{
// ✅ Parser le coup
lastMove = parseMove(lastMoveUci);
// Affichage avec notation standard
Serial.print("🎯 Coup: ");
Serial.println(lastMove.display);
if (lastMove.isCastling)
{
Serial.print(" Détails: ");
Serial.print(lastMove.from);
Serial.print(" -> ");
Serial.print(lastMove.to);
Serial.print(" (");
Serial.print(lastMove.isKingSide ? "petit roque" : "grand roque");
Serial.println(")");
}
Serial.println("\n🎯 NOUVEAU COUP:");
Serial.println(" De: " + lastMove.from);
Serial.println(" À: " + lastMove.to);
Serial.println(" Notation: " + lastMove.display);
if (doc.containsKey("status"))
{
Serial.print("État: ");
Serial.println(doc["status"].as<String>());
Serial.println();
}
if (onMoveReceived)
{
String fen = doc.containsKey("fen") ? doc["fen"].as<String>() : "";
onMoveReceived(lastMove.from, lastMove.to, lastMove.uci, fen);
}
}
}
}
if (doc.containsKey("status"))
{
const char *status = doc["status"];
Serial.println("📢 État partie: " + String(status));
if (onGameStateChanged)
{
onGameStateChanged(String(status));
}
}
}
else if (strcmp(type, "chatLine") == 0)
{
Serial.println("💬 Message chat reçu");
}
else
{
Serial.println("⚠️ Type inconnu: " + String(type));
}
}
bool LichessBoard::isHexString(const String &str)
{
if (str.length() == 0 || str.length() > 8)
return false;
for (char c : str)
{
if (!isxdigit(c))
return false;
}
return true;
}
bool LichessBoard::streamCurrentGame()
{
Serial.println("🔍 Recherche de la partie en cours...");
WiFiClientSecure tempClient;
tempClient.setInsecure();
if (!tempClient.connect("lichess.org", 443))
{
Serial.println("❌ Échec connexion pour récupérer les parties");
return false;
}
// Requête pour obtenir les parties en cours
String request = "GET /api/account/playing HTTP/1.1\r\n" +
String("Host: lichess.org\r\n") +
"Authorization: Bearer " + apiToken + "\r\n" +
"Accept: application/json\r\n" +
"Connection: close\r\n\r\n";
tempClient.print(request);
// Lire les headers
while (tempClient.connected())
{
String line = tempClient.readStringUntil('\n');
if (line == "\r" || line.length() == 0)
{
break;
}
}
// Lire le body JSON
String jsonResponse = "";
while (tempClient.available())
{
jsonResponse += tempClient.readString();
}
tempClient.stop();
Serial.println("📥 Réponse API:");
Serial.println(jsonResponse);
// ⚠️ ALLOCATION DYNAMIQUE SUR LE HEAP
DynamicJsonDocument *doc = new DynamicJsonDocument(4096);
DeserializationError error = deserializeJson(*doc, jsonResponse);
if (error)
{
Serial.print("❌ Erreur parsing JSON: ");
Serial.println(error.c_str());
return false;
}
// Vérifier s'il y a des parties en cours
if (!(*doc).containsKey("nowPlaying") || (*doc)["nowPlaying"].size() == 0)
{
Serial.println("❌ Aucune partie en cours");
return false;
}
// Récupérer la première partie (la plus récente)
JsonObject firstGame = (*doc)["nowPlaying"][0];
const char *gameId = firstGame["gameId"];
if (!gameId)
{
Serial.println("❌ Impossible de récupérer l'ID de partie");
return false;
}
Serial.print("✅ Partie trouvée: ");
Serial.println(gameId);
// Afficher infos supplémentaires
if (firstGame.containsKey("opponent"))
{
const char *opponent = firstGame["opponent"]["username"];
Serial.print(" Adversaire: ");
Serial.println(opponent);
}
if (firstGame.containsKey("isMyTurn"))
{
bool myTurn = firstGame["isMyTurn"];
Serial.print(" Mon tour: ");
Serial.println(myTurn ? "Oui" : "Non");
}
// Se connecter au stream de cette partie
return connectToGame(gameId);
}
bool LichessBoard::streamMyGames()
{
Serial.println("🎮 Démarrage du stream de toutes mes parties...");
if (!client.connect("lichess.org", 443))
{
Serial.println("❌ Échec connexion SSL");
return false;
}
String request = "GET /api/board/game/stream/incoming HTTP/1.1\r\n" +
String("Host: lichess.org\r\n") +
"Authorization: Bearer " + apiToken + "\r\n" +
"Connection: keep-alive\r\n\r\n";
client.print(request);
Serial.println("📡 Stream de toutes les parties démarré");
// Lire les headers
while (client.connected())
{
String line = client.readStringUntil('\n');
Serial.println("Header: " + line);
if (line == "\r" || line.length() == 0)
{
break;
}
}
streaming = true;
lastHeartbeat = millis();
Serial.println("✅ Stream actif!");
return true;
}

55
src/lichessboard.h Normal file
View File

@@ -0,0 +1,55 @@
#ifndef LICHESS_BOARD_H
#define LICHESS_BOARD_H
#include <Arduino.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
class LichessBoard
{
public:
struct Move
{
String from;
String to;
String uci; // Notation UCI (ex: e1g1)
String display; // Notation affichage (ex: O-O)
bool isCastling;
bool isKingSide;
};
LichessBoard(const char *token);
void setMoveCallback(std::function<void(String, String, String, String)> callback);
void setGameStateCallback(std::function<void(String)> callback);
bool connectToGame(const char *gameId);
void loop();
void stop();
bool isStreaming() const { return streaming; }
String getCurrentGameId() const { return currentGameId; }
Move getLastMove() const { return lastMove; }
bool streamCurrentGame(); // Stream la partie en cours
bool streamMyGames(); // Stream toutes les parties en cours
private:
WiFiClientSecure client;
const char *apiToken;
bool streaming;
String currentGameId;
Move lastMove;
unsigned long lastHeartbeat;
const unsigned long heartbeatTimeout = 35000;
std::function<void(String, String, String, String)> onMoveReceived;
std::function<void(String)> onGameStateChanged;
void parseLine(const String &line);
bool isHexString(const String &str);
Move parseMove(const String &uci);
};
#endif

295
src/main.cpp Normal file
View File

@@ -0,0 +1,295 @@
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include "LichessBoard.h"
const char *ssid = "home";
const char *password = "0123456789";
const char *lichessToken = "lip_ccje7pt8qzmrVT48d1V8";
WebServer server(80);
LichessBoard lichess(lichessToken);
String lastMoveDisplay = "";
String currentGameId = "";
String gameStatus = "Aucune partie";
// ========== PAGE WEB ==========
const char *htmlPage = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lichess Board Stream</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
margin: 0;
}
.container {
max-width: 600px;
margin: 0 auto;
background: rgba(255,255,255,0.1);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 30px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
h1 {
text-align: center;
margin-bottom: 30px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.info-box {
background: rgba(255,255,255,0.2);
padding: 15px;
border-radius: 10px;
margin: 15px 0;
}
.btn {
background: #4CAF50;
color: white;
border: none;
padding: 15px 30px;
border-radius: 10px;
font-size: 16px;
cursor: pointer;
width: 100%;
margin: 10px 0;
transition: background 0.3s;
}
.btn:hover {
background: #45a049;
}
.btn-stop {
background: #f44336;
}
.btn-stop:hover {
background: #da190b;
}
.status {
text-align: center;
font-size: 24px;
margin: 20px 0;
}
input {
width: 100%;
padding: 12px;
border-radius: 8px;
border: none;
margin: 10px 0;
font-size: 16px;
box-sizing: border-box;
}
.last-move {
font-size: 48px;
text-align: center;
margin: 20px 0;
text-shadow: 3px 3px 6px rgba(0,0,0,0.5);
}
</style>
</head>
<body>
<div class="container">
<h1> Lichess Board Stream</h1>
<div class="status">
<div id="streaming"> En attente</div>
</div>
<div class="last-move" id="lastMove">--</div>
<div class="info-box">
<strong>Partie:</strong> <span id="gameId">Aucune</span><br>
<strong>État:</strong> <span id="status">--</span>
</div>
<button class="btn" onclick="streamCurrent()">
🎮 Streamer ma partie en cours
</button>
<div style="margin: 20px 0; text-align: center; color: rgba(255,255,255,0.7);">
OU
</div>
<input type="text" id="gameIdInput" placeholder="ID de partie (ex: abc12345)">
<button class="btn" onclick="streamSpecific()">
🎯 Streamer cette partie
</button>
<button class="btn btn-stop" onclick="stopStream()">
Arrêter le stream
</button>
</div>
<script>
function updateInfo() {
fetch('/json')
.then(r => r.json())
.then(data => {
document.getElementById('streaming').innerHTML =
data.streaming ? '🔴 EN DIRECT' : ' En attente';
document.getElementById('lastMove').innerText =
data.lastMove || '--';
document.getElementById('gameId').innerText =
data.gameId || 'Aucune';
document.getElementById('status').innerText =
data.status || '--';
});
}
function streamCurrent() {
fetch('/stream-current')
.then(r => r.text())
.then(msg => alert(msg));
}
function streamSpecific() {
const gameId = document.getElementById('gameIdInput').value;
if (!gameId) {
alert('Veuillez entrer un ID de partie');
return;
}
fetch('/start?gameId=' + gameId)
.then(r => r.text())
.then(msg => alert(msg));
}
function stopStream() {
fetch('/stop')
.then(r => r.text())
.then(msg => alert(msg));
}
setInterval(updateInfo, 1000);
updateInfo();
</script>
</body>
</html>
)rawliteral";
void setup()
{
Serial.begin(115200);
Serial.println("\n\n=== DÉMARRAGE ===");
// Connexion WiFi
WiFi.begin(ssid, password);
Serial.print("Connexion WiFi");
while (WiFi.status() != WL_CONNECTED)
{
delay(500);
Serial.print(".");
}
Serial.println("\n✅ WiFi connecté!");
Serial.print("IP: ");
Serial.println(WiFi.localIP());
// ========== CALLBACKS LICHESS ==========
lichess.setMoveCallback([](String from, String to, String uci, String fen)
{
LichessBoard::Move move = lichess.getLastMove();
lastMoveDisplay = move.display;
Serial.println("\n━━━━━━━━━━━━━━━━━━━━━━━━");
Serial.println("🔔 NOUVEAU COUP REÇU!");
Serial.println("━━━━━━━━━━━━━━━━━━━━━━━━");
Serial.println(" Notation: " + move.display);
if (move.isCastling) {
Serial.println(" ⚠️ C'est un ROQUE!");
if (move.isKingSide) {
Serial.println(" Type: Petit roque (O-O)");
if (from == "e1") {
Serial.println(" Blancs: ♔ e1→g1, ♖ h1→f1");
} else {
Serial.println(" Noirs: ♚ e8→g8, ♜ h8→f8");
}
} else {
Serial.println(" Type: Grand roque (O-O-O)");
if (from == "e1") {
Serial.println(" Blancs: ♔ e1→c1, ♖ a1→d1");
} else {
Serial.println(" Noirs: ♚ e8→c8, ♜ a8→d8");
}
}
} else {
Serial.println(" Type: Coup normal");
Serial.println(" De: " + from + " → À: " + to);
}
Serial.println("━━━━━━━━━━━━━━━━━━━━━━━━\n"); });
lichess.setGameStateCallback([](String state)
{
gameStatus = state;
Serial.println("🎮 État partie: " + state); });
// ========== ROUTES WEB ==========
// Page principale
server.on("/", HTTP_GET, []()
{ server.send(200, "text/html", htmlPage); });
// API JSON
server.on("/json", HTTP_GET, []()
{
StaticJsonDocument<512> doc;
doc["streaming"] = lichess.isStreaming();
doc["gameId"] = lichess.getCurrentGameId();
doc["lastMove"] = lastMoveDisplay;
doc["status"] = gameStatus;
doc["connected"] = WiFi.status() == WL_CONNECTED;
doc["uptime"] = millis() / 1000;
String json;
serializeJson(doc, json);
server.send(200, "application/json", json); });
// ⭐ NOUVELLE ROUTE: Stream partie en cours
server.on("/stream-current", HTTP_GET, []()
{
if (lichess.streamCurrentGame()) {
server.send(200, "text/plain", "✅ Stream démarré sur votre partie en cours");
} else {
server.send(500, "text/plain", "❌ Aucune partie en cours ou erreur");
} });
// Stream partie spécifique
server.on("/start", HTTP_GET, []()
{
if (server.hasArg("gameId")) {
currentGameId = server.arg("gameId");
if (lichess.connectToGame(currentGameId.c_str())) {
server.send(200, "text/plain", "✅ Stream démarré: " + currentGameId);
} else {
server.send(500, "text/plain", "❌ Erreur connexion");
}
} else {
server.send(400, "text/plain", "❌ Paramètre gameId manquant");
} });
// Arrêter le stream
server.on("/stop", HTTP_GET, []()
{
lichess.stop();
server.send(200, "text/plain", "⏹️ Stream arrêté"); });
server.begin();
Serial.println("🌐 Serveur web démarré");
Serial.println("👉 Ouvrez http://" + WiFi.localIP().toString());
}
void loop()
{
server.handleClient();
lichess.loop();
}

11
test/README Normal file
View File

@@ -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