Faire clignoter plusieurs LED

Ce tutoriel aborde le sujet de la programmation multitâches avec MicroPython. Bien évidemment, nous n’allons pas réaliser un système d’exploitation ! Dans un premier temps, nous nous limiterons à démontrer comment, par une gestion astucieuse du temps, et éventuellement par l’usage de timers, il est possible de donner l’illusion que le STM32WB55 exécute plusieurs tâches indépendantes simultanément. Nous avons choisi trois tâches très simples utilisant les LED intégrées à la NUCLEO-WB55 :

  • Tâche 1 : fait clignoter la LED rouge (sérigraphiée LED3 sur le PCB) à la fréquence de 0.5 Hz
  • Tâche 2 : fait clignoter la LED verte (sérigraphiée LED2 sur le PCB) à la fréquence de 2 Hz
  • Tâche 3 : fait clignoter la LED bleue (sérigraphiée LED1 sur le PCB) à la fréquence de 10 Hz

Mais MicroPython est une API très puissante qui permet aussi d’implémenter simplement du véritable multitâche avec les bibliothèques uasyncio et thread. Nous finirons donc notre tutoriel avec un script mettant en oeuvre la bibliothèque uasyncio (sachant que thread, plus simple d’utilisation, est encore en phase béta à la date où ce tutoriel est rédigé).

Méthode numéro 1 : Partage du temps avec la méthode time.ticks_ms()

Les scripts présentés ci-après sont disponibles dans la zone de téléchargement.

Cette première approche est directement inspirée de nombreux exemples que l’on trouve sur Internet, généralement pour l’API Arduino. Tout le traitement est entièrement réalisé dans la “boucle principale” (while True:).

Le code de main.py

import time # Bibliothèque pour gérer les temporisations

# Variable pour suivre le temps écoulé depuis le lancement du script
n = 0

# Variables globales pour les dates de clignotement des LED
blink1 = 0
blink2 = 0
blink3 = 0

while True: # Boucle infinie

	# On teste le temps écoulé pour chaque tâche.
	# Remarquez que les tests sur les durées ne portent pas sur une égalité (stricte) mais sur un dépassement.
	# Par exemple on écrit "if time.ticks_diff(now, blink1) > 99" et pas "if time.ticks_diff(now, blink1) == 99".
	# En effet, il est peu probable que la boucle infinie réalise son test exactement à l'instant qui est prévu.
	# Il y aura donc une certaine incertitude (faible) sur les fréquences de clignotement des LED.
	
	# Nombre de millisecondes écoulées depuis le lancement du script
	n = time.ticks_ms()
	
	# Tâche 1 : clignotement de la LED rouge
	if time.ticks_diff(n, blink1) > 1999: # Toutes les 2000 ms depuis la dernière inversion...
		pyb.LED(1).toggle() # On inverse la LED...
		blink1 = n # Et on mémorise la date de cet évènement.
	
	# Tâche 2 : clignotement de la LED verte
	if time.ticks_diff(n, blink2) > 499: # Toutes les 500 ms depuis la dernière inversion...
		pyb.LED(2).toggle() # On inverse la LED...
		blink2 = n # Et on mémorise la date de cet évènement.
	
	# Tâche 3 : clignotement de la LED bleue
	if time.ticks_diff(n, blink3) > 99: # Toutes les 100 ms depuis la dernière inversion...
		pyb.LED(3).toggle() # on inverse la LED...
		blink3 = n # Et on mémorise la date de cet évènement.

Le code est particulièrement lisible, mais la méthode est impitoyable pour le microcontrôleur qui est actif et consomme de l’énergie 100% du temps alors que le clignotement des LED - la partie utile du programme - ne l’occupe qu’une très petite fraction du temps. Cette méthode peut facilement se généraliser à plus de “taches” (faire clignoter d’autres LED, scruter plusieurs boutons, etc.). Mais une autre de ses limitations apparaît immédiatement : la lisibilité et la facilité à maintenir et modifier le programme vont rapidement se dégrader lorsqu’on multipliera les “taches”.

