Le kit que j'avais mal soudé
J'ai raté des soudures sur mon premier kit de qualité de l'air, j'en ai racheté un, et j'y ai fait tourner un fichier Arduino de 406 lignes pendant des années. C'est en réparant le premier que je me suis enfin décidé à tout réécrire.
Le kit que j'avais mal soudé
J'ai deux kits de mesure de qualité de l'air AirGradient. Sur le premier, j'ai raté des soudures, donc pendant longtemps seul le deuxième a vraiment fonctionné. Il faisait tourner un seul fichier Arduino de 406 lignes, et tout ce que je lui demandais, c'était d'envoyer ses mesures dans Prometheus.
Depuis septembre, les deux cartes tournent avec le même firmware. On est autour de 5 100 lignes, réparties dans 62 fichiers, avec 87 tests, et il ne reste plus rien de la librairie du départ. Rien de tout ça n'était prévu, ça s'est fait une décision après l'autre.
Le premier kit
Pour ceux qui ne connaissent pas, AirGradient vend ses capteurs en kits à monter soi-même. Dans la boîte il y a un Wemos D1 mini, un circuit imprimé, un sachet de capteurs, un petit écran OLED, et le fer à souder c'est pour vous. Le mien, c'était la v1 je crois.
Je n'ai jamais mis leur firmware dessus. Dès le début je voulais mon propre programme, parce que tout le reste chez moi passe déjà par Prometheus et Grafana (mes prises connectées aussi) et je voulais la qualité de l'air sur les mêmes tableaux de bord. Donc je l'ai écrit, en m'appuyant sur la librairie Arduino d'AirGradient.
Sauf que quelques soudures étaient ratées. Je n'avais pas le courage de chercher lesquelles, alors j'ai commandé le nouveau kit. Une v3 il me semble, et c'était encore du développement de leur côté à ce moment-là. Mon programme est passé sur celui-là.
406 lignes
Un seul fichier, la librairie AirGradient qui faisait le boulot côté capteurs, et mon code qui collait le tout. La partie qui m'intéressait vraiment, c'était l'export. Ça ressemblait à ça (j'ai coupé après la deuxième métrique, vous avez compris le principe) :
String generateMetrics() {
String message = "";
String idString = "{id=\"" + String(deviceId) + "\",mac=\"" + WiFi.macAddress().c_str() + "\"}";
message += "# HELP tvoc TVOC value, in ppm\n";
message += "# TYPE tvoc gauge\n";
message += "tvoc";
message += idString;
message += String(TVOC);
message += "\n";
message += "# HELP co2 CO2 value, in ppm\n";
// ...et pareil pour chaque mesure
Et franchement, ça marchait très bien. Prometheus le scrapait, Grafana faisait de jolies courbes, et au fil du temps j'ai rajouté un capteur barométrique et retravaillé l'affichage du petit écran 128x64. La plus vieille copie que j'ai encore date d'octobre 2023.
Le relire aujourd'hui me fait un peu sourire. C'est vraiment le programme écrit pour un seul appareil, par la seule personne qui allait y toucher. Le nom et le mot de passe du WiFi étaient des constantes dans le code, donc changer mon mot de passe voulait dire reflasher. Si le réseau ne répondait pas, setupWifi() restait là à afficher « Trying to connect... » à l'infini. L'heure, c'était timeClient.setTimeOffset(3600 * 2), autrement dit UTC+2. Juste en été. En hiver j'allais modifier la valeur à la main. La page des métriques était construite dans une grosse String, sur une puce qui a dans les 40 Ko de mémoire. Et un capteur qui n'avait pas encore répondu renvoyait 0, ce qui sur une courbe de CO2 donne l'air le plus pur que vous ayez jamais respiré.
Ma préférée : regardez la ligne d'aide au-dessus. Le COV « in ppm ». Le SGP30 donne des ppb. C'était faux à chaque scrape, pendant des années, et je ne l'avais jamais vu.
Rien de tout ça ne posait vraiment problème. Un boîtier, une pièce, un réseau, et j'en connaissais toutes les manies par cœur.
Dix mois à ne pas faire grand-chose
Bien plus tard, j'ai décidé de réparer le premier kit. L'idée était simple : refaire les soudures, flasher le même fichier, terminé. Sauf que le premier kit n'a pas le même matériel. Écran plus petit (64x48), pas de SGP30, pas de baromètre. Il me fallait donc une deuxième copie du fichier avec des morceaux en moins, et ensuite chaque correction à faire deux fois. Bof.
C'est là que je me suis dit qu'il fallait faire ça proprement, avec PlatformIO, en vrais modules. Premier commit le 3 octobre 2025. Un mois plus tard j'avais les identifiants WiFi stockés en EEPROM et une petite page de configuration. Et puis je n'y ai plus touché pendant dix mois.
Je m'y suis remis le 6 septembre 2026, avec 22 commits dans la journée. Tag 1.0.0 deux jours après.
Lâcher la librairie
Je n'avais pas prévu d'abandonner la librairie AirGradient. Mais en lisant son code pour comprendre ce que faisaient vraiment mes capteurs, je n'avais plus trop envie de la garder.
Le capteur de CO2, un SenseAir S8, parle en Modbus RTU, et chaque réponse se termine par un CRC16. La librairie trouvait la réponse en cherchant un octet de début dans le tampon et ne vérifiait jamais le CRC. Une trame abîmée pouvait donc ressortir tranquillement comme une mesure de CO2. Le nouveau pilote vérifie le CRC d'abord, et je le teste avec les trames d'exemple de la doc SenseAir.
Le capteur de particules, un PMS5003, envoie des trames de 32 octets avec une somme de contrôle. Je les lis maintenant octet par octet, il se recale s'il perd le fil, et il fait la moyenne de toutes les bonnes trames sur une fenêtre de mesure au lieu d'en prendre une seule. Sur ce capteur, ce sont le ventilateur et le laser qui s'usent, donc il tourne par cycles : 30 secondes de chauffe (c'est la datasheet qui le demande), 10 secondes de mesure, puis 5 minutes à l'arrêt. Ça fait environ 1 030 heures de laser par an au lieu de 8 760.
Le SGP30, c'est celui qui pique un peu. Son algorithme de baseline veut une mesure par seconde. Mon ancienne boucle le lisait toutes les 2,5 secondes. Et au démarrage, l'ancien code appelait SGP.GenericReset(), un reset I2C en « appel général ». Ça ne remet pas à zéro que le SGP30, ça touche tout ce qui écoute sur le bus, l'écran compris. Oups.
WiFiManager et NTPClient sont partis aussi. Le cœur ESP8266 a déjà un client SNTP, et si on lui donne une chaîne de fuseau POSIX (CET-1CEST,M3.5.0,M10.5.0/3 chez moi) il gère l'heure d'été tout seul. Fini le décalage à modifier deux fois par an.
Un firmware pour les deux cartes
Faire tourner le même binaire sur les deux cartes m'a obligé à revoir toutes les suppositions de l'ancien fichier.
Le firmware ne sait plus à l'avance quels capteurs il a. Au démarrage il interroge chaque adresse I2C, et ce qui ne répond pas disparaît : pas de métriques, pas de carte dans l'interface web. C'est comme ça que la petite carte fait tourner exactement la même image sans savoir qu'il lui manque deux capteurs. L'écran, lui, impossible à détecter, donc c'est un réglage.
Plus de faux zéros non plus. Si un capteur n'a rien de récent, la métrique n'est tout simplement pas là. Ce qui crée un autre souci, parce qu'une métrique absente peut vouloir dire que le capteur est mort, ou que tout le boîtier a perdu le WiFi. Il y a donc deux séries en plus par capteur, air_quality_sensor_up et air_quality_sensor_detected, toujours présentes. On peut alerter sur leur valeur au lieu de se battre avec absent(), et faire la différence entre « jamais branché » et « ne répond plus ».
Toutes les anciennes constantes sont maintenant dans un fichier JSON sur la flash. Il est écrit dans un fichier temporaire puis renommé par-dessus le vrai, donc couper le courant en pleine sauvegarde ne laisse pas une config à moitié écrite. Sans WiFi configuré, la carte lance son propre point d'accès avec un portail captif, on s'y connecte avec son téléphone, on choisit son réseau dans la liste et c'est réglé. Les mises à jour passent par le réseau, soit directement depuis PlatformIO, soit en envoyant le fichier depuis le navigateur.

