Emission-réception en code Morse

Ce tutoriel explique comment envoyer un message depuis une carte NUCLEO-WB55 (émetteur) équipée d’une LED infrarouge (IR) à une autre carte NUCLEO-WB55 (récepteur) équipée d’un capteur photodiode. La figure ci-dessous résume le principe de notre montage émetteur-récepteur :


Morse IR transmitter


On note que :

  1. Le module LED infrarouge et le module photodiode doivent être alignés en regard à quelques dizaines de centimètres tout au plus l’un de l’autre pour que le transfert d’informations via la modulation de lumière infrarouge se déroule correctement. La photodiode doit reçevoir assez de lumière infrarouge de la LED pour que son signal soit exploitable, la puissance transmise s’atténue en 1/d2d est la distance entre les deux modules.

  2. L’environnement lumineux du montage est également très important : il est nécessaire que l’expérience de communication soit menée en intérieur sous un éclairage artificiel qui n’émet presque pas d’IR afin que le signal de la diode émettrice ne soit pas “noyé” dans le fond lumineux. A priori (nous n’avons pas testé ce cas de figure), en extérieur et sous le Soleil, la communication devrait être compromise car le rayonnement de notre étoile contient assez d’infrarouge pour aveugler le détecteur.

  3. Dans ce tutoriel, la communication ne se fait que de l’émetteur vers le récepteur. On pourrait améliorer le système en équipant les deux cartes d’un module LED IR et d’un module photodiode, afin que chacune puisse assumer alternativement le rôle d’émetteur ou de récepteur (à la manière des talkie-walkie, on parle de communication half-duplex). Ce perfectionnement (que nous vous conseillons de programmer avec l’aide du module uasyncio), serait un bon exercice pour aller plus loin.

  4. Nous ne détaillerons pas le principe du code Morse ; vous trouverez une présentation sur ce sujet sur Wikipédia. Le script gérant l’encodage/le décodage en Morse est contenu dans le fichier morsecode.py et adapté du travail réalisé par M. Olivier Lenoir.

Matériel requis

  1. Deux cartes d’extension de base Grove
  2. Deux cartes NUCLEO-WB55
  3. Un module Grove LED (infrarouge)
  4. Un module Grove Photodiode

Les modules Grove LED infrarouge et détecteur de lumière :


Grove - Morse modules


Crédit images : Seeed Studio

Première étape : émission et réception analogique

Dans un premier temps, nous allons détailler le script MicroPython pour l’émission du message encodé en Morse avec la LED IR et sa réception “brute” sous forme d’une série de valeurs analogiques. En analysant la séquence de valeurs analogiques reçues, nous pourrons réfléchir à l’algorithme pour la décoder en un message intelligible, qui fera l’objet de la deuxième étape de notre tutoriel.

Les classes MicroPython pour encoder et décoder

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

L’encodage, l’émission et le décodage sont assurés par les classes MorseDecode et MorseEncode. Celles-ci sont directement adaptées du code mis en ligne par M. Olivier Lenoir.

Nous ne rentrerons pas dans les détails des lignes de code (abondamment commentées) du module morsecode.py qui contient MorseDecode et MorseEncode, mais il est important que vous preniez le temps de les lire pour bien comprendre la structure des signaux sur lesquels nous allons travailler.

On appelle tick le quantum de temps pour la communication en Morse, il correspond à la durée minimum utilisée pour encoder un symbole ou pour séparer deux symboles. Dans nos programmes, nous choisirons un tick égal à une seconde pour avoir le temps de comprendre ce qui s’affiche et faire des captures d’écran. Il est possible de réduire considérablement tick, pour envoyer les caractères à une fréquence plus élevée, mais il faudra bien entendu veiller à le laisser suffisamment long pour que délai nécessaire à exécution des scripts par le microcontrôleur de la NUCLEO-WB55 ne compromette pas l’expérience de communication, notamment la synchronisation entre l’émetteur et le récepteur.

Pour illuster notre sujet, en code Morse, “Hello World” sécrit :

.... . .-.. .-.. --- |.-- --- .-. .-.. -.. |