Méthode numéro 2 : Partage du temps à l’aide d’un timer système

Les scripts présentés ci-après sont disponibles dans la zone de téléchargement.

Cette deuxième approche utilise un timer qui est démarré avant la boucle principale et qui décompte le temps de façon totalement indépendante. Vous pouvez imaginer le timer comme un compteur intégré au microcontrôleur.

Une fois paramétré et démarré, un timer va incrémenter son compteur interne CNT par pas de 1, de 0 jusqu’à une valeur maximum, par exemple CNT_max = 4095. Lorsque CNT = CNT_max le timer génère une interruption dite “de dépassement de compteur”. A ce moment là, CNT revient à zéro et le timer recommence son cycle de décompte : CNT = 0, CNT = 1… Le schéma qui suit illustre le décompte d’un timer pour CNT_max=5.


Principe décompte timer


Nous utilisons l’interruption de dépassement de compteur pour générer une base de temps qui sert au clignotement des LED. Par comparaison avec la première solution, cette version n’est pas tellement plus compliquée et présente un avantage : ce n’est plus le Cortex M4 qui compte le temps écoulé dans la boucle principale, mais l’un des timers du STM32WB55. Le Cortex M4 sera donc soulagé de cette tâche et on peut imaginer que le timing des diodes sera plus précis. Il reste néanmoins monopolisé à 100% (du temps) par la boucle principale mais, cette fois-ci, du fait que nous utilisons une interruption, nous pouvons bénéficier de la fonction pyb.wfi() pour placer le microcontrôleur en mode économie d’énergie jusqu’à la prochaine interruption (qui va le “réveiller”), ou pendant une milliseconde.

Le code de main.py

from pyb import Timer, wfi # Pour gérer les timers et le mode économie d'énergie

# Variable globale qui servira de rééfrence de temps
n = 0

# Variables globales pour les dates de clignotement des LED
blink1 = 0
blink2 = 0
blink3 = 0

# Routine de service de l'interruption (ISR) de dépassement de compteur du timer 1.
# Elle incrémente la variable n de 1 tous les 100-ièmes de seconde.
def tick(timer):
	global n # TRES IMPORTANT, ne pas oublier le mot-clef "global" devant la variable n
	n += 1

# Démarre le timer 1 à la fréquence de 100 Hz.
tim1 = Timer(1, freq=100)

# Assigne la fonction "tick" à l'interruption de dépassement de compteur du timer 1.
# Elle sera appelée 100 fois par seconde.
tim1.callback(tick)

while True: # Boucle infinie

	# On teste le temps ; chaque unité de n indiquant qu'un centième de seconde s'est écoulé.
	# Remarquez que les tests sur les durées ne portent pas sur une égalité (stricte) mais sur un dépassement.
	# Par exemple on écrit "n - blink1 > 9" et pas "n - blink1 == 10".
	# En effet, le timer incrémentant n de façon idépendante, il est probable que la boucle infinie "rate" la valeur
	# n = 10 l'essentiel de ses itérations.
	
	wfi() # Place le microcontrôleur en mode économie d'énergie
	
	if n - blink1 > 9: # Toutes les 0.1s depuis sa dernière inversion...
		pyb.LED(3).toggle() # On inverse la LED bleue...
		blink1 = n # Et on mémorise la date de cet évènement.
	if n - blink2 > 49: # Toutes les 0.5s depuis sa dernière inversion...
		pyb.LED(2).toggle() # On inverse la LED verte...
		blink2 = n # Et on mémorise la date de cet évènement.
	if n - blink3 > 199: # Toutes les deux secondes depuis sa dernière inversion...
		pyb.LED(1).toggle() # On inverse la LED rouge...
		blink3 = n # Et on mémorise la date de cet évènement.
		
		# On remet les compteurs de temps à zéro lorsque la LED avec la plus longue période a changé d'état.
		# Ceci afin d'éviter un dépassement de capacité de la variable n si le script "tourne" trop longtemps.
		n = 0
		blink1 = 0
		blink2 = 0
		blink3 = 0

