Stéphane.
AutomationArchivé

Débitmètre Raspberry Pi — compter des impulsions sans jamais rien écrire

Un capteur à effet Hall YF-S201 sur un Raspberry Pi, une interruption GPIO par tour de rotor, et deux défauts dans dix lignes qui rendaient la mesure fausse puis muette. Relecture six ans après.

Technologies employées

  • Raspberry Pi 3B
  • YF-S201
  • Python 3
  • RPi.GPIO
  • Supervisor
  • Linux
  • Bash

Fin avril 2020, j’ai voulu savoir combien d’eau passait réellement dans une canalisation. Un capteur à quelques euros, un Raspberry Pi, une trentaine de lignes de Python : sur le papier, l’affaire d’une soirée. Le montage a fonctionné, les impulsions sont bien arrivées, et le fichier de mesures est resté vide. En relisant ce code six ans plus tard, j’y trouve deux défauts que je n’avais pas vus à l’époque — l’un rendait la mesure fausse d’un facteur 60, l’autre l’empêchait purement et simplement d’être enregistrée.

Le capteur et le principe

Un YF-S201 est un boîtier en plastique traversé par un rotor à aimant, avec un capteur à effet Hall collé contre la paroi. L’eau fait tourner le rotor, l’aimant passe devant le capteur, et celui-ci commute. On n’obtient pas un débit : on obtient un train d’impulsions dont la fréquence est proportionnelle au débit.

Câblage : un Raspberry Pi 3 Model B relié par un fil rouge, un noir et un jaune à un capteur de débit d’eau YF-S201, ainsi qu’à un capteur de température et d’humidité DHT11 bleu.
Trois fils entre la carte et le capteur : alimentation, masse, et le signal sur la broche 11 — soit GPIO 17 en numérotation BCM.

Le capteur est donné pour 1 à 30 litres par minute, sous 1,75 MPa au maximum, et sa constante vaut 7,5 : 7,5 impulsions par seconde pour un litre par minute. Toute la mesure tient dans cette phrase, et c’est précisément elle que mon code a mal traduite.

Un DHT11 figure aussi sur le schéma, pour relever température et humidité. Il n’a jamais été lu : la ligne correspondante est restée commentée. C’est un détail — mais il explique le second défaut.

La chaîne de mesure

Rien n’est interrogé : tout part d’une interruption matérielle. Le programme ne demande jamais au capteur où il en est, c’est le capteur qui réveille le programme à chaque tour.

  1. rotorl’eau le fait tourner
  2. effet Hallune impulsion par tour
  3. GPIO 17front montant
  4. callbackcompteur + 1
  5. toutes les 2 scalcul et écriture

Le calcul et l’écriture du fichier ont lieu DANS la fonction d’interruption — c’est l’origine des ennuis.

Aucune scrutation dans la chaîne : le front montant réveille la fonction, qui n’a qu’un compteur à incrémenter.

Ce choix-là était bon. GPIO.add_event_detect sur front montant coûte infiniment moins qu’une boucle qui relit une broche des milliers de fois par seconde, et le programme principal peut dormir. La numérotation était juste elle aussi : broche physique 11, GPIO.setmode(GPIO.BCM), broche 17. C’est cohérent, et c’est le genre de détail où l’on se trompe une fois sur deux.

Ce qui vient après l’est beaucoup moins.

Deux défauts en dix lignes

rpi-debitmetre/00-scripts/python_debitmetre.py
tdelta = (time.time() - time_start)
if tdelta >= 2:
    flow = (pulseCount / calibrationFactor) / (tdelta * 60)
    flow = round(flow, 2)

    fichier = open("/home/pi/mesuresDebit.log", "a")
    fichier.write("%s,%s,%s,%s\n" % (time.strftime('%a %H:%M:%S'), flow))
    fichier.close()

Le calcul est faux d’un facteur 60. La constante du capteur relie une fréquence à un débit : le débit en litres par minute vaut le nombre d’impulsions par seconde divisé par 7,5. Il fallait donc diviser le compteur par la durée écoulée, puis par 7,5. J’ai divisé par 7,5, puis par la durée multipliée par 60 — converti des minutes en secondes dans le mauvais sens, et ajouté une division par la durée là où il n’en fallait pas.

