PYTHON → ROBOTIQUE · 25

Séance 25 · Partie D · Électronique et embarqué

MicroPython 2 :
bus et WiFi.

Les capteurs sérieux ne renvoient pas une tension : ils parlent. Un gyroscope, un écran, un capteur de pression échangent des octets sur un bus. Et un ESP32 parle aussi au reste du monde : votre robot devient un objet connecté.

Durée : 2 séances · Objectifs : le bus I2C (scan, registres, MPU6050, écran OLED), un mot sur SPI et UART, WiFi sur ESP32 / Pico W, requêtes HTTP, un serveur web sur la carte, asyncio embarqué.

Ce que vous saurez faire à la fin
  • Brancher n’importe quel capteur I2C et lire ses registres, avec ou sans bibliothèque.
  • Afficher des informations sur un petit écran.
  • Connecter la carte au WiFi, envoyer des mesures à un serveur, piloter le robot depuis un téléphone.
  • Faire cohabiter capteurs, réseau et contrôle avec asyncio.

Matériel : ESP32 (ou Pico W), MPU6050 (gyroscope/accéléromètre, 3 €), écran OLED SSD1306 128×64 I2C (4 €), fils Dupont. Un réseau WiFi 2,4 GHz avec son mot de passe.

01 / I2C

Le bus I2C : deux fils, jusqu’à 127 périphériques

  Carte                 Capteur 1     Capteur 2
  SDA ──────┬──────────── SDA ──────── SDA
  SCL ──────┼─┬────────── SCL ──────── SCL
  3V3 ─[4k7]┘ │           VCC          VCC
  3V3 ─[4k7]──┘
  GND ────────────────── GND ──────── GND

  SDA : données (dans les deux sens)
  SCL : horloge, imposée par la carte (maître)
  Chaque périphérique a une ADRESSE (7 bits)
  Résistances de rappel : souvent déjà sur les modules
from machine import Pin, I2C

# ESP32 : SDA=21, SCL=22 par défaut ; Pico : I2C(0, sda=Pin(4), scl=Pin(5))
i2c = I2C(0, sda=Pin(21), scl=Pin(22), freq=400_000)

adresses = i2c.scan()
print([hex(a) for a in adresses])
# ex. ['0x3c', '0x68'] : l'écran OLED et le MPU6050

Le scan est le premier réflexe : si l’adresse n’apparaît pas, c’est un problème de câblage, pas de code.

Adresses courantes : 0x68 MPU6050, 0x3C OLED, 0x76 BMP280 (pression), 0x27 LCD, 0x48 ADS1115 (ADC 16 bits).

Comment ça marche

Le maître envoie l’adresse ; le périphérique concerné répond ; puis on lit ou écrit des octets. Le bus est partagé : un seul parle à la fois, l’horloge synchronise. 100 ou 400 kHz : lent (40 Ko/s) mais suffisant pour des capteurs. Les octets ont une structure : le périphérique expose des registres numérotés (séance 13), comme une petite mémoire ; lire le registre 0x3B du MPU6050 donne l’accélération X. Les fiches techniques (datasheets) sont des cartes de ces registres.

01 / I2C

Lire un MPU6050 sans bibliothèque : parler aux registres

from machine import Pin, I2C
import struct, time

i2c = I2C(0, sda=Pin(21), scl=Pin(22))
MPU = 0x68

# Réveiller le capteur : registre PWR_MGMT_1 (0x6B) ← 0
i2c.writeto_mem(MPU, 0x6B, bytes([0]))

def lire_brut():
    # 14 octets à partir de 0x3B : ax ay az temp gx gy gz, big-endian, signés 16 bits
    d = i2c.readfrom_mem(MPU, 0x3B, 14)
    return struct.unpack(">7h", d)         # séance 13 : struct et entiers signés

while True:
    ax, ay, az, t, gx, gy, gz = lire_brut()
    # Pleine échelle par défaut : ±2 g → 16384 LSB/g ; ±250 °/s → 131 LSB/(°/s)
    print(f"a = ({ax/16384:+.2f}, {ay/16384:+.2f}, {az/16384:+.2f}) g   "
          f"gyro = ({gx/131:+6.1f}, {gy/131:+6.1f}, {gz/131:+6.1f}) °/s   "
          f"T = {t/340 + 36.53:.1f} °C")
    time.sleep(0.2)
Pourquoi lire les registres soi-même

