PYTHON → ROBOTIQUE · 26

Séance 26 · Partie D · Projet

Liaison
PC ↔ carte.

Le PC calcule, affiche, enregistre. La carte lit et agit, vite et sûrement. Cette séance assemble les deux en un système complet : un firmware sur la carte, un tableau de bord sur le PC, et un protocole entre eux. C’est l’architecture de votre robot pour la partie E.

Durée : 2 séances · Objectifs : concevoir l’architecture, écrire le firmware (Arduino ou MicroPython), le protocole, la liaison Python, un tableau de bord matplotlib en direct, un enregistreur, et tester le tout sans matériel grâce à un simulateur.

Ce que vous saurez faire à la fin
  • Répartir les responsabilités entre carte et PC.
  • Écrire un firmware robuste : commandes, télémétrie, sécurité.
  • Visualiser des mesures en temps réel et les rejouer.
  • Développer sans le matériel, puis brancher sans rien changer.

Matériel : la carte de votre choix (Arduino ou Pico), le capteur ultrason, un servo ou une LED PWM, le potentiomètre. Sur PC : pyserial, matplotlib.

01 / Architecture

Qui fait quoi

La carte (firmware)

  • Lit les capteurs à cadence fixe (ultrason 15 Hz, potentiomètre 50 Hz).
  • Applique les commandes reçues (servo, PWM, moteurs) — bornées.
  • Sécurités locales : arrêt si obstacle < 10 cm, chien de garde si silence du PC > 500 ms.
  • Envoie la télémétrie à 20 Hz, une ligne par trame.
  • Ne décide rien de compliqué.

Le PC (Python)

  • Reçoit la télémétrie dans un thread (séance 20), la met en file.
  • Affiche un tableau de bord vivant (matplotlib).
  • Enregistre tout en CSV avec horodatage.
  • Prend les décisions « intelligentes » (planification, interface, journal).
  • Envoie des commandes courtes ; ne suppose jamais qu’elles sont arrivées : la télémétrie le confirme.

Règle : si le PC meurt, le robot doit s’arrêter tout seul. Si la carte redémarre, le PC doit s’en apercevoir.

Le protocole (séance 20, finalisé)

Télémétrie carte → PC : T=12345;D=42.5;P=512;S=90;E=0\n (temps ms, distance cm, potentiomètre, servo °, code d’erreur). Commandes PC → carte : SERVO 90, LED 128, PING, STOP. Réponses : OK …, ERR …, PONG. Un message = une ligne. Tout est validé des deux côtés.

02 / Firmware

Le firmware Arduino, complet

#include <Servo.h>
const byte TRIG = 7, ECHO = 8, LED = 9, POT = A0, SERVO = 10;
const unsigned long PERIODE_TELEM = 50, PERIODE_ULTRASON = 66, TIMEOUT_PC = 500;
Servo servo;
float distance = -1; int pot = 0; int angle = 90; byte erreur = 0;
unsigned long dernier_message_pc = 0;
char ligne[64]; byte n = 0;

void securiser() { analogWrite(LED, 0); servo.write(90); erreur |= 1; }

void traiter(char* cmd) {
  dernier_message_pc = millis();
  if (strncmp(cmd, "SERVO ", 6) == 0) {
    angle = constrain(atoi(cmd + 6), 10, 170);          // borner !
    servo.write(angle); Serial.println("OK SERVO");
  } else if (strncmp(cmd, "LED ", 4) == 0) {
    analogWrite(LED, constrain(atoi(cmd + 4), 0, 255)); Serial.println("OK LED");
  } else if (strcmp(cmd, "PING") == 0) { Serial.println("PONG");
  } else if (strcmp(cmd, "STOP") == 0) { securiser(); erreur = 0; Serial.println("OK STOP");
  } else { Serial.print("ERR "); Serial.println(cmd); }
}

void lire_serie() {
  while (Serial.available()) {
    char c = Serial.read();
    if (c == '\n' || c == '\r') { if (n) { ligne[n] = 0; traiter(ligne); n = 0; } }
    else if (n < sizeof(ligne) - 1) ligne[n++] = c;
    else n = 0;                                          // ligne trop longue : on jette
  }
}

float mesurer_distance() {
  digitalWrite(TRIG, LOW); delayMicroseconds(2); digitalWrite(TRIG, HIGH); delayMicroseconds(10); digitalWrite(TRIG, LOW);
  unsigned long d = pulseIn(ECHO, HIGH, 25000);
  return d ? d * 0.01715 : -1;
}

