Salut à tous ! ![]()
Je partage un petit projet perso que je viens de finir : un afficheur qui alterne entre une horloge (heure, date, barre de progression des secondes) et une page météo avec des icônes animées soleil dont les rayons tournent, gouttes qui tombent, flocons, éclair d'orage… le tout sur un panneau LED matriciel.
Les données de température et de conditions viennent de l'API Open-Meteo (gratuite, sans clé), et l'heure est synchronisée par NTP. Avantage pas besoin de RTC !
Comment ça marche ?
L'heure, sans horloge. Au démarrage, la carte se connecte au WiFi puis synchronise l'heure par NTP. Une seule ligne gère le fuseau français et le passage heure d'été / heure d'hiver automatiquement :
configTzTime("CET-1CEST,M3.5.0,M10.5.0/3", "pool.ntp.org", "time.nist.gov");
La météo, gratuitement. C'est là qu'intervient Open-Meteo. C'est une API météo open-source, gratuite pour un usage non commercial (maximum de 10 000 appels API par jours) et sans clé API : pas de compte, pas d'inscription simplement y faire référence en précisant qu'il s'agit d'une licence CC BY 4.0, on tape une URL et on reçoit du JSON. Elle s'appuie notamment sur les modèles de Météo-France (Arome / Arpège), ce qui tombe bien chez nous.
On lui envoie des coordonnées GPS et on récupère la température + un weathercode (codes WMO standard), qu'on range ensuite dans une catégorie d'icône.
Le matériel :
- Un panneau LED HUB75 64×32
- Une carte Adafruit Matrix Portal S3 (ESP32) pour piloter le tout le WiFi intégré est parfait pour aller chercher l'heure et la météo
- Une alimentation 5 V de 4A (consommation max du panneau)
Le fonctionnement
Côté logiciel, ça reste assez simple :
- Au démarrage : connexion WiFi synchro NTP (avec le fuseau France et le passage heure d'été/hiver automatique) → première requête météo.
- Ensuite le programme alterne deux pages à intervalle réglable : ~6 s d'horloge, ~5 s de météo.
- La météo se rafraîchit toutes les 15 min en tâche de fond.
- Les icônes sont dessinées à la main (cercles, lignes, rectangles) et animées via un compteur de « phase » incrémenté à 15 fps → c'est ce qui fait tourner les rayons du soleil et descendre les gouttes.
- Les codes météo renvoyés par Open-Meteo sont regroupés en 7 catégories (soleil, éclaircies, nuageux, brouillard, pluie, neige, orage) pour choisir l'icône et le libellé à afficher.
Côté bibliothèques : le cœur ESP32 (WiFi / HTTPClient / WiFiClientSecure), ArduinoJson v7 pour parser la réponse de l'API, `time.h` pour le NTP, et une lib de pilotage du panneau HUB75.
Deux ou trois pièges rencontrés...
- Mise en page : sur 64 px de large, il faut vraiment réfléchir à la disposition. J'ai fini avec l'icône et la température symétriques de part et d'autre du centre, et le libellé centré en dessous comme une légende.
- Couleurs inversées : mon jaune sortait… violet ! C'est la signature classique d'une inversion des canaux vert/bleu du panneau : le jaune (255, 205, 0) devient magenta (255, 0, 205), alors que le blanc reste blanc (d'où le fait qu'on ne le voit pas tout de suite). À vérifier selon le panneau et la lib utilisés.
- Pour valider le rendu des icônes sans attendre la vraie météo, j'ai ajouté un petit mode test qui utilise les boutons UP/DOWN de la Matrix Portal pour faire défiler les 7 catégories à la main.
Le code :
J'ai utilisé Claude pour faire un Wrapper de Protomatter pour simplifier la configuration du ou des panneaux. Je vous mets la librarie dispo sur le Github de Robot-Maker attention elle est valable uniquement pour le matrix portal S3 (PIN fixés dans la librarie) : https://github.com/R...rie-Hub75Screen
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h> // à installer via le gestionnaire de bibliothèques (v7)
#include <time.h>
#include <math.h>
#include <Hub75Screen.h>
//=================== TEST ICONE =================
//#define TEST_ICONS //à décommenter pour tester les icones
// ================== CONFIG À REMPLIR ==================
#define WIFI_SSID "NOM_WIFI"
#define WIFI_PASS "MOT_DE_PASSE"
// Coordonnées Géographique
#define LAT "43.49"
#define LON "-1.48"
#define TZ_FRANCE "CET-1CEST,M3.5.0,M10.5.0/3"
// =====================================================
Hub75Screen screen(64, 32, 1, Hub75Screen::HORIZONTAL, 4, true);
const uint8_t Brightness = 100;
const uint32_t PAGE_MS[2] = {6000, 5000};
const char *jours[] = {"DIM","LUN","MAR","MER","JEU","VEN","SAM"};
const char *mois[] = {"JAN","FEV","MAR","AVR","MAI","JUIN",
"JUIL","AOUT","SEP","OCT","NOV","DEC"};
enum WxCat { WX_CLEAR, WX_PARTLY, WX_CLOUD, WX_FOG, WX_RAIN, WX_SNOW, WX_STORM };
bool wxValid = false;
int wxTemp = 0;
WxCat wxCat = WX_CLOUD;
WxCat wxCategory(int code) {
if (code == 0) return WX_CLEAR;
if (code == 1 || code == 2) return WX_PARTLY;
if (code == 3) return WX_CLOUD;
if (code == 45 || code == 48) return WX_FOG;
if (code >= 71 && code <= 77) return WX_SNOW;
if (code == 85 || code == 86) return WX_SNOW;
if (code >= 95) return WX_STORM;
return WX_RAIN;
}
const char *wxLabel(WxCat c) {
switch (c) {
case WX_CLEAR: return "SOLEIL";
case WX_PARTLY: return "COUVERT";
case WX_CLOUD: return "NUAGEUX";
case WX_FOG: return "BROUILLARD";
case WX_RAIN: return "PLUIE";
case WX_SNOW: return "NEIGE";
case WX_STORM: return "ORAGE";
}
return "";
}
-
void printCentered(const char *s, int y, uint8_t size, uint16_t col) {
int w = (int)strlen(s) * 6 * size;
screen.setTextSize(size);
screen.setTextColor(col);
screen.setCursor((screen.width() - w) / 2, y);
screen.print(s);
}
void boot(const char *msg) {
screen.clear();
printCentered(msg, 12, 1, screen.color(255, 255, 255)); // Texte de boot en BLANC
screen.update();
}
void drawTempSmall(int t, int x, int y, uint16_t col) {
char num[6];
snprintf(num, sizeof(num), "%d", t);
int nw = (int)strlen(num) * 6;
screen.setTextSize(1);
screen.setTextColor(col);
screen.setCursor(x, y); screen.print(num);
screen.drawCircle(x + nw + 1, y + 1, 1, col); // le petit "°"
screen.setCursor(x + nw + 4, y); screen.print("C");
}
// =================== ICÔNES MÉTÉO ANIMÉES (version compacte) ===================
void drawSun(int cx, int cy, int r, int phase) {
uint16_t jaune = screen.color(255, 0, 200);
uint16_t clair = screen.color(255, 240, 150);
for (int a = 0; a < 360; a += 45) { // 8 rayons au lieu de 12
float rad = (a + phase * 2) * DEG_TO_RAD;
screen.drawLine(cx + cos(rad) * (r + 2), cy + sin(rad) * (r + 2),
cx + cos(rad) * (r + 4), cy + sin(rad) * (r + 4), jaune);
}
screen.fillCircle(cx, cy, r, jaune);
screen.fillCircle(cx - 1, cy - 1, r / 3, clair);
}
void drawNiceCloud(int cx, int cy) {
uint16_t body = screen.color(255, 255, 255);
screen.fillCircle(cx - 4, cy + 1, 3, body);
screen.fillCircle(cx + 4, cy + 1, 3, body);
screen.fillCircle(cx - 1, cy - 2, 4, body);
screen.fillCircle(cx + 2, cy, 3, body);
screen.fillRect(cx - 6, cy + 1, 12, 3, body);
}
void drawRain(int cx, int cyCloud, int phase) {
uint16_t rain = screen.color(60, 130, 255);
const int cols[] = {-4, -1, 2, 5};
const int span = 6;
for (int i = 0; i < 4; i++) {
int y = (phase * 2 + i * 5) % span;
int top = cyCloud + 6 + y;
screen.drawLine(cx + cols[i], top, cx + cols[i], top + 2, rain);
}
}
void drawBolt(int cx, int cy) {
uint16_t bolt = screen.color(255, 0, 235);
screen.drawLine(cx + 1, cy, cx - 2, cy + 4, bolt);
screen.drawLine(cx - 2, cy + 4, cx + 1, cy + 4, bolt);
screen.drawLine(cx + 1, cy + 4, cx - 2, cy + 9, bolt);
}
void drawWeatherIcon(WxCat c, int cx, int cy, int phase) {
switch (c) {
case WX_CLEAR:
drawSun(cx, cy, 4, phase);
break;
case WX_PARTLY:
drawSun(cx - 4, cy - 4, 3, phase);
drawNiceCloud(cx + 2, cy + 3);
break;
case WX_CLOUD:
drawNiceCloud(cx, cy + (int)lround(sin(phase * 0.15) * 1.5));
break;
case WX_FOG:
drawNiceCloud(cx, cy - 2);
for (int i = 0; i < 3; i++)
screen.drawLine(cx - 6, cy + 5 + i * 2, cx + 6, cy + 5 + i * 2, screen.color(120,120,135));
break;
case WX_RAIN:
drawNiceCloud(cx, cy - 2);
drawRain(cx, cy - 2, phase);
break;
case WX_SNOW:
drawNiceCloud(cx, cy - 2);
for (int i = 0; i < 4; i++) {
int y = (phase + i * 4) % 7;
screen.fillCircle(cx - 4 + i * 3, cy + 4 + y, 1, screen.color(220, 230, 255));
}
break;
case WX_STORM:
drawNiceCloud(cx, cy - 2);
drawRain(cx, cy - 2, phase);
if ((phase % 45) < 5) drawBolt(cx, cy + 1);
break;
}
}
// ---------- Météo (Open-Meteo) ----------
void fetchWeather() {
if (WiFi.status() != WL_CONNECTED) return;
WiFiClientSecure client;
client.setInsecure();
HTTPClient https;
String url = String("https://api.open-meteo.com/v1/forecast?latitude=")
+ LAT + "&longitude=" + LON + "¤t_weather=true";
if (https.begin(client, url)) {
if (https.GET() == 200) {
JsonDocument doc;
if (deserializeJson(doc, https.getString()) == DeserializationError::Ok) {
float temp = doc["current_weather"]["temperature"] | NAN;
int wc = doc["current_weather"]["weathercode"] | -1;
if (!isnan(temp) && wc >= 0) {
wxTemp = (int)lround(temp);
wxCat = wxCategory(wc);
wxValid = true;
}
}
}
https.end();
}
}
// ---------- Rendu : PAGE HORLOGE ----------
void renderClock(const struct tm &t) {
// Heure HH:MM en taille 2, centrée en BLANC
char hhmm[6];
snprintf(hhmm, sizeof(hhmm), "%02d:%02d", t.tm_hour, t.tm_min);
printCentered(hhmm, 2, 2, screen.color(255, 255, 255));
// Barre des secondes (barre de progression en blanc)
int barW = map(t.tm_sec, 0, 59, 0, 58);
screen.drawRect(2, 19, 60, 3, screen.color(40, 40, 60));
screen.fillRect(3, 20, barW, 1, screen.color(255, 255, 255));
// Date au format JJ/MM/AAAA en BLANC
char d[16];
snprintf(d, sizeof(d), "%02d/%02d/%04d", t.tm_mday, t.tm_mon + 1, t.tm_year + 1900);
printCentered(d, 23, 1, screen.color(255, 255, 255));
}
// ---------- Rendu : PAGE MÉTÉO ----------
void renderWeather(int phase) {
if (!wxValid) {
printCentered("METEO ?", 12, 1, screen.color(255, 255, 255));
return;
}
uint16_t white = screen.color(255, 255, 255);
const int midY = 9;
drawWeatherIcon(wxCat, 16, midY, phase);
char num[6];
snprintf(num, sizeof(num), "%d", wxTemp);
int len = strlen(num);
uint8_t ts = (len <= 2) ? 2 : 1;
int digitsW = len * 6 * ts;
int degR = (ts == 2) ? 2 : 1;
int blockW = digitsW + degR + 3;
int x = 48 - blockW / 2;
int y = midY - (7 * ts) / 2;
screen.setTextSize(ts);
screen.setTextColor(white);
screen.setCursor(x, y);
screen.print(num);
screen.drawCircle(x + digitsW + degR, y + degR, degR, white);
screen.setTextSize(1);
screen.setTextColor(white);
const char *lbl = wxLabel(wxCat);
int lw = strlen(lbl) * 6;
screen.setCursor((64 - lw) / 2, 24);
screen.print(lbl);
}
// ---------- Aiguillage des pages ----------
void render(int page, int phase) {
struct tm t;
if (!getLocalTime(&t)) { boot("SYNC..."); return; }
screen.clear();
if (page == 0) renderClock(t);
else renderWeather(phase);
screen.update();
}
// ---------- Setup / Loop ----------
uint32_t lastWx = 0;
uint32_t pageStart = 0;
int page = 0;
void setup() {
Serial.begin(115200);
if (!screen.begin()) { for (;;) delay(100); }
screen.setBrightness(Brightness);
#ifdef TEST_ICONS
testIconsSetup();
return;
#endif
boot("WIFI...");
WiFi.begin(WIFI_SSID, WIFI_PASS);
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) delay(250);
if (WiFi.status() == WL_CONNECTED) {
boot("NTP...");
configTzTime(TZ_FRANCE, "pool.ntp.org", "time.nist.gov");
fetchWeather();
lastWx = millis();
} else {
boot("PAS DE WIFI");
delay(1500);
}
pageStart = millis();
}
void loop() {
#ifdef TEST_ICONS
testIconsLoop();
return;
#endif
static int phase = 0;
uint32_t now = millis();
if (now - lastWx > 15UL * 60UL * 1000UL) {
fetchWeather();
lastWx = now;
}
if (now - pageStart > PAGE_MS[page]) {
page = (page + 1) % 2;
pageStart = now;
}
if (screen.tick(15)) {
render(page, phase);
phase++;
}
}
// =================== MODE TEST : DISPOSITION DES ICÔNES ===================
#if defined(BUTTON_UP) && defined(BUTTON_DOWN)
#define BTN_UP BUTTON_UP // macros du Matrix Portal si le core les définit
#define BTN_DOWN BUTTON_DOWN
#else
#define BTN_UP 6 // Matrix Portal S3 : UP=6 / DOWN=7
#define BTN_DOWN 7 // Matrix Portal M4 : UP=2 / DOWN=3
#endif
const char *wxCatName[] = {"CLEAR","PARTLY","CLOUD","FOG","RAIN","SNOW","STORM"};
void testIconsSetup() {
pinMode(BTN_UP, INPUT_PULLUP);
pinMode(BTN_DOWN, INPUT_PULLUP);
wxValid = true;
wxTemp = 23; // température factice pour juger la mise en page
Serial.println("== MODE TEST ICONES ==");
}
void testIconsLoop() {
static int cat = 0;
static int phase = 0;
static bool autoCycle = false; // défilement auto (activé par UP)
static bool upPrev = HIGH, downPrev = HIGH;
static uint32_t lastEdge = 0; // horodatage du dernier appui pris en compte
static uint32_t lastStep = 0; // dernier changement d'icône en auto
const uint32_t DEBOUNCE = 150; // ms anti-rebond
const uint32_t STEP_MS = 1000; // 1 icône par seconde
uint32_t now = millis();
bool up = digitalRead(BTN_UP);
bool down = digitalRead(BTN_DOWN);
if (now - lastEdge > DEBOUNCE) {
if (upPrev == HIGH && up == LOW) {
autoCycle = !autoCycle;
lastStep = now;
lastEdge = now;
Serial.println(autoCycle ? "AUTO ON" : "AUTO OFF");
}
else if (downPrev == HIGH && down == LOW) {
autoCycle = false;
cat = (cat + 6) % 7;
lastEdge = now;
Serial.println(wxCatName[cat]);
}
}
upPrev = up;
downPrev = down;
if (autoCycle && now - lastStep >= STEP_MS) {
cat = (cat + 1) % 7;
lastStep = now;
Serial.println(wxCatName[cat]);
}
wxCat = (WxCat)cat;
if (screen.tick(15)) {
screen.clear();
renderWeather(phase);
screen.update();
phase++;
}
}
La suite
Ça tourne bien ! Quelques pistes en tête : afficher la prévision du lendemain, régler la luminosité automatiquement selon l'heure, ou ajouter une 3ᵉ page (API spotify, Mail, ect...).