Une bibliothèque cache ces cinq lignes ; mais quand elle n’existe pas pour votre capteur, ou qu’elle bogue, savoir lire la datasheet et écrire readfrom_mem vous rend autonome. Les données arrivent en big-endian (octet de poids fort d’abord) et signées : struct fait la conversion. Le vecteur accélération au repos pointe vers le haut (réaction à la gravité) et donne l’inclinaison ; le gyroscope donne la vitesse de rotation, à intégrer pour l’angle — avec la dérive de la séance 19. La fusion des deux (filtre complémentaire) est le défi ★★.

01 / I2C

Un écran OLED : afficher l’état du robot

# Copier ssd1306.py (bibliothèque MicroPython officielle) sur la carte
from machine import Pin, I2C
import ssd1306, time

i2c = I2C(0, sda=Pin(21), scl=Pin(22))
ecran = ssd1306.SSD1306_I2C(128, 64, i2c)

ecran.fill(0)                          # tout noir
ecran.text("ROVER-1", 0, 0)            # texte 8x8 px, position (x, y)
ecran.text("bat 87%", 0, 12)
ecran.rect(0, 30, 128, 10, 1)          # cadre
ecran.fill_rect(2, 32, int(124 * 0.87), 6, 1)   # barre de batterie
ecran.line(0, 50, 127, 63, 1)
ecran.show()                           # envoyer le tampon à l'écran (~20 ms)

# Un graphe défilant de la distance
historique = [0] * 128
while True:
    d = 40 + 20 * math.sin(time.ticks_ms() / 500)   # remplacer par le capteur
    historique.pop(0); historique.append(int(d))
    ecran.fill(0)
    for x, v in enumerate(historique):
        ecran.pixel(x, 63 - min(63, v), 1)
    ecran.show(); time.sleep_ms(50)

L’écran est un tableau de 128 × 64 bits en RAM (1 Ko) : on dessine dedans, puis show() l’envoie par I2C.

  • text, pixel, line, rect, fill_rect : c’est le module framebuf.
  • Un affichage remplace le port série pour déboguer un robot autonome.
  • Ne pas appeler show() plus de 20 fois par seconde : le bus sature.
SPI et UART, en deux mots

SPI : 4 fils (horloge, deux données, sélection), beaucoup plus rapide (10+ Mbit/s) : écrans couleur, cartes SD, radios. UART : la liaison série de la séance 20, entre deux cartes ou vers un GPS, un module Bluetooth HC-05. Chaque bus a son module dans machine avec la même logique : configurer, write, read.

02 / WiFi

Connecter la carte au réseau

# boot.py ou début de main.py
import network, time

def connecter(ssid, mot_de_passe, essais=20):
    wlan = network.WLAN(network.STA_IF)   # mode station (client)
    wlan.active(True)
    if not wlan.isconnected():
        wlan.connect(ssid, mot_de_passe)
        for _ in range(essais):
            if wlan.isconnected(): break
            time.sleep(0.5)
    if wlan.isconnected():
        print("Connecté :", wlan.ifconfig()[0])   # l'adresse IP
        return wlan
    print("Échec WiFi")
    return None

wlan = connecter("MonReseau", "motdepasse")
# Envoyer une mesure à un serveur (HTTP)
import urequests as requests   # ou requests selon la version

def envoyer(temp):
    try:
        r = requests.post("http://192.168.1.10:8000/mesure",
                          json={"robot": "rover1", "temp": temp},
                          timeout=5)
        print(r.status_code, r.text)
        r.close()                     # important : libérer la mémoire
    except OSError as e:
        print("réseau :", e)

# Lire l'heure sur Internet
import ntptime
ntptime.settime()                     # règle l'horloge de la carte (UTC)
print(time.localtime())
Sécurité et bon sens

Ne mettez jamais le mot de passe WiFi dans un fichier que vous partagez : un secrets.py non versionné, importé. Le réseau est lent et peut échouer : timeout, try/except OSError, et jamais d’appel réseau dans la boucle de contrôle des moteurs (séance 20 : bloquant). Le mode point d’accès (network.AP_IF) crée un WiFi propre à la carte : le téléphone s’y connecte directement, sans box.

02 / WiFi

Un serveur web sur la carte : piloter depuis le téléphone

import socket

PAGE = """HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\n
<html><body style="font-family:sans-serif;text-align:center">
<h1>Rover</h1><p>Distance : {d} cm</p>
<p><a href="/avance"><button style="font-size:2em">▲</button></a></p>
<p><a href="/gauche"><button style="font-size:2em">◀</button></a>
<a href="/stop"><button style="font-size:2em">■</button></a>
<a href="/droite"><button style="font-size:2em">▶</button></a></p>
</body></html>"""