Sur un débit réel de 10 L/min, le capteur émet 75 impulsions par seconde, soit 150 sur les deux secondes de la fenêtre. La ligne écrite annonce alors 0,17 L/min. Le rapport est constant : la mesure est 60 fois trop basse, toujours.

Rien n’est jamais écrit. La chaîne de format porte quatre %s et ne reçoit que deux valeurs. Python lève TypeError: not enough arguments for format string — et la lève dans le fil d’exécution de l’interruption, où RPi.GPIO l’affiche sans arrêter le programme. Le processus continue donc de tourner, l’affichage à l’écran continue de défiler, et mesuresDebit.log reste vide.

L’origine du défaut est visible quelques lignes plus haut dans le fichier : la lecture du DHT11 est commentée. Les quatre emplacements attendaient l’horodatage, le débit, l’humidité et la température. En retirant la lecture des deux dernières valeurs, j’ai retiré les arguments sans retirer leurs emplacements.

DéfautConséquence observableCe qu’il fallait écrire
Conversion de fréquence inverséeDébit annoncé 60 fois trop bas, systématiquement(pulseCount / tdelta) / 7.5
Quatre %s pour deux valeursTypeError à chaque cycle, fichier de mesures videDeux emplacements, ou remettre les deux mesures
Ouverture du fichier dans l’interruptionÉcriture disque à la cadence du capteur, compteur exposéAccumuler, et écrire depuis la boucle principale

Le troisième point du tableau n’est pas un bug, mais il en aurait produit : ouvrir, écrire et fermer un fichier depuis une fonction d’interruption place une entrée-sortie disque sur le chemin critique du capteur. À trente litres par minute, le front montant arrive 225 fois par seconde. L’incrément et sa remise à zéro ne sont pas davantage protégés : rien ne garantit qu’aucune impulsion ne tombe entre le calcul et la remise à zéro du compteur.

Ce que Supervisor apportait

La seconde moitié du dépôt ne parle plus du capteur, mais de sa disponibilité : un programme de mesure qui s’arrête et que personne ne relance ne mesure rien.

Supervisor démarre le script au démarrage de la machine et le relance en cas d’arrêt inattendu. Au-dessus, un script Python interroge supervisorctl status toutes les cinq secondes, le compare à un état écrit dans un fichier, et corrige l’écart. Un état désiré, un état constaté, une boucle qui les rapproche : c’est de la réconciliation, six ans avant que je ne mette ce mot dessus et que je ne l’applique à des clusters entiers.

Deux choses n’allaient pas, en revanche, et la première est sérieuse.

rpi-debitmetre/04-conf/etc/supervisor/conf.d/http.conf
[inet_http_server]
port = 0.0.0.0:9001

L’interface XML-RPC de Supervisor, exposée sur toutes les interfaces, sans nom d’utilisateur ni mot de passe. Quiconque atteignait ce port pouvait arrêter, démarrer ou relire les journaux de n’importe quel programme supervisé — le tout tournant sous root. Sur un réseau domestique de 2020, le risque restait théorique ; écrire cela aujourd’hui dans un dépôt me vaudrait un refus en revue, et à juste titre.

La seconde est plus modeste : le script vérifie que le binaire supervisord existe et que le service tourne, mais sa boucle principale s’exécute quand même si ces contrôles échouent. La vérification était écrite, son résultat n’était pas utilisé.

Enseignements

  1. Une constante de capteur est une relation entre deux unités : l’écrire en toutes lettres en commentaire aurait suffi à faire voir l’erreur de facteur 60.
  2. Un processus vivant n’est pas un processus qui fonctionne. Ici tout était au vert et le fichier de mesures restait vide.
  3. Une fonction d’interruption ne fait qu’une chose : incrémenter. Le calcul et l’écriture appartiennent à la boucle principale.
  4. Une erreur avalée par une bibliothèque est pire qu’un plantage : le plantage aurait signalé le défaut en deux secondes.
  5. Une interface d’administration sans authentification sur 0.0.0.0 reste une interface d’administration sans authentification, même sur un réseau domestique.

Ce projet n’a jamais produit une seule mesure exploitable, et c’est pour cela qu’il a sa place ici. La partie réputée difficile — l’électronique, l’interruption matérielle, la supervision — fonctionnait. Ce qui a échoué tenait dans une division et dans deux %s de trop, sans que rien ne vienne le dire. Six ans plus tard, l’essentiel de mon travail consiste à faire en sorte qu’une situation pareille se signale d’elle-même.