Autrefois, les systèmes d'automatisation domestique, souvent appelés « maison intelligente », étaient horriblement chers et seuls les riches pouvaient se les procurer. Aujourd'hui, on trouve sur le marché des ensembles relativement abordables avec des capteurs, des boutons/interrupteurs et des dispositifs d'action pour contrôler l'éclairage, les prises, la ventilation, l'approvisionnement en eau et d'autres consommateurs. Même le bricoleur le plus maladroit peut désormais se joindre à la beauté de cet univers en assemblant des dispositifs pour la maison intelligente sans débourser une fortune.

En règle générale, les dispositifs proposés sont soit des capteurs, soit des mécanismes d'action. Ils permettent de mettre en œuvre facilement des scénarios comme « lorsqu'un capteur de mouvement est activé, allumer la lumière » ou « l'interrupteur près de la sortie éteint la lumière dans tout l'appartement ». Mais la télémétrie, étonnamment, n'a pas vraiment décollé. Au mieux, il ne s'agit que d'un graphique de température et d'humidité, ou de la puissance instantanée d'une prise spécifique.
Récemment, j'ai fait installer des compteurs d'eau avec sortie à impulsions. Chaque litre qui passe par le compteur déclenche un contact. Il ne reste plus qu'à se connecter aux fils et à essayer d'en tirer quelque chose de bénéfique. Par exemple, analyser la consommation d'eau heure par heure et par jour de la semaine. Bien sûr, si l'appartement a plusieurs colonnes d'eau, il est plus pratique de voir toutes les lectures en temps réel sur un seul écran, plutôt que de fouiller dans des niches difficiles d'accès avec une lampe de poche.
Sous le capot se trouve ma version d'un appareil basé sur l'ESP8266, qui compte les impulsions des compteurs d'eau et envoie les lectures au serveur de la maison intelligente via MQTT. Nous allons programmer en micropython en utilisant la bibliothèque uasyncio. Lors de la création du firmware, j'ai rencontré plusieurs difficultés intéressantes dont je parlerai également dans cet article. C'est parti !
Configuration