def servir(wlan, moteurs, capteur):
    s = socket.socket()
    s.bind(("0.0.0.0", 80)); s.listen(2)
    print("Ouvrir http://" + wlan.ifconfig()[0])
    while True:
        client, addr = s.accept()                   # bloquant : voir asyncio plus loin
        requete = client.recv(512).decode()
        chemin = requete.split(" ")[1] if " " in requete else "/"
        if chemin == "/avance":   moteurs.avancer(70)
        elif chemin == "/gauche": moteurs.tourner(-50)
        elif chemin == "/droite": moteurs.tourner(50)
        elif chemin == "/stop":   moteurs.stop()
        client.send(PAGE.format(d=capteur.distance() or "?"))
        client.close()
Ce qui se passe

Un serveur web est un programme qui attend des connexions sur le port 80, lit une requête texte (GET /avance HTTP/1.1), et renvoie du texte (une page HTML). Le navigateur du téléphone fait le reste. Trente lignes, et votre robot a une télécommande. Limites : accept() bloque, donc pas de contrôle en parallèle — d’où asyncio ; et aucune sécurité — n’importe qui sur le WiFi pilote le robot. Pour du sérieux : WebSocket (temps réel) ou MQTT (messages légers entre objets).

02 / WiFi

Tout ensemble : asyncio sur la carte

import asyncio                          # uasyncio sur les anciennes versions
from machine import Pin

etat = {"distance": None, "commande": "stop"}

async def tache_capteur(capteur):
    while True:
        etat["distance"] = capteur.distance()
        await asyncio.sleep_ms(60)

async def tache_controle(moteurs):
    while True:
        d = etat["distance"]
        if d is not None and d < 15:
            moteurs.stop(); etat["commande"] = "stop"       # sécurité prioritaire
        elif etat["commande"] == "avance":
            moteurs.avancer(70)
        await asyncio.sleep_ms(50)

async def gerer_client(lecteur, ecrivain):
    requete = await lecteur.readline()
    chemin = requete.decode().split(" ")[1] if b" " in requete else "/"
    if chemin in ("/avance", "/stop"): etat["commande"] = chemin[1:]
    ecrivain.write(PAGE.format(d=etat["distance"] or "?"))
    await ecrivain.drain(); await ecrivain.wait_closed()

async def clignoter(led):
    while True:
        led.toggle(); await asyncio.sleep_ms(500)

async def main(capteur, moteurs):
    asyncio.create_task(tache_capteur(capteur))
    asyncio.create_task(tache_controle(moteurs))
    asyncio.create_task(clignoter(Pin(2, Pin.OUT)))
    await asyncio.start_server(gerer_client, "0.0.0.0", 80)
    while True: await asyncio.sleep(1)

asyncio.run(main(capteur, moteurs))
La séance 20 sur une puce

Quatre coroutines, un seul cœur, aucune ne bloque les autres : le capteur lit à 16 Hz, le contrôle décide à 20 Hz, le serveur répond quand un client arrive, la LED clignote. L’état partagé est un dictionnaire — sûr ici car asyncio ne change de tâche qu’aux await. La sécurité (obstacle → stop) est dans la tâche de contrôle, jamais dans le serveur : même si le réseau plante, le robot s’arrête devant le mur. C’est l’architecture d’un vrai robot connecté.

03 / Défis

Défi ★ — Tableau de bord

Consigne

Sur l’écran OLED : nom du robot, adresse IP (ou « pas de WiFi »), distance ultrason en gros chiffres, inclinaison du MPU6050 en degrés, une barre de batterie (tension lue sur un pont diviseur, séance 21). Rafraîchi 5 fois par seconde, sans sleep bloquant (timer ou ticks). Bonus : l’écran affiche « ! » clignotant si la distance < 15 cm.