La page d'état. Elle est servie par la carte elle-même, sans rien charger de l'extérieur, donc elle marche même depuis le point d'accès de configuration.
Ce qu'on ne trouve que sur le vrai matériel
Tout ce qui ne touche pas au matériel a des tests qui tournent sur mon PC en cinq secondes environ : le parseur de trames, le CRC, la table AQI, les calculs de pression, la courbe de batterie. Le reste, il a fallu le trouver sur les cartes.
Parfois, après un reset, plus rien ne répondait sur le bus I2C. En fait, si un capteur se fait couper au milieu de l'envoi d'un octet, il garde la ligne de données à l'état bas en attendant des coups d'horloge qui ne viendront jamais, et tout le bus reste bloqué tant qu'il ne la relâche pas. La solution est un peu à l'ancienne : avant Wire.begin(), on agite la ligne d'horloge à la main, jusqu'à neuf impulsions, puis on envoie un STOP.
Autre souci : après un redémarrage à chaud, la carte passait parfois en mode configuration alors que mon WiFi allait très bien. La connexion plus l'adresse DHCP prenaient régulièrement plus de 20 secondes, et 20 secondes c'était justement mon délai. Passé à 30.
Ensuite Grafana s'est mis à afficher un redémarrage toutes les sept semaines environ, alors que rien n'avait redémarré. millis() repart à zéro au bout de 49,7 jours, et mon uptime c'était juste millis() / 1000. Maintenant l'uptime compte les débordements.
J'ai aussi ajouté un shield batterie Wemos sur la plus grande carte, et ça a été une aventure à part. La tension de la batterie se lit sur A0 à travers une résistance sur le shield, et la mienne fait 100 kOhm là où le shield classique en a 130. Ça fait passer la pleine échelle de 4,5 V à 4,2 V, donc ma première jauge était complètement fausse jusqu'à ce que je comprenne pourquoi. L'autonomie tourne autour de deux heures, et je ne peux pas faire grand-chose : le SGP30 chauffe une petite plaque en permanence, 48 mA à lui seul, et on ne peut pas le mettre en veille sans perdre son étalonnage. Le plus inquiétant, ce sont les pics. Quand le capteur de CO2 mesure pendant que la radio émet, la batterie fournit environ 1,17 A à travers le convertisseur. La cellule (610 mAh) est donnée pour 2 C, donc 1,22 A. Ça ne laisse pas beaucoup de marge.
Dernier point, et c'est ce que corrige la 1.4.0 : avec un signal faible, vers -70 dBm, la carte ratait des scrapes de temps en temps parce que la radio fait des petites siestes entre deux balises. Il y a maintenant un réglage pour la garder éveillée. Il est désactivé par défaut parce qu'il coûte environ 60 mA, et sur batterie ça se sent.
Bref
Le fichier unique n'était pas mauvais. Il a fait ce que je lui demandais pendant des années. Mot de passe en dur, fuseau en dur, faux zéros, tout ça tenait tant qu'il n'y avait qu'un seul boîtier. C'est la deuxième carte qui a tout fait ressortir d'un coup.
Ce qui m'embête un peu plus, ce sont les petites erreurs qui étaient là depuis le début. L'histoire des ppm et des ppb, le SGP30 lu au mauvais rythme, un reset qui frappait tout le bus. Rien n'avait jamais l'air cassé, donc rien ne me prévenait. Je ne les ai trouvées que parce que je réécrivais les pilotes à partir des datasheets et que j'ai dû vraiment relire chaque ligne que je remplaçais.
Si vous avez un kit DIY AirGradient et un Prometheus qui traîne quelque part, ça devrait marcher chez vous aussi. Le code est sur GitHub, sous licence MIT.