| désigne un séparateur entre deux mots. Voici l’encodage de chacun des caractères qui composent notre message ci-dessus:

  • H : ....
  • E : .
  • L : .-..
  • O : ---
  • W : .--
  • R : .-.
  • D : -..
  • Espace : |

D’après morsecode.py, si on note t la durée d’un tick, on comprend que :

  • à . correspond : LED allumée pendant 1t puis éteinte pendant 1t
  • à - correspond : LED allumée pendant 3t puis éteinte pendant 1t
  • à | correspond : LED éteinte pendant 6t
  • deux caractères sont toujours séparés par 2t pendant lesquels la LED est éteinte

On notera que, bien que nous utilisions une LED IR pour envoyer les messages encodés, la classe MorseEncode permet en principe d’utiliser n’importe quel périphérique analogique de sortie. Un buzzer, par exemple, aurait tout aussi bien pu faire l’affaire pour envoyer notre message sous forme d’impulsions sonores. Il aurait alors fallu utiliser un micro sur le récepteur (à la place de la photodiode) et travailler dans une pièce relativement silencieuse pour que la communication ne soit pas compromise.

Le code MicroPython pour l’émetteur

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

Copiez le fichier morsecode.py dans PYBLASH. Editez le script main.py du périphérique PYBLASH et collez-y le code qui suit :

# Objet du code : 
# Emission d'un message codé en Morse avec une LED infrarouge (IR).
# Etape 1 : émission du message 'EN NT'
# Le commutateur du Grove Base Shield est placé sur 3.3V
# Source adaptée de :
# https://gitlab.com/olivierlenoir/MicroPython-MorseCode/-/blob/master/micropython/

# Quantum de temps pour le code Morse ; durée minimum (en millisecondes) qui sépare
# deux symboles. 
TICK = const(1000) # Une seconde

from pyb import Pin # Classe pour gérer les broches (GPIO)

# La LED IR est connectée à la broche D4
led = Pin('D4', Pin.OUT_PP)

# Instantiation de l'encodeur émetteur de code Morse
from morsecode import MorseEncode
morse = MorseEncode(led, tick = TICK)

# Affichage d'un en-tête dans le terminal série
print("\n" + "-" * 32)
print("Emetteur de code Morse")
print("-" * 32 + "\n")

# Envoi en boucle du message "EN NT" encodé en Morse
# Le code Morse correspondant est : '. -. [espace]-. - '
# Où : 
#	[espace] désigne 6 ticks pendant lesquels aucun signal n'est émis.
#	'. ' correspond à 'E'
#	'-. ' correspond à 'N'
#	'-' correspond à 'T'
while True:
	morse.message('EN NT')

Ce script appelle la méthode message de la classe MorseEncode du module morsecode.py pour envoyer les impulsions correspondantes avec la LED infrarouge.

Le code MicroPython pour le récepteur

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

Editez le script main.py du périphérique PYBLASH et collez-y le code qui suit :

# Objet du code : 
# Réception d'un message codé en MORSE avec une LED infrarouge (IR)
# Etape 1 : Réception en "polling", capture du signal analogique de la LED IR
# Lecture et numérisation du signal  avec un capteur Grove de luminosité (LS06-S phototransistor)
# Le commutateur du Grove Base Shield est placé sur 3.3V

from pyb import ADC, Pin # Convertisseur analogique-numérique et GPIO
from time import sleep_ms # Pour les temporisations

# Quantum de temps pour le code Morse ; durée minimum (en millisecondes) qui sépare
# deux symboles. 
TICK = const(1000) # Une seconde

# Instanciation et démarrage du convertisseur analogique-numérique
adc = ADC(Pin('A1'))

# Affichage d'un en-tête dans le terminal série
print("\n" + "-" * 32)
print("Récepteur de code Morse")
print("-" * 32 + "\n")

while True:
	# Numérise la valeur lue sur la photodiode 
	Mesure = adc.read()
	print("Luminosité %d" %Mesure)
	sleep_ms(TICK) # Temporisation d'une demi-seconde

Affichage et analyse du signal reçu sur le terminal série de l’USB User

Disposez la LED IR et la photodiode face à face à la distance d = 5 centimètres (par exemple), puis appuyez sur CTRL+D pour observer les messages qui défilent dans les terminaux PuTTY de l’émetteur et du récepteur :


