From 8cf698330428e5a180d0cdc64526b915f03ac5f4 Mon Sep 17 00:00:00 2001 From: Yorick Barbanneau Date: Wed, 12 Jul 2023 11:30:04 +0200 Subject: [PATCH] Add Macropad asyncio article --- .../files/async_array/code.py | 42 ++ .../files/async_blink/code.py | 48 ++ .../files/async_serie/code.py | 45 ++ .../files/lock/code.py | 52 ++ .../2023/adafruit_macropad_asyncio/index.md | 452 ++++++++++++++++++ 5 files changed, 639 insertions(+) create mode 100644 content/articles/2023/adafruit_macropad_asyncio/files/async_array/code.py create mode 100644 content/articles/2023/adafruit_macropad_asyncio/files/async_blink/code.py create mode 100644 content/articles/2023/adafruit_macropad_asyncio/files/async_serie/code.py create mode 100644 content/articles/2023/adafruit_macropad_asyncio/files/lock/code.py create mode 100644 content/articles/2023/adafruit_macropad_asyncio/index.md diff --git a/content/articles/2023/adafruit_macropad_asyncio/files/async_array/code.py b/content/articles/2023/adafruit_macropad_asyncio/files/async_array/code.py new file mode 100644 index 0000000..ef2d034 --- /dev/null +++ b/content/articles/2023/adafruit_macropad_asyncio/files/async_array/code.py @@ -0,0 +1,42 @@ +from adafruit_macropad import MacroPad +import usb_cdc, asyncio, random + +macropad = MacroPad() +macropad.pixels.brightness = 0.3 +color = 0xad20b4 + +async def get_key(taskslist): + while True: + key_event = macropad.keys.events.get() + if key_event: + if key_event.pressed: + print("key k:{} t:{}".format(key_event.key_number, len(taskslist))) + taskslist.append(asyncio.create_task(blink(key_event.key_number))) + await asyncio.sleep(0) + +async def blink(led): + timer = random.random() * 3 + for _ in range(5): + macropad.pixels[led] = color + await asyncio.sleep(timer) + macropad.pixels[led] = 0x0 + await asyncio.sleep(timer) + +async def manage_tasks(taskslist): + tasks = 0 + print("Run task manager t:{}".format(len(taskslist))) + while True: + for task_number in range(0, len(taskslist)): + if taskslist[task_number].done(): + print("Remove task t:{}/{}".format(task_number + 1,len(taskslist))) + taskslist.pop(task_number) + break + await asyncio.sleep(0) + +async def main(): + tasks = [] + tasks.append(asyncio.create_task(get_key(tasks))) + tasks.append(asyncio.create_task(manage_tasks(tasks))) + await asyncio.gather(*tasks) + +asyncio.run(main()) diff --git a/content/articles/2023/adafruit_macropad_asyncio/files/async_blink/code.py b/content/articles/2023/adafruit_macropad_asyncio/files/async_blink/code.py new file mode 100644 index 0000000..1a14e19 --- /dev/null +++ b/content/articles/2023/adafruit_macropad_asyncio/files/async_blink/code.py @@ -0,0 +1,48 @@ +from adafruit_macropad import MacroPad +import usb_cdc, asyncio + +macropad = MacroPad() +timer = 1 +color = 0xad20b4 +led = 0 + +def exec_command (data): + global timer + command,option = data.split() + print("cmd:{} opt:{}".format(command,option)) + if command == 'time': + timer = float(option) + +async def blink(led, color): + macropad.pixels.brightness = 0.3 + while True: + try: + if macropad.pixels[led] == (0,0,0): + macropad.pixels[led] = color + else: + macropad.pixels[led] = 0x0 + print("l:{} c:{} t:{}".format(led,hex(color),timer)) + await asyncio.sleep(timer) + except asyncio.CancelledError: + pass + +async def check_serial(task_led): + data = bytearray() + serial = usb_cdc.data + # Avec le timeout, même si la ligne ne contient pas \n, elle + # sera pris en compte + serial.timeout = 1 + while True: + if serial.in_waiting: + data = serial.readline() + print("serial data received") + exec_command(data.decode("utf-8")) + task_led.cancel() + await asyncio.sleep(0) + +async def main(): + t_led = asyncio.create_task(blink(led, color)) + t_serial = asyncio.create_task(check_serial(t_led)) + await asyncio.gather(t_led, t_serial) + +asyncio.run(main()) diff --git a/content/articles/2023/adafruit_macropad_asyncio/files/async_serie/code.py b/content/articles/2023/adafruit_macropad_asyncio/files/async_serie/code.py new file mode 100644 index 0000000..8ec7726 --- /dev/null +++ b/content/articles/2023/adafruit_macropad_asyncio/files/async_serie/code.py @@ -0,0 +1,45 @@ +from adafruit_macropad import MacroPad +import usb_cdc, asyncio + +macropad = MacroPad() +timer = 1 +color = 0xad20b4 +led = 0 + +def exec_command (data): + global timer + command,option = data.split() + print("cmd:{} opt:{}".format(command,option)) + if command == 'time': + timer = float(option) + +async def blink(led, color): + #global timer + macropad.pixels.brightness = 0.3 + while True: + if macropad.pixels[led] == (0,0,0): + macropad.pixels[led] = color + else: + macropad.pixels[led] = 0x0 + print("l:{} c:{} t:{}".format(led,hex(color),timer)) + await asyncio.sleep(timer) + +async def check_serial(): + data = bytearray() + serial = usb_cdc.data + # Avec le timeout, même si la ligne ne contient pas \n, elle + # sera pris en compte + serial.timeout = 1 + while True: + if serial.in_waiting: + data = serial.readline() + print("serial data received") + exec_command(data.decode("utf-8")) + await asyncio.sleep(0) + +async def main(): + t_led = asyncio.create_task(blink(led, color)) + t_serial = asyncio.create_task(check_serial()) + await asyncio.gather(t_led, t_serial) + +asyncio.run(main()) diff --git a/content/articles/2023/adafruit_macropad_asyncio/files/lock/code.py b/content/articles/2023/adafruit_macropad_asyncio/files/lock/code.py new file mode 100644 index 0000000..38a2faa --- /dev/null +++ b/content/articles/2023/adafruit_macropad_asyncio/files/lock/code.py @@ -0,0 +1,52 @@ +from adafruit_macropad import MacroPad +import usb_cdc, asyncio, random + +macropad = MacroPad() +macropad.pixels.brightness = 0.3 +run_color = 0xad20b4 +wait_color = 0x21f312 + +# déclaration de notre Mutex +blink_mutex = asyncio.Lock() + +async def get_key(taskslist): + while True: + key_event = macropad.keys.events.get() + if key_event: + if key_event.pressed: + taskslist.append(asyncio.create_task(blink(key_event.key_number))) + await asyncio.sleep(0) + +async def blink(led): + timer = random.random() * 3 + macropad.pixels[led] = wait_color + if blink_mutex.locked(): + print("Wait for mutex l:{}".format(led)) + + await blink_mutex.acquire() + + print("Aquire mutex l:{}".format(led)) + for _ in range(5): + macropad.pixels[led] = run_color + await asyncio.sleep(timer) + macropad.pixels[led] = 0x0 + await asyncio.sleep(timer) + + blink_mutex.release() + +async def manage_tasks(taskslist): + tasks = 0 + while True: + for task_number in range(0, len(taskslist)): + if taskslist[task_number].done(): + taskslist.pop(task_number) + break + await asyncio.sleep(0) + +async def main(): + tasks = [] + tasks.append(asyncio.create_task(get_key(tasks))) + tasks.append(asyncio.create_task(manage_tasks(tasks))) + await asyncio.gather(*tasks) + +asyncio.run(main()) diff --git a/content/articles/2023/adafruit_macropad_asyncio/index.md b/content/articles/2023/adafruit_macropad_asyncio/index.md new file mode 100644 index 0000000..9580e71 --- /dev/null +++ b/content/articles/2023/adafruit_macropad_asyncio/index.md @@ -0,0 +1,452 @@ +Title: Adafruit Macropad : multitâche coopératif avec asyncio +Category: linux +Tags: Adafruit, CircuitPython, Python +Date: 2023-07-11 15:10 +cover: assets/backgrounds/Adafruit_Macropad.jpg +status: draft + +Nous avons découvert dans mon +[précédent article]({filename}../adafruit_macropad_conn_serie/index.md) +le Macropad et comment utiliser la connexion série pour envoyer et recevoir des +données depuis un ordinateur. + +Dans la première partie de cet article, nous avions été confronté à un problème +alors que nous voulions changer la fréquence du clignotement de la DEL : il +fallait attendre que l'instruction `time.sleep` soit terminée. + +Nous allons vois ici comment utiliser `asyncio` et l'implémentation de `async` / +`await` pour corriger ce problème. Nous essaierons ensuite d'aller plus loin +afin d'appréhender cette bibliothèque. + +## Qu'est-ce que `asyncio` + +C'est une bibliothèque de *Python* qui permet de réaliser de la **programmation +asynchrone**. Le principe ici est d'éviter que le programme attende sans rien +faire. Ça tombe bien puisque dans l'exemple de la DEL qui clignote, notre +programme ne fait qu'attendre, il est d'ailleurs impossible de traiter les +entrées / sorties sur le port série à ce moment. + +Attention, il n'est **pas question ici de parallélisme**, `asyncio` ne gère pas +de fils d'exécution. Dans l'interpréteur Python c'est la classe `Thread` qui +s'en charge. Mais d'abord *CircuitPython* ne [les supporte pas](l_threads_cp) et +ils sont plus difficiles à gérer (concurrences, sections critiques, etc.). + +Dans le cas d'`asyncio`, nous parlerons [**de coroutines**][l_coroutines] pour +des fonctions déclarée avec le mot clef `async`. + +[l_threads_cp]:https://github.com/adafruit/circuitpython/issues/1124 +[l_coroutines]:https://fr.wikipedia.org/wiki/Coroutine + +## Installer `asyncio` sur le Macropad + +Il est possible que la bibliothèque `asyncio` et ses dépendances ne soient pas +installées, il est alors possible de les installer avec `circup`. Commençons pas +créer un *environnement virtuel Python*: + +``` +python -m venv ~/venv_circup +source ~/venv_circup/bin/activate +pip install circup +``` + +Branchez ensuite le Macropad, montez le périphérique de stockage CIRCUITPY puis +lancez la commande suivante: + +``` +circup install asyncio +``` + +Et voilà! + +## La DEL clignotante + +Utilisons maintenant `asyncio` pour réécrire le code de la DEL clignotante. +Voici le code à placer dans le fichier `code.py` à la racine du disque +*CIRCUITPY* vous pouvez télécharger le fichier `code.py` +[ici]({attach}./files/async_serie/code.py): + +```python +from adafruit_macropad import MacroPad +import usb_cdc, asyncio + +macropad = MacroPad() +timer = 1 +color = 0xad20b4 +led = 0 + +def exec_command (data): + global timer + command,option = data.split() + print("cmd:{} opt:{}".format(command,option)) + if command == 'time': + timer = float(option) + +async def blink(led, color): + #global timer + macropad.pixels.brightness = 0.3 + while True: + if macropad.pixels[led] == (0,0,0): + macropad.pixels[led] = color + else: + macropad.pixels[led] = 0x0 + print("l:{} c:{} t:{}".format(led,hex(color),timer)) + await asyncio.sleep(timer) + +async def check_serial(): + data = bytearray() + serial = usb_cdc.data + # Avec le timeout, même si la ligne ne contient pas \n, elle + # sera pris en compte + serial.timeout = 1 + while True: + if serial.in_waiting: + data = serial.readline() + print("serial data received") + exec_command(data.decode("utf-8")) + await asyncio.sleep(0) + +async def main(): + t_led = asyncio.create_task(blink(led, color)) + t_serial = asyncio.create_task(check_serial()) + await asyncio.gather(t_led, t_serial) + +asyncio.run(main()) +``` + +Notre fonction de clignotement est définie par `async def blink()` : c'est +maintenant une fonction asynchrone. Une fois l'état de la DEL changé, notre +fonction d'attente `asyncio.sleep()` remplace `timer.sleep`. C'est en effet +ce module qui se charge maintenant de l'attente, ainsi il pourra gérer +**l'enchainement des tâches**. + +La partie du code utilisé pour la gestion des entrées par le port série est elle +aussi placée dans la fonction asynchrone `check_serial()`. Cette fois +`asyncio.sleep` est définis à 0 afin de laisser l'ordonnanceur du module `asyncio` +interrompre notre fonction et ainsi **empêcher une coroutine à l'exécution +longue bloquer les autres**. + +Une nouvelle fonction asynchrone fait son apparition : `main()`. C'est ici que +nous définissions nos tâches (contenants nos *coroutines*) `t_led` et`t_serial`. +Ensuite `asyncio.gather()` permet de lancer l'exécution concurrente de nos deux +tâches. + +Enfin, notre fonction `main()` est lancée via `asyncio.run` permettant alors de +de l'exécuter jusqu'à sa fin. + +Une fois le fichier sauvegardé, la DEL n°0 devrait se mettre à clignoter avec +une fréquence d'une seconde. + +### Oui mais! + +Lançons *minicom* sur le port série affichant la console . Nous observons +ainsi les actions effectuées par notre programme grâce au différents `print` +disséminées dans le code. + +```shell +minicom -D /dev/ttyACM0 -c on +``` + +Maintenant, depuis un autre terminal, changeons la fréquence de clignotement de +la DEL pour la placer à 20 secondes puis le remettre à 1 seconde: + +```shell +printf "time 20\n" > /dev/ttyACM1 +printf "time 1\n" > /dev/ttyACM1 +``` + +En observant les messages affichés dans la *minicom*, nous pouvons voir que +contrairement à la version sans coroutine, **les messages sont reçus et traités +immédiatement**. Voici les messages affichés via nos différents `print()` dans +*minicom*: + +```shell +[...] +l:0 c:0xad20b4 t:1.0 +l:0 c:0xad20b4 t:1.0 +l:0 c:0xad20b4 t:1.0 +serial data received +cmd:time opt:20 +l:0 c:0xad20b4 t:20.0 +serial data received +cmd:time opt:1 +l:0 c:0xad20b4 t:1.0 +l:0 c:0xad20b4 t:1.0 +[...] +``` + +Les lignes `serial data received` et `cmd:time opt:<>` sont apparues +immédiatement, mais lors du changement de fréquence de 20 à 1, il a fallu +**attendre que les 20 secondes définies précédement soient ecoulées** pour que +la modification soit prise en compte par la coroutine `blink()`. + +## Prendre en compte immédiatement la modification de fréquence + +Comment pouvons nous faire en sorte que la modification de fréquence soit +immédiatement répercutée? Tout simplement en **utilisant la méthode `cancel()`** +sur notre tache `t_led`. Celle-ci lève l'exception `CancelError` dans la +coroutine `blink()`. C'est bien entendu à nous d'implémenter la gestion de cette +exception. + + +Vous pouvez télécharger le fichier `code.py` +[ici]({attach}./files/async_blink/code.py), voici le code: + +```python +from adafruit_macropad import MacroPad +import usb_cdc, asyncio + +macropad = MacroPad() +timer = 1 +color = 0xad20b4 +led = 0 + +def exec_command (data): + global timer + command,option = data.split() + print("cmd:{} opt:{}".format(command,option)) + if command == 'time': + timer = float(option) + +async def blink(led, color): + macropad.pixels.brightness = 0.3 + while True: + try: + if macropad.pixels[led] == (0,0,0): + macropad.pixels[led] = color + else: + macropad.pixels[led] = 0x0 + print("l:{} c:{} t:{}".format(led,hex(color),timer)) + await asyncio.sleep(timer) + except asyncio.CancelledError: + pass + +async def check_serial(task_led): + data = bytearray() + serial = usb_cdc.data + # Avec le timeout, même si la ligne ne contient pas \n, elle + # sera pris en compte + serial.timeout = 1 + while True: + if serial.in_waiting: + data = serial.readline() + print("serial data received") + exec_command(data.decode("utf-8")) + task_led.cancel() + await asyncio.sleep(0) + +async def main(): + t_led = asyncio.create_task(blink(led, color)) + t_serial = asyncio.create_task(check_serial(t_led)) + await asyncio.gather(t_led, t_serial) + +asyncio.run(main()) +``` +C'est notre fonction `check_serial()` qui lance le `cancel()` à partir de la +tache `t_led` que nous lui passons en paramètre. L'effet est alors immédiat : +l'exception `CancelError` est lancée dans `blink()` forçant cette dernière à +recommencer depuis le début du `while`. Notre modification **est donc appliquée +immédiatement**. + +## Gestions dynamique des tâches + +Maintenant que nous avons vu le fonctionnement de base d'`asyncio`, prenons un +exemple un peu plus complet. + + +Vous pouvez télécharger le fichier `code.py` +[ici]({attach}./files/async_array/code.py), voici le code: + +```python +from adafruit_macropad import MacroPad +import usb_cdc, asyncio, random + +macropad = MacroPad() +macropad.pixels.brightness = 0.3 +color = 0xad20b4 + +async def get_key(taskslist): + while True: + key_event = macropad.keys.events.get() + if key_event: + if key_event.pressed: + print("key k:{} t:{}".format(key_event.key_number, len(taskslist))) + taskslist.append(asyncio.create_task(blink(key_event.key_number))) + await asyncio.sleep(0) + +async def blink(led): + timer = random.random() * 3 + for _ in range(5): + macropad.pixels[led] = color + await asyncio.sleep(timer) + macropad.pixels[led] = 0x0 + await asyncio.sleep(timer) + +async def manage_tasks(taskslist): + tasks = 0 + print("Run task manager t:{}".format(len(taskslist))) + while True: + for task_number in range(0, len(taskslist)): + if taskslist[task_number].done(): + print("Remove task t:{}/{}".format(task_number + 1,len(taskslist))) + taskslist.pop(task_number) + break + await asyncio.sleep(0) + +async def main(): + tasks = [] + tasks.append(asyncio.create_task(get_key(tasks))) + tasks.append(asyncio.create_task(manage_tasks(tasks))) + await asyncio.gather(*tasks) + +asyncio.run(main()) +``` + +Lorsque nous appuyons sur une des douze touches du clavier de notre *MacroPad*, +la DEL en dessous clignote cinq fois. Vous l'aurez compris chaque clignotement +est en fait une coroutine gérée par `asyncio`. + +Les différentes tâche sont répertoriée dans un tableau qui est passé à la +fonction `asyncio.gather()`. Plus intéressant, afin de supprimer de notre +tableau les coroutines terminée, nous passons par la fonction `manage_tasks()` +en utilisant `asyncio.done()` afin de vérifier que la tâche testée soit bien +terminée. + + +Nous avons donc deux tâches lancées dès le départ: + + * `get_key()` changée d'écouter les évènements clavier et de lancer les + différents clignotements en fonction de la touche appuyée; + * `manage_tasks` chargée de nettoyer le tableau des tâches; + +Notre fonction `manage_tasks()` est simple, nous parcourons l'ensemble du +tableau et lorsque nous trouvons une coroutine terminée nous supprimons +l'élément du tableau. Remarquez le `break` lorsque notre fonction supprime un +élément de notre `taskslist`, c'est nécessaire afin d'éviter une erreur d'index +dut au fait que **notre tableau contiendra un élément de moins**; + + +Voici l'affichage des informations sur l'écran du *Macropad* (ou sur la console +*miniciom*, qui est plus agréable) : + +```shell +Run task manager t:2 +key k:0 t:2 +key k:3 t:3 +key k:6 t:4 +Remove task t:3/5 +Remove task t:4/4 +Remove task t:3/3 +``` + +Nous pouvons ainsi voir la création des tâches par la fonction `get_key()` puis +leur destruction dans `manage_tasks()` -- merci les `print()`. + +## Gestion de la concurrence + +Contrairement à sa grande [sœur *CPython*][l_cpython], notre implémentation de +Python ici présente ne contient pas de primitive de synchronisation comme les +sémaphores, variables conditions, ... : seul le `Lock` sont présent. +C'est l'implémentation Python des [Mutex][l_mutex]: l'accès à une +partie du code protégée par un *Mutex* sera sérialisé. Une seule coroutine y +aura accès à la fois, les autres attendent leur tour une à une. + +Voici un exemple de code pour notre Macropad utilisant `Lock`, vous pouvez +télécharger le fichier `code.py` [ici]({attach}./files/lock/code.py): + +```python +from adafruit_macropad import MacroPad +import usb_cdc, asyncio, random + +macropad = MacroPad() +macropad.pixels.brightness = 0.3 +run_color = 0xad20b4 +wait_color = 0x21f312 + +# déclaration de notre Mutex +blink_mutex = asyncio.Lock() + +async def get_key(taskslist): + while True: + key_event = macropad.keys.events.get() + if key_event: + if key_event.pressed: + taskslist.append(asyncio.create_task(blink(key_event.key_number))) + await asyncio.sleep(0) + +async def blink(led): + timer = random.random() * 3 + macropad.pixels[led] = wait_color + if blink_mutex.locked(): + print("Wait for mutex l:{}".format(led)) + + await blink_mutex.acquire() + + print("Aquire mutex l:{}".format(led)) + for _ in range(5): + macropad.pixels[led] = run_color + await asyncio.sleep(timer) + macropad.pixels[led] = 0x0 + await asyncio.sleep(timer) + + blink_mutex.release() + +async def manage_tasks(taskslist): + tasks = 0 + while True: + for task_number in range(0, len(taskslist)): + if taskslist[task_number].done(): + taskslist.pop(task_number) + break + await asyncio.sleep(0) + +async def main(): + tasks = [] + tasks.append(asyncio.create_task(get_key(tasks))) + tasks.append(asyncio.create_task(manage_tasks(tasks))) + await asyncio.gather(*tasks) + +asyncio.run(main()) +``` + +Lors de l'appui sur une touche, la fonction `blink()` DEL passe au vert, si le +*Mutex* est libre, on passe de suite à la phase de clignotement. Ainsi une tâche +en attente sera matérialisée. + +Nous pouvons observer sur l'écran du *Macropad* différentes informations +relatives à l'attente / acquisitions de notre `blink_mutex`. Ces informations +permettent de comprendre ce qui se passe lors de l'exécution de nos coroutines : + +```shell +run task manager t:2 +Aquire mutex l:3 +Wait for mutex l:7 +Wait for mutex l:11 +Aquire mutex l:7 +Aquire mutex l:11 +Aquire mutex l:0 +Wait for mutex l:5 +Wait for mutex l:6 +Aquire mutex l:5 +Aquire mutex l:6 +``` + +[l_cpython]:https://docs.python.org/3/library/asyncio-sync.html +[l_mutex]:https://fr.wikipedia.org/wiki/Exclusion_mutuelle + +## En conclusion + +Nous avons vu dans cet article, au fils des différents exemples, comment +utiliser `asyncio` pour gérer des coroutines, permettant d'implémenter dans nos +programme pour notre Macropad du multitĉhe coopératif. + +Un petit tous sur [la documentation][L-circuip_async] spécifique à `asyncio` +dans CircuitPython vous permettra d'aller plus loin. Elle est cependant très +succincte et manque cruellement d'exemples. Si vous voulez en apprendre plus +sur la programmation asynchrone en Python, vous avez aussi l'excellent tutoriel +de nohar sur [Zeste de savoir][l_zeste], il m'a été d'une grande aide pour la +rédaction de cet article. + +[L-circuip_async]:https://docs.circuitpython.org/projects/asyncio/en/latest/index.html +[l_zeste]:https://zestedesavoir.com/articles/1568/decouvrons-la-programmation-asynchrone-en-python/ + +*[DEL]: Diode ÉlectroLuminescente