L'holo-écran s'allume
Chevalier du câble DupontApprenti Sith du câble Dupont ★★ 📟 « Aidez-moi, Nathan Kenobi. Vous êtes mon seul écran. »📟 Escale facultative. Prédiction : tu vas vouloir regarder.
📡 Transmission de l'Alliance · R2-DuPont
« Une escale qui ne figure pas exactement dans le plan de vol : un holo-écran, des données à afficher. Kaminoled n'est pas obligatoire pour poursuivre la mission, mais un vrai explorateur ne refuse jamais une planète bonus. »
🛰️ Haut commandement · Opération Contre-Circuit
« Un signal secondaire vient d'apparaître. Cette planète ne figure pas parmi les sept objectifs principaux, mais elle abrite un holo-écran susceptible de fournir des informations supplémentaires. Statut : escale facultative. Prédiction : tu peux continuer sans t'y rendre… mais tu sais très bien que tu vas vouloir regarder. »
Objectif : brancher un petit écran OLED à l'ESP32, comprendre comment il communique (le bus I2C), y afficher du texte et des dessins, puis en faire le tableau de bord de la manette : joysticks, gâchettes, boutons et batterie en direct, un radar animé, et une page d'infos.
🧭 Notions de la mission : Le bus I2C · Anatomie d'un programme · Tableaux, boucles, fonctions · Le temps
Équipement de mission¶
🧰 Matériel
- ESP32 sur son support + câble USB de données
- Écran OLED 0,96″ (le 2ᵉ pour le défi bonus)
- Plaque d'essai + 4 câbles Dupont
- Manette PS4 chargée
💻 Logiciels
- Arduino IDE 2 + cartes « ESP32 + Bluepad32 »
- Bibliothèque Adafruit SSD1306 (et ses compagnes)
🌐 Connexion internet
Oui : pour installer les bibliothèques
🔓 Missions à réussir avant
Mission 1 (la manette)
🛠️ Outils
Aucun
⏱️ Durée
1 h
Comment ça marche ?¶
L'écran fait 128 pixels de large et 64 de haut : 8 192 petits points, chacun allumé ou éteint. Il est de type OLED : chaque pixel produit sa propre lumière, sans éclairage derrière, d'où son noir parfait et son contraste éclatant (tout est expliqué sur la page de l'écran OLED).
Pour lui parler, l'ESP32 utilise le bus I2C : seulement deux fils de communication, SDA (les données) et SCL (l'horloge), sur lesquels plusieurs appareils peuvent se brancher, chacun avec son adresse. Le nôtre répond en général à l'adresse 0x3C. Le fonctionnement est détaillé dans la fiche Le bus I2C.
Côté programme, on ne dessine pas directement sur l'écran : on dessine dans une image en mémoire, puis display() l'envoie d'un coup. Comme au cinéma : on prépare chaque image, puis on la projette.
display().Étape 1 · Installer les bibliothèques¶
- Croquis → Inclure une bibliothèque → Gérer les bibliothèques…
- Chercher Adafruit SSD1306.
- Installer « Adafruit SSD1306 » par Adafruit. L'IDE propose d'installer aussi ses compagnes (Adafruit GFX Library, Adafruit BusIO) : répondre « Tout installer ».
Étape 2 · Câbler (USB débranché !)¶
Le module a 4 broches, dont les noms sont imprimés au-dessus : GND, VCC, SCL et SDA. Leur ordre varie d'un modèle à l'autre : lis bien les noms avant de brancher.
| Broche de l'écran | ESP32 (connecteurs du support) | Rôle |
|---|---|---|
| VCC | 3V3 | l'alimentation, en 3,3 V |
| GND | GND | la masse |
| SCL | D22 | l'horloge du bus I2C |
| SDA | D21 | les données du bus I2C |
- Débrancher l'USB.
- Planter l'écran sur la plaque d'essai, une broche par colonne.
- Relier chaque broche au support avec un câble Dupont, d'après le tableau.
- Vérifier deux fois VCC et GND : inversés, ils peuvent abîmer l'écran.
D21 et D22 sont maintenant pris
Le défi « piste d'atterrissage » de la mission 0 utilisait aussi D21 et D22 pour deux de ses LEDs. Pour avoir la piste et l'écran en même temps, déplace ces deux LEDs sur D23 et D4, deux broches libres (et change le tableau LEDS[] du programme en conséquence).
🎓 Académie : Le bus I2C · La tension
Étape 3 · Le scanner I2C : qui est là ?¶
Avant d'afficher quoi que ce soit, on vérifie que l'ESP32 « voit » l'écran. Ce petit programme appelle toutes les adresses possibles, une par une, et note celles qui répondent. C'est comme frapper à toutes les portes d'un couloir.
- Nouveau croquis, coller le programme, enregistrer sous mission1bis_scanner, téléverser.
- Ouvrir le moniteur série à 115200 bauds.
Le programme · cliquer pour l'afficher
// Mission 1 bis · Le scanner I2C : qui est branché sur le bus ?
#include <Wire.h>
void setup() {
Serial.begin(115200);
Wire.begin(21, 22); // SDA sur D21, SCL sur D22
delay(500);
Serial.println("Recherche des appareils I2C...");
int trouves = 0;
for (int adresse = 1; adresse < 127; adresse++) {
Wire.beginTransmission(adresse); // « il y a quelqu'un à cette adresse ? »
if (Wire.endTransmission() == 0) { // 0 = quelqu'un a répondu
Serial.printf(" ✔ appareil trouvé à l'adresse 0x%02X (%d)\n", adresse, adresse);
trouves++;
}
}
Serial.printf("%d appareil(s) trouvé(s).\n", trouves);
}
void loop() {}
Ce que tu dois lire :
Une autre adresse ?
Si le scanner trouve 0x3D, note-la : il faudra remplacer 0x3C par 0x3D dans les programmes suivants. S'il ne trouve rien, vérifie le câblage : SDA et SCL inversés, c'est l'erreur la plus fréquente. Appuie sur le bouton EN de la carte pour relancer le scan.
Étape 4 · Premier affichage¶
- Téléverser le programme ci-dessous (enregistré sous mission1bis_bonjour).
- L'écran affiche « Bonjour » en jaune, et « Nathan ! » en bleu dans un cadre, avec une petite planète.

