This commit is contained in:
2026-10-16 17:00:37 +02:00
commit 38e1e404fc
16 changed files with 15801 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
{
"features": {
"ghcr.io/khannurien/devcontainer-features/claude-code:1": {
"version": "1.2.0",
"resolved": "ghcr.io/khannurien/devcontainer-features/claude-code@sha256:17eb69bfd15ff5206c0bff2945935a4349f7423cabee461a0ba2681d52205db9",
"integrity": "sha256:17eb69bfd15ff5206c0bff2945935a4349f7423cabee461a0ba2681d52205db9"
},
"ghcr.io/khannurien/devcontainer-features/opencode:1": {
"version": "1.1.0",
"resolved": "ghcr.io/khannurien/devcontainer-features/opencode@sha256:8df3f8c98a104ce7e089ba3e8df39e0d3890753c8dc92e03deb54527d30d3574",
"integrity": "sha256:8df3f8c98a104ce7e089ba3e8df39e0d3890753c8dc92e03deb54527d30d3574"
},
"ghcr.io/khannurien/devcontainer-features/rtk:1": {
"version": "1.1.0",
"resolved": "ghcr.io/khannurien/devcontainer-features/rtk@sha256:4a60b3a1d1b64904de3d856c61cdf7e5943e3c74d114609787dc88d9db7c788f",
"integrity": "sha256:4a60b3a1d1b64904de3d856c61cdf7e5943e3c74d114609787dc88d9db7c788f"
}
}
}

View File

@@ -0,0 +1,38 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/markdown
{
"name": "Markdown Editing",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/base:bookworm",
// Features to add to the dev container. More info: https://containers.dev/features.
// typst is installed by .devcontainer/setup.sh, not by the devcontainers-extra
// feature: 1.0.6 resolves "latest" to the legacy v23-03-28 tag.
"features": {},
// Configure tool-specific properties.
"customizations": {
// Configure properties specific to VS Code.
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"yzhang.markdown-all-in-one",
"streetsidesoftware.code-spell-checker",
"streetsidesoftware.code-spell-checker-cspell-bundled-dictionaries",
"DavidAnson.vscode-markdownlint",
"shd101wyy.markdown-preview-enhanced",
"bierner.github-markdown-preview",
"myriad-dreamin.tinymist"
]
}
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Installs the colour emoji font typst needs: see .devcontainer/setup.sh.
"postCreateCommand": "bash .devcontainer/setup.sh"
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

33
.devcontainer/setup.sh Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# noto color emoji, colrv1 flavour.
#
# typst >= 0.14 exports colr colour glyphs to pdf, but it does not load the cbdt
# bitmap font that debian's fonts-noto-color-emoji package installs (`typst fonts`
# does not even list it), so emoji used in source.md silently disappear from the
# pdf. the colrv1 build is picked up by font fallback, no markup needed.
set -euo pipefail
# noto-emoji only tags releases sporadically, so pin the font by commit
EMOJI_FONT_COMMIT=f3ae03f5e9b3b8516fa151f7168159ca1a3e7515
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
# typst, static musl build: x86_64 releases no longer ship a gnu build
TYPST_VERSION=0.15.1
case "$(uname -m)" in
x86_64 | amd64) typst_arch=x86_64 ;;
aarch64 | arm64) typst_arch=aarch64 ;;
*) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
curl -fsSL "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-${typst_arch}-unknown-linux-musl.tar.xz" \
| tar -xJ -C "$tmp"
sudo install -m 755 "$tmp/typst-${typst_arch}-unknown-linux-musl/typst" /usr/local/bin/typst
curl -fsSL -o "$tmp/Noto-COLRv1.ttf" \
"https://raw.githubusercontent.com/googlefonts/noto-emoji/${EMOJI_FONT_COMMIT}/fonts/Noto-COLRv1.ttf"
sudo install -D -m 644 "$tmp/Noto-COLRv1.ttf" /usr/local/share/fonts/Noto-COLRv1.ttf
command -v fc-cache > /dev/null && fc-cache -f > /dev/null
typst fonts | grep -i emoji

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"cSpell.language": "fr,en"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

828
td-corrige.md Normal file
View File