Morse receptor output (analog)


Les commentaires sur la figure ci-dessus devraient vous permettre de comprendre sans difficultés comment un symbole du code Morse côté émetteur (par exemple ‘.’) se traduit par une série de valeurs analogiques de la luminosité côté récepteur (dans le cas présent ‘2500’ puis ‘549’ pour le premier ‘.’ reçu).

On constate que la luminosité mesurée par la photodiode pour d = 5 cm est systématiquement supérieure à 2500 (unités sans dimensions, valeur “brute” renvoyée par l’ADC) lorsque la LED IR est allumée, et inférieure à 630 lorsqu’elle est éteinte (mais pas nulle pour autant).
Il est tout à fait normal que l’état “LED IR éteinte” donne un signal non nul en moyenne ; n’oubliez pas que le capteur est plongé dans la lumière ambiante (spectre visible) de la pièce où vous menez l’expérience, qui, dans le contexte où je l’ai menée, correspondait à un signal d’intensité 500 en moyenne. Ceci nous apprend que les IR correspondent à un “excédent de signal” de presque 2000 lorsqu’ils sont émis et que notre protodiode est particulièrement sensible à ce rayonnement. La lumière ambiante joue ici le rôle d’un bruit de fond qui se superpose au signal utile.

Pour la suite, sur la base de ces observations, nous allons choisir un “seuil” pour le signal mesuré égal à 2000 et nous considérerons que la valeur échantillonnée correspond à un “état 0” si le signal est inférieur au seuil, et à un “état 1” dans le cas contraire. Ce seuil est donc fixé empiriquement, de sorte que nous puissions dissocier le signal utile du bruit ambiant sans ambigüité.

Attention, si l’espacement d entre la diode IR et la photodiode réceptrice change (surtout si vous les éloignez l’une de l’autre), vous devrez modifier la valeur du seuil pour l’adapter aux nouvelles intensités observées. Bien évidemment, il existe un espacement dmax au-delà duquel le signal IR sera complètement “noyé” dans la lumière ambiante et pour lequel sa réception deviendra impossible. Pour pallier à cette limitation (çad augmenter la portée du signal) il faut soit une LED IR plus puissante, soit un détecteur d’IR plus sensible, soit les deux simultanément. Vous pouvez aussi travailler dans l’obscurité.

Deuxième étape : application d’un seuil au signal reçu et utilisation d’un timer

Dans ce deuxième temps, nous allons perfectionner le script du module récepteur sur la base de l’analyse qui précède afin de transformer le signal analogique en messages constitués d’une série de ‘0’ et de ‘1’ après application du ‘seuil’ évoqué ci-avant, de valeur 2000.

Le code MicroPython pour l’émetteur

Nous ne reviendrons pas sur le script de l’émetteur qui reste inchangé ; on envoie toujours comme message ‘EN NT’.

Le code MicroPython pour le récepteur

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

Editez le script main.py du périphérique PYBLASH et collez-y le code qui suit :

# Objet du code : 
# Réception d'un message codé en MORSE avec une photodiode. 
# Etape 2 : On applique un seuil au signal analogique. Récéption contrôlée par un timer.
# Source adaptée de :
# https://gitlab.com/olivierlenoir/MicroPython-MorseCode/-/blob/master/micropython/

from micropython import alloc_emergency_exception_buf # Voir explications dans le script
from pyb import ADC, Pin, Timer # Accès aux ADC, aux GPIO et aux timers

# Quantum de temps pour le code Morse : durée minimum (en millisecondes) qui sépare
# deux symboles. Attention, cette valeur doit être la même dans le script de l'émetteur !
TICK = const(1000)

# Seuil de détection pour le signal analogique.
# Au-dessus de cette valeur, l'impulsion IR reçue est considérée comme "haute" ('1')
# Au-dessous de cette valeur, l'impulsion IR reçue est considérée comme "basse" ('0')
SEUIL_LUM = const(2000)

# Instanciation et démarrage du convertisseur analogique-numérique
adc = ADC(Pin('A1'))

# Tableau tampon pour assurer une remontée correcte des messages d'erreurs lorsque
# celles-ci surviennent dans la routine de service d'une interruption.
alloc_emergency_exception_buf(100)