Correction (structure)
def afficher():
    ecran.fill(0)
    ecran.text("ROVER-1  " + (wlan.ifconfig()[0] if wlan else "no wifi"), 0, 0)
    d = capteur.distance()
    ecran.text(f"{d:5.1f} cm" if d else "  --- cm", 0, 16)
    ecran.text(f"incl {inclinaison():+5.1f}", 0, 28)
    ecran.rect(0, 44, 128, 8, 1); ecran.fill_rect(1, 45, int(126 * batterie()), 6, 1)
    if d and d < 15 and (time.ticks_ms() // 250) % 2:
        ecran.text("!", 120, 16)
    ecran.show()

Timer(period=200, mode=Timer.PERIODIC, callback=lambda t: micropython.schedule(lambda _: afficher(), 0))

micropython.schedule sort l’affichage (lent, alloue de la mémoire) de l’interruption du timer pour l’exécuter dans le fil principal. Sans cela : erreur « schedule queue full » ou plantage.

03 / Défis

Défi ★★ — Filtre complémentaire : un angle fiable

Consigne

L’accéléromètre donne un angle juste au repos mais bruité et faussé par les mouvements ; le gyroscope donne une vitesse angulaire précise mais qui dérive une fois intégrée (séance 19). Le filtre complémentaire les combine : angle = α × (angle + gyro × dt) + (1 − α) × angle_accel, avec α ≈ 0,98.

Implémenter sur la carte à 100 Hz. Comparer les trois angles (accel seul, gyro seul, filtré) envoyés au traceur série de Thonny (ou à liaison.py). Incliner lentement, puis secouer : que voit-on ? Tester α = 0,9 et 0,999.

Lecture

Le gyro seul dérive (biais intégré : 0,5 °/s × 10 s = 5°). L’accéléromètre seul est bruité et voit le choc. Le filtre à 0,98 prend le meilleur des deux : le gyro pour les mouvements rapides, l’accéléromètre pour recaler lentement. α trop grand : la dérive revient ; trop petit : le bruit revient. C’est un filtre de Kalman du pauvre, et il stabilise des drones et des gyropodes. Avec le vrai capteur, calibrez d’abord le biais du gyro (moyenne de 200 lectures au repos, à soustraire).

03 / Défis

Défi ★★★ — Esprit prépa : télémétrie robuste et horloge

Consigne

1. Concevoir un protocole binaire pour envoyer par WiFi (UDP, socket.SOCK_DGRAM) 50 fois par seconde : horodatage (ms, 32 bits), 3 accélérations et 3 vitesses angulaires (int16), distance (uint16), batterie (uint8), et un CRC-8 pour détecter les paquets corrompus. Utiliser struct.pack (séance 13). Quelle taille ? Quel débit ?
2. Sur PC, un récepteur Python qui vérifie le CRC, compte les paquets perdus (numéro de séquence) et trace la latence.
3. Les horloges de la carte et du PC ne sont pas synchronisées : proposer une méthode pour estimer le décalage et la dérive (indice : échange aller-retour, comme NTP), et la tester.

Direction

22 octets par paquet (21 + CRC), 8,8 kbit/s : négligeable pour le WiFi, énorme pour une liaison série à 9600 bauds — le binaire compte. Le CRC détecte toute erreur sur 1 bit et 99,6 % des erreurs multiples ; c’est un polynôme sur le corps à deux éléments (séance 13, XOR = addition modulo 2), théorie que vous verrez en prépa sous le nom de codes cycliques. UDP ne garantit ni l’ordre ni l’arrivée : le numéro de séquence mesure les pertes. Pour l’horloge : envoyer t₁, recevoir la réponse avec t₂ (carte) et t₃ (PC) ; décalage ≈ t₂ − (t₁ + t₃)/2 ; répéter et garder les aller-retours les plus courts. C’est l’algorithme de NTP, qui synchronise les horloges d’Internet à la milliseconde.

04 / Vérification

Que renvoie i2c.scan() ?

Deux questions supplémentaires

1. Pourquoi r.close() après une requête HTTP ? Pour libérer la mémoire du socket : sur 264 Ko, quelques requêtes non fermées suffisent à tout bloquer.

2. Où placer la sécurité « obstacle → stop » : dans le serveur web ou dans la tâche de contrôle ? Dans la tâche de contrôle, qui tourne quoi qu’il arrive au réseau.

Référence

Les mots à retenir

MotDéfinition
I2CBus 2 fils, maître/esclaves adressés, ~400 kbit/s.
Registre (capteur)Case mémoire numérotée exposée par un périphérique.
SPI / UARTBus rapide 4 fils / liaison série point à point.
network.WLANInterface WiFi ; STA (client) ou AP (point d’accès).
HTTPRequête texte / réponse ; base du web et des API.
Filtre complémentaireFusion gyro (rapide) + accéléromètre (stable).
CRCSomme de contrôle polynomiale pour détecter les erreurs.

Pour continuer

Le robot a des sens et une voix

Dernière séance de la partie D : assembler la liaison PC ↔ carte complète, avec un tableau de bord Python en direct.

À faire chez soi

Ressources

machine.I2C · network · asyncio MicroPython

← Séance 24SommaireSéance 26 : Liaison PC ↔ carte →