Le programme · cliquer pour l'afficher
// Mission 1 bis · Premier affichage sur l'holo-écran
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 ecran(128, 64, &Wire, -1); // 128 × 64 pixels, sur le bus I2C principal
void setup() {
Serial.begin(115200);
Wire.begin(21, 22);
if (!ecran.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // 0x3C : l'adresse trouvée par le scanner
Serial.println("Écran introuvable : vérifie le câblage et l'adresse.");
while (true) delay(100); // on s'arrête là
}
ecran.clearDisplay(); // 1. on efface la mémoire de l'image
ecran.setTextColor(SSD1306_WHITE);
ecran.setTextSize(2); // texte deux fois plus gros : 16 pixels de haut
ecran.setCursor(22, 0); // en haut : pile dans la bande jaune (lignes 0 à 15)
ecran.println("Bonjour");
ecran.setCursor(8, 24); // en dessous : dans la partie bleue
ecran.println("Nathan !");
ecran.drawRect(0, 16, 128, 48, SSD1306_WHITE); // un cadre autour de la partie bleue
ecran.fillCircle(110, 50, 8, SSD1306_WHITE); // une petite planète
ecran.display(); // 2. on envoie l'image à l'écran
}
void loop() {}
Les coordonnées de l'écran¶
Chaque pixel a une adresse : sa colonne x (de 0 à 127, de gauche à droite) et sa ligne y (de 0 à 63, de haut en bas). Le point (0, 0) est en haut à gauche ; y augmente quand on descend, comme les lignes d'un livre.
Notre écran est bicolore
Les lignes 0 à 15 s'allument en jaune, les lignes 16 à 63 en bleu. Le programme ne choisit pas la couleur : elle dépend seulement de la ligne. Astuce : le titre en haut, dans le jaune, et les dessins à partir de y = 16, sinon ils sont coupés en deux couleurs.
| Fonction | Ce qu'elle dessine |
|---|---|
drawPixel(x, y, couleur) |
un seul point |
drawLine(x1, y1, x2, y2, couleur) |
un trait entre deux points |
drawRect(x, y, largeur, hauteur, couleur) |
un rectangle vide (fillRect : plein) |
drawCircle(x, y, rayon, couleur) |
un cercle de centre (x, y) (fillCircle : plein) |
drawTriangle(x1, y1, x2, y2, x3, y3, couleur) |
un triangle (fillTriangle : plein) |
setCursor(x, y) puis print("…") |
du texte, à partir du point (x, y) |
setTextSize(n) |
texte n fois plus grand : en taille 1, une lettre fait 6 × 8 pixels |
clearDisplay() |
efface tout (dans la mémoire) |
display() |
envoie l'image à l'écran : sans lui, rien ne change ! |
Pas d'accents sur l'écran
La police de l'écran ne connaît que les lettres sans accent : « Pret » s'affiche bien, « Prêt » donne un drôle de symbole. Le moniteur série, lui, accepte les accents.
Étape 5 · Le programme Holo-écran¶
Qui fait quoi¶
| Page | Ce qu'elle affiche |
|---|---|
| Attente (manette pas connectée) | « En attente de la manette… » avec des points animés |
| 1 · Cockpit | les deux joysticks (un point dans un cercle), les 4 boutons (formes pleines quand on appuie), L2 et R2 en jauges, la batterie en haut à droite |
| 2 · Radar | un balayage qui tourne, et trois cibles qui s'allument à son passage |
| 3 · Infos | le modèle de manette, la batterie en %, le temps depuis le démarrage |
OPTIONS passe à la page suivante.