@@ -0,0 +1,828 @@
## TD 1 : Corrigé
### Points essentiels
#### 1. Sortie numérique
```cpp
gpio_set_dir(pin, GPIO_OUT);
gpio_put(pin, value);
```
Le programme impose un niveau logique à une broche.
#### 2. Entrée numérique
```cpp
gpio_set_dir(pin, GPIO_IN);
bool value = gpio_get(pin);
```
Le programme observe un niveau électrique externe.
#### 3. Pull-up
```cpp
gpio_pull_up(pin);
```
Elle évite une entrée flottante et permet un câblage simple du bouton vers GND.
#### 4. Niveau et événement
```text
niveau : « le bouton est actuellement appuyé »
événement : « le bouton vient d'être appuyé »
```
La détection de front nécessite de conserver un état précédent.
#### 5. ADC
```cpp
uint16_t value = adc_read();
```
Une tension analogique est convertie en une valeur numérique.
#### 6. PWM
```cpp
pwm_set_gpio_level(pin, level);
```
### Hypothèses et remarques générales
Les solutions suivantes utilisent le brochage du sujet :
```cpp
constexpr uint LED_PIN = 15;
constexpr uint BUTTON_PIN = 14;
```
Les boutons sont supposés connectés entre le GPIO et GND. On utilise donc une **pull-up interne** :
```text
bouton relâché \(\rightarrow\) GPIO = 1
bouton appuyé \(\rightarrow\) GPIO = 0
```
Ainsi, il est souvent pratique d'écrire :
```cpp
bool pressed = !gpio_get(BUTTON_PIN);
```
Pour les exercices utilisant `printf()`, il faut appeler :
```cpp
stdio_init_all();
```
et activer la sortie USB ou UART dans `CMakeLists.txt` selon l'environnement utilisé.
### Exercice 1 : Branchements
![](./images/Robot_Schema_completed_breadboard.png)
### Exercice 2 : Hello GPIO
#### Solution minimale
```cpp
#include "pico/stdlib.h"
constexpr uint LED_PIN = 15;
int main() {
gpio_init(LED_PIN);
gpio_set_dir(LED_PIN, GPIO_OUT);
while (true) {
gpio_put(LED_PIN, 1);
sleep_ms(1000);
gpio_put(LED_PIN, 0);
sleep_ms(1000);
}
}
```
`gpio_init()` initialise le GPIO, `gpio_set_dir()` fixe sa direction et `gpio_put()` fixe son niveau logique.
#### Clignotement à 5 Hz
Une fréquence de 5 Hz correspond à une période :
```text
T = 1 / f = 1 / 5 = 0,2 s = 200 ms
```
Avec un rapport cyclique de 50 %, chaque demi-période dure 100 ms :
```cpp
while (true) {
gpio_put(LED_PIN, true);
sleep_ms(100);
gpio_put(LED_PIN, false);
sleep_ms(100);
}
```
#### 100 ms ON / 900 ms OFF
```cpp
while (true) {
gpio_put(LED_PIN, true);
sleep_ms(100);
gpio_put(LED_PIN, false);
sleep_ms(900);
}
```
La période vaut ici 1 s, soit une fréquence de 1 Hz, avec un rapport cyclique de 10 %.
#### Fonction `blink()`
```cpp
void blink(uint pin, uint duration_ms) {
gpio_put(pin, true);
sleep_ms(duration_ms);
gpio_put(pin, false);
sleep_ms(duration_ms);
}
```
Utilisation :
```cpp
while (true) {
blink(LED_PIN, 250);
}
```
---
### Exercice 3 : Lire un bouton
Le **numéro du GPIO** (GPIO14) identifie une broche logique du microcontrôleur ; son **état logique** est la valeur 0 ou 1 sur cette broche.
```cpp
#include <cstdio>
#include "pico/stdlib.h"
constexpr uint BUTTON_PIN = 14;
int main() {
stdio_init_all();
gpio_init(BUTTON_PIN);
gpio_set_dir(BUTTON_PIN, GPIO_IN);
gpio_pull_up(BUTTON_PIN);
while (true) {
bool raw = gpio_get(BUTTON_PIN);
bool pressed = !raw;
printf("button = %d, pressed = %d\n", raw, pressed);
sleep_ms(100);
}
}
```
La résistance de pull-up impose un état connu lorsque le bouton est ouvert. Sans elle, l'entrée pourrait être flottante et donc prendre un niveau indéterminé.
Avec ce câblage :
```text
bouton relâché : entrée reliée faiblement au 3,3 V -> 1
bouton appuyé : entrée reliée directement à GND -> 0
```
D'où :
```cpp
bool pressed = !gpio_get(BUTTON_PIN);
```
La valeur physique « bouton appuyé » est ici représentée par un zéro logique : on parle souvent d'entrée **active à l'état bas**.
---
### Exercice 4 : Utilisation des moteurs
```text
programme
│
│ consigne : vitesse = -255 ... +255
▼
controleMoteur()
│
├── signe ───────────► choix du sens
│
└── valeur absolue ──► rapport cyclique PWM
│
▼
GPIO A / GPIO B
│
▼
pont en H
│
▼
moteur
```
```cpp
#define LEFT_MOTOR_PIN_A 26
#define LEFT_MOTOR_PIN_B 27
#define RIGHT_MOTOR_PIN_A 2
#define RIGHT_MOTOR_PIN_B 3
```
```cpp
void controleMoteur(uint pinA, uint pinB, int vitesse)
{
int puissance = vitesse < 0 ? -vitesse : vitesse;
if (vitesse > 0) {
pwm_set_gpio_level(pinA, puissance);
pwm_set_gpio_level(pinB, 0);
}
else if (vitesse < 0) {
pwm_set_gpio_level(pinA, 0);
pwm_set_gpio_level(pinB, puissance);
}
else {
pwm_set_gpio_level(pinA, 0);
pwm_set_gpio_level(pinB, 0);
}
}
```
```cpp
void controleMoteurs(int gauche, int droite)
{
controleMoteur(LEFT_MOTOR_PIN_A, LEFT_MOTOR_PIN_B, gauche);
controleMoteur(RIGHT_MOTOR_PIN_A, RIGHT_MOTOR_PIN_B, droite);
}
```
```text
┌── moteur avant gauche
vitesse gauche ── L298N ─┤
└── moteur arrière gauche
┌── moteur avant droit
vitesse droite ── L298N ─┤
└── moteur arrière droit
```
### Exercice 5 : Missions
1. Écrire un programme qui fait avancer le robot pendant 1 seconde, puis reculer le robot pendant 1 seconde.
2. Écrire un programme qui fait tourner le robot de 90° vers la gauche.
Chaque exercice est traité avec le protocole JSON, puis avec l'API `robot.js` (téléphone) et l'API `Robot.h` (robot).
#### Tourner de 90°
Le robot n'a ni encodeur ni gyroscope : il ne mesure pas l'angle dont il a tourné. Un virage sur place (roue gauche en arrière, roue droite en avant) dure un temps fixé, et ce temps donne l'angle. La valeur `600` ms à la vitesse `180` est un point de départ, à mesurer sur chaque robot et sur le sol de la salle.
1. Poser le robot sur le sol de la salle, aligné sur un repère (bord de dalle, ruban adhésif).
2. Lancer le quart de tour à gauche avec la durée courante.
3. Relever l'angle obtenu.
4. Corriger la durée en proportion : si 600 ms donnent 75°, essayer `600 * 90 / 75 = 720` ms.
5. Recommencer jusqu'à obtenir 90°, puis vérifier avec quatre quarts de tour d'affilée : le robot doit revenir face au repère.
**Remarque.** `turnLeft()` dans `robot.js` vaut par défaut 500 ms à la vitesse 200.
Cette valeur par défaut n'est pas un quart de tour mesuré.
**Attention.** La durée mesurée ne vaut que pour une vitesse, un sol et un
niveau de batterie. Changer l'un des trois oblige à la mesurer de nouveau.
#### Protocole JSON
Conventions des échanges ci-dessous :
- `→` : ligne envoyée au robot (console USB, ou corps de `POST /api/command`).
- `←` : ligne envoyée par le robot (console USB, ou `GET /api/lines`).
- Les champs des capteurs (`front_mm`, `front_ok`, `front_q`, `side_mm`, `side_ok`, `side_q`) sont omis des lignes de télémétrie pour la lisibilité.
- Les valeurs de `ms` et de `seq` sont des exemples. La télémétrie part toutes les 200 ms par défaut ; seules les lignes utiles sont montrées.
##### Question 1 : avancer 1 s, puis reculer 1 s
**À la main** (console USB ou `curl`). Deux commandes de mouvement, la seconde envoyée après la ligne `done:true` de la première :
```text
→ {"seq":1,"left":200,"right":200,"duration":1000}
← {"seq":1,"ms":41230,"left":200,"right":200,"moving":true,"mission":0}
← {"seq":1,"ms":42230,"left":0,"right":0,"moving":false,"mission":0,"done":true}
→ {"seq":2,"left":-200,"right":-200,"duration":1000}
← {"seq":2,"ms":42430,"left":-200,"right":-200,"moving":true,"mission":0}
← {"seq":2,"ms":43390,"left":0,"right":0,"moving":false,"mission":0,"done":true}
```
`"duration":1000` est la valeur par défaut et peut être omis. `seq` est facultatif ; il permet de savoir à quelle commande correspond une ligne `done`.
Avec `curl`, en remplaçant `<robot-ip>` par l'adresse affichée sur l'écran du robot :
```sh
curl -d '{"seq":1,"left":200,"right":200,"duration":1000}' http://<robot-ip>/api/command
curl -d '{"seq":2,"left":-200,"right":-200,"duration":1000}' http://<robot-ip>/api/command
```
**Remarque.** En salle, les ordinateurs ne sont pas sur le point d'accès du robot. `curl` s'utilise alors contre `client/fakerobot.js`, à l'adresse `http://localhost:8080`.
**Erreur fréquente.** Envoyer les deux commandes d'un coup, dans le même corps de requête ou sans attendre le `done`. Un mouvement remplace le mouvement en cours : le robot exécute le retour presque aussitôt et l'aller n'a pas lieu.
**Avec `robot.js`, classe `Robot`.** `forward()` et `backward()` envoient les mêmes lignes. `send()` ajoute `seq` après les autres champs ; les tags partent d'une valeur aléatoire :
```text
→ {"left":200,"right":200,"duration":1000,"seq":5731}
← … ligne done:true avec "seq":5731 …
→ {"left":-200,"right":-200,"duration":1000,"seq":5732}
← … ligne done:true avec "seq":5732 …
```
**Avec `robot.js`, classe `Mission`.** `Mission.run()` encadre les étapes par deux commandes de réglage :
```text
→ {"telemetry":100,"seq":5731}
→ {"left":200,"right":200,"duration":1000,"seq":5732}
← … ligne done:true avec "seq":5732 …
→ {"left":-200,"right":-200,"duration":1000,"seq":5733}
← … ligne done:true avec "seq":5733 …
→ {"stop":true,"seq":5734}
```
`{"telemetry":100}` fait passer la télémétrie à 100 ms pour les conditions `stopWhen()`. `{"stop":true}` final laisse le robot arrêté ; il ne produit pas de ligne `done`.
**Avec `Robot.h`.** Une seule commande traverse la liaison. Les deux mouvements sont décidés sur le robot et n'apparaissent que dans la télémétrie :
```text
→ {"mission":1}
← {"seq":-1,"ms":41230,"left":200,"right":200,"moving":true,"mission":1}
← {"seq":-1,"ms":42430,"left":-200,"right":-200,"moving":true,"mission":1}
← {"seq":-1,"ms":43230,"left":0,"right":0,"moving":false,"mission":0,"done":true}
```
Il n'y a qu'une ligne `done:true`, à la fin du retour : le retour remplace l'aller sans que les roues s'arrêtent. `seq` reste à `-1` tant qu'aucune commande n'a fourni de tag. Depuis le téléphone, `bot.mission(1)` envoie `{"mission":1,"seq":5731}` et la télémétrie porte alors `"seq":5731`.
##### Question 2 : quart de tour à gauche
**À la main.** Une seule commande de mouvement, roue gauche en arrière, roue droite en avant :
```text
→ {"seq":3,"left":-180,"right":180,"duration":600}
← {"seq":3,"ms":50100,"left":-180,"right":180,"moving":true,"mission":0}
← {"seq":3,"ms":50700,"left":0,"right":0,"moving":false,"mission":0,"done":true}
```
`600` est la durée à mesurer.
**Avec `robot.js`, classe `Robot`.** `turnLeft(180, 600)` envoie :
```text
→ {"left":-180,"right":180,"duration":600,"seq":5735}
← … ligne done:true avec "seq":5735 …
```
**Avec `robot.js`, classe `Mission`.**
```text
→ {"telemetry":100,"seq":5735}
→ {"left":-180,"right":180,"duration":600,"seq":5736}
← … ligne done:true avec "seq":5736 …
→ {"stop":true,"seq":5737}
```
**Avec `Robot.h`.**
```text
→ {"mission":2}
← {"seq":-1,"ms":50100,"left":-180,"right":180,"moving":true,"mission":0}
← {"seq":-1,"ms":50700,"left":0,"right":0,"moving":false,"mission":0,"done":true}
```
`"mission":0` apparaît dès la première ligne : `quarterTurnLeft()` remet `_mission` à 0 sur le tour même où elle lance le virage (voir la remarque de l'exercice 2 côté `Robot.h`).
#### API `robot.js` (téléphone)
Les méthodes de `Robot` envoient une commande et rendent son `seq` sans attendre. `waitUntilDone(seq)` attend la ligne `done:true` du robot. Le code ci-dessous s'exécute dans un module ES (`await` au premier niveau) ou dans une fonction `async`.
##### Question 1 : avancer 1 s, puis reculer 1 s
```js
import { Robot } from './robot.js';
const bot = new Robot();
bot.start(); // lit la télémétrie toutes les 100 ms
const aller = bot.forward(200, 1000);
if (await bot.waitUntilDone(aller) === null) throw new Error('pas de fin d\'aller');
const retour = bot.backward(200, 1000);
if (await bot.waitUntilDone(retour) === null) throw new Error('pas de fin de retour');
```
`waitUntilDone()` rend `null` après 6 s sans ligne `done`. Le test évite d'envoyer le retour alors que l'aller n'a jamais été confirmé.
Même programme sous forme de mission du téléphone, dans l'objet `missions` de `robot/web/missions.js` :
```js
'avancer-reculer': ({ speed = 200 } = {}) =>
new Mission('avancer-reculer')
.forward(speed, 1000)
.backward(speed, 1000),
```
`Mission.run()` envoie chaque étape, attend son `done` et laisse le robot arrêté à la fin.
**Attention.** Entre l'aller et le retour, le robot reste immobile un court moment : il arrête ses roues à l'échéance, puis le téléphone doit recevoir la ligne `done` (jusqu'à 100 ms de scrutation) et envoyer la commande suivante (un aller-retour HTTP). Combien de millisecondes séparent le `ms` de la ligne `done` de l'aller et le `ms` de la première ligne avec `"left":-200` ?
##### Question 2 : quart de tour à gauche
```js
import { Robot } from './robot.js';
const QUARTER_TURN_SPEED = 180;
const QUARTER_TURN_MS = 600; // à mesurer, voir « Tourner de 90° »
const bot = new Robot();
bot.start();
const virage = bot.turnLeft(QUARTER_TURN_SPEED, QUARTER_TURN_MS);
if (await bot.waitUntilDone(virage) === null) throw new Error('pas de fin de virage');
```
`turnLeft(speed, duration)` envoie `drive(-speed, speed, duration)` : la roue gauche recule, la roue droite avance, le robot tourne sur place.
Sous forme de mission du téléphone, avec les deux constantes ci-dessus déclarées en tête de `missions.js` :
```js
'quart-gauche': ({ speed = QUARTER_TURN_SPEED } = {}) =>
new Mission('quart-gauche')
.turnLeft(speed, QUARTER_TURN_MS),
```
**Erreur fréquente.** Passer le curseur de vitesse de la page à cette mission sans recalibrer : `QUARTER_TURN_MS` n'est juste qu'à `QUARTER_TURN_SPEED`.
#### API `Robot.h` (robot)
Un programme du robot est une mission : une méthode de la classe `Robot` appelée à chaque tour de `Robot::update()`. Elle ne doit jamais attendre (`sleep_ms()` est interdit) : elle lit l'heure avec `nowMs()`, bouge avec `drive()` et rend la main tout de suite. Elle garde son état dans des membres déclarés à côté d'elle.
Le code ci-dessous part du firmware de départ, où aucune mission n'existe : il utilise les numéros 1 et 2. Si les missions du programme des TP sont déjà en place (1 à 5), prendre 6 et 7 et ajouter les noms à la fin de
`MISSION_NAMES`.
##### Déclaration des deux missions
Dans `robot/Robot.h` :
```cpp
static constexpr const char* MISSION_NAMES = "avancer-reculer,quart-gauche";
void mission() {
switch (_mission) {
case 0: break;
case 1: forwardThenBack(); break;
case 2: quarterTurnLeft(); break;
default: break;
}
}
```
Dans `execute()`, remettre l'exercice 1 à son début à chaque sélection de mission. Un `{"mission":1}` envoyé après un arrêt en cours de route repart alors de l'aller :
```cpp
if (command.has("mission")) {
_mission = (int)command.integer("mission");
_backStep = 0;
}
```
##### Question 1 : avancer 1 s, puis reculer 1 s
```cpp
// Avancer 1 s, puis reculer 1 s. Étapes : 0 à lancer, 1 aller, 2 retour.
int _backStep = 0;
uint32_t _backUntil = 0;
void forwardThenBack() {
if (_backStep != 0 && (int32_t)(nowMs() - _backUntil) < 0) return; // étape en cours
if (_backStep == 0) {
drive(200, 200, 1000);
_backStep = 1;
} else if (_backStep == 1) {
drive(-200, -200, 1000);
_backStep = 2;
} else {
_backStep = 0;
_mission = 0; // fini : enforceSafety() arrête les roues sur ce tour
return;
}
_backUntil = _moveDeadline; // l'étape finit avec le mouvement
}
```
Lancement depuis la console USB ou par le bouton « avancer-reculer » du téléphone :
```json
{"mission":1}
```
`mission()` s'exécute avant `enforceSafety()` dans `Robot::update()`. Sur le tour où l'aller arrive à échéance, `forwardThenBack()` a déjà lancé le retour avec une nouvelle échéance : les roues passent de +200 à -200 sans arrêt intermédiaire. Sur le tour où le retour arrive à échéance, la mission se désactive, puis `enforceSafety()` arrête les roues et envoie la ligne `done:true`.
**Attention.** La comparaison `(int32_t)(nowMs() - _backUntil) < 0` reste juste quand l'horloge de 32 bits repasse à zéro ; `nowMs() < _backUntil` ne l'est pas. Voir [`API.md` — Time](API.md#time).
**Attention.** Passer directement de +200 à -200 sollicite les moteurs et la batterie. Si le robot redémarre au changement de sens, ajouter une étape d'arrêt de 200 ms entre l'aller et le retour avec `drive(0, 0, 200)`.
##### Question 2 : quart de tour à gauche
Dans la classe `Robot`, à côté de la méthode :
```cpp
static constexpr int QUARTER_TURN_SPEED = 180;
static constexpr uint32_t QUARTER_TURN_MS = 600; // à mesurer, voir « Tourner de 90° »
// Un seul mouvement : enforceSafety() l'arrête à l'échéance.
void quarterTurnLeft() {
drive(-QUARTER_TURN_SPEED, QUARTER_TURN_SPEED, QUARTER_TURN_MS);
_mission = 0;
}
```
Lancement :
```json
{"mission":2}
```
Le quart de tour est un seul mouvement. La mission le lance une fois, puis se désactive pour ne pas le relancer au tour suivant. L'échéance armée par `drive()` arrête les roues et produit la ligne `done:true`, comme pour une commande tapée dans la console.
**Remarque.** Pendant le virage, la télémétrie indique déjà `"mission":0`. Pour garder `"mission":2` jusqu'à la fin, attendre que `motors.isMoving()` soit faux avant de remettre `_mission` à 0, avec un booléen qui retient que le virage a été lancé.
**Erreur fréquente.** Écrire `QUARTER_TURN_MS` dans `Config.h` avec `#define` alors qu'il est aussi déclaré dans `Robot.h` : le préprocesseur remplace le nom dans la déclaration et la compilation échoue. Le déclarer à un seul endroit.
#### Comparaison des deux versions
| Question | `robot.js` | `Robot.h` |
| ---------------------------------- | ------------------------------------ | ----------------------------------- |
| Où s'exécute le programme ? | Dans le navigateur du téléphone | Sur le Pico |
| Qui décide de lancer le retour ? | Le téléphone, après la ligne `done` | La mission, à l'échéance de l'aller |
| Arrêt entre l'aller et le retour ? | Oui, au moins un aller-retour réseau | Non |
| Modifier le programme demande… | Un rechargement de la page¹ | Une recompilation et un flash |
¹ Contre `client/fakerobot.js`. Sur le vrai robot, la page est servie par le firmware : la modifier demande aussi une recompilation et un flash.
## TD 2 : Corrigé
### Rappel de la formule PID discrète
\[
u[n] = K_p \cdot e[n] + K_i \cdot \sum_{k=0}^{n} e[k] + K_d \cdot (e[n] - e[n-1])
\]
avec \(e[n] = distance_{cible} - distance_{mesurée}\).
**Remarque.** Discrétisées avec une période \(\Delta t\), l'intégrale devient \(\sum e[k] \cdot \Delta t\) et la dérivée \(\frac{e[n] - e[n-1]}{\Delta t}\). Si \(\Delta t\) est fixe, on pose \(K_i = K'_i \cdot \Delta t\) et \(K_d = K'_d / \Delta t\) (\(K'_i\), \(K'_d\) : gains du PID continu). À 50 Hz, \(K'_i = 5\) et \(K'_d = 0.004\) donnent \(K_i = 0.1\) et \(K_d = 0.2\). Changer la fréquence de la boucle oblige à recalculer \(K_i\) et \(K_d\).
- \(K_p\) : gain proportionnel \(\rightarrow\) réagit à l'erreur actuelle.
- \(K_i\) : gain intégral \(\rightarrow\) corrige les erreurs accumulées dans le temps.
- \(K_d\) : gain dérivé \(\rightarrow\) réagit à la vitesse de variation de l'erreur et amortit les changements rapides.
### Exercice 1 : composante proportionnelle (P)
> Distance cible = 10 cm, distance mesurée = 6 cm
1. **Que signifie \(e[0]\) ? Calculez sa valeur.**
\[
e[0] = 10 - 6 = 4 \text{ cm}
\]
C'est l'erreur au premier pas de temps : le robot est 4 cm trop près du mur. Une erreur positive signifie « trop près ».
2. **Si \(K_p = 0.5\), quelle est la correction appliquée ?**
\[
u[0] = K_p \cdot e[0] = 0.5 \cdot 4 = 2
\]
Le robot applique une correction de 2 unités de commande (par exemple, un écart de vitesse entre les roues) pour s'écarter du mur.
3. **Que se passe-t-il si \(K_p = 2\) ?**
\[
u[0] = 2 \cdot 4 = 8
\]
La correction est quatre fois plus forte. Le robot réagit plus vite, mais risque de dépasser la cible (sur-correction) puis d'osciller.
### Exercice 2 : composante intégrale (I)
> \(e[0] = 4, e[1] = 4, e[2] = 4\) : l'erreur persiste.
1. **Commande totale après 3 mesures (avec \(K_p = 0.5\) et \(K_i = 0.1\)) :**
- Correction proportionnelle = \(0.5 \cdot 4 = 2\)
- Correction intégrale = \(K_i \cdot \sum e[k] = 0.1 \cdot (4 + 4 + 4) = 1.2\)
- Commande totale \(u[2] = 2 + 1.2 = 3.2\)
**Remarque.** Le terme dérivé est nul ici : \(e[2] - e[1] = 0\).
2. **Quelle est la part due à l'intégrale ?**
\(1.2\) sur \(3.2\), soit 37,5 % de la commande. Tant que l'erreur reste à 4, cette part augmente de \(0.4\) à chaque pas de temps.
3. **Que se passe-t-il si \(K_i\) est trop grand ?**
- La somme accumulée pèse trop dans la commande \(\rightarrow\) sur-correction.
- L'intégrale ne diminue que lorsque l'erreur change de signe : le robot dépasse la cible et oscille, même quand l'erreur actuelle est faible.
### Exercice 3 : composante dérivée (D)
> \(e[2] = 4\), \(e[3] = 1\) : l'erreur diminue rapidement, le robot s'éloigne du mur.
1. **Variation d'erreur :**
\[
e_d = e[3] - e[2] = 1 - 4 = -3
\]
2. **Correction dérivée :**
\[
u_d = K_d \cdot e_d = 0.2 \cdot (-3) = -0.6
\]
Le terme dérivé est négatif : il **freine** la commande car l'erreur diminue rapidement.
3. **Commande totale \(u[3]\) (avec \(K_p = 0.5\), \(K_i = 0.1\) et \(K_d = 0.2\)) :**
\[
u[3] = 0.5 \cdot 1 + 0.1 \cdot (4 + 4 + 4 + 1) + 0.2 \cdot (-3) = 0.5 + 1.3 - 0.6 = 1.2
\]
Le robot **réduit** sa correction pour ne pas dépasser la consigne.
**Erreur fréquente.** Multiplier deux fois par \(K_d\) : \(0.2 \cdot (-0.6)\). La correction dérivée \(-0.6\) contient déjà le gain.
### Exercice 4 : pertinence des composantes
1. **Analyse des comportements :**
| Situation | Interprétation | Réglage probable |
| ------------------------------ | --------------------------------------------------------------- | ----------------------------------------------- |
| A : oscille autour de la cible | \(K_p\) trop grand, \(K_i\) trop grand ou \(K_d\) trop faible | Diminuer \(K_p\) ou \(K_i\), augmenter \(K_d\) |
| B : corrige trop lentement | \(K_p\) trop faible, ou \(K_d\) trop grand (amortissement excessif) | Augmenter \(K_p\), diminuer \(K_d\) |
| C : dépasse systématiquement | \(K_i\) trop grand (somme accumulée pendant l'approche), \(K_p\) trop grand ou \(K_d\) trop faible | Diminuer \(K_i\), augmenter \(K_d\) |
2. **Comment corriger ?** Régler les gains un par un, sur le robot :
1. Mettre \(K_i = 0\) et \(K_d = 0\).
2. Augmenter \(K_p\) jusqu'à ce que le robot oscille autour de la cible, puis le diviser par deux.
3. Augmenter \(K_d\) jusqu'à ce que les oscillations restantes s'amortissent.
4. Si une erreur persiste à l'équilibre, ajouter un petit \(K_i\).
5. Borner la somme \(\sum e[k]\) (*anti-windup*) pour qu'elle ne s'accumule pas quand le robot est bloqué ou loin de la cible.
**Attention.** Ne modifier qu'un gain à la fois, sinon on ne sait pas lequel a produit l'effet observé.
## TD 3 : Corrigé
### Exercice 1 : temps de calcul et fréquence de lecture
1. **Fréquence maximale de la boucle** :
- Le capteur mesure pendant que le processeur lit et calcule : la boucle attend seulement la mesure suivante.
- Période de la boucle = 20 ms, soit **50 Hz**.
- Élément limitant : le capteur.
**Erreur fréquente.** Additionner 20 ms + 0,5 ms + 0,02 ms. Ce calcul vaut pour un capteur qui attend la fin de la lecture avant de lancer la mesure suivante (mode « une seule mesure ») : on obtiendrait alors \(\frac{1}{0.02052} \approx 48.7 \text{ Hz}\).
2. **Part du temps processeur** :
- Lecture + calcul = 0,5 + 0,02 = 0,52 ms toutes les 20 ms
- \(\frac{0.52}{20} = 2.6\,\%\)
- Le reste sert notamment à la pile Wi-Fi (pilote CYW43439 et TCP/IP), au serveur HTTP, à la télémétrie, à l'afficheur et au télémètre à ultrasons.
3. **Boucle à 100 Hz** :
- Le VL53L1X ne produit que 50 mesures par seconde : la boucle lit chaque mesure deux fois.
- Une fois sur deux, \(e[n] - e[n-1] = 0\) ; la fois suivante, toute la variation arrive d'un coup. Le terme dérivé alterne entre 0 et le double de sa valeur.
- Le terme intégral compte chaque mesure deux fois.
- Solution : ne calculer le PID que lorsqu'une nouvelle mesure est disponible.
4. **Fréquence raisonnable à 0.5 m/s** :
- À 50 Hz, le robot avance de \(\frac{0.5}{50} = 1 \text{ cm}\) entre deux mesures ; à 25 Hz, de 2 cm.
- Le coût en calcul étant faible (question 2), **50 Hz** est adapté. 25 Hz reste acceptable si l'on accepte 2 cm entre deux mesures.
### Exercice 2 : occupation mémoire
1. **Mémoire utilisée pour 100 erreurs** :
- \(100 \times 4\) octets = **400 octets**
- SRAM = 264 kio = 270 336 octets, soit \(\frac{400}{270336} \approx 0.15\,\%\)
- Sur le Pico WH, cet historique n'est pas une contrainte.
2. **Ce que le PID doit conserver** :
- La somme des erreurs \(S[n] = S[n-1] + e[n]\), mise à jour à chaque pas
- L'erreur précédente \(e[n-1]\)
- Soit 2 `float` = **8 octets**. L'historique complet n'est nécessaire que pour une intégrale sur fenêtre glissante ou un filtre.
3. **Historique de télémétrie** :
- Un échantillon : 4 + 4 \(\times\) 2 = 12 octets (pas d'octets de remplissage : le `uint32_t` est en tête)
- À 50 Hz : \(12 \times 50 = 600\) octets/s
- Tampon de 32 kio : \(\frac{32768}{600} \approx 54.6\) s, soit **environ 54 s**
4. **Réduire l'empreinte** :
- Tampon circulaire : on garde les \(N\) dernières secondes et on écrase les plus anciennes.
- Sous-échantillonner l'historique à 10 Hz : 5 fois moins de mémoire, le PID continue à 50 Hz.
- Stocker un écart de temps sur un `uint16_t` au lieu de `ms` sur un `uint32_t` : 10 octets au lieu de 12.
- Ne rien stocker sur le robot : le téléphone reçoit déjà la télémétrie et garde l'historique, où la mémoire ne manque pas.
### Exercice 3 : énergie et efficacité
1. **Autonomie des moteurs** :
- Sans oscillations : 1000 mAh / 100 mA = **10 heures**
- Avec oscillations : 1000 mAh / 150 mA ≈ **6,67 heures** (6 h 40 min)
2. **Gain en autonomie** :
- 10 h − 6,67 h = **3,33 heures** (3 h 20 min)
- Soit 50 % d'autonomie en plus (\(\frac{10}{6.67} = 1.5\))
3. **Stratégie PID pour réduire les oscillations** :
- Diminuer \(K_p\) s'il est trop agressif
- Augmenter \(K_d\) pour amortir les variations
- Réduire \(K_i\) et borner la somme si elle s'accumule trop
### Exercice 4 : précision et bruit de mesure
1. **Erreur moyenne** :
- Moyenne = (198 + 202 + 195 + 205 + 200) / 5 = **200 mm**
- Erreur moyenne (biais) = 200 − 200 = **0 mm**
- Erreur absolue moyenne = (2 + 2 + 5 + 5 + 0) / 5 = **2,8 mm**
**Écart-type** :
- Écarts à la moyenne : −2, +2, −5, +5, 0 ; carrés : 4, 4, 25, 25, 0 ; somme = 58
- Écart-type de la population : \(\sqrt{58 / 5} = \sqrt{11.6} \approx\) **3,4 mm**
- Écart-type de l'échantillon : \(\sqrt{58 / 4} = \sqrt{14.5} \approx\) **3,8 mm**
2. **Influence du bruit sur le terme dérivé** :
- Erreurs \(e[n] = 200 - mesure\) : 2, −2, 5, −5, 0
- Variations \(e[n] - e[n-1]\) : −4, +7, −10, +5 mm
- Avec \(K_d = 0.2\) : termes dérivés −0,8, +1,4, −2,0, +1,0, alors que le robot ne bouge pas.
- À 50 Hz, une variation de 10 mm en 20 ms correspond à une vitesse apparente de 0,5 m/s, la vitesse du robot dans les exercices 1 et 5.
- La différence de deux mesures bruitées a un écart-type \(\sqrt{2}\) fois plus grand que celui d'une mesure. Le terme dérivé amplifie le bruit : commandes saccadées, oscillations.
3. **Méthodes de filtrage** :
- Moyenne glissante sur \(N\) mesures : retard de \(\frac{N-1}{2}\) périodes, soit 40 ms pour \(N = 5\) à 50 Hz.
- Médiane sur 3 mesures : élimine une valeur aberrante isolée, retard d'une période (20 ms).
- Filtre exponentiel : \(y[n] = \alpha \cdot x[n] + (1 - \alpha) \cdot y[n-1]\) ; plus \(\alpha\) est petit, plus le filtre lisse et plus il retarde.
- Ne filtrer que le terme dérivé, pour garder la réactivité des termes P et I.
**Attention.** Le retard du filtre s'ajoute à la latence de la boucle (exercice 5).
### Exercice 5 : latence dans une architecture distribuée
La chaîne entre la mesure et la commande :
- La mesure attend la ligne de télémétrie suivante : de 0 à 20 ms (la ligne porte la dernière mesure, prise au plus 20 ms plus tôt).
- La ligne attend la prochaine requête `GET /api/lines` du téléphone : de 0 à 100 ms.
- La réponse traverse le Wi-Fi jusqu'au téléphone.
- Le téléphone calcule le PID : 1 ms.
- Le `POST /api/command` traverse le Wi-Fi jusqu'au robot.
1. **Latence, canal libre** :
- Meilleur cas : 0 + 0 + 5 + 1 + 5 = **11 ms**
- Pire cas : 20 + 100 + 5 + 1 + 5 = **131 ms**
- À comparer avec le PID local : au plus 20 ms + 0,52 ms.
2. **Latence, canal encombré** :
- Meilleur cas : 0 + 0 + 50 + 1 + 50 = **101 ms**
- Pire cas : 20 + 100 + 50 + 1 + 50 = **221 ms**
3. **Distance parcourue à 0.5 m/s** (0,5 mm par milliseconde) :
| Cas | Latence | Distance |
| ----------------------- | ------- | --------- |
| Canal libre, meilleur | 11 ms | 5,5 mm |
| Canal libre, pire | 131 ms | 6,6 cm |
| Canal encombré, meilleur | 101 ms | 5,1 cm |
| Canal encombré, pire | 221 ms | 11,1 cm |
| PID local, pire | 20,5 ms | 1,0 cm |
4. **Est-ce acceptable pour des obstacles à 20 cm ?**
- Dans le pire cas, le robot parcourt plus de la moitié des 20 cm avant que la commande n'arrive, et il lui faut encore une distance de freinage.
- La latence varie d'un facteur 20 entre le meilleur et le pire cas : le réglage du PID ne peut pas en tenir compte.
- Acceptable pour des corrections lentes de trajectoire. Inacceptable pour l'arrêt devant un obstacle.
5. **Canaux Wi-Fi 2,4 GHz** :
- **3 canaux** de 20 MHz sans chevauchement : 1, 6 et 11. En Europe, où les canaux 1 à 13 sont autorisés, on peut en placer 4 : 1, 5, 9 et 13.
- Une douzaine de robots se partagent donc 3 ou 4 canaux, avec le Wi-Fi de l'établissement, le Bluetooth et les autres équipements de la bande.
- Sur un même canal, une seule station émet à la fois : chacune attend que le canal soit libre (CSMA/CA), et les collisions provoquent des retransmissions. La latence augmente et devient irrégulière.
6. **Stratégies pour réduire l'impact de la latence** :
- **Calcul local** : faire tourner le PID sur le Pico à 50 Hz ; il n'occupe que 2,6 % du temps processeur (exercice 1).
- **Hybridation** : le téléphone envoie des consignes lentes (distance au mur, vitesse, mission) ; le robot exécute le PID et l'arrêt devant un obstacle. C'est le rôle de la mission `stopAtObstacle()` du TP 1.
- **Prédiction** : le champ `ms` date chaque ligne sur l'horloge du robot. Le téléphone estime l'âge de la mesure et extrapole la distance : \(d_{prédite} = d_{mesurée} - v \cdot \text{âge}\).
- **Sécurité** : chaque commande porte une `duration`. Si le téléphone n'envoie plus rien, le robot s'arrête de lui-même à l'échéance (`enforceSafety()`).
- **Réseau** : choisir un canal peu occupé pour le point d'accès du robot ; réduire la période de scrutation, au prix de plus de trafic sur le canal.

8058
td-corrige.pdf Normal file

File diff suppressed because it is too large Load Diff

101
td-corrige.typ Normal file
View File

@@ -0,0 +1,101 @@
#import "@preview/minicise:0.1.0": sheet
#import "@preview/mitex:0.2.7": mitex
#import "@preview/cmarker:0.1.10"
// --------------------------------------------------------- configuration ----
#show: sheet.with(
title: [Exercices de cours -- Corrigés],
course: [Objets connectés et robotique],
semester: [L3 IFA, S5],
date: [2026],
author: [Vincent Lannurien],
)
// the markdown source rendered as the body of this sheet
#let md-source = "td-corrige.md"
// set to `false` to drop the first page with the table of contents
#let show-toc = false
// ---------------------------------------------------------------- styles ----
#show figure.caption: set text(size: 0.9em, fill: luma(70))
// the outline picks up the headings cmarker emits further down
#show outline.entry.where(level: 2): set text(weight: "semibold")
// each `##` heading starts on a fresh page, except the first one, which stays
// on the title page — `weak` avoids a blank page when we are already at the top
#let md-section = counter("md-section")
#show heading.where(level: 2, outlined: true): it => {
context if md-section.get().first() > 0 {
pagebreak(weak: true)
}
md-section.step()
it
}
// ------------------------------------------------------------------ math ----
// hugo goldmark delimits math with `\(...\)` and `\[...\]`, while cmarker
// expects the typst/markdown `$...$` and `$$...$$` forms
#let goldmark-math(markdown) = {
markdown
.replace("\\[", "$$")
.replace("\\]", "$$")
.replace("\\(", "$")
.replace("\\)", "$")
}
// ---------------------------------------------------------------- images ----
// maximum footprint of an image coming from the markdown source
#let img-max-height = 7cm
// set to `none` to drop the frame around screenshots
#let img-stroke = 0.5pt + luma(180)
// scale an image down so it fits both the available width and `img-max-height`,
// without ever blowing it up past its natural size
#let fitted-image(source, ..args) = layout(available => {
let natural = measure(image(source, ..args))
let factor = calc.min(
1.0,
available.width / natural.width,
img-max-height / natural.height,
)
image(source, width: natural.width * factor, ..args)
})
// handle `![alt](src)` and `<img src=... width=... height=...>`
#let md-image(source, alt: none, width: auto, height: auto, ..args) = {
// an explicit width/height in the markdown wins over the automatic fitting
let body = if width == auto and height == auto {
fitted-image(source, alt: alt, ..args)
} else {
image(source, alt: alt, width: width, height: height, ..args)
}
let framed = block(stroke: img-stroke, radius: 3pt, clip: true, body)
// alt text, when there is one, becomes a numbered caption
if alt == none or alt == "" {
align(center, framed)
} else {
figure(framed, caption: alt)
}
}
// ------------------------------------------------------------------- toc ----
// outline's own title is a level-1 heading, which would outrank every heading in
// the source (they start at `##`), so we emit it ourselves at the right level
#if show-toc {
heading(level: 2, outlined: false)[Sommaire]
outline(title: none, depth: 3, indent: 1em)
pagebreak()
}
// ------------------------------------------------------------- exercices ----
#cmarker.render(goldmark-math(read(md-source)), math: mitex, scope: (image: md-image))