void setup() {
  pinMode(TRIG, OUTPUT); pinMode(ECHO, INPUT); pinMode(LED, OUTPUT);
  servo.attach(SERVO); servo.write(90);
  Serial.begin(115200); Serial.println("BOOT");           // le PC saura qu'on a redémarré
}

void loop() {
  static unsigned long t_telem = 0, t_ultra = 0;
  unsigned long maintenant = millis();
  lire_serie();
  if (maintenant - t_ultra >= PERIODE_ULTRASON) { t_ultra = maintenant; distance = mesurer_distance(); }
  pot = analogRead(POT);
  if (distance > 0 && distance < 10) securiser();                          // sécurité locale
  if (dernier_message_pc && maintenant - dernier_message_pc > TIMEOUT_PC) securiser();   // chien de garde PC
  if (maintenant - t_telem >= PERIODE_TELEM) {
    t_telem = maintenant;
    Serial.print("T="); Serial.print(maintenant); Serial.print(";D="); Serial.print(distance, 1);
    Serial.print(";P="); Serial.print(pot); Serial.print(";S="); Serial.print(angle);
    Serial.print(";E="); Serial.println(erreur);
  }
}
Points de conception

Tableau de char fixe au lieu de String (pas de fragmentation). Toute valeur reçue est bornée avec constrain. Deux sécurités indépendantes du PC. BOOT au démarrage : le PC détecte un reset (et réinitialise ses commandes). Le code d’erreur est un champ de bits (séance 13) : bit 0 = sécurité déclenchée. La version MicroPython (Pico) est en défi ★ : mêmes fonctions, même protocole.

03 / PC

liaison.py, finalisé

import serial, threading, queue, time, csv

class Liaison:
    def __init__(self, port, debit=115200, journal="journal.csv"):
        self.port = serial.Serial(port, debit, timeout=0.1)
        self.mesures = queue.Queue(); self.reponses = queue.Queue()
        self.stop = threading.Event(); self.reboots = 0
        self.journal = open(journal, "a", newline=""); self.csv = csv.writer(self.journal)
        time.sleep(2)
        threading.Thread(target=self._lecteur, daemon=True).start()
        threading.Thread(target=self._battement, daemon=True).start()

    def _lecteur(self):
        while not self.stop.is_set():
            ligne = self.port.readline().decode(errors="replace").strip()
            if not ligne: continue
            if ligne == "BOOT": self.reboots += 1; continue
            if ligne.startswith(("OK", "ERR", "PONG")): self.reponses.put(ligne); continue
            m = analyser_mesure(ligne)                     # séance 20
            if m and "T" in m:
                m["pc"] = time.time(); self.mesures.put(m)
                self.csv.writerow([m["pc"], m.get("T"), m.get("D"), m.get("P"), m.get("S"), m.get("E")])

    def _battement(self):                                  # le chien de garde de la carte a besoin de nouvelles
        while not self.stop.is_set():
            self.envoyer("PING"); time.sleep(0.2)

    def envoyer(self, cmd, *args):
        self.port.write((cmd + "".join(f" {a}" for a in args) + "\n").encode())

    def dernieres(self):
        l = []
        while not self.mesures.empty(): l.append(self.mesures.get())
        return l

    def fermer(self):
        self.envoyer("STOP"); self.stop.set(); time.sleep(0.3)
        self.port.close(); self.journal.close()
Nouveautés par rapport à la séance 20

Un second thread envoie PING 5 fois par seconde : c’est le battement de cœur qui nourrit le chien de garde de la carte. Le journal CSV est écrit dans le thread lecteur, jamais dans la boucle d’affichage. Les réponses (OK, ERR) vont dans une file séparée : on peut vérifier qu’une commande a été acceptée. reboots compte les BOOT : s’il augmente en cours de route, l’alimentation est en cause (séance 21).

03 / PC

Le tableau de bord en direct (matplotlib, sur PC)

import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from collections import deque
import sys

liaison = Liaison(sys.argv[1] if len(sys.argv) > 1 else "COM3")
N = 300                                                   # 15 s à 20 Hz
t, d, p = deque(maxlen=N), deque(maxlen=N), deque(maxlen=N)

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 6), sharex=True)
(ligne_d,) = ax1.plot([], [], label="distance (cm)"); ax1.set_ylim(0, 200); ax1.legend(); ax1.grid(alpha=.3)
(ligne_p,) = ax2.plot([], [], label="potentiomètre"); ax2.set_ylim(0, 1023); ax2.legend(); ax2.grid(alpha=.3)
titre = fig.suptitle("")