Le cœur de tout le système est un module basé sur le microcontrôleur ESP8266. Initialement, j'avais prévu d'utiliser l'ESP-12, mais le mien était défectueux. J'ai donc dû me contenter du module ESP-07 qui était disponible. Heureusement, ils sont identiques en ce qui concerne les broches et les fonctionnalités, la seule différence étant l'antenne : l'ESP-12 a une antenne intégrée, tandis que l'ESP-07 en a une externe. Cependant, même sans antenne, le signal WiFi est correctement capté dans ma salle de bain.
Le câblage du module est standard :
- un bouton de réinitialisation avec tirage et condensateur (bien que les deux soient déjà présents à l'intérieur du module)
- Le signal enable (CH_PD) est relié à l'alimentation
- Le GPIO15 est connecté à la masse. Cela n'est nécessaire qu'au démarrage, mais de toute façon, je n'ai rien d'autre à connecter sur cette broche.
Pour passer le module en mode de programmation, il faut relier le GPIO2 à la masse, et pour plus de confort, j'ai prévu un bouton de démarrage. En état normal, cette broche est alimentée.
L'état de la ligne GPIO2 est vérifié uniquement au début du fonctionnement — lors de l'alimentation ou juste après un reset. Ainsi, le module se charge soit normalement, soit passe en mode de programmation. Après le démarrage, cette sortie peut être utilisée comme un GPIO classique. Et puisqu'il y a déjà un bouton, on peut lui attribuer une fonction utile.
Pour la programmation et le débogage, j'utiliserai l'UART, que j'ai connecté sur la platine. Quand nécessaire, je branche simplement un adaptateur USB-UART. Il faut juste se rappeler que le module est alimenté en 3.3V. Si l'on oublie de régler l'adaptateur sur cette tension et qu'on applique du 5V, le module risque de griller.
Je n'ai pas de problème avec l'électricité dans la salle de bain — la prise est située à environ un mètre des compteurs, donc je vais alimenter à partir de 220V. Comme source d'alimentation, je vais utiliser un petit de Tenstar Robot. Personnellement, je suis à l'aise avec l'électronique analogique et de puissance, et ici il y a un bloc d'alimentation prêt à l'emploi dans un petit boîtier.
Pour indiquer les modes de fonctionnement, j'ai prévu une LED connectée au GPIO2. Cependant, je ne l'ai pas soudée, car le module ESP-07 a déjà une LED, qui est d'ailleurs connectée au même GPIO2. Mais sur la carte, elle peut rester — au cas où je voudrais la sortir sur le boîtier.
Passons à la partie la plus intéressante. Les compteurs d'eau n'ont aucune logique, on ne peut pas demander les valeurs actuelles. La seule chose qui nous est accessible, ce sont des impulsions — un contact qui se ferme pour chaque litre. Les broches des relais sont connectées au GPIO12/GPIO13. Je vais activer la résistance de tirage par logiciel à l'intérieur du module.
Au départ, j'avais oublié d'inclure les résistances R8 et R9, et dans ma version de la carte, elles n'existent pas. Mais comme je partage déjà le schéma pour le grand public, il vaut mieux corriger cette omission. Les résistances sont nécessaires pour ne pas griller le port dans le cas où le firmware bug et mettrait un niveau haut sur la broche, tandis que le relais court-circuite cette ligne à la masse (avec une résistance, il ne circulera au maximum que 3.3V/1000Ω = 3.3mA).
Il est temps de réfléchir à ce qu'il faut faire si l'électricité disparaît. La première option serait de demander les valeurs initiales des compteurs au serveur dès le départ. Cependant, cela nécessiterait une complexification considérable du protocole d'échange. De plus, le bon fonctionnement de l'appareil dépend dans ce cas de l'état du serveur. Si, après une coupure de courant, le serveur ne redémarre pas (ou redémarre plus tard), le compteur d'eau ne pourrait pas demander les valeurs initiales et fonctionnerait alors de manière incorrecte.
C'est pourquoi j'ai décidé de réaliser l'enregistrement des valeurs des compteurs dans une puce mémoire connectée par I2C. Je n'ai pas d'exigences particulières concernant la taille de la mémoire flash — il suffit de stocker deux nombres (le nombre de litres d'eau chaude et d'eau froide). Même le plus petit module conviendrait. En revanche, il faut prêter attention au nombre de cycles d'écriture. Pour la plupart des modules, c'est 100 000 cycles, et pour certains, jusqu'à un million.
On pourrait penser qu'un million, c'est beaucoup. Mais pendant mes 4 années de vie dans mon appartement, j'ai consommé un peu plus de 500 mètres cubes d'eau, soit 500 000 litres ! Et cela représente 500 000 enregistrements en mémoire flash. Et c'est seulement pour l'eau froide. Bien sûr, je pourrais remplacer la puce tous les deux ans, mais il existe des puces FRAM. Du point de vue de la programmation, c'est le même EEPROM I2C, mais avec un nombre de cycles de réécriture très élevé (des centaines de millions). Cependant, je n'ai pas encore réussi à me rendre au magasin pour acheter ces puces, donc je vais garder la 24LC512 classique pour l'instant.
Carte imprimée
Au départ, je prévoyais de fabriquer la carte dans des conditions domestiques. C'est pourquoi la carte a été conçue comme unilatérale. Cependant, après avoir bataillé pendant une heure avec un fer à repasser laser et un masque de soudure (sans cela, ce n'est pas très chic), j'ai finalement décidé de commander des cartes chez des Chinois.

Juste avant de commander les cartes, j'ai réalisé qu'en plus de la puce de mémoire flash, je pouvais connecter quelque chose d'autre d'utile au bus I2C, par exemple un écran. Que montrer dessus est encore une question, mais il faut que les connexions soient faites sur la carte. Étant donné que j'allais commander des cartes à l'usine, il n'était plus sensé de me limiter à une carte unilatérale, donc les lignes I2C seront les seules au verso de la carte.
Le circuit imprimé à une face présentait un gros problème. Étant donné que le schéma était unilatéral, il était prévu de placer les pistes et les composants SMD d'un côté, tandis que les composants en sortie, les connecteurs et l'alimentation étaient situés de l'autre. Lorsque j'ai reçu les cartes un mois plus tard, j'ai oublié le plan initial et j'ai soudures tous les composants sur la face avant. Ce n'est que lorsqu'il s'est agi de souder l'alimentation que j'ai réalisé que le plus et le moins étaient inversés. J'ai dû bricoler avec des jumpers. Sur l'image ci-dessus, j'ai déjà modifié le câblage, mais la terre est reliée d'une partie de la carte à l'autre par les broches du bouton Boot (bien qu'il aurait été possible de tracer la piste sur la seconde couche).
Voici ce que cela donne

Boîtier
La prochaine étape est le boîtier. Avec une imprimante 3D, ce n'est pas un problème. Je ne me suis pas trop cassé la tête - j'ai juste dessiné une boîte aux dimensions requises et fait des découpes aux endroits nécessaires. Le couvercle est fixé au boîtier avec de petites vis autotaraudeuses.

J'ai déjà mentionné que le bouton Boot peut être utilisé comme un bouton polyvalent - donc nous allons le placer sur le panneau avant. Pour cela, j'ai dessiné un "puits" spécial où se trouve le bouton.

À l'intérieur du boîtier se trouvent également des supports sur lesquels la carte est installée et maintenue en place par une seule vis M3 (il n'y avait plus de place sur la carte).
J'ai choisi l'afficheur après avoir imprimé le premier prototype du boîtier. Un afficheur standard à deux lignes ne tenait pas dans ce boîtier, mais j'ai découvert un écran OLED SSD1306 128×32 dans mes affaires. C'est un peu petit, mais ce n'est pas un écran que je vais regarder tous les jours - ça ira.
En réfléchissant à comment les fils allaient être acheminés, j'ai décidé de coller l'afficheur au centre du boîtier. L'ergonomie est vraiment au ras du sol - le bouton est en haut, l'afficheur en bas. Mais j'ai déjà dit que l'idée de fixer l'afficheur est arrivée trop tard et j'avais la flemme de redessiner le circuit imprimé pour déplacer le bouton.
L'appareil monté. Le module d'affichage est collé à l'aide de colle chaude.