316
td.md Normal file
View File

@@ -0,0 +1,316 @@
## TD 1 : programmation pour Raspberry Pi Pico
### API du Pico SDK
```cpp
// Initialise un GPIO afin de pouvoir l'utiliser comme entrée ou sortie.
// pin : numéro du GPIO (ex. 15 pour GPIO15).
gpio_init(pin);
// Configure la direction d'un GPIO.
// GPIO_IN : le programme lit l'état électrique de la broche.
gpio_set_dir(pin, GPIO_IN);
// GPIO_OUT : le programme impose l'état électrique de la broche.
gpio_set_dir(pin, GPIO_OUT);
// Impose un niveau logique sur un GPIO configuré en sortie.
// value : 0/false = niveau bas ; 1/true = niveau haut.
// Ex. : allumer/éteindre une LED.
gpio_put(pin, value);
// Lit le niveau logique présent sur un GPIO configuré en entrée.
// Retourne false (niveau bas) ou true (niveau haut).
// Ex. : lire l'état d'un bouton ou d'un capteur numérique.
bool value = gpio_get(pin);
// Active une résistance interne reliant faiblement le GPIO au 3.3 V.
// Permet notamment de fixer l'état d'une entrée lorsqu'aucun composant
// externe ne lui impose de niveau.
gpio_pull_up(pin);
// Même principe, mais la résistance interne relie le GPIO à GND.
gpio_pull_down(pin);
// Suspend l'exécution du programme pendant une durée donnée.
// milliseconds : durée en millisecondes.
// Ex. : sleep_ms(1000) attend environ une seconde.
sleep_ms(milliseconds);
// Initialise les entrées/sorties standard utilisées notamment par printf().
// À appeler une fois au démarrage si le programme utilise printf().
stdio_init_all();
// Affiche du texte et des valeurs sur la sortie standard.
// %d est remplacé ici par la valeur entière passée en argument.
// Ex. : observer la valeur renvoyée par un capteur.
printf("value = %d\n", value);
// Fixe le niveau PWM produit sur un GPIO.
// value est compris entre 0 et la valeur passée à pwm_set_wrap().
// 0 = toujours bas ; max_value = presque/toujours haut selon la configuration.
// Les valeurs intermédiaires font varier le rapport cyclique.
// Ex. : contrôler la luminosité d'une LED ou la vitesse d'un moteur.
pwm_set_gpio_level(pin, value);
```
---
### Exercice 1 : Branchements
![](./images/Robot_Schema_unwired_breadboard.png)
### Exercice 2 : Hello GPIO
Une LED est reliée au GPIO 15 via une résistance.
1. Configurer GPIO 15 comme sortie numérique.
2. Allumer la LED pendant 1 seconde.
3. Éteindre la LED pendant 1 seconde.
4. Répéter indéfiniment.
5. Modifier le programme pour obtenir une fréquence de clignotement de 5 Hz avec un rapport cyclique de 50 %.
6. Modifier ensuite le clignotement pour obtenir 100 ms allumé et 900 ms éteint.
7. Écrire une fonction, qui allume puis éteint une LED, chaque phase durant `duration_ms` millisecondes, telle que :
```cpp
void blink(uint pin, uint duration_ms);
```
---
### Exercice 3 : Lire un bouton
Un bouton relie GPIO 14 à GND lorsqu'il est appuyé.
1. Configurer GPIO 14 comme entrée.
2. Activer sa résistance interne de pull-up.
3. Lire son état toutes les 100 ms.
4. Afficher l'état brut sur le terminal série sous la forme :
```text
button = 1
button = 1
button = 0
...
```
5. Ajouter une variable booléenne `pressed` qui vaut `true` lorsque le bouton est physiquement appuyé.
Questions :
- Pourquoi utilise-t-on ici une résistance de pull-up ?
- Quelle valeur logique est lue lorsque le bouton est relâché ?
- Quelle valeur logique est lue lorsqu'il est appuyé ?
- Pourquoi `pressed` peut-il être l'inverse de la valeur retournée par `gpio_get()` ?
---
### Exercice 4 : Utilisation des moteurs
Le robot possède quatre moteurs, regroupés deux par deux : les deux moteurs de gauche sont commandés ensemble, de même que les deux moteurs de droite. Chaque groupe est piloté par un pont en H à l'aide de deux broches du GPIO.
Pour un groupe de moteurs, la vitesse est comprise entre \([-255, 255]\) :
- le signe indique le sens de rotation ;
- la valeur absolue indique la puissance appliquée par PWM ;
- 0 correspond à l'arrêt.
1. Écrire la fonction `void controleMoteurs(int vitesseGauche, int vitesseDroite)` telle que :
```cpp
controleMoteurs(255, 255); // avancer à pleine puissance
controleMoteurs(128, 128); // avancer à environ 50 %
controleMoteurs(-255, -255); // reculer à pleine puissance
controleMoteurs(0, 0); // arrêt
```
On utilisera les constantes suivantes :
```cpp
#define LEFT_MOTOR_PIN_A ...
#define LEFT_MOTOR_PIN_B ...
#define RIGHT_MOTOR_PIN_A ...
#define RIGHT_MOTOR_PIN_B ...
```
Pour chaque groupe de moteurs :
- si la vitesse est positive, appliquer le PWM sur la broche A et mettre B à 0 ;
- si la vitesse est négative, mettre A à 0 et appliquer le PWM sur B ;
- si la vitesse est nulle, mettre les deux broches à 0.
2. Qu'est-ce que produit l'appel suivant ?
```cpp
controleMoteurs(200, -200);
```
---
### Exercice 5 : Missions
À partir de l'API de commandes décrite dans le cours :
1. Écrire un programme qui fait avancer le robot pendant 1 seconde, puis reculer le robot pendant 1 seconde.
2. Écrire un programme qui fait tourner le robot de 90° vers la gauche.
## TD 2 : comportement autonome avec un contrôleur PID
Équation d'un contrôleur PID en temps continu :
\[
u(t) = K_p \cdot e(t) + K_i \cdot \int_0^t e(\tau) \, d\tau + K_d \cdot \frac{de(t)}{dt}
\]
Et en version discrète, pour une implantation numérique :
\[
u[n] = K_p \cdot e[n] + K_i \cdot \sum_{k=0}^{n} e[k] + K_d \cdot (e[n] - e[n-1])
\]
Expliquer le rôle de chacune des composantes de la fonction discrète du PID, sachant que :
- \(u[n]\) : commande envoyée au système au pas de temps \(n\)
- \(e[n]\) : erreur mesurée au pas de temps \(n\), avec \(e[n] = distance_{cible} - distance_{mesurée}\)
Pour simplifier :
- erreur proportionnelle \(e_p\) : distance voulue − distance mesurée
- erreur intégrale \(e_i\) : somme des \(e_p\) calculées depuis le début
- erreur dérivée \(e_d\) : \(e_{p}[n] - e_{p}[n-1]\)
**Remarque.** La période d'échantillonnage \(\Delta t\) est fixe. Elle est intégrée aux gains : \(K_i\) contient le facteur \(\Delta t\) de l'intégrale, \(K_d\) contient le facteur \(1 / \Delta t\) de la dérivée.
---
### Exercice 1 : composante proportionnelle (P)
*Comment le terme proportionnel influence-t-il la réaction du robot ?*
> Un robot doit maintenir une distance de 10 cm avec un mur lorsqu'il le longe.
> Sa distance actuelle au mur est de 6 cm.
1. Que signifie \(e[0]\) ? Calculez sa valeur.
2. Si le gain proportionnel \(K_p = 0.5\), quelle est la correction appliquée ?
3. Que se passe-t-il si \(K_p = 2\) ?
---
### Exercice 2 : composante intégrale (I)
*Comment l'intégrale corrige-t-elle les erreurs persistantes ?*
> Le robot reste trop près du mur pendant plusieurs pas de temps.
> Aux trois premiers pas de temps : \(e[0] = 4, e[1] = 4, e[2] = 4\).
> On garde \(K_p = 0.5\) et on prend un gain intégral \(K_i = 0.1\).
1. Calculer la commande totale \(u[2]\) après 3 mesures (composantes P et I).
2. Quelle est la part due à l'intégrale ?
3. Que se passe-t-il si \(K_i\) est trop grand ?
---
### Exercice 3 : composante dérivée (D)
*Comment la dérivée anticipe-t-elle les changements rapides ?*
> Le robot s'éloigne du mur. On poursuit l'exercice 2 :
> \(e[2] = 4\), puis \(e[3] = 1\).
> On garde \(K_p = 0.5\) et \(K_i = 0.1\), et on prend un gain dérivé \(K_d = 0.2\).
1. Calculer la variation d'erreur \(e[3] - e[2]\).
2. Calculer la correction due à la dérivée.
3. Quelle est la commande totale \(u[3]\) ?
---
### Exercice 4 : pertinence des composantes
*Comment diagnostiquer un mauvais réglage du contrôleur ?*
> - Situation A : le robot oscille autour de la distance cible.
> - Situation B : le robot corrige trop lentement sa trajectoire.
> - Situation C : le robot dépasse systématiquement la distance cible.
1. Que dire de \(K_p\), \(K_i\) et \(K_d\) dans chacune de ces trois situations ?
2. Comment peut-on corriger ces dysfonctionnements ?
## TD 3 : distribution du calcul et étude des compromis
Le robot embarque un Raspberry Pi Pico WH :
- microcontrôleur RP2040 : deux cœurs Cortex-M0+, sans unité de calcul flottant ;
- 264 KB de SRAM, 2 MB de mémoire flash ;
- puce radio CYW43439 : Wi-Fi 802.11n dans la bande 2,4 GHz.
Le robot crée son propre point d'accès Wi-Fi. Le téléphone s'y connecte, lit la télémétrie par `GET /api/lines` et envoie les commandes par `POST /api/command`.
---
### Exercice 1 : temps de calcul et fréquence de lecture
> Le capteur VL53L1X mesure en continu, indépendamment du processeur. Il produit au plus une mesure toutes les 20 ms (**50 Hz**).
>
> - Pico WH cadencé à 125 MHz
> - Lecture d'un résultat sur le bus I2C : \~0,5 ms
> - Calcul d'un PID en `float` : \~20 µs
1. La boucle de contrôle attend chaque nouvelle mesure, la lit, puis calcule le PID. Quelle est la fréquence maximale de cette boucle ? Quel élément la limite ?
2. Quelle part du temps processeur la lecture et le calcul occupent-ils à cette fréquence ? Citer d'autres tâches du robot qui utilisent le reste.
3. Que se passe-t-il si l'on fait tourner la boucle à 100 Hz ?
4. Proposez une fréquence raisonnable pour un robot roulant à 0.5 m/s.
---
### Exercice 2 : occupation mémoire
> On veut stocker un historique de 100 erreurs pour calculer un PID discret.
>
> - Pico WH : 264 KB de SRAM
> - Chaque erreur est un `float` (4 octets)
1. Quelle quantité de mémoire est utilisée pour stocker 100 erreurs ? Quelle part de la SRAM cela représente-t-il ?
2. Quelles valeurs le PID discret du TD 2 doit-il réellement conserver d'un pas de temps au suivant ? Combien d'octets faut-il ?
3. On veut garder sur le robot un historique de la télémétrie pour le tracer sur le téléphone. Un échantillon contient `ms` (`uint32_t`), `front_mm`, `side_mm`, `left` et `right` (`int16_t` chacun). À 50 Hz, combien d'octets par seconde faut-il ? Combien de secondes tiennent dans un tampon de 32 KB ?
4. Proposer une solution pour réduire cette empreinte mémoire sans perdre l'information utile.
---
### Exercice 3 : énergie et efficacité
> Un robot utilise un PID mal réglé qui provoque des oscillations.
>
> - Les moteurs consomment 100 mA en ligne droite
> - En cas d'oscillations : consommation moyenne = 150 mA
> - Batterie des moteurs = 1000 mAh
1. Quelle est l'autonomie des moteurs avec et sans oscillations ?
2. Quel est le gain en autonomie si le PID est bien réglé ?
3. Quelle stratégie PID permet de réduire les oscillations ?
---
### Exercice 4 : précision et bruit de mesure
> Le robot est immobile face à un mur situé à 200 mm. Le VL53L1X renvoie des distances entières en millimètres.
>
> - Mesures successives : 198, 202, 195, 205, 200
> - Distance cible du PID : 200 mm
1. Calculez l'erreur moyenne et l'écart-type des mesures.
2. Calculez les termes \(e[n] - e[n-1]\) du PID pour cette série. Quelle est l'influence du bruit sur le terme dérivé ?
3. Proposez une méthode pour filtrer les mesures avant le calcul du PID. Quel est son coût en latence à 50 Hz ?
---
### Exercice 5 : latence dans une architecture distribuée
> Pour alléger le Pico, on propose de calculer le PID sur le téléphone, dans `robot.js`. Le robot envoie ses mesures dans la télémétrie ; le téléphone calcule la commande et la renvoie par Wi-Fi.
>
> - Capteur VL53L1X : une mesure toutes les 20 ms
> - Télémétrie : une ligne toutes les 100 ms (`{"telemetry":100}`)
> - Le téléphone lit les lignes en attente par `GET /api/lines` toutes les 100 ms
> - Trajet d'une requête ou d'une réponse HTTP sur le Wi-Fi : \~5 ms quand le canal est libre, \~50 ms quand il est encombré
> - Calcul du PID dans le navigateur : \~1 ms
> - Vitesse du robot : 0.5 m/s
1. Quelle est la latence entre la mesure et l'application de la commande, dans le meilleur et dans le pire cas, quand le canal est libre ?
2. Même question quand le canal est encombré.
3. Quelle distance le robot parcourt-il pendant ces latences ?
4. Est-ce acceptable pour un robot devant éviter des obstacles à 20 cm ?
5. Une salle de TP compte une douzaine de robots, chacun avec son point d'accès 2,4 GHz. Combien de canaux de 20 MHz ne se chevauchent pas dans cette bande ? Quel est l'effet sur la latence ?
6. Proposez une stratégie pour réduire l'impact de cette latence (*e.g.* prédiction, calcul local, hybridation).