def maj(_):
    for m in liaison.dernieres():
        t.append(m["T"] / 1000); d.append(m["D"] if m["D"] > 0 else float("nan")); p.append(m["P"])
    if t:
        ligne_d.set_data(t, d); ligne_p.set_data(t, p)
        ax1.set_xlim(t[0], max(t[-1], t[0] + 1))
        titre.set_text(f"servo {m['S']}°  erreur {m['E']}  reboots {liaison.reboots}")
        liaison.envoyer("SERVO", int(10 + p[-1] * 160 / 1023))   # le potentiomètre pilote le servo via le PC
    return ligne_d, ligne_p

def touche(e):
    if e.key == " ": liaison.envoyer("STOP")
    if e.key == "l": liaison.envoyer("LED", 255)
    if e.key == "k": liaison.envoyer("LED", 0)
fig.canvas.mpl_connect("key_press_event", touche)

try:
    anim = FuncAnimation(fig, maj, interval=50, cache_frame_data=False)
    plt.show()                                            # bloque jusqu'à fermeture de la fenêtre
finally:
    liaison.fermer()
FuncAnimation

matplotlib rappelle maj toutes les 50 ms ; on y vide la file de mesures et on met à jour les courbes (sans les recréer : set_data). deque(maxlen) fait une fenêtre glissante gratuite. La boucle « potentiomètre → PC → servo » ferme une boucle à travers le PC : mesurez la latence (temps entre un mouvement du potentiomètre et celui du servo) — c’est pourquoi le contrôle rapide reste sur la carte. Les touches donnent une télécommande minimale.

04 / Sans matériel

Le simulateur de carte : développer avant de brancher

Pourquoi un simulateur

Le firmware et le simulateur implémentent le même contrat : on peut développer le tableau de bord, l’enregistreur, les stratégies, avec port = CarteSimulee(), puis remplacer par serial.Serial(...). Observez : SERVO 999 est borné à 170, BIDULE renvoie ERR, et quand l’obstacle simulé passe sous 10 cm, E passe à 1. Les équipes de robotique (et l’industrie aérospatiale) travaillent ainsi : hardware-in-the-loop seulement à la fin.

04 / Sans matériel

Rejouer un journal

Le journal est votre boîte noire

Après chaque essai : rejouer, chercher les anomalies, mesurer. Un robot qui s’est arrêté « sans raison » a une raison dans le journal. fill_between … where colore les périodes où la sécurité était active : on voit immédiatement si elle se déclenche sur des mesures aberrantes (fausses alertes) — auquel cas il faut filtrer dans le firmware avant de décider.

05 / Défis

Défi ★ — Le même firmware en MicroPython

Consigne

Porter le firmware sur Pico/ESP32 : mêmes commandes, même télémétrie, mêmes sécurités, cadences avec ticks_ms, lecture série non bloquante via sys.stdin et select.poll() (ou l’UART). Vérifier avec le tableau de bord PC sans le modifier.

Correction (squelette)
import sys, select, time
from machine import Pin, PWM, ADC, time_pulse_us

poll = select.poll(); poll.register(sys.stdin, select.POLLIN)
servo = PWM(Pin(16)); servo.freq(50); led = PWM(Pin(15)); pot = ADC(26)
trig, echo = Pin(17, Pin.OUT), Pin(18, Pin.IN)
etat = {"angle": 90, "erreur": 0, "dernier_pc": None, "distance": -1}

def set_servo(a): etat["angle"] = max(10, min(170, a)); servo.duty_u16(int((1 + etat["angle"] / 180) / 20 * 65535))
def securiser(): led.duty_u16(0); set_servo(90); etat["erreur"] |= 1

def traiter(cmd):
    etat["dernier_pc"] = time.ticks_ms()
    parts = cmd.split()
    if not parts: return
    if parts[0] == "SERVO" and len(parts) > 1: set_servo(int(parts[1])); print("OK SERVO")
    elif parts[0] == "LED" and len(parts) > 1: led.duty_u16(max(0, min(255, int(parts[1]))) * 257); print("OK LED")
    elif parts[0] == "PING": print("PONG")
    elif parts[0] == "STOP": securiser(); etat["erreur"] = 0; print("OK STOP")
    else: print("ERR", parts[0])