On peut voir le résultat final sur KDPV.
Firmware
Passons à la partie logicielle. Pour de petits projets comme celui-ci, j'aime beaucoup utiliser le langage Python ()- le code est très compact et facile à comprendre. Heureusement, il n'est pas nécessaire de descendre au niveau des registres pour gagner des microsecondes - tout peut être fait en Python.
Il semble que tout soit simple, mais en réalité ce n'est pas si évident — plusieurs fonctions indépendantes sont envisagées dans l'appareil :
- L'utilisateur clique sur le bouton et regarde l'écran
- Les litres s'accumulent et mettent à jour les valeurs dans la mémoire flash
- Le module surveille le signal WiFi et se reconnecte si nécessaire
- Mais sans la lampe clignotante, cela ne fonctionne pas du tout
Il ne faut pas laisser une fonction ne pas fonctionner pendant qu'une autre est en panne pour une raison quelconque. J'ai déjà eu ma dose de cactus dans d'autres projets et maintenant je vois les bugs du style “nous avons perdu un litre, parce qu'à ce moment-là, l'écran était en train de se mettre à jour” ou “l'utilisateur ne peut rien faire tant que le module se connecte au WiFi”. Bien sûr, certaines choses peuvent être réalisées via des interruptions, mais on peut se heurter à des limites de durée, de profondeur d'appels ou de changements de variables non atomiques. Et puis, le code qui s'occupe de tout en même temps se transforme rapidement en désordre.
Dans j'ai utilisé la préemption classique du multitâche et FreeRTOS, mais dans ce cas, un modèle s'est avéré beaucoup plus approprié. En effet, l'implémentation Python des coroutines est tout simplement incroyable — tout est fait de manière simple et pratique pour les programmeurs. Il suffit d'écrire la logique, en indiquant simplement où il est possible de basculer entre les threads.
Je propose d'étudier les différences entre le multitâche préemptif et coopératif en option. Maintenant, passons enfin au code.
#####################################
# Counter class - implements a single water counter on specified pin
#####################################
class Counter():
debounce_ms = const(25)
def __init__(self, pin_num, value_storage):
self._value_storage = value_storage
self._value = self._value_storage.read()
self._value_changed = False
self._pin = Pin(pin_num, Pin.IN, Pin.PULL_UP)
loop = asyncio.get_event_loop()
loop.create_task(self._switchcheck()) # Thread runs foreverChaque compteur est traité par une instance de la classe Counter. Tout d'abord, la valeur initiale du compteur est lue dans l'EEPROM (value_storage) — cela permet une récupération après une perte de courant.
Le pin est initialisé avec un pull-up intégré : si le capteur reed est fermé — la ligne est à zéro, s'il est ouvert, la ligne est tirée vers l'alimentation et le contrôleur lit un.
Ici, une tâche distincte est également lancée, qui s'occupera de l'interrogation du pin. Chaque compteur lancera sa propre tâche. Voici son code
""" Interroger le pin et avancer la valeur lorsque d'autres litres sont passés """
async def _switchcheck(self):
last_checked_pin_state = self._pin.value() # Obtenir l'état initial
# Poll pour un changement d'état du pin
while True:
state = self._pin.value()
if state != last_checked_pin_state:
# L'état a changé : agir maintenant.
last_checked_pin_state = state
if state == 0:
self._another_litre_passed()
# Ignorer les changements d'état ultérieurs jusqu'à ce que le commutateur soit stabilisé
await asyncio.sleep_ms(Counter.debounce_ms)Un délai de 25 ms est nécessaire pour filtrer les rebonds des contacts, et en même temps, il régule la fréquence à laquelle la tâche se réveille (tant que cette tâche dort, d'autres tâches fonctionnent). Toutes les 25 ms, la fonction se réveille, vérifie la broche et si les contacts du capteur sont fermés, cela signifie qu'un autre litre a passé le compteur et cela doit être traité.
def _another_litre_passed(self):
self._value += 1
self._value_changed = True
self._value_storage.write(self._value)Le traitement d'un litre supplémentaire est trivial : il suffit d'augmenter le compteur. Il serait également bon d'enregistrer la nouvelle valeur sur la clé USB.
Pour faciliter son utilisation, des "accessoires" sont prévus.
def value(self):
self._value_changed = False
return self._value
def set_value(self, value):
self._value = value
self._value_changed = FalseEh bien, profitons maintenant des joies de Python et de la bibliothèque uasync et rendons l'objet compteur attendable (comment traduire cela en français ? Celui que l'on peut attendre ?)
def __await__(self):
while not self._value_changed:
yield from asyncio.sleep(0)
return self.value()
__iter__ = __await__ C'est une fonction pratique qui attend que la valeur du compteur soit mise à jour — la fonction se réveille de temps en temps et vérifie le drapeau _value_changed. L'astuce de cette fonction est que le code appelant peut dormir lors de l'appel de cette fonction et rester en veille jusqu'à ce qu'il obtienne une nouvelle valeur.
Et les interruptions ?Oui, à ce stade, vous pouvez me taquiner, en disant que j'ai moi-même parlé des interruptions, mais en réalité, j'ai mis en place un simple sondage de broche. En réalité, les interruptions sont la première chose que j'ai essayée. Sur l'ESP8266, il est possible d'organiser une interruption sur un front, et même d'écrire un gestionnaire pour cette interruption en Python. Dans cette interruption, il est possible de mettre à jour la valeur de la variable. Cela suffirait probablement si le compteur était un appareil esclave — celui qui attend que sa valeur soit demandée.
Malheureusement (ou heureusement ?), mon appareil est actif, il doit envoyer des messages par protocole MQTT et enregistrer des données dans l'Eeprom. Et ici commencent les limitations : dans les interruptions, il est impossible d'allouer de la mémoire et d'utiliser une grande pile, donc on peut oublier l'envoi de messages sur le réseau. Il existe des fonctionnalités comme micropython.schedule() qui permettent de lancer une fonction « dès que possible », mais la question se pose : « quel en est l'intérêt ? ». Par exemple, nous envoyons en ce moment un message quelconque, et une interruption s'invite, altérant les valeurs des variables. Ou, par exemple, une nouvelle valeur de compteur est arrivée du serveur alors que nous n'avons pas encore enregistré l'ancienne. Bref, il faut mettre en place une synchronisation ou trouver une autre solution.
Et de temps en temps, une erreur RuntimeError : schedule stack full survient, et qui sait pourquoi ?
Avec un sondage explicite et uasync, cela devient plus esthétique et fiable dans ce cas.
J'ai extrait le travail avec l'Eeprom dans une petite classe.
class EEPROM():
i2c_addr = const(80)
def __init__(self, i2c):
self.i2c = i2c
self.i2c_buf = bytearray(4) # Éviter la création/destruction du tampon à chaque appel
def read(self, eeprom_addr):
self.i2c.readfrom_mem_into(self.i2c_addr, eeprom_addr, self.i2c_buf, addrsize=16)
return ustruct.unpack_from("<I", self.i2c_buf)[0]
def write(self, eeprom_addr, value):
ustruct.pack_into("<I", self.i2c_buf, 0, value)
self.i2c.writeto_mem(self.i2c_addr, eeprom_addr, self.i2c_buf, addrsize=16)Travailler directement avec des octets en Python est compliqué, alors que ce sont précisément les octets qui sont enregistrés en mémoire. J'ai dû créer une conversion entre un entier et des octets en utilisant la bibliothèque ustruct.
Pour ne pas avoir à transmettre chaque fois l'objet I2C et l'adresse de la mémoire, j'ai tout enveloppé dans une petite classe pratique.
class EEPROMValue():
def __init__(self, i2c, eeprom_addr):
self._eeprom = EEPROM(i2c)
self._eeprom_addr = eeprom_addr
def read(self):
return self._eeprom.read(self._eeprom_addr)
def write(self, value):
self._eeprom.write(self._eeprom_addr, value)L'objet I2C lui-même est créé avec ces paramètres.
i2c = I2C(freq=400000, scl=Pin(5), sda=Pin(4))Nous arrivons à la partie la plus intéressante — la mise en œuvre de la communication avec le serveur via MQTT. Eh bien, il n'est pas nécessaire d'implémenter le protocole — une réalisation asynchrone a été trouvée sur internet. . Nous allons l'utiliser.
Tout ce qui est le plus intéressant est regroupé dans la classe CounterMQTTClient, qui est basée sur la bibliothèque MQTTClient. Commençons par les périphériques.
#####################################
# Class handles both counters and sends their status to MQTT
#####################################
class CounterMQTTClient(MQTTClient):
blue_led = Pin(2, Pin.OUT, value = 1)
button = Pin(0, Pin.IN)
hot_counter = Counter(12, EEPROMValue(i2c, EEPROM_ADDR_HOT_VALUE))
cold_counter = Counter(13, EEPROMValue(i2c, EEPROM_ADDR_COLD_VALUE))Ici, les broches de l'ampoule et du bouton sont créées et configurées, ainsi que les objets des compteurs d'eau froide et chaude.
L'initialisation n'est pas si triviale
def __init__(self):
self.internet_outage = True
self.internet_outages = 0
self.internet_outage_start = ticks_ms()
with open("config.txt") as config_file:
config['ssid'] = config_file.readline().rstrip()
config['wifi_pw'] = config_file.readline().rstrip()
config['server'] = config_file.readline().rstrip()
config['client_id'] = config_file.readline().rstrip()
self._mqtt_cold_water_theme = config_file.readline().rstrip()
self._mqtt_hot_water_theme = config_file.readline().rstrip()
self._mqtt_debug_water_theme = config_file.readline().rstrip()
config['subs_cb'] = self.mqtt_msg_handler
config['wifi_coro'] = self.wifi_connection_handler
config['connect_coro'] = self.mqtt_connection_handler
config['clean'] = False
config['clean_init'] = False
super().__init__(config)
loop = asyncio.get_event_loop()
loop.create_task(self._heartbeat())
loop.create_task(self._counter_coro(self.cold_counter, self._mqtt_cold_water_theme))
loop.create_task(self._counter_coro(self.hot_counter, self._mqtt_hot_water_theme))
loop.create_task(self._display_coro())Pour définir les paramètres de travail de la bibliothèque mqtt_as, un grand dictionnaire de différentes configurations — config — est utilisé. La plupart des paramètres par défaut nous conviennent, mais de nombreux paramètres doivent être définis explicitement. Pour ne pas coder les paramètres directement dans le code, je les conserve dans un fichier texte config.txt. Cela permet de modifier le code indépendamment des paramètres, ainsi que de créer plusieurs dispositifs identiques avec différents paramètres.
Le dernier bloc de code lance plusieurs coroutines pour gérer diverses fonctions du système. Voici par exemple une coroutine qui gère les compteurs.
async def _counter_coro(self, counter, topic):
# Publier la valeur initiale
value = counter.value()
await self.publish(topic, str(value))
# Publier chaque nouvelle valeur
while True:
value = await counter
await self.publish_msg(topic, str(value))La coroutine attend dans une boucle une nouvelle valeur du compteur et, dès qu'elle apparaît, envoie un message via le protocole MQTT. Le premier morceau de code envoie la valeur initiale même si l'eau ne s'écoule pas à travers le compteur.
La classe de base MQTTClient se gère elle-même, initie la connexion WiFi et se reconnecte lorsque la connexion est perdue. En cas de changements d'état de la connexion WiFi, la bibliothèque nous informe en appelant wifi_connection_handler.
async def wifi_connection_handler(self, state):
self.internet_outage = not state
if state:
self.dprint('WiFi est actif.')
duration = ticks_diff(ticks_ms(), self.internet_outage_start) / 1000
await self.publish_debug_msg('ReconnectéAprès', duration)
else:
self.internet_outages += 1
self.internet_outage_start = ticks_ms()
self.dprint('WiFi est hors ligne.')
await asyncio.sleep(0)La fonction est honnêtement tirée des exemples. Dans ce cas, elle compte le nombre de coupures (internet_outages) et leur durée. Lors de la restauration de la connexion, le temps d'arrêt est envoyé au serveur.
D'ailleurs, le dernier sleep est nécessaire uniquement pour rendre la fonction asynchrone — dans la bibliothèque, elle est appelée via await, et seules les fonctions contenant un autre await peuvent être appelées comme cela.
En plus de la connexion WiFi, il faut également établir une connexion avec le courtier MQTT (serveur). C'est aussi géré par la bibliothèque, et nous avons l'occasion de faire quelque chose d'utile lorsque la connexion est établie.
async def mqtt_connection_handler(self, client):
await client.subscribe(self._mqtt_cold_water_theme)
await client.subscribe(self._mqtt_hot_water_theme)Ici, nous nous abonnissons à plusieurs messages — le serveur a maintenant la possibilité de définir les valeurs actuelles des compteurs en envoyant le message correspondant.
def mqtt_msg_handler(self, topic, msg):
topicstr = str(topic, 'utf8')
self.dprint("Message MQTT reçu topic={}, msg={}".format(topicstr, msg))
if topicstr == self._mqtt_cold_water_theme:
self.cold_counter.set_value(int(msg))
if topicstr == self._mqtt_hot_water_theme:
self.hot_counter.set_value(int(msg))Cette fonction traite les messages reçus, et en fonction du sujet (nom du message), les valeurs de l'un des compteurs sont mises à jour.
Un couple de fonctions utilitaires
# Publish a message if WiFi and broker is up, else discard
async def publish_msg(self, topic, msg):
self.dprint("Publishing message on topic {}: {}".format(topic, msg))
if not self.internet_outage:
await self.publish(topic, msg)
else:
self.dprint("Message was not published - no internet connection")Cette fonction s'occupe d'envoyer le message si la connexion est établie. Si la connexion n'est pas présente, le message est ignoré.
Et c'est juste une fonction pratique qui forme et envoie des messages de débogage.
async def publish_debug_msg(self, subtopic, msg):
await self.publish_msg("{}\/{}".format(self._mqtt_debug_water_theme, subtopic), str(msg))
Il y a tant de texte, et nous n'avons même pas encore fait clignoter la LED. Voilà.
# Blink flash LED if WiFi down
async def _heartbeat(self):
while True:
if self.internet_outage:
self.blue_led(not self.blue_led()) # Fast blinking if no connection
await asyncio.sleep_ms(200)
else:
self.blue_led(0) # Rare blinking when connected
await asyncio.sleep_ms(50)
self.blue_led(1)
await asyncio.sleep_ms(5000)J'ai prévu 2 modes de clignotement. Si la connexion est perdu (ou juste établie), alors le dispositif clignotera rapidement. Si la connexion est établie, le dispositif clignote une fois toutes les 5 secondes. Si nécessaire, d'autres modes de clignotement peuvent être implémentés ici.
Mais la LED c'est juste un petit amusement. Nous avons aussi envisagé d'utiliser l'écran.
async def _display_coro(self):
display = SSD1306_I2C(128,32, i2c)
while True:
display.poweron()
display.fill(0)
display.text("FROID: {:.3f}".format(self.cold_counter.value() / 1000), 16, 4)
display.text("CHAUD: {:.3f}".format(self.hot_counter.value() / 1000), 16, 20)
display.show()
await asyncio.sleep(3)
display.poweroff()
while self.button():
await asyncio.sleep_ms(20)Voilà ce dont je parlais — comme il est simple et pratique d'utiliser des coroutines. Cette petite fonction décrit TOUTE l'interaction avec l'utilisateur. La coroutine attend simplement que le bouton soit pressé et allume l'écran pendant 3 secondes. L'écran affiche les relevés actuels des compteurs.
Il reste encore quelques petits détails. Voici la fonction qui (re)démarre tout ça. La boucle principale s'occupe simplement d'envoyer différentes informations de débogage une fois par minute. En gros, je présente les choses telles qu'elles sont — je ne pense pas qu'il soit nécessaire d'en dire plus.
async def main(self):
while True:
try:
await self._connect_to_WiFi()
await self._run_main_loop()
except Exception as e:
self.dprint('Erreur de communication globale : ', e)
await asyncio.sleep(20)
async def _connect_to_WiFi(self):
self.dprint('Connexion à WiFi et MQTT')
sta_if = network.WLAN(network.STA_IF)
sta_if.connect(config['ssid'], config['wifi_pw'])
conn = False
while not conn:
await self.connect()
conn = True
self.dprint('Connecté !')
self.internet_outage = False
async def _run_main_loop(self):
# Boucle infinie
mins = 0
while True:
gc.collect() # Pour les statistiques de RAM.
mem_free = gc.mem_free()
mem_alloc = gc.mem_alloc()
try:
await self.publish_debug_msg("Uptime", mins)
await self.publish_debug_msg("Repubs", self.REPUB_COUNT)
await self.publish_debug_msg("Outages", self.internet_outages)
await self.publish_debug_msg("MemFree", mem_free)
await self.publish_debug_msg("MemAlloc", mem_alloc)
except Exception as e:
self.dprint("Une exception s'est produite : ", e)
mins += 1
await asyncio.sleep(60)Eh bien, encore quelques configurations et constantes pour une description complète.
#####################################
# Constants and configuration
#####################################
config['keepalive'] = 60
config['clean'] = False
config['will'] = ('/ESP/Wemos/Water/LastWill', 'Goodbye cruel world!', False, 0)
MQTTClient.DEBUG = True
EEPROM_ADDR_HOT_VALUE = const(0)
EEPROM_ADDR_COLD_VALUE = const(4)Tout cela se lance comme ça
client = CounterMQTTClient()
loop = asyncio.get_event_loop()
loop.run_until_complete(client.main())Il se passe quelque chose avec ma mémoire.
Ainsi, tout le code est là. J'ai téléchargé les fichiers à l'aide de l'outil ampy — il permet de les téléverser sur la mémoire interne (celle de l'ESP-07) et ensuite d'y accéder depuis le programme comme à des fichiers ordinaires. J'y ai également téléchargé les bibliothèques que j'utilise, mqtt_as, uasyncio, ssd1306 et collections (utilisé dans mqtt_as).
Nous lançons et… nous obtenons un MemoryError. En effet, plus j'essayais de comprendre où la mémoire fuyait, plus je plaçais des impressions de débogage, plus cette erreur apparaissait rapidement. Une recherche rapide sur Google m'a permis de comprendre que le microcontrôleur a en tout 30 Ko de mémoire, alors que 65 Ko de code (y compris les bibliothèques) ne peuvent pas tenir.
Mais il existe une solution. Il s'avère que Micropython n'exécute pas le code directement à partir d'un fichier .py — ce fichier est d'abord compilé. De plus, il est compilé directement sur le microcontrôleur, se transformant en bytecode qui est ensuite stocké dans la mémoire. Et pour faire fonctionner le compilateur, un certain volume de RAM est également nécessaire.
Le truc consiste à débarrasser le microcontrôleur de la compilation gourmande en ressources. On peut compiler les fichiers sur un grand ordinateur, puis injecter le bytecode prêt à l'emploi dans le microcontrôleur. Pour cela, il faut télécharger le firmware Micropython et assembler .
Je n'ai pas écrit de Makefile, mais j'ai manuellement parcouru et compilé tous les fichiers nécessaires (y compris les bibliothèques) à peu près comme ça
mpy-cross water_counter.pyIl ne reste plus qu'à transférer les fichiers avec l'extension .mpy, en n'oubliant pas de supprimer les .py correspondants du système de fichiers de l'appareil.
Tout le développement a été fait dans le programme (IDE ?) ESPlorer. Il permet de transférer des scripts dans le microcontrôleur et de les exécuter immédiatement. Dans mon cas, toute la logique et la création de tous les objets se trouvent dans le fichier water_counter.py (.mpy). Mais pour que tout cela se lance automatiquement au démarrage, il doit y avoir un autre fichier nommé main.py. Et celui-ci doit être précisément un .py, et non un .mpy précompilé. Voici son contenu trivial
import water_counterOn lance — ça fonctionne. Mais la mémoire libre est alarmante, d'environ 1 Ko. J'ai encore des projets d'expansion des fonctionnalités de l'appareil, et ce kilooctet ne sera clairement pas suffisant. Mais il s'est avéré qu'il y a aussi une solution pour ce cas.
Voici de quoi il s'agit. Même si les fichiers sont compilés en bytecode et se trouvent sur le système de fichiers interne, en réalité, ils sont quand même chargés dans la mémoire RAM et s'exécutent à partir de là. Mais il s'avère que Micropython peut exécuter du bytecode directement à partir de la mémoire flash, mais pour cela, il faut l'intégrer directement dans le firmware. Ce n'est pas compliqué, bien que cela ait pris pas mal de temps sur mon netbook (surtout qu'il y avait Linux là-bas).
L'algorithme est le suivant :
- Télécharger et installer . Cet outil compile le compilateur et les bibliothèques pour les programmes sur ESP8266. Il se compose selon les instructions sur la page principale du projet (j'ai choisi l'installation STANDALONE=yes)
- Télécharger
- Les bibliothèques nécessaires à placer dans ports/esp8266/modules au sein du répertoire de micropython.
- On compile le firmware selon les instructions dans le fichier
- Téléchargez le firmware dans le microcontrôleur (je le fais sur Windows avec les programmes ESP8266Flasher ou le script Python esptool).
Voilà, maintenant 'import ssd1306' chargera le code directement depuis le firmware et la mémoire RAM ne sera pas utilisée pour cela. Avec cette astuce, j'ai intégré uniquement le code des bibliothèques dans le firmware, tandis que le code principal de mon programme s'exécute depuis le système de fichiers. Cela permet de modifier facilement le programme sans recompilation du firmware. Actuellement, j'ai environ 8,5 Ko de RAM libre. Cela permettra de réaliser encore beaucoup de fonctionnalités utiles à l'avenir. Et si jamais la mémoire venait à manquer, il est possible d'intégrer le programme principal dans le firmware.
Et maintenant, que faire avec ça ?
Ok, le matériel est soudé, le firmware est écrit, la boîte est imprimée, l'appareil est fixé au mur et clignote joyeusement avec une lampe. Mais pour l'instant, c'est toujours une boîte noire (dans tous les sens du terme) et son utilité est encore limitée. Il est temps de s'occuper des messages MQTT qui sont envoyés au serveur.
Mon 'maison intelligente' fonctionne sur Le module MQTT existe peut-être par défaut, ou il s'installe facilement depuis le marché des extensions — je ne me souviens plus d'où il vient. MQTT n'est pas autonome — il nécessite ce qu'on appelle un broker — un serveur qui reçoit, classe et redirige les messages MQTT aux clients. J'utilise mosquitto, qui (comme Majordomo) fonctionne sur le même netbook.
Une fois que l'appareil a envoyé un message, la valeur apparaît immédiatement dans la liste.