4006
td.pdf Normal file

File diff suppressed because it is too large Load Diff

101
td.typ Normal file
View File

@@ -0,0 +1,101 @@
#import "@preview/minicise:0.1.0": sheet
#import "@preview/mitex:0.2.7": mitex
#import "@preview/cmarker:0.1.10"
// --------------------------------------------------------- configuration ----
#show: sheet.with(
title: [Exercices de cours],
course: [Objets connectés et robotique],
semester: [L3 IFA, S5],
date: [2026],
author: [Vincent Lannurien],
)
// the markdown source rendered as the body of this sheet
#let md-source = "td.md"
// set to `false` to drop the first page with the table of contents
#let show-toc = false
// ---------------------------------------------------------------- styles ----
#show figure.caption: set text(size: 0.9em, fill: luma(70))
// the outline picks up the headings cmarker emits further down
#show outline.entry.where(level: 2): set text(weight: "semibold")
// each `##` heading starts on a fresh page, except the first one, which stays
// on the title page — `weak` avoids a blank page when we are already at the top
#let md-section = counter("md-section")
#show heading.where(level: 2, outlined: true): it => {
context if md-section.get().first() > 0 {
pagebreak(weak: true)
}
md-section.step()
it
}
// ------------------------------------------------------------------ math ----
// hugo goldmark delimits math with `\(...\)` and `\[...\]`, while cmarker
// expects the typst/markdown `$...$` and `$$...$$` forms
#let goldmark-math(markdown) = {
markdown
.replace("\\[", "$$")
.replace("\\]", "$$")
.replace("\\(", "$")
.replace("\\)", "$")
}
// ---------------------------------------------------------------- images ----
// maximum footprint of an image coming from the markdown source
#let img-max-height = 7cm
// set to `none` to drop the frame around screenshots
#let img-stroke = 0.5pt + luma(180)
// scale an image down so it fits both the available width and `img-max-height`,
// without ever blowing it up past its natural size
#let fitted-image(source, ..args) = layout(available => {
let natural = measure(image(source, ..args))
let factor = calc.min(
1.0,
available.width / natural.width,
img-max-height / natural.height,
)
image(source, width: natural.width * factor, ..args)
})
// handle `![alt](src)` and `<img src=... width=... height=...>`
#let md-image(source, alt: none, width: auto, height: auto, ..args) = {
// an explicit width/height in the markdown wins over the automatic fitting
let body = if width == auto and height == auto {
fitted-image(source, alt: alt, ..args)
} else {
image(source, alt: alt, width: width, height: height, ..args)
}
let framed = block(stroke: img-stroke, radius: 3pt, clip: true, body)
// alt text, when there is one, becomes a numbered caption
if alt == none or alt == "" {
align(center, framed)
} else {
figure(framed, caption: alt)
}
}
// ------------------------------------------------------------------- toc ----
// outline's own title is a level-1 heading, which would outrank every heading in
// the source (they start at `##`), so we emit it ourselves at the right level
#if show-toc {
heading(level: 2, outlined: false)[Sommaire]
outline(title: none, depth: 3, indent: 1em)
pagebreak()
}
// ------------------------------------------------------------- exercices ----
#cmarker.render(goldmark-math(read(md-source)), math: mitex, scope: (image: md-image))