# Routine de service de l'interruption (ISR) de dépassement de compteur du timer 1.
# Cette ISR reçoit les impulsions infrarouge et les traduit en symboles '0' et '1' selon leur intensité

@micropython.native # Directive pour optimiser le bytecode
def ecoute(timer):

	# Lecture de l'impulsion IR reçue et numérisation de celle-ci avec l'ADC puis
	# comparaison de la valeur avec le SEUIL_LUM fixé.

	if adc.read() < SEUIL_LUM: # Si la valeur numérisée est inférieure au seuil ...
		print('0', end='') # Affiche un '0' (sans saut de ligne)

	else: # Si la valeur numérisée est supérieure au seuil ...
		print('1', end='') # Affiche un '1' (sans saut de ligne)

# Fréquence du timer à l'écoute des impulsions IR : 1 Hz
FREQ = const(1) 

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

# Assigne la fonction "ecoute" à l'interruption de dépassement de compteur du timer 1.
tim1.callback(ecoute)

# Affichage d'un en-tête dans le terminal série
print("\n" + "-" * 32)
print("Récepteur de code Morse")
print("-" * 32 + "\n")

Ce code apporte deux améliorations importantes :

  1. Il utilise un seuil, SEUIL_LUM = const(2000), pour transformer le signal analogique en une suite de ‘0’ et de ‘1’ qui sera plus facile à manipuler pour les opérations de décodage.
  2. Ce sont désormais un timer, tim1, et sa routine de service d’interruption ISR, ecoute(timer), qui réalisent la réception du message. Cette approche est plus efficace et plus élégante que la bouche infinie de la première version, nous comprendrons mieux son intérêt lorsque nous rajouterons l’étape de décodage du signal.

Affichage des messages reçus sur le terminal série de l’USB User

Appuyez sur CTRL+D et observez les messages qui défilent dans les terminaux PuTTY :


Morse receptor output (text)


Le signal reçu est une chaîne de ‘0’ et de ‘1’ totalement équivalente à la séquence de valeurs analogiques que nous avions à l’issue de la première étape.

Troisième étape : décodage asynchrone du signal reçu

Cette dernière étape montre comment traduire “à la volée” les symboles Morse reçus. On utilise pour cela le module uasyncio) pour la création d’une tache asynchrone. Pourquoi ne pas programmer le décodage du message dans la routine de service de l’interruption (ISR) du timer 1, déjà chargée de sa réception ? Essentiellement pour quatre raisons :

  1. Séparer les fonctions “réception” et “décodage” rend le programme plus lisible, plus logique, plus facile à maintenir.
  2. Le code d’une ISR doit toujours être réduit à un minimum d’instructions pour éviter que son temps d’exécution ne devienne trop long. Dans notre cas, ecoute(timer) est déjà bien trop compliquée !
  3. Cette approche “concurrente” permet de continuer à reçevoir des caractères tout en décodant le dernier message complet reçu ; le système reste réactif et le risque de “rater” un caractère est réduit car la tache de décodage ne bloque pas l’ISR de réception.
  4. L’Etape de décodage du message nécessite de manipuler une liste de caractères de taille dynamique. Or allouer dynamiquement de la mémoire est impossible (ou à minima complexe et fortement déconseillé) au sein d’une ISR en MicroPython. Cette opération ne pose en revanche aucun problème à une tache asynchrone.

Le code MicroPython pour l’émetteur

Nous ne reviendrons pas sur le script de l’émetteur ; pour seul changement, on envoie désormais comme message ‘Hello World’.

Le code MicroPython pour le récepteur

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

Copiez le fichier morsecode.py dans PYBLASH. Editez le script main.py du périphérique PYBLASH et collez-y le code qui suit :

# Objet du code : 
# Réception d'un message codé en MORSE avec une photodiode.
# Source adaptée de :
# https://gitlab.com/olivierlenoir/MicroPython-MorseCode/-/blob/master/micropython/

from micropython import schedule, alloc_emergency_exception_buf # Voir explications dans le script
from pyb import ADC, Pin, Timer # Accès aux ADC, aux GPIO et aux timers