Ces valeurs peuvent maintenant être liées aux objets du système, utilisées dans des scénarios d'automatisation et soumises à diverses analyses — tout cela dépasse le cadre de cet article. Pour ceux qui s'intéressent à Majordomo, je peux recommander Un camarade construit également une maison intelligente et explique clairement comment configurer le système.
Je ne montrerai que quelques graphiques. C'est un graphique simple des valeurs sur 24 heures.

On voit qu'il n'y a presque eu aucune utilisation de l'eau la nuit. Quelques fois, quelqu'un est allé aux toilettes, et il semble que le filtre à osmose inverse absorbe quelques litres pendant la nuit. Le matin, la consommation augmente considérablement. D'habitude, j'utilise l'eau du chauffe-eau, mais ici j'ai voulu prendre un bain et j'ai temporairement basculé sur l'eau chaude municipale — cela se voit également clairement sur le graphique inférieur.
D'après ce graphique, j'ai appris que pour aller aux toilettes, cela représente 6 à 7 litres d'eau, prendre une douche consomme entre 20 et 30 litres, faire la vaisselle environ 20 litres, et prendre un bain nécessite 160 litres. En une journée, ma famille consomme environ 500 à 600 litres.
Pour les plus curieux, vous pouvez consulter les enregistrements de chaque valeur spécifique.