Méthode numéro 3 : Utiliser plusieurs timers

Les scripts présentés ci-après sont disponibles dans la zone de téléchargement.

Cette troisième approche utilise trois timers du STM32WB55, les paramétrise aux fréquences désirées et exploite leurs interruptions respectives de dépassement de compteur pour inverser les LED.

  • Avantage 1 : On se rapproche d’un véritable multitâche matériel, mais on n’y arrive pas tout à fait car les fonctions de service de l’interruption de dépassement des timers doivent être exécutées par le Cortex M4 du STM32WB55.
  • Avantage 2 : Il n’y a pas de programme “principal” (ie : de boucle “while True:”) ; cette approche consomme donc bien moins d’énergie que celles qui précèdent.
  • Inconvénient : On utilise un timer pour piloter chaque LED. Cette débauche de ressources matérielles atteindra vite ses limites. L’interpréteur MicroPython pour la NUCLEO-WB55 implémentant 4 timers (TIM1, TIM2, TIM16 et TIM17) on ne pourra pas gérer plus que 4 LED (ou, plus généralement, 4 tâches) de cette façon.

Variante 1 : Le code avec des lambda-expressions

Nous utilisons des lambda-expressions pour en réponse aux interruptions des timers. C’est une écriture compacte et formelle propre à MicroPython.

from pyb import Timer # Bibliothèque pour gérer les timers

tim1 = Timer(1, freq= 0.5) # Fréquence du timer 1 fixée à 0.5 Hz

# Callback : appelle la routine de service de l'interruption de dépassement du timer.
# Ici cette routine est pyb.LED(1).toggle(), l'écriture est condensée sous forme d'une lambda-expression.
tim1.callback(lambda t: pyb.LED(1).toggle()) # On inverse la LED rouge une fois toutes les deux seconde

tim2 = Timer(2, freq= 2) # Fréquence du timer 2 fixée à 2 Hz
tim2.callback(lambda t: pyb.LED(2).toggle()) # On inverse LED verte deux fois par seconde.

tim16 = Timer(16, freq=10) # Fréquence du timer 16 fixée à 10 Hz
tim16.callback(lambda t: pyb.LED(3).toggle()) # On inverse LED bleue dix fois par seconde.

Variante 2 : Le code sans lambda-expressions

Nous utilisons explicitement des *routines de service pour les trois interruptions (ISR) de dépassement des timers, une écritue équivalente aux lambda-expressions mais plus conventionnelle en programmation embarquée.

Des optimisations sont également mises en oeuvre :

  • Le décorateur (i.e. la directive) @micropython.native avant une fonction, qui indique au compilateur bytecode de générer un code binaire spécifique au STM32WB55. En général, cela conduit à un programme deux fois plus performant au prix de quelques contraintes d’écriture du code détaillées ici.
  • Une boucle while True: qui réduit la consommation du microcontrôleur gâce à l’instruction pyb.wfi() déjà présentée.
from pyb import Timer # Bibliothèque pour gérer les timers

@micropython.native # Demande à MicroPython de générer un code binaire optimisé pour cette fonction
def blink_LED_rouge(timer): # ISR du timer 1
	pyb.LED(1).toggle()

tim1 = Timer(1, freq= 0.5) # Fréquence du timer 1 fixée à 0.5 Hz
# Appelle l'ISR de l'interruption de dépassement du timer 1 : la fonction blink_LED_rouge
tim1.callback(blink_LED_rouge)

@micropython.native
def blink_LED_verte(timer): # ISR du timer 2
	pyb.LED(2).toggle()

tim2 = Timer(2, freq= 2) # Fréquence du timer 2 fixée à 2 Hz
# Appelle l'ISR de l'interruption de dépassement du timer 2 : la fonction blink_LED_verte
tim2.callback(blink_LED_verte)

@micropython.native
def blink_LED_bleue(timer): # ISR du timer 16
	pyb.LED(3).toggle()