# Quantum de temps pour le code Morse : durée minimum (en millisecondes) qui sépare
# deux symboles. Attention, cette valeur doit être la même dans le script de l'émetteur !
TICK = const(1000)

# Fréquence du timer à l'écoute des impulsions IR : 1 Hz
FREQ = const(1) 

# Seuil de détection pour le signal analogique.
# Au-dessus de cette valeur, l'impulsion IR reçue est considérée comme "haute" (1)
# Au-dessous de cette valeur, l'impulsion IR reçue est considérée comme "basse" (0)
SEUIL_LUM = const(2000)

# Instanciation et démarrage du convertisseur analogique-numérique
adc = ADC(Pin('A1'))

# Tableau tampon pour assurer une remontée correcte des messages d'erreurs lorsque
# celles-ci surviennent dans la routine de service d'une interruption.
alloc_emergency_exception_buf(100)

# Variables globales pour le décodage "à la volée" du message en Morse
nb_low = 0 # Décompte des impulsions IR "basses" reçues consécutivement
nb_high = 0 # Décompte des impulsions IR "hautes" reçues consécutivement
symbol= '' # Dernier symbole du message Morse reçu (aucun)
append = False # Doit-on ajouter un symbole reçu au message en Morse en cours de réception ?
decode = False # Dispose-t-on d'un mot complet prêt à être traduit ?
message_morse = [] # Liste des symbolesconstituant un mot complet, dans leur ordre de réception

# Routine de service de l'interruption (ISR) de dépassement de compteur du timer 1.
# Cette ISR reçoit les impulsions infrarouge et les traduit en symboles '.', '-' et ' '.

@micropython.native # Directive pour optimiser le bytecode
def listen(timer):

	# Accès aux variables globales
	global nb_low, nb_high
	global symbol, decode, append

	# Lecture de l'impulsion IR reçue et numérisation de celle-ci avec l'ADC puis
	# comparaison de la valeur avec le SEUIL_LUM fixé.
	
	# Suspend la lecture des impulsions jusqu'à ce que la coroutine "decode_task"
	# ait terminé son travail
	while append or decode:
		pass

	if adc.read() < SEUIL_LUM: # Si la valeur numérisée est inférieure au seuil ...

		nb_low += 1 # On compte une impulsion basse supplémentaire
		
		# Si on avait compté 1 impulsion(s) haute(s) consécutive(s) jusqu'à présent...
		if nb_high == 1:
			symbol = '.' # Alors le dernier symbole Morse transmis était un '.'
			append = True # On peut ajouter ce '.' à la liste des symboles reçus
#			print('.', end='')

		# Si on avait compté 3 impulsions hautes consécutives jusqu'à présent...
		elif nb_high == 3:
			symbol = '-' # Alors le dernier symbole Morse transmis était un '-'
			append = True # On peut ajouter ce '-' à la liste des symboles reçus
#			print('-', end='')

		 # On vient de reçevoir une impulsion basse, donc le décompte des impulsions hautes
		 # consécutives et remis à zéro
		nb_high = 0
	
	else: # Si la valeur numérisée est supérieure au seuil ...
	
		nb_high += 1 # On compte une impulsion haute supplémentaire

		# Si on avait compté 3 impulsions basses consécutives jusqu'à présent...
		if nb_low == 3:
			symbol = ' ' # Alors le dernier symbole Morse transmis était un ' ' (séparateur de caractères)
			append = True # On peut ajouter ce ' ' à la liste des symboles reçus
#			print(' ', end='')

		# Si on avait compté 9 impulsions basses consécutives jusqu'à présent...
		elif nb_low == 9:
			# Alors le dernier symbole reçu est un espace long ; on a reçu un mot complet !
			decode = True # On peut lancer la traduction du mot reçu.

		 # On vient de reçevoir une impulsion haute, donc le décompte des impulsions basses
		 # consécutives et remis à zéro
		nb_low = 0

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

# Assigne la fonction "listen" à l'interruption de dépassement de compteur du timer 1.
tim1.callback(listen)

# Instantiation du décodeur de code Morse
from morsecode import MorseDecode
morse = MorseDecode()

import uasyncio # Pour l'exécution asynchrone