136
tp1.md Normal file
View File

@@ -0,0 +1,136 @@
## TP 1 : Découverte du robot
### Objectifs
- Identifier les composants et vérifier le câblage.
- Compiler et charger un programme sur la carte.
- Piloter le robot par le câble USB.
- Ajouter une commande au programme.
### Matériel
- [ ] Raspberry Pi Pico WH
- [ ] Shield Grove
- [ ] Carte moteur L298N
- [ ] 4x moteurs à courant continu
- [ ] Télémètre à ultrasons Grove
- [ ] Télémètre à infrarouges VL53L1X
- [ ] Afficheur 16x2 Grove
- [ ] 2x batteries (une pour les moteurs, une pour le Pico via un abaisseur de tension)
- [ ] Câble micro-USB
Le schéma de câblage est dans `schematics/sketches/Robot_Schema_completed.fzz`.
> ⚠️ Vous êtes responsables de l'inventaire de vos boîtes ! Il sera vérifié en fin de cycle.
> Merci de signaler aux enseignants tout matériel cassé ou perdu afin de le remplacer.
### Mise en place
- On travaille avec une machine sous **Linux** :
- ⚙️ Démarrer sous Debian sur les machines du département
- On utilise l'environnement de développement [**Visual Studio Code**](https://code.visualstudio.com/) :
- ✅ Disponible sur les machines du département
- Installer l'extension pour VS Code : "*Dev Containers*"
![](./images/vscode-devcontainers.png)
- On télécharge l'archive du projet depuis Moodle et on la décompresse :
```sh
unzip rubo-smol.zip
```
- On initialise l'environnement logiciel. Ces étapes devront être réalisées **à chaque TP** :
- On utilisera un conteneur de développement. Sur les machines du département, il faut exécuter une première fois la commande `docker` pour initialiser le *daemon* :
```sh
docker
```
- Ouvrir le projet dans VS Code :
```sh
code rubo-smol/
```
- Rouvrir le projet dans le conteneur (<kbd>Ctrl</kbd> + <kbd>P</kbd> > "*Reopen in Container*")
### Déroulé
1. Ouvrir `robot/Config.h`. Le schéma de câblage est en tête de fichier. Vérifier chaque fil sur le robot avant de brancher l'alimentation.
2. Dans le conteneur, compiler le firmware (le compilateur et le SDK sont déjà installés) :
```sh
python3 tools/build.py
```
3. Brancher la carte en USB, et vérifier qu'elle est bien reconnue :
```sh
python3 tools/pico.py list
```
Puis charger le programme. Enclencher `BOOTSEL` n'est normalement pas nécessaire :
```sh
python3 tools/build.py --flash
```
4. Ouvrir la console :
```sh
python3 tools/pico.py console
```
Quitter par <kbd>Ctrl</kbd> + <kbd>A</kbd> puis <kbd>Ctrl</kbd> + <kbd>X</kbd>. Relever la liste des périphériques détectés au démarrage. Un capteur marqué `ENABLED BUT NOT RESPONDING` signale un problème de câblage.
5. Tester les moteurs en tapant les commandes dans la console :
```json
{"left": 200, "right": 200}
{"left": 200, "right": -200}
{"left": 0, "right": 200}
```
Si un moteur tourne à l'envers, inverser la valeur de `LEFT_MOTOR_REVERSED` ou de `RIGHT_MOTOR_REVERSED` dans `Config.h`, puis recompiler et recharger.
Si le robot recule au lieu d'avancer, inverser les deux ne suffit pas : il faut aussi échanger les broches du moteur gauche et du moteur droit, sinon les virages sont inversés.
6. Relever la vitesse minimale à laquelle chaque moteur démarre. En dessous, le moteur bourdonne sans tourner.
7. Approcher la main du capteur et suivre la valeur `front_mm` dans la télémétrie. N'utiliser cette distance que si `front_ok` est vrai ; sinon `front_mm` vaut `-1`.
8. Lire la fonction `enforceSafety()` dans `robot/Robot.h` et les constantes `DEFAULT_DURATION_MS` et `MAX_DURATION_MS` dans `Config.h`.
9. Écrire la mission 1, arrêt devant un obstacle.
- Lire d'abord `Robot::update()` dans `robot/Robot.h` : à chaque tour, il actualise les capteurs, lit les commandes, puis appelle `mission()`.
- `{"mission":1}` ne fait pas avancer le robot ! La mission arrête seulement un mouvement en cours en cas d'obstacle.
- Dans `Robot.h`, remplacer `MISSION_NAMES = ""` par `MISSION_NAMES = "obstacle"`.
- Dans le `switch` de `mission()`, ajouter `case 1: stopAtObstacle(); break;` après `case 0`.
- Toujours **dans la classe `Robot`**, près de `mission()`, commencer la méthode suivante :
```cpp
void stopAtObstacle() {
if (!front.valid()) return; // ne pas utiliser une mesure absente
// À compléter : si front.distanceMm() n'est pas sous 150 mm, retourner.
// À compléter : si aucune roue n'avance, retourner.
// À compléter : arrêter le mouvement et signaler sa fin.
}
```
- `front.distanceMm()` donne la distance en millimètres.
- `motors.left()` et `motors.right()` donnent des vitesses signées (positive = marche avant).
- Pour la dernière ligne, chercher comment `enforceSafety()` arrête les roues, désarme `_hasDeadline` et envoie une télémétrie `done`.
- On utilise `drive()` pour **démarrer** un mouvement avec une échéance ; ici on veut arrêter celui qui est en cours. La méthode doit retourner tout de suite, sans boucle d'attente.
- Compiler avec `python3 tools/build.py` ; corriger les erreurs avant de charger avec `python3 tools/build.py --flash`. Poser d'abord le robot sur un support, roues en l'air. Dans la console, envoyer :
```json
{"mission": 1}
{"left": 200, "right": 200, "duration": 3000}
```
- Approcher une main du capteur avant à moins de 150 mm : les roues doivent s'arrêter **avant** les trois secondes, et une ligne de télémétrie doit contenir `"done":true` et `"moving":false` (cette mission envoie elle-même la ligne `done` quand elle arrête les roues).
- Avec la main toujours proche, essayer `{"left":-150,"right":-150}` : la marche arrière doit rester
possible.
- `{"mission":0}` désactive la mission ; `{"stop":true}` arrête aussi le robot et la désactive, mais n'envoie pas de ligne `done`.