tim16 = Timer(16, freq=10) # Fréquence du timer 16 fixée à 10 Hz
# Appelle l'ISR de l'interruption de dépassement du timer 16 : la fonction blink_LED_bleue
tim16.callback(blink_LED_bleue)

@micropython.native
def main():
	while True:
		pyb.wfi() # Place le micorocontrôleur en mode économie d'énergie

# Boucle principale	
main()

Méthode numéro 4 : Gestion du multitâche avec uasyncio

Les scripts présentés ci-après sont disponibles dans la zone de téléchargement.

Cette dernière approche utilise la bibliothèque uasyncio intégrée au firmware MicroPython. Celle-ci est une excellente initiation aux notions et mécanismes des systèmes d’exploitation temps réel pour l’embarqué (RTOS) tels que FreeRTOS très utilisés, mais aussi très délicats à maîtriser. Même s’il offre des possibilités qui rappellent celles d’un RTOS, [uasyncio] n’est pas aussi puissant car il ne gère pas la priorité des tâches et il ne garantit pas le temps d’exécution de celles-ci. La simplicité de sa mise en oeuvre et la frugalité de son usage de la RAM doivent bien avoir des contreparties !

L’exemple de programmation asychrone que nous allons utiliser est étonnament facile à mettre en oeuvre en MicroPython et peut se transposer facilement à bien d’autres applications pour lesquelles le système doit rester réactif aux interaction de l’utilisateur. Vous trouverez un tel exemple avec le tutoriel sur le module Grove capteur de gestes PAJ7620U2.

On remarquera que cette approche ne fait intervenir aucune interruption et qu’elle sollicite donc au maximum le microcontrôleur. Elle est élégante et facile à mettre en oeuvre mais elle n’est pas adaptée pour réduire la consommation d’énergie.

Le code de main.py

# Objet du script : 
# Faire clignoter simultanément les trois LED de la NUCLEO-WB55 à des fréquences différentes.
# On utilise la bibliothèque "uasyncio" pour la programmation asynchrone.
# Code directement adapté de https://docs.micropython.org/en/latest/library/uasyncio.html
# Tutoriel sur uasyncio : 
#  https://github.com/peterhinch/micropython-async/blob/master/v3/docs/TUTORIAL.md

import uasyncio # On importe la bibliothèque pour l'exécution asynchrone
print("Version de uasyncio : ", uasyncio.__version__) # Version de uasyncio

# Coroutine asynchrone pour faire clignoter la LED sur la broche led
@micropython.native # Décorateur pour générer du code binaire "natif" STM32WB55 (plus performant)
async def blink(led, period_ms):
	while True:
		# Allume la LED
		led.on() 
		# Temporisation non blocante de period_ms millisecondes
		await uasyncio.sleep_ms(period_ms)
		# Eteint la LED
		led.off()
		# Temporisation non blocante de period_ms millisecondes
		await uasyncio.sleep_ms(period_ms)

# Crée trois taches concurrentes "blink", une par LED
@micropython.native
async def main(led_rouge, led_verte, led_bleue):

	task = uasyncio.create_task(blink(led_rouge, 2000)) # Tache pour la LED rouge
	uasyncio.create_task(blink(led_verte, 500)) # Tache pour la LED verte
	uasyncio.create_task(blink(led_bleue, 100)) # Tache pour la LED bleue

	# Prolonge l'exécution du programme jusqu'à ce que task ait terminé 
	# (ce qui n'arrivera jamais)
	await task

# Le code en syntaxe pyboard (valable avec la NUCLEO-WB55)
from pyb import LED
# Appel au planificateur qui lance l'exécution de la fonction main avec ses trois 
# tâches concurrentes. Aucune instruction du script située au-dessous de cette 
# ligne ne sera exécutée.
uasyncio.run(main(LED(1), LED(2), LED(3)))

# Le code en syntaxe générique (pour des LED externes par exemple, connectées
# aux broches D2, D3, D4).
# from machine import Pin
# uasyncio.run(main(Pin('D2'), Pin('D3'), Pin('D4')))

Vous trouverez un tutoriel complet sur l’usage de la bibliothèque uasyncio sur cette page.