# Procède au décodage de msg écrit en code Morse vers l'alphabet latin
@micropython.native # Directive pour optimiser le bytecode
def morse_to_latin(msg):
	print(morse.decode(msg))

# Coroutine / tache (asynchrone) de décodage des mots reçus depuis le code Morse vers
# l'alphabet latin.

@micropython.native # Directive pour optimiser le bytecode
async def decode_task():

	DELAY = TICK // 10 # Pour temporiser ...

	# Accès aux variables globales
	global symbol, decode, append

	while True:

		if append: # Si on a reçu un nouveau symbole ...

			message_morse.append(symbol) # Ajoute le symbole à ceux déjà reçus
			append = False # Signale à l'ISR "listen" que le travail est fait

		elif decode: # Si on a reçu tous les symboles d'un mot complet ...

			msg = ''.join(message_morse) # Sauvegarde le mot
			message_morse.clear() # Vide la liste des symboles reçus
			decode = False # Signale à l'ISR "listen" que le travail est fait

			# Procède au décodage du mot en alphabet latin "dès que possible"
			schedule(morse_to_latin, msg)

		await uasyncio.sleep_ms(DELAY)

# Affichage d'un en-tête dans le terminal série
print("\n" + "-" * 32)
print("Récepteur de code Morse")
print("-" * 32 + "\n")

# Appel au planificateur qui lance l'exécution asynchrone de la fonction decode_task 
uasyncio.run(decode_task())

Ce script est significativement plus complexe et a considérablement évolué par comparaison avec celui de l’étape 2. Pour identifier les symboles Morse reçus, on a modifié l’ISR ecoute(timer) afin qu’elle compte le nombre d’états “hauts” et “bas” consécutifs :

  • Lorsqu’un état “bas” est reçu alors que l’on avait jusqu’ici reçu un seul état “haut”, cela signifie que le symbole Morse transmis est ‘.’.
  • Lorsqu’un état “bas” est reçu alors que l’on avait jusqu’ici reçu tois états “hauts”, cela signifie que le symbole Morse transmis est ‘-‘.
  • Lorsqu’un état “haut” est reçu, si on avait compté 3 états “bas” consécutifs jusqu’à présent cela signifie que le symbole Morse transmis est un séparateur de caractères.
  • Lorsqu’un état “haut” est reçu, si on avait compté 9 états “bas” consécutifs jusqu’à présent cela signifie que le symbole Morse transmis est un séparateur de mots (un espace).

Chaque symbole morse identifié est ensuite ajouté à une liste de caractères par la fonction asynchrone (ou coroutine) decode_task() qui se charge à son tour d’appeler une autre fonction asynchrone morse_to_latin(msg) lorsque cette liste contient tous les caractères d’un mot.
Une lecture attentive du code, abondamment commenté, et son expérimentation, devraient vous permettre de bien comprendre comment il fonctionne.

Affichage des messages reçus et décodés sur le terminal série de l’USB User

Appuyez sur CTRL+D et observez les messages qui défilent dans les terminaux PuTTY :


Morse receptor output (text)


Commentaires pour finir

Cet exemple est très riche en contenu pédagogique, il permet d’aborder et d’illustrer de belles problématiques de physique et de technologie que vous pourriez souhaiter développer :

  • La notion de rayonnement infrarouge, sa “dilution” avec la distance au récepteur (du fait que les IR sont des ondes électromagnétiques sphériques progressives) et sa réception sur une photodiode (effet photovoltaïque).
  • Les concepts de base de la programmation asynchrone préemptive.
  • Les problématiques du bruit et des erreurs de transmissions dans un canal de communication.
  • La nécessité d’encoder les communications, justement pour rendre le message plus résistant au “bruit”, voire pour pouvoir le corriger en cas d’erreur de transmission.
  • La nécessité de synchroniser l’émetteur et le récepteur, imposée par le besoin d’encoder chaque caractère de façon robuste sur une durée assez longue afin que l’on puisse le décoder correctement, ce qui limite naturellement le débit maximum de la communication.
  • Etc.

Comme déjà évoqué, une amélioration de ce projet consisterait à rendre le système half-duplex, en permettant d’alterner les rôles d’émetteur et de récepteur.