2061
tp1.pdf Normal file

File diff suppressed because it is too large Load Diff

101
tp1.typ Normal file
View File

@@ -0,0 +1,101 @@
#import "@preview/minicise:0.1.0": sheet
#import "@preview/mitex:0.2.7": mitex
#import "@preview/cmarker:0.1.10"
// --------------------------------------------------------- configuration ----
#show: sheet.with(
title: [Exercices de TP],
course: [Objets connectés et robotique],
semester: [L3 IFA, S5],
date: [2026],
author: [Vincent Lannurien],
)
// the markdown source rendered as the body of this sheet
#let md-source = "tp1.md"
// set to `false` to drop the first page with the table of contents
#let show-toc = true
// ---------------------------------------------------------------- styles ----
#show figure.caption: set text(size: 0.9em, fill: luma(70))
// the outline picks up the headings cmarker emits further down
#show outline.entry.where(level: 2): set text(weight: "semibold")
// each `##` heading starts on a fresh page, except the first one, which stays
// on the title page — `weak` avoids a blank page when we are already at the top
#let md-section = counter("md-section")
#show heading.where(level: 2, outlined: true): it => {
context if md-section.get().first() > 0 {
pagebreak(weak: true)
}
md-section.step()
it
}
// ------------------------------------------------------------------ math ----
// hugo goldmark delimits math with `\(...\)` and `\[...\]`, while cmarker
// expects the typst/markdown `$...$` and `$$...$$` forms
#let goldmark-math(markdown) = {
markdown
.replace("\\[", "$$")
.replace("\\]", "$$")
.replace("\\(", "$")
.replace("\\)", "$")
}
// ---------------------------------------------------------------- images ----
// maximum footprint of an image coming from the markdown source
#let img-max-height = 6cm
// set to `none` to drop the frame around screenshots
#let img-stroke = 0.5pt + luma(180)
// scale an image down so it fits both the available width and `img-max-height`,
// without ever blowing it up past its natural size
#let fitted-image(source, ..args) = layout(available => {
let natural = measure(image(source, ..args))
let factor = calc.min(
1.0,
available.width / natural.width,
img-max-height / natural.height,
)
image(source, width: natural.width * factor, ..args)
})
// handle `![alt](src)` and `<img src=... width=... height=...>`
#let md-image(source, alt: none, width: auto, height: auto, ..args) = {
// an explicit width/height in the markdown wins over the automatic fitting
let body = if width == auto and height == auto {
fitted-image(source, alt: alt, ..args)
} else {
image(source, alt: alt, width: width, height: height, ..args)
}
let framed = block(stroke: img-stroke, radius: 3pt, clip: true, body)
// alt text, when there is one, becomes a numbered caption
if alt == none or alt == "" {
align(center, framed)
} else {
figure(framed, caption: alt)
}
}
// ------------------------------------------------------------------- toc ----
// outline's own title is a level-1 heading, which would outrank every heading in
// the source (they start at `##`), so we emit it ourselves at the right level
#if show-toc {
heading(level: 2, outlined: false)[Sommaire]
outline(title: none, depth: 3, indent: 1em)
pagebreak()
}
// ------------------------------------------------------------- exercices ----
#cmarker.render(goldmark-math(read(md-source)), math: mitex, scope: (image: md-image))