J'ai appris que lorsque le robinet est ouvert, l'eau coule à une vitesse d'environ 1 litre toutes les 5 secondes.
Cependant, sous cette forme, les statistiques sont peut-être difficiles à lire. Dans majordomo, il y a aussi d'autres options pour visualiser la consommation sous forme de graphiques par jours, semaines et mois. Voici, par exemple, un graphique de consommation sous forme de barres.

Pour l'instant, j'ai des données seulement pour une semaine. Dans un mois, ce graphique sera plus représentatif : chaque jour aura sa propre barre. Les ajustements des valeurs que j'entrer manuellement (la barre la plus haute) nuisent un peu au tableau. Et il n'est pas encore clair si j'ai mal saisi les premières valeurs, presque un mètre de moins, ou s'il y a un bug dans le firmware et que tous les litres ne sont pas comptabilisés. Il faut plus de temps.
Il reste encore à peaufiner les graphiques, à les éclaircir et à les colorer. Je pourrais également envisager, dans un but de débogage, de construire un graphique de consommation de mémoire — peut-être qu'il y a une fuite. Je réfléchirai aussi à comment afficher les périodes où l'internet était absent. Pour l'instant, tout cela reste une idée.
Conclusion
Aujourd'hui, mon appartement est devenu un peu plus intelligent. Avec ce petit appareil, il me sera plus facile de suivre la consommation d'eau dans la maison. Si auparavant je râlais « encore beaucoup d'eau consommée ce mois-ci », maintenant je pourrai identifier la source de cette consommation.
Certaines personnes pourraient trouver étrange de regarder les données à l'écran alors qu'il est à un mètre du compteur. Mais dans un futur pas très éloigné, je prévois de déménager dans un autre appartement où il y aura plusieurs colonnes d'eau, et les compteurs seront probablement situés dans la cage d'escalier. Ainsi, un appareil de relevé à distance sera très utile.
Je prévois également d'élargir la fonctionnalité de l'appareil. Je suis déjà en train de m'intéresser aux vannes motorisées. Actuellement, pour changer entre l'eau du chauffe-eau et de la ville, je dois tourner trois robinets dans un coin difficile d'accès. Ce serait beaucoup plus pratique de le faire d'une seule pression sur un bouton avec une indication correspondante. Et bien sûr, il convient de mettre en place une protection contre les fuites.
Dans cet article, j'ai présenté ma version d'un appareil basé sur l'ESP8266. À mon avis, j'ai créé une version de firmware assez intéressante avec micropython en utilisant des coroutines — c'est simple et élégant. J'ai essayé de décrire de nombreux détails et erreurs que j'ai rencontrés en cours de route. Peut-être que j'ai trop détaillé les choses, personnellement, en tant que lecteur, je préfère faire défiler ce qui est superflu, plutôt que de deviner ce qui n'a pas été dit.
Comme toujours, je suis ouvert aux critiques constructives.
Source : habr.com
