L'antenne, d'abord
La XIAO ESP32S3 est livrée avec une petite antenne Wi-Fi à clipser sur son connecteur. Sans elle, la portée tombe à quelques centimètres et le réseau peut ne même pas apparaître.
ESP32, Wi-Fi et WebSocket
Créer un point d'accès Wi-Fi sur un ESP32, servir une page web, puis échanger des messages dans les deux sens avec le navigateur grâce à un WebSocket.
Logiciels :
À la fin de ce tutoriel, la XIAO crée son propre réseau Wi-Fi. Un téléphone qui s’y connecte ouvre une page web servie par la carte, avec deux boutons qui allument et éteignent la LED de la XIAO. Dans l’autre sens, la carte envoie en continu la position du joystick, que la page affiche en direct.
C’est le squelette réseau du projet Snake : une page servie par la console, et une ligne ouverte dans les deux sens pour les messages du jeu. Les notions (point d’accès, HTTP, WebSocket, protocole) sont expliquées dans le concept Wi-Fi, HTTP et WebSocket.
Ce tutoriel part d’un projet PlatformIO neuf : le code du Pong n’est pas nécessaire. Le joystick reste câblé comme dans l’atelier (axe X sur D0).
Le Wi-Fi (WiFi.h) et le serveur web (WebServer.h) font partie du core Arduino-ESP32 : rien à installer. Le serveur WebSocket vient de la bibliothèque WebSockets de Links2004, à déclarer dans platformio.ini.
[env:seeed_xiao_esp32s3]
platform = espressif32
board = seeed_xiao_esp32s3
framework = arduino
monitor_speed = 115200
lib_deps =
links2004/WebSockets@^2.4.0
WiFi.softAP() transforme la carte en point d’accès : elle crée un réseau avec le nom (SSID) et le mot de passe donnés, et prend l’adresse 192.168.4.1. Choisis un nom unique dans la salle, sinon les réseaux des autres binômes se confondront avec le tien. Le mot de passe doit faire au moins 8 caractères, sinon le point d’accès ne démarre pas.
#include <Arduino.h>
#include <WiFi.h>
constexpr char WIFI_SSID[] = "Console-Binome1"; // unique dans la salle
constexpr char WIFI_PASSWORD[] = "snake2026"; // 8 caractères minimum
void setup() {
Serial.begin(115200);
delay(1500); // laisse au moniteur série le temps de se rouvrir après le reset
WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
Serial.print("Point d'accès prêt, adresse : ");
Serial.println(WiFi.softAPIP());
}
void loop() {}
Le serveur web écoute sur le port 80. On lui indique quoi répondre pour chaque adresse demandée : ici, pour la racine /, la page du joueur.
La page est écrite directement dans le code, dans une chaîne brute (raw string literal) : tout ce qui se trouve entre les deux marqueurs rawliteral est recopié tel quel, guillemets et retours à la ligne compris. Pour l’instant, la page se contente d’un titre et d’un texte d’état ; le JavaScript arrive à l’étape suivante.
server.handleClient() doit être appelé à chaque tour de loop() : c’est là que le serveur regarde si une requête est arrivée et y répond.
#include <WebServer.h>
WebServer server(80);
const char PAGE[] = R"rawliteral(
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Console XIAO</title>
</head>
<body>
<h1>Console XIAO</h1>
<p>La page est servie par la carte.</p>
</body>
</html>
)rawliteral";
void handleRoot() {
server.send(200, "text/html", PAGE); // 200 = « OK » en HTTP
}
Dans setup(), après le point d’accès :
server.on("/", handleRoot);
server.begin();
Et dans loop() :
void loop() {
server.handleClient();
}
Le serveur WebSocket écoute sur un autre port, le 81. Comme le serveur web, il doit être servi à chaque tour de loop() avec webSocket.loop().
Les événements (un navigateur se connecte, se déconnecte, envoie un message) arrivent par une fonction de rappel : onWebSocketEvent(), que la bibliothèque appelle elle-même. Chaque navigateur connecté reçoit un numéro de client (0, 1, 2…).
Côté page, quelques lignes de JavaScript ouvrent la connexion vers ws://192.168.4.1:81/ et affichent son état. Si la connexion tombe, la page retente toute seule une seconde plus tard.
#include <WebSocketsServer.h>
WebSocketsServer webSocket(81);
void onWebSocketEvent(uint8_t client, WStype_t type, uint8_t* payload, size_t length) {
switch (type) {
case WStype_CONNECTED:
Serial.printf("Client %u connecté\n", client);
break;
case WStype_DISCONNECTED:
Serial.printf("Client %u déconnecté\n", client);
break;
case WStype_TEXT:
Serial.printf("Client %u : %.*s\n", client, (int)length, (const char*)payload);
break;
default:
break;
}
}
Dans setup() :
webSocket.begin();
webSocket.onEvent(onWebSocketEvent);
Dans loop(), à côté de server.handleClient() :
webSocket.loop();
Remplace le <body> de la page par celui-ci :
<body>
<h1>Console XIAO</h1>
<p>Liaison : <strong id="etat">connexion...</strong></p>
<script>
let ws;
function connecter() {
ws = new WebSocket("ws://" + location.hostname + ":81/");
ws.onopen = () => {
document.getElementById("etat").textContent = "connectée";
};
ws.onclose = () => {
document.getElementById("etat").textContent = "perdue, nouvel essai...";
setTimeout(connecter, 1000); // reconnexion automatique
};
}
connecter();
</script>
</body>
location.hostname reprend l’adresse depuis laquelle la page a été chargée (192.168.4.1) : la page n’a pas besoin de la connaître à l’avance.
Ajoutons deux boutons qui allument et éteignent la LED de la XIAO. Il faut d’abord fixer le protocole : le navigateur envoie le texte LED:1 pour allumer et LED:0 pour éteindre. Tout autre message est ignoré.
Dans la fonction de rappel, on fait le minimum : on vérifie le message et on mémorise la commande. C’est loop() qui l’applique ensuite, comme pour une interruption. Ainsi, toute la logique reste au même endroit, dans la boucle principale.
int ledCommand = -1; // -1 : aucune commande en attente
// dans onWebSocketEvent(), remplace le case WStype_TEXT :
case WStype_TEXT:
// Message attendu : "LED:0" ou "LED:1", rien d'autre
if (length == 5 && memcmp(payload, "LED:", 4) == 0) {
if (payload[4] == '1') ledCommand = 1;
if (payload[4] == '0') ledCommand = 0;
}
break;
Dans setup(), pinMode(LED_BUILTIN, OUTPUT);, puis dans loop() :
if (ledCommand != -1) {
digitalWrite(LED_BUILTIN, ledCommand == 1 ? LOW : HIGH); // LED de la XIAO : LOW l'allume
ledCommand = -1;
}
Côté page, deux boutons et une fonction qui n’envoie que si la liaison est ouverte :
<button onclick="envoyer('LED:1')">Allumer</button>
<button onclick="envoyer('LED:0')">Éteindre</button>
function envoyer(message) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
}
(La fonction envoyer() se place dans le <script>, à côté de connecter().)
Dans l’autre sens, la carte envoie la position du joystick sous la forme JOY:2048. Deux précautions évitent de saturer la liaison :
millis() (jamais par delay()) ;broadcastTXT() envoie le message à tous les navigateurs connectés.
constexpr int PIN_JOYSTICK_X = D0;
constexpr uint32_t SEND_PERIOD_MS = 100; // 10 messages par seconde au plus
void sendJoystick() {
static uint32_t lastSendMs = 0;
static int lastSentValue = -1000;
uint32_t nowMs = millis();
if (nowMs - lastSendMs < SEND_PERIOD_MS) {
return;
}
lastSendMs = nowMs;
int x = analogRead(PIN_JOYSTICK_X);
if (abs(x - lastSentValue) <= 40) {
return; // pas de vrai changement
}
lastSentValue = x;
char message[16];
snprintf(message, sizeof(message), "JOY:%d", x);
webSocket.broadcastTXT(message);
}
Appelle sendJoystick() dans loop(). Côté page, un paragraphe pour la valeur, et le traitement des messages reçus dans connecter() :
<p>Joystick : <strong id="joy">?</strong></p>
ws.onmessage = (event) => {
const [type, valeur] = event.data.split(":");
if (type === "JOY") {
document.getElementById("joy").textContent = valeur;
}
};
Dans un jeu, le réseau s’ajoute à la game loop sans la modifier :
server.handleClient() et webSocket.loop() s’appellent à chaque tour de loop(), avant le test de cadence des images ;void loop() {
// Réseau : à chaque tour, sans attendre
server.handleClient();
webSocket.loop();
// Game loop : une image toutes les FRAME_TIME_MS
static uint32_t lastFrameMs = 0;
uint32_t nowMs = millis();
if (nowMs - lastFrameMs < FRAME_TIME_MS) {
return;
}
lastFrameMs = nowMs;
InputState input = inputRead(); // boutons, joystick ET messages reçus
gameUpdate(game, input, nowMs); // règles, validation des demandes
rendererDraw(game); // écran de la console
networkSendUpdates(game); // ce qui a changé, vers le navigateur
}
Le nombre de navigateurs connectés s’obtient avec webSocket.connectedClients() : de quoi attendre un deuxième joueur avant de lancer la partie, ou mettre le jeu en pause s’il disparaît.
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
constexpr char WIFI_SSID[] = "Console-Binome1";
constexpr char WIFI_PASSWORD[] = "snake2026";
constexpr int PIN_JOYSTICK_X = D0;
constexpr uint32_t SEND_PERIOD_MS = 100;
WebServer server(80);
WebSocketsServer webSocket(81);
int ledCommand = -1; // -1 : aucune commande en attente
const char PAGE[] = R"rawliteral(
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Console XIAO</title>
</head>
<body>
<h1>Console XIAO</h1>
<p>Liaison : <strong id="etat">connexion...</strong></p>
<p>Joystick : <strong id="joy">?</strong></p>
<button onclick="envoyer('LED:1')">Allumer</button>
<button onclick="envoyer('LED:0')">Éteindre</button>
<script>
let ws;
function connecter() {
ws = new WebSocket("ws://" + location.hostname + ":81/");
ws.onopen = () => {
document.getElementById("etat").textContent = "connectée";
};
ws.onclose = () => {
document.getElementById("etat").textContent = "perdue, nouvel essai...";
setTimeout(connecter, 1000);
};
ws.onmessage = (event) => {
const [type, valeur] = event.data.split(":");
if (type === "JOY") {
document.getElementById("joy").textContent = valeur;
}
};
}
function envoyer(message) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
}
connecter();
</script>
</body>
</html>
)rawliteral";
void handleRoot() {
server.send(200, "text/html", PAGE);
}
void onWebSocketEvent(uint8_t client, WStype_t type, uint8_t* payload, size_t length) {
switch (type) {
case WStype_CONNECTED:
Serial.printf("Client %u connecté\n", client);
break;
case WStype_DISCONNECTED:
Serial.printf("Client %u déconnecté\n", client);
break;
case WStype_TEXT:
if (length == 5 && memcmp(payload, "LED:", 4) == 0) {
if (payload[4] == '1') ledCommand = 1;
if (payload[4] == '0') ledCommand = 0;
}
break;
default:
break;
}
}
void sendJoystick() {
static uint32_t lastSendMs = 0;
static int lastSentValue = -1000;
uint32_t nowMs = millis();
if (nowMs - lastSendMs < SEND_PERIOD_MS) {
return;
}
lastSendMs = nowMs;
int x = analogRead(PIN_JOYSTICK_X);
if (abs(x - lastSentValue) <= 40) {
return;
}
lastSentValue = x;
char message[16];
snprintf(message, sizeof(message), "JOY:%d", x);
webSocket.broadcastTXT(message);
}
void setup() {
Serial.begin(115200);
delay(1500);
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, HIGH); // LED éteinte (logique inversée)
analogReadResolution(12);
WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
Serial.print("Point d'accès prêt, adresse : ");
Serial.println(WiFi.softAPIP());
server.on("/", handleRoot);
server.begin();
webSocket.begin();
webSocket.onEvent(onWebSocketEvent);
}
void loop() {
server.handleClient();
webSocket.loop();
if (ledCommand != -1) {
digitalWrite(LED_BUILTIN, ledCommand == 1 ? LOW : HIGH);
ledCommand = -1;
}
sendJoystick();
}
La console crée son réseau Wi-Fi et sert sa propre page. Le téléphone pilote la LED de la carte, la carte envoie au téléphone la position du joystick, en temps réel et dans les deux sens. La page se reconnecte seule si la carte redémarre, et la carte ignore les messages invalides.
include/page.h pour alléger main.cpp, ou dans le système de fichiers de la carte (LittleFS) quand elle grossit.<canvas>, et la mettre à jour à partir des messages reçus.JSON.parse() côté page).