print("BOOT"); set_servo(90)
t_telem = t_ultra = time.ticks_ms(); tampon = ""
while True:
    while poll.poll(0):
        c = sys.stdin.read(1)
        if c in "\r\n":
            try: traiter(tampon.strip())
            except ValueError: print("ERR valeur")
            tampon = ""
        elif len(tampon) < 60: tampon += c
    now = time.ticks_ms()
    if time.ticks_diff(now, t_ultra) >= 66:
        t_ultra = now
        trig.off(); time.sleep_us(2); trig.on(); time.sleep_us(10); trig.off()
        d = time_pulse_us(echo, 1, 25000); etat["distance"] = d * 0.01715 if d > 0 else -1
    if 0 < etat["distance"] < 10: securiser()
    if etat["dernier_pc"] and time.ticks_diff(now, etat["dernier_pc"]) > 500: securiser()
    if time.ticks_diff(now, t_telem) >= 50:
        t_telem = now
        print(f"T={now};D={etat['distance']:.1f};P={pot.read_u16() >> 6};S={etat['angle']};E={etat['erreur']}")

pot.read_u16() >> 6 ramène 16 bits à 10 bits pour rester compatible avec l’échelle Arduino : le PC ne voit pas la différence. C’est l’intérêt d’un protocole défini indépendamment du matériel.

05 / Défis

Défi ★★ — Mesurer la latence et la fiabilité

Consigne

1. Latence aller-retour : envoyer PING, mesurer le temps jusqu’au PONG (via la file reponses), 200 fois ; moyenne, écart-type, max. Comparer 115200 et 9600 bauds.
2. Pertes : ajouter un numéro de séquence N= à la télémétrie et compter les trous côté PC pendant 5 minutes. Puis débrancher/rebrancher le câble : que voit le PC (BOOT, trous, exception) ? Rendre la Liaison capable de se reconnecter.
3. Charge : passer la télémétrie à 200 Hz. À partir de quelle cadence des lignes arrivent-elles corrompues ou en retard ? Calculer la limite théorique (séance 20 : bits par ligne × cadence vs débit).

Direction

Latence typique USB-série : 2 à 20 ms, dominée par le tampon du convertisseur USB (l’Uno regroupe les octets par 1 ms). À 9600 bauds, une trame de 40 caractères prend 42 ms rien qu’à transmettre : la cadence de 20 Hz devient impossible. Reconnexion : intercepter serial.SerialException dans le lecteur, fermer, boucler sur Serial() toutes les secondes jusqu’au succès, et remettre les commandes à zéro (la carte a redémarré). Ces mesures sont exactement celles qu’on exige d’un système embarqué avant de le certifier.

05 / Défis

Défi ★★★ — Esprit prépa : commande à distance et stabilité

Consigne

Fermer une boucle de régulation à travers la liaison : le PC lit la distance, calcule une consigne de servo pour « suivre » un objet (garder l’objet devant le capteur monté sur le servo), et l’envoie. Contrôleur proportionnel : angle += Kp × (distance_gauche − distance_droite) avec un balayage… ou plus simplement : maintenir la distance à 20 cm avec un chariot (défi de la partie E).

Étudier l’effet de la latence : dans le simulateur, ajouter un retard configurable (0, 50, 200, 500 ms) entre la mesure et son arrivée. Avec un même Kp, à partir de quel retard la boucle oscille-t-elle ? Tracer l’amplitude des oscillations en fonction du retard. Expliquer avec le simulateur de la séance 15 pourquoi un retard déstabilise (indice : le contrôleur agit sur une information périmée, comme une douche dont le robinet réagit avec délai).

Direction

Jusqu’à ~100 ms, l’amplitude résiduelle reste négligeable ; vers 200–300 ms elle est multipliée par 10 (oscillations) ; à 500 ms la boucle diverge (l’amplitude sature à cause de la force bornée). Le retard ajoute un déphasage : le contrôleur pousse alors que le chariot est déjà passé. En prépa, on montre qu’un retard τ dans une boucle fermée retire une marge de phase de ω·τ et fait basculer le système au-delà d’une fréquence critique. Conséquence pratique : les boucles rapides vivent sur la carte ; le PC ne ferme que des boucles lentes (navigation, à 1–5 Hz). C’est la justification finale de toute l’architecture de cette séance.

06 / Vérification

Où doit se trouver l’arrêt d’urgence sur obstacle ?

Deux questions supplémentaires

1. À quoi sert le PING périodique ? À nourrir le chien de garde de la carte : sans nouvelles du PC pendant 500 ms, elle se met en sécurité.

2. Pourquoi BOOT au démarrage ? Pour que le PC détecte un redémarrage de la carte et renvoie ses commandes.

Pour continuer

Fin de la partie D

Vous avez un système complet : capteurs, actionneurs, firmware, liaison, tableau de bord, journal, simulateur. La partie E le monte sur roues.

Matériel pour la partie E

À faire chez soi

← Séance 25SommaireSéance 27 : Moteurs et robot mobile →