Aperçus calculés sur ordinateur, à partir du vrai programme et de la vraie bibliothèque graphique d'Adafruit : c'est ce que l'écran devrait afficher, avec sa bande jaune en haut et le reste en bleu.
Le code¶
Nouveau croquis, coller le programme ou télécharger le fichier, enregistrer sous mission1bis_holoecran.
Le programme · cliquer pour l'afficher
// Mission 1 bis · Kaminoled : l'holo-écran de bord
// Trois pages, qu'on fait défiler avec OPTIONS :
// 1. le cockpit : joysticks, gâchettes, boutons et batterie de la manette, en direct
// 2. le radar : un balayage avec des cibles qui s'allument
// 3. les infos : modèle de manette, batterie, temps écoulé
// Rappel : la police de l'écran ne connaît pas les accents.
#include <Bluepad32.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
Adafruit_SSD1306 ecran(128, 64, &Wire, -1);
ControllerPtr manette = nullptr;
const int NB_PAGES = 3;
int page = 0;
bool avantOptions = false;
unsigned long derniereImage = 0;
// ================= Connexion =================
void onConnectedController(ControllerPtr ctl) {
if (manette != nullptr) return;
manette = ctl;
ctl->setColorLED(0, 120, 255);
Serial.println("🎮 Manette connectée");
}
void onDisconnectedController(ControllerPtr ctl) {
if (manette == ctl) {
manette = nullptr;
Serial.println("❌ Manette déconnectée");
}
}
// ================= Petits outils de dessin =================
// Un joystick : un cercle, et un point qui montre sa position
void dessinerJoystick(int cx, int cy, int x, int y) {
ecran.drawCircle(cx, cy, 13, SSD1306_WHITE);
int px = cx + x * 10 / 512; // -508..512 → -10..10 pixels
int py = cy + y * 10 / 512;
ecran.fillCircle(px, py, 3, SSD1306_WHITE);
}
// Une jauge horizontale : un cadre, rempli selon la valeur (0 à maxi)
void dessinerJauge(int x, int y, int largeur, int valeur, int maxi) {
ecran.drawRect(x, y, largeur, 7, SSD1306_WHITE);
int plein = (largeur - 2) * constrain(valeur, 0, maxi) / maxi;
ecran.fillRect(x + 1, y + 1, plein, 5, SSD1306_WHITE);
}
// Les 4 boutons de face : la forme est pleine quand le bouton est appuyé
void dessinerBoutons(int cx, int cy) {
bool triangle = manette->y(), rond = manette->b(), croix = manette->a(), carre = manette->x();
// △ en haut
if (triangle) ecran.fillTriangle(cx, cy - 13, cx - 4, cy - 6, cx + 4, cy - 6, SSD1306_WHITE);
else ecran.drawTriangle(cx, cy - 13, cx - 4, cy - 6, cx + 4, cy - 6, SSD1306_WHITE);
// ○ à droite
if (rond) ecran.fillCircle(cx + 10, cy, 4, SSD1306_WHITE);
else ecran.drawCircle(cx + 10, cy, 4, SSD1306_WHITE);
// □ à gauche
if (carre) ecran.fillRect(cx - 14, cy - 4, 8, 8, SSD1306_WHITE);
else ecran.drawRect(cx - 14, cy - 4, 8, 8, SSD1306_WHITE);
// ✕ en bas
ecran.drawLine(cx - 4, cy + 6, cx + 4, cy + 14, SSD1306_WHITE);
ecran.drawLine(cx + 4, cy + 6, cx - 4, cy + 14, SSD1306_WHITE);
if (croix) ecran.drawCircle(cx, cy + 10, 7, SSD1306_WHITE); // un cercle autour quand appuyé
}
// ================= Les pages =================
void pageAttente() {
ecran.setTextSize(1);
ecran.setCursor(0, 3);
ecran.print("HOLO-ECRAN"); // le titre, dans la bande jaune
ecran.drawFastHLine(0, 13, 128, SSD1306_WHITE);
ecran.setCursor(10, 20);
ecran.print("En attente de la");
ecran.setCursor(10, 32);
ecran.print("manette");
int points = (millis() / 400) % 4; // des points qui s'animent
for (int i = 0; i < points; i++) ecran.print(".");
ecran.setCursor(10, 50);
ecran.print("SHARE + PS");
}
void pageCockpit() {
ecran.setTextSize(1);
ecran.setCursor(0, 3);
ecran.print("COCKPIT");
int batterie = manette->battery(); // 0 = inconnue, sinon 1 à 255
if (batterie > 0) dessinerJauge(88, 3, 40, batterie, 255);
ecran.drawFastHLine(0, 13, 128, SSD1306_WHITE); // fin de la bande jaune
dessinerJoystick(16, 36, manette->axisX(), manette->axisY());
dessinerJoystick(111, 36, manette->axisRX(), manette->axisRY());
dessinerBoutons(64, 34);
ecran.setCursor(36, 56); ecran.print("L2");
dessinerJauge(50, 56, 22, manette->brake(), 1020);
ecran.setCursor(76, 56); ecran.print("R2");
dessinerJauge(90, 56, 22, manette->throttle(), 1020);
}
void pageRadar() {
const int cx = 64, cy = 40, r = 22; // sous la bande jaune (lignes 16 à 63)
ecran.setTextSize(1);
ecran.setCursor(0, 3);
ecran.print("RADAR");
ecran.drawCircle(cx, cy, r, SSD1306_WHITE);
ecran.drawCircle(cx, cy, r / 2, SSD1306_WHITE);
ecran.drawPixel(cx, cy, SSD1306_WHITE);
float angle = (millis() % 3000) / 3000.0 * 2 * PI; // un tour toutes les 3 secondes
ecran.drawLine(cx, cy, cx + r * cos(angle), cy + r * sin(angle), SSD1306_WHITE);
// Trois cibles : elles s'allument juste après le passage du balayage
const float CIBLES[3][2] = {{0.8, 16}, {2.6, 9}, {4.4, 19}}; // {angle, distance}
for (int i = 0; i < 3; i++) {
float ecart = angle - CIBLES[i][0];
if (ecart < 0) ecart += 2 * PI;
if (ecart < 1.2) { // pendant un petit moment après le passage
int x = cx + CIBLES[i][1] * cos(CIBLES[i][0]);
int y = cy + CIBLES[i][1] * sin(CIBLES[i][0]);
ecran.fillCircle(x, y, 2, SSD1306_WHITE);
}
}
}
void pageInfos() {
ecran.setTextSize(1);
ecran.setCursor(0, 3);
ecran.print("INFOS");
ecran.drawFastHLine(0, 13, 128, SSD1306_WHITE);
ecran.setCursor(0, 18);
ecran.print("Manette : ");
ecran.print(manette->getModelName());
ecran.setCursor(0, 29);
ecran.print("Batterie : ");
int b = manette->battery();
if (b > 0) { ecran.print(b * 100 / 255); ecran.print(" %"); }
else ecran.print("?");
unsigned long s = millis() / 1000;
ecran.setCursor(0, 40);
ecran.printf("En marche : %02lu:%02lu", s / 60, s % 60);
ecran.setCursor(0, 53);
ecran.print("OPTIONS : page +1");
}
// ================= Programme principal =================
void setup() {
Serial.begin(115200);
Wire.begin(21, 22); // SDA sur D21, SCL sur D22
if (!ecran.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("Écran introuvable : vérifie le câblage et l'adresse.");
}
ecran.clearDisplay();
ecran.setTextColor(SSD1306_WHITE);
ecran.display();
BP32.setup(&onConnectedController, &onDisconnectedController);
BP32.forgetBluetoothKeys(); // à mettre en commentaire une fois que tout marche
Serial.println("Holo-écran prêt. Maintiens SHARE + PS pour coupler la manette.");
}
void loop() {
BP32.update();
bool connectee = manette && manette->isConnected();
if (connectee) {
bool options = manette->miscStart();
if (options && !avantOptions) { // OPTIONS vient d'être appuyé
page = (page + 1) % NB_PAGES;
Serial.printf("Page %d\n", page + 1);
}
avantOptions = options;
}
if (millis() - derniereImage >= 40) { // environ 25 images par seconde
derniereImage = millis();
ecran.clearDisplay();
if (!connectee) pageAttente();
else if (page == 0) pageCockpit();
else if (page == 1) pageRadar();
else pageInfos();
ecran.display();
}
delay(5);
}
Comment il est construit¶
| Bloc | Son travail |
|---|---|
dessinerJoystick, dessinerJauge, dessinerBoutons |
de petites fonctions de dessin, réutilisées partout |
pageAttente, pageCockpit, pageRadar, pageInfos |
une fonction par page : chacune dessine son image |
if (millis() - derniereImage >= 40) |
une nouvelle image toutes les 40 ms, soit 25 images par seconde, sans bloquer la manette |
clearDisplay() … display() |
chaque image est entièrement redessinée, puis envoyée |
page = (page + 1) % NB_PAGES |
OPTIONS fait tourner les pages en boucle : 0, 1, 2, 0, 1… |
Ce qu'on apprend ici
- Un écran, c'est une grille : tout dessin est une question de coordonnées.
- Le dessin hors écran : on prépare l'image en mémoire, on l'envoie d'un coup, et rien ne clignote.
- Des fonctions qui dessinent :
dessinerJauge(x, y, largeur, valeur, maxi)sert pour la batterie et pour les deux gâchettes. - Le cosinus et le sinus : dans
pageRadar, ils transforment un angle en position sur le cercle.
🎓 Académie : Le bus I2C · Tableaux, boucles, fonctions · Le temps
Étape 6 · Téléverser et jouer¶
- Téléverser, puis coupler la manette (SHARE + PS).
- L'écran passe de « En attente » au cockpit : bouge les joysticks, appuie sur les gâchettes et les boutons.
- OPTIONS : le radar, puis les infos, puis retour au cockpit.
Si ça coince¶
| Symptôme | Cause probable | Solution |
|---|---|---|
| Le scanner ne trouve rien | SDA et SCL inversés, ou un fil mal enfoncé | SDA → D21, SCL → D22 |
| Le scanner ne trouve rien, écran froid | VCC ou GND pas branché | vérifier l'alimentation : VCC sur 3V3 |
| « Écran introuvable » | mauvaise adresse | mettre celle du scanner (0x3C ou 0x3D) |
| L'écran reste noir, aucune erreur | display() oublié |
toujours finir par ecran.display() |
| Image décalée, colonne de « neige » à droite | l'écran a une puce SH1106, pas SSD1306 | bibliothèque Adafruit SH110X : on adaptera le programme ensemble |
| Des symboles bizarres dans le texte | des accents | écrire sans accents sur l'écran |
Adafruit_SSD1306.h: No such file or directory |
bibliothèque non installée | étape 1 |
Préserver l'écran
Comme tous les OLED, une image fixe affichée pendant des heures peut finir par laisser une trace. Pour une création qui reste allumée longtemps, on prévoit un mode veille ou des images qui bougent.
Défis bonus¶
Les solutions sont rangées dans des holocrons scellés : essaie d'abord, l'indice est là si tu bloques, et le code d'accès du maître de l'atelier ouvre la solution.
Défi 1 · Ton nom qui défile¶
Une 4ᵉ page : NATHAN en grandes lettres, qui défile de droite à gauche comme sur une enseigne.
Indice
Dessine le texte à une position x qui diminue avec le temps (millis()). Quand il est complètement sorti à gauche, il doit revenir à droite : le reste de la division (%) sait faire ça. Et setTextWrap(false) l'empêche de passer à la ligne au bord de l'écran.
Défi 2 · Le deuxième écran¶
Avec le 2ᵉ écran : le premier garde le cockpit, le second affiche le radar en permanence.
Indice
Les deux écrans ont la même adresse (0x3C) : sur le même bus, ils se mélangeraient. Mais l'ESP32 a deux bus I2C ! Le second (Wire1) peut utiliser les broches RX2 (GPIO 16) pour SDA et TX2 (GPIO 17) pour SCL.
Défi 3 · Le compte à rebours de décollage¶
△ lance un compte à rebours de 10 secondes en grands chiffres, avec une barre de progression, puis « DECOLLAGE! » qui clignote.
Indice
Note l'heure de l'appui sur △. Le chiffre à afficher, c'est 10 moins le nombre de secondes écoulées. Pour le clignotement final, regarde la fonction invertDisplay(true / false).
L'holo-écran dans les autres missions
Une fois l'écran apprivoisé, il reste branché ! Chaque mission a sa version « holo-écran » de son programme : le ruban et son courant · la porte du hangar · le radar à balayage · les moteurs et leurs volts · le poste de commandement de la base.
Validation¶
- Le scanner trouve l'écran, et je connais son adresse
- « Bonjour Nathan ! » s'affiche
- Le cockpit montre les joysticks, les boutons, les gâchettes et la batterie en direct
- OPTIONS fait défiler les trois pages
- Bonus : au moins un défi réussi