Le 1/1000ème de seconde est sans doute superflu, mais il illustre bien la précision qu’il est possible d’obtenir avec un microcontrôleur moderne comme le Raspberry Pi Pico. Ce projet vous propose de réaliser un chronomètre autonome, pensé pour des jeux ou activités scolaires, doté d’un écran OLED bien lisible et alimenté par une batterie LiPo. Compact, robuste et simple à mettre en œuvre, il permet de chronométrer vos épreuves en toute autonomie, sans dépendre d’un PC ou d’une alimentation secteur.
Au sommaire :
- 1 Construire un chronomètre scolaire autonome avec Raspberry Pi Pico et écran OLED
- 1.1 Pourquoi un chronomètre scolaire DIY ?
- 1.2 Les composants du projet
- 1.2.1 🖥️ Raspberry Pi Pico
- 1.2.2 📺 Écran OLED 128×64
- 1.2.3 🔋 Batterie LiPo plate 3,7 V – 1350 mAh
- 1.2.4 🔌 Module chargeur USB-C TP4056
- 1.2.5 Optocoupleur EL357N : isolation du signal de charge
- 1.2.6 🔋 Pourquoi connecter directement la batterie au Pico via une diode parfaite XL0410 ?
- 1.2.7 🔘 Boutons poussoirs et interrupteur marche/arrêt
- 1.2.8 🔊 Buzzer actif
- 1.2.9 📦 Boîtier / impression 3D
- 1.3 Connexion de l’écran OLED au Raspberry Pi Pico
- 1.4 Test de l’écran
- 1.5 Concevoir un écran, ce n’est pas du hasard !
- 1.6 Utiliser les 2 cœurs du RP2040
- 1.7 Utiliser lock pour sécuriser le fonctionnement
- 1.8 Arrêt coopératif
- 1.9 Le projet final de chronomètre
- 1.10 Schéma
- 1.11 Programme
- 1.12 Montage du chronomètre
- 1.13 Vidéo
- 1.14 ⏱️ Un chronomètre précis jusqu’au 1/1000e de seconde
- 1.15 Comprendre le fonctionnement (interruptions, armement, réarmement)
- 1.15.1 1) Les interruptions : réagir immédiatement aux capteurs
- 1.15.2 2) Armement de la mesure
- 1.15.3 3) Déclenchement du chrono (fonction on_start())
- 1.15.4 4) Arrêt et calcul du temps (fonction on_stop())
- 1.15.5 5) Réarmement automatique
- 1.15.6 6) Résumé séquentiel
- 1.15.7 Pourquoi cette architecture est pédagogique ?
- 1.16 Conclusion
Construire un chronomètre scolaire autonome avec Raspberry Pi Pico et écran OLED
Pourquoi un chronomètre scolaire DIY ?
Un chronomètre scolaire DIY répond à des besoins très concrets sur le terrain : chronométrer des ateliers, jeux éducatifs ou défis de calcul mental sans dépendre d’un smartphone, d’une prise secteur ou d’un matériel coûteux. L’objectif est d’avoir un outil simple, robuste et autonome, utilisable partout (classe, cour, gymnase, sortie).
Les alternatives classiques montrent vite leurs limites :
- Les apps de téléphone détournent l’attention, sont fragiles et peu lisibles à distance.
- Les chronos “sportifs” du commerce sont souvent mono-usage, à l’écran étroit, avec des piles bouton qui lâchent au mauvais moment et un coût non négligeable quand il en faut plusieurs.
Côté pédagogie, ce peut devenir un projet éducatif Raspberry Pi Pico à part entière : de la définition du besoin à la mise au point, les élèves découvrent logique, mesure du temps, affichage, gestion d’énergie et documentation. C’est aussi l’occasion d’introduire méthodologie, travail d’équipe et esprit maker. L’article s’appuie sur un tutoriel Raspberry Pi Pico chronomètre concret, prêt à reproduire.
Les choix techniques découlent de l’usage prévu : un chronomètre autonome OLED pour une excellente lisibilité, des batteries 18650 alimentent un Raspberry Pi Pico pour garantir 1 à 2 heures d’autonomie avec recharge simple en USB-C. Le tout intégré dans un boîtier solide, imprimé en 3D et modifiable (fichiers 3D fournis).
Les composants du projet
Pour réaliser ce chronomètre scolaire DIY, il faut réunir quelques composants simples et peu coûteux. Chacun a été choisi pour sa robustesse et son adéquation avec l’usage en milieu scolaire.
🖥️ Raspberry Pi Pico

Le cœur du projet est un Raspberry Pi Pico, un microcontrôleur économique, facile à programmer et parfaitement adapté à la mesure du temps. Sa précision permet même d’atteindre le 1/1000ᵉ de seconde si nécessaire.
👉 Important : la version d’origine sans WiFi suffit largement pour ce projet. Inutile de payer plus cher pour le modèle Pico W, la connectivité réseau n’apportant aucun avantage ici.
👉https://www.kubii.com/fr/cartes-micro-controleurs/3205-1639-raspberry-pi-pico-3272496311589.html
📺 Écran OLED 128×64

Un écran OLED 128×64 pixels offre une excellente lisibilité, même à distance ou en pleine lumière. Sa consommation est très faible, ce qui contribue à l’autonomie. L’affichage en blanc sur fond noir donne un rendu net pour ce chronomètre autonome OLED.
👉 https://s.click.aliexpress.com/e/_c3xvxzvH (lien affilié)
🔋 Batterie LiPo plate 3,7 V – 1350 mAh

J’ai finalement opté pour une batterie LiPo plate 3,7 V de 1350 mAh (≈ 50 × 34,5 mm). Plus compacte qu’une 18650, elle s’intègre parfaitement dans le boîtier et se colle au double-face mousse. Elle alimente directement VSYS du Pico via la diode idéale, le TP4056 assurant la recharge en USB-C. Les seuils d’affichage restent identiques (mesure sur VSYS) : c’est la même chimie Li-Ion, seule la capacité change.
👉 https://amzn.to/4nysA9m (lien affilié)
🔌 Module chargeur USB-C TP4056

Compact et économique, le TP4056 est un chargeur linéaire spécialement conçu pour les batteries Li-Ion 3,7 V. Alimenté en 5 V via USB-C, il délivre un courant de charge jusqu’à 1 A, avec une précision de 1,5 % et une tension finale de 4,2 V ± 1 %. Deux LED d’état indiquent la progression : rouge pendant la charge, verte ou bleue lorsque la batterie est pleine. Simple à intégrer dans tout projet portable, il fonctionne entre –10 °C et +85 °C et propose des bornes B+ / B- pour la batterie et OUT+ / OUT- pour l’alimentation du circuit. Attention : la protection contre l’inversion de polarité n’est pas intégrée.
👉 https://s.click.aliexpress.com/e/_c41uWyLN (lien affilié)
Optocoupleur EL357N : isolation du signal de charge
L’optocoupleur EL357N sert ici à isoler électriquement le circuit de charge (TP4056) du Raspberry Pi Pico. Il permet de détecter l’état de la LED CHRG du module chargeur sans relier directement les deux masses, évitant ainsi toute perturbation ou risque de retour de tension vers le microcontrôleur.

Module optocoupleur EL357N pour détection de charge TP4056
Le principe est simple : lorsque le TP4056 indique une charge en cours (LED rouge allumée), un courant traverse la LED interne de l’optocoupleur. Celui-ci excite le phototransistor intégré, qui met la sortie à l’état bas. Dès que la charge est terminée (LED éteinte), le transistor se bloque et la sortie repasse à l’état haut.

Module optocoupleur EL357N pour détection de charge TP4056
Côté Raspberry Pi Pico, la sortie du module est reliée à l’entrée GPIO 13, configurée en INPUT_PULLUP.
Le signal est donc actif bas : LOW lorsque la batterie se charge, et HIGH lorsqu’elle est pleine ou déconnectée.
L’ensemble fonctionne en 3,3 V (VCC du module raccordé au 3V3 du Pico, masse commune GND).
Ce montage offre une détection fiable de l’état de charge tout en protégeant le microcontrôleur : aucune liaison directe n’existe entre la partie alimentation (5 V USB du TP4056) et la partie logique (3,3 V du Pico). L’optocoupleur joue ainsi pleinement son rôle de sécurité et d’isolation galvanique.
🔋 Pourquoi connecter directement la batterie au Pico via une diode parfaite XL0410 ?

Le Raspberry Pi Pico peut être alimenté directement sur sa broche VSYS, qui accepte une plage de tension comprise entre 1,8 V et 5,5 V. Dans cette configuration, la batterie Li-Ion (3,7 V nominale) issue du module TP4056 est reliée à VSYS à travers une diode idéale XL0410. Cette diode assure une isolation automatique entre la source USB et la batterie : elle empêche tout retour de courant vers le chargeur lorsque le Pico est alimenté par l’USB, tout en introduisant une chute de tension quasi nulle (quelques millivolts seulement).
Ainsi, le Pico est alimenté en toute sécurité et de façon stable directement par la batterie, sans passer par un régulateur externe. Le module TP4056 gère la charge, et la diode XL0410 protège le circuit en assurant la bascule transparente entre l’alimentation USB (VBUS) et la batterie (VSYS). Cette solution simple et efficace garantit une excellente autonomie et réduit les pertes dues à la conversion.
👉 https://fr.aliexpress.com/item/1005006143396303.html
🔘 Boutons poussoirs et interrupteur marche/arrêt
![]() |
![]() |
Le chronomètre nécessite un minimum de commandes utilisateur. Deux boutons poussoirs suffisent : l’un pour lancer/arrêter le comptage, l’autre pour remettre le temps à zéro. Afin de préserver l’autonomie de la batterie, on ajoute un interrupteur marche/arrêt (A/M) qui coupe complètement l’alimentation lorsque l’appareil n’est pas utilisé. Cela évite toute consommation résiduelle et protège la batterie 18650 sur le long terme.
👉 https://s.click.aliexpress.com/e/_c3l6Hy79 (lien affilié)
👉 https://s.click.aliexpress.com/e/_c40WRizZ (lien affilié)
🔊 Buzzer actif

Un buzzer actif complète l’affichage visuel. Il permet d’avoir un retour sonore immédiat lors du départ, ou de l’arrêt d’un chronométrage. Très pratique en contexte scolaire, il rend l’utilisation plus intuitive et permet aux élèves de suivre l’action sans avoir constamment les yeux fixés sur l’écran. Un modèle actif comme celui-ci est recommandé, car il se pilote directement depuis une sortie du Raspberry Pi Pico sans programmation de fréquence complexe.
👉 https://amzn.to/4nuQrHM (lien affilié)
📦 Boîtier / impression 3D
![]() |
![]() |
Enfin, pour protéger l’électronique, un boîtier est indispensable. On peut en acheter un tout fait ou l’imprimer en 3D, afin de l’adapter parfaitement à l’écran et au support de batterie. Cela garantit robustesse et transport facile. Je propose un boîtier en 3D, tous ls fichiers sont disponibles.
Pour l’écran j’ai créé un cache adapté qui vient se fixer sur le boîtier. Le fichier est disponible sur Thingiverse.
![]() |
![]() |
Pour le boîtier du chronomètre, j’ai modélisé l’appareil et dessiné le boîtier autour.
Connexion de l’écran OLED au Raspberry Pi Pico

On va commencer par tester l’écran et la chaine de programmation du Raspberry Pi PICO. L’écran OLED utilisé fonctionne en I²C, ce qui simplifie énormément le câblage (seulement 4 fils nécessaires : VCC, GND, SCL, SDA).
Le Raspberry Pi Pico propose plusieurs broches compatibles I²C, mais nous allons utiliser le bus par défaut pour éviter toute confusion.

👉 Ainsi, l’écran est alimenté en 3,3 V directement depuis le Pico, et la communication passe par le bus I²C matériel.
⚡ Astuce pratique : certains écrans OLED peuvent être livrés configurés en adresse I²C 0x3C ou 0x3D. Si l’affichage ne fonctionne pas, il faudra scanner le bus I²C pour détecter la bonne adresse. Mon écran était configuré en 0x3C.
Test de l’écran
Avant d’aller plus loin dans la réalisation du chronomètre, il est indispensable de vérifier que l’écran OLED est correctement câblé et reconnu par le Pico. Ce petit test permet de s’assurer que la communication I²C fonctionne et que l’adresse de l’écran est bien détectée.

Ce programme lit la température interne du RP2040 et l’affiche sur l’OLED SSD1306 via I²C. Il incrémente un compteur pour vérifier l’actualisation toutes les 2 secondes. Il est adapté de Jörn Weise et inclut un scan I²C pratique et un petit calibrage possible. Le programme envoie les informations sur le port série.
"""
Read internal temp-sensor and show on I2C OLED (SSD1306)
Author: Joern Weise / Modif framboise314
License: GNU GPL 3.0
Created: 23 Oct 2021
Updated: 01 Oct 2025
Target: Raspberry Pi Pico / RP2040 (MicroPython)
"""
from machine import Pin, I2C, ADC
from ssd1306 import SSD1306_I2C
import utime
# --- Internal temperature sensor (ADC4) ---
sensor = ADC(4) # ADC(4) is the internal temperature sensor
CONVERSION = 3.3 / 65535.0 # 16-bit ADC to volts
CALIB_27C_V = 0.706 # Datasheet ref voltage at 27°C
SLOPE_V_PER_C = 0.001721 # Datasheet slope (V/°C)
CALIB_OFFSET = 0.0 # Optional manual offset (°C), tweak if needed
def read_internal_temp_c():
v = sensor.read_u16() * CONVERSION # volts
temp_c = 27.0 - (v - CALIB_27C_V) / SLOPE_V_PER_C + CALIB_OFFSET
return temp_c
# --- I2C bus ---
sda = Pin(0)
scl = Pin(1)
i2c = I2C(0, sda=sda, scl=scl, freq=400000)
# Scan and print I2C devices
devices = i2c.scan()
for idx, addr in enumerate(devices):
print("I2C Address {:>2}: {}".format(idx, hex(addr).upper()))
if not devices:
print("⚠️ Aucun périphérique I2C détecté. Vérifie le câblage et l'adresse de l'OLED.")
# --- OLED display (SSD1306) ---
WIDTH = 128
HEIGHT = 64
oled = SSD1306_I2C(WIDTH, HEIGHT, i2c) # Adresse par défaut 0x3C ; si besoin, vérifie/force l'adresse dans la lib
# --- Main loop ---
counter = 0
try:
while True:
temp = read_internal_temp_c()
print("Temperature: {:.2f} C | Counter: {}".format(temp, counter))
oled.fill(0)
oled.text("Internal Temp", 6, 8)
oled.text("Temp:", 6, 24)
oled.text("{:.2f}".format(temp), 50, 24)
oled.text("C", 95, 24) # Le caractere ° n'est pas toujours dispo en font 8x8
oled.text("Counter: {}".format(counter), 6, 40)
oled.show()
counter += 1
utime.sleep(2)
except KeyboardInterrupt:
# Nettoyage visuel sympa
oled.fill(0)
oled.text("Stopped.", 6, 24)
oled.show()
print("Arrêt demande par l'utilisateur (Ctrl+C).")
Concevoir un écran, ce n’est pas du hasard !


La mise en page d’un écran OLED, surtout sur un petit format comme un 128 × 64 pixels, ne se fait jamais au hasard. Chaque point compte ! Avant même de coder, il est essentiel de réfléchir à la disposition des éléments : titres, symboles, chiffres, jauges… Un simple décalage de deux ou trois pixels peut déséquilibrer l’ensemble ou rendre une information moins lisible.
Pour ma part, j’ai choisi une méthode « à l’ancienne » : le dessin sur papier quadrillé. Une grille à l’échelle du module (128 colonnes × 64 lignes) permet de placer visuellement les zones de texte et les icônes, d’ajuster les marges, et de valider les proportions. On peut ainsi vérifier que le chronomètre, la jauge de batterie et les symboles gardent une bonne lisibilité sans se gêner mutuellement.
Cette approche manuelle a un autre avantage : elle aide à convertir directement les dessins en données binaires. Par exemple, le symbole d’éclair que j’ai dessiné sur la grille m’a servi de base pour coder son motif pixel par pixel dans le programme. Chaque carré noir correspond à un bit à 1, chaque carré blanc à un bit à 0 ; il suffit ensuite de transposer la forme dans un tableau de bytes pour l’afficher sur l’écran.
Travailler sur papier, c’est donc à la fois un exercice de conception graphique et une aide au codage. Cela permet de valider visuellement avant de passer au code, d’éviter les tâtonnements et de conserver une logique claire dans le placement des éléments.
Un peu de papier, un stylo et une bonne dose de rigueur : c’est parfois la meilleure des interfaces de conception !
Utiliser les 2 cœurs du RP2040
Ce programme illustre l’utilisation des deux cœurs du RP2040. Par défaut, le cœur 0 exécute la boucle principale, et on peut démarrer une tâche parallèle sur le cœur 1 grâce au module _thread. Ici, le cœur 0 incrémente un compteur (+1 toutes les 10ms), tandis que le cœur 1 affiche la valeur ce compteur chaque seconde. Les deux threads communiquent grâce à une variable partagée en mémoire (compteur), ce qui permet d’échanger simplement des informations entre cœurs. On obtient ainsi un petit exemple concret de parallélisme où calcul et affichage fonctionnent en même temps.
"""
Exemple simple : Utiliser les 2 cœurs du RP2040
Auteur : framboise314
Licence : GPL v3.0
Créé : 03/10/2025
Carte : Raspberry Pi Pico (RP2040) / MicroPython
"""
import _thread
import utime
# Variable partagée
compteur = 0
# --- Fonction exécutée sur le cœur 0 (par défaut) ---
def tache_chrono():
global compteur
while True:
compteur += 1 # incrémente toutes les 10 ms
utime.sleep_ms(10)
# --- Fonction exécutée sur le cœur 1 ---
def tache_affichage():
global compteur
while True:
print("Valeur du compteur :", compteur)
utime.sleep(1) # affiche toutes les secondes
# Lancement du programme
print("Démarrage : Cœur 0 = chrono, Cœur 1 = affichage")
_thread.start_new_thread(tache_affichage, ()) # lance sur le 2e cœur
tache_chrono() # tourne sur le cœur principal
Le programme sort les infos sur le port série, ici on n’utilise pas encore l’écran :
⚠️ Attention aux accès simultanés !
Dans l’exemple précédent, les deux cœurs manipulent la même variable compteur sans protection particulière. Cela peut sembler fonctionner correctement, mais en réalité on risque de créer une condition de concurrence : si un cœur lit la valeur au moment exact où l’autre est en train de la modifier, le résultat peut être incohérent (valeurs qui sautent, affichage erroné, incréments perdus). Ces erreurs sont parfois difficiles à détecter, car elles ne se produisent pas à chaque exécution. La solution est d’utiliser un verrou (Lock) pour garantir que la variable partagée n’est jamais accédée par deux threads en même temps.
Utiliser lock pour sécuriser le fonctionnement
"""
Exemple : Deux cœurs + verrou (_thread.Lock) sur RP2040
Version : 1.0.0
Auteur : framboise314
Licence : GPL v3.0
Carte : Raspberry Pi Pico (RP2040) / MicroPython
But : Protéger l'accès à une variable partagée entre deux threads
"""
import _thread
import utime
# --- Ressources partagées ---
compteur = 0
verrou = _thread.allocate_lock()
# --- Tâche sur le cœur 0 : incrémente le compteur ---
def tache_chrono():
global compteur
while True:
# section critique : on modifie la variable partagée
with verrou:
compteur += 1
utime.sleep_ms(10)
# --- Tâche sur le cœur 1 : lit et affiche le compteur ---
def tache_affichage():
global compteur
while True:
# section critique : on lit la variable partagée
with verrou:
valeur = compteur
print("Compteur =", valeur)
utime.sleep(1)
# --- Lancement ---
print("Démarrage : cœur 0 = chrono (écrit), cœur 1 = affichage (lit)")
_thread.start_new_thread(tache_affichage, ()) # exécute sur le 2e cœur
tache_chrono() # boucle principale sur le cœur 0
Sur le port de COM on obtient :

Pourquoi utiliser un lock ?
-
Les deux threads accèdent à la même variable (
compteur). Sans verrou, ils peuvent lire/écrire en même temps, provoquant des conditions de concurrence (valeurs incohérentes, lectures “déchirées”, sauts imprévisibles). -
Le
Lockcrée une section critique : un seul thread à la fois peut lire ou modifier la variable partagée. Résultat : un affichage fiable et un incrément sans perte.
Utiliser un verrou pour sécuriser les accès
Pour éviter les problèmes liés aux conditions de concurrence, on place les parties sensibles du code (lecture ou écriture de la variable partagée) dans une section critique protégée par un verrou. En MicroPython, on crée ce verrou avec _thread.allocate_lock(). Ensuite, on l’utilise avec l’instruction with verrou: qui garantit qu’un seul thread accède à la variable à la fois. Pendant ce temps, l’autre cœur doit attendre son tour. Ce mécanisme rend les échanges entre cœurs sûrs et prévisibles, et c’est une bonne pratique dès qu’une ressource est partagée entre plusieurs threads.
Le verrou (Lock)
Imaginez deux personnes qui veulent écrire en même temps sur le même cahier.
- Sans règle, elles risquent de se marcher dessus : une phrase coupée, un mot effacé => c’est la condition de concurrence.
- Le verrou joue le rôle d’une clé :
- Si la première personne prend la clé, elle peut écrire tranquille.
- L’autre doit attendre que la clé soit rendue pour écrire à son tour.
- 👉 Résultat : le cahier reste lisible, rien n’est corrompu.
Dans notre programme, la variable compteur est ce “cahier partagé”. Le Lock empêche les deux cœurs du Pico de la modifier ou de la lire en même temps.
Le drapeau coopératif
- Un programme qui tourne dans une boucle infinie ne s’arrête jamais par lui-même.
Le drapeau coopératif (en anglais Flag) est une variable globale (ici en_marche) que tous les threads surveillent. - Tant que en_marche = True, les boucles continuent.
- Quand on appuie sur Ctrl+C, on change le drapeau en False.
- Chaque thread, en voyant ce drapeau passer à False, sort de sa boucle gentiment au lieu de bloquer la carte.
C’est un peu comme le drapeau rouge en formule 1 : tous les coureurs retournent aux stands e dès qu’ils le voient, à vitesse réduite. Ici lorsqu’un thread voit le flag en_marche à False, il s’arrête.
En combinant verrou et drapeau coopératif, on obtient un système fiable :
- Les deux cœurs travaillent ensemble sans se gêner (grâce au verrou).
- Et ils savent s’arrêter proprement quand on leur dit stop (grâce au drapeau).
Arrêt coopératif
Dans ce programme, les deux cœurs du RP2040 travaillent en parallèle, mais surveillent un drapeau coopératif. Lorsque vous appuyez sur Ctrl+C ou que vous cliques sur STOP dans Thonny, ce drapeau passe à False et chaque boucle s’arrête proprement, sans bloquer la carte.
"""
Projet : Chrono 2 cœurs + OLED (arrêt coopératif)
Version : 1.0.1
Auteur : framboise314
Licence : GPL v3.0
Carte : Raspberry Pi Pico (RP2040) / MicroPython
Notes : Cœur 0 = chrono ; Cœur 1 = affichage. Accès protégé (Lock).
Ctrl+C met fin proprement aux deux boucles (drapeau 'en_marche').
"""
import _thread
import utime
from machine import Pin, I2C
from ssd1306 import SSD1306_I2C
# --- Paramètres I2C / OLED ---
PIN_SDA, PIN_SCL, I2C_FREQ = 0, 1, 400000
OLED_W, OLED_H = 128, 64
# --- Ressources partagées ---
compteur = 0
verrou = _thread.allocate_lock()
en_marche = True # drapeau d'arrêt coopératif
# --- Init I2C + OLED ---
i2c = I2C(0, sda=Pin(PIN_SDA), scl=Pin(PIN_SCL), freq=I2C_FREQ)
oled = SSD1306_I2C(OLED_W, OLED_H, i2c)
oled.fill(0); oled.text("Pico RP2040", 8, 8); oled.text("Init OLED OK", 8, 24); oled.show()
utime.sleep(2.0)
# --- Fonctions courtes ---
def lire_compteur():
global compteur
with verrou:
return compteur
def incrementer_compteur():
global compteur
with verrou:
compteur += 1
# --- Tâches des 2 cœurs ---
def tache_chrono():
global en_marche
while en_marche:
incrementer_compteur()
utime.sleep_ms(10) # sommeil court => Ctrl+C pris en compte vite
def tache_affichage():
global en_marche
while en_marche:chrono2.3.0.
v = lire_compteur()
oled.fill(0)
oled.text("Chrono 2 coeurs", 6, 4)
oled.text("Compteur:", 6, 22)
oled.text(str(v), 90, 22)
oled.text("Maj 1sec | OK", 6, 40)
oled.show()
utime.sleep(1)
# --- Lancement + arrêt propre ---
try:
_thread.start_new_thread(tache_affichage, ())
tache_chrono() # cœur principal
except KeyboardInterrupt:
# signale l'arrêt aux deux tâches
en_marche = False
utime.sleep_ms(60) # laisse l'autre cœur sortir de sa boucle
oled.fill(0); oled.text("Stop propre", 12, 24); oled.show()
print("Arrêt demandé (Ctrl+C) -> ok, vous pouvez relancer.")
Le projet final de chronomètre
Schéma

Programme
Voici le programme chrono2.3.0.py que vous pouvez aussi télécharger avec la bibli ssd1306 et le logo du FabLAb en cliquant sur ce lien.
# =====================================================================
# Programme : Chrono Pico v2.3.0 (bi-cœur)
# Auteur : François / framboise314
# Licence : MIT
# Date : 2025-11-02
# Matériel : Raspberry Pi Pico + SSD1306 128x64 (I2C addr 0x3C)
# ---------------------------------------------------------------------
# Invariants d'UI & fonctionnement (identiques v2.2.1) :
# - Démarrage normal : bandeau "CHRONO" + ▶/■ + chrono 7-seg (v1.0.4)
# + batterie v1.6.2 (barres + éclair GP13, message "RECHARGER / LA BATTERIE")
# - Démarrage RAZ maintenu (mode DIAG) :
# * A GAUCHE : température interne (xx.x°C) à la place de "CHRONO"
# * EN HAUT-DROITE : batterie avec la tension VSYS au centre du corps
# * Eclair si charge (GP13=LOW)
# - I2C : GP0/GP1 @ 400 kHz, addr 0x3C. Pas d’auto-détection.
# - VSYS : ADC29 (VSYS/3) → ×3. USB présent si VSYS ≥ 4.5 V.
# ---------------------------------------------------------------------
# Nouvelle archi v2.3.0 :
# - Cœur 1 (thread) = LOGIQUE CHRONO + LECTURE BOUTONS (A/M, RAZ)
# - Cœur 0 (main) = AFFICHAGE OLED + ADC VSYS + TEMP + BUZZER
# - Communication via état partagé protégé par mutex.
# =====================================================================
from machine import Pin, I2C, ADC
from ssd1306 import SSD1306_I2C
import time, _thread
from logo_bitmap import draw_logo # logo centré, fond blanc (v1.6.2)
# ------------------------- I2C / OLED -------------------------------
I2C_SDA_PIN = 0
I2C_SCL_PIN = 1
I2C_FREQ = 400_000
OLED_W, OLED_H = 128, 64
I2C_ADDR = 0x3C
# ------------------------- MARGES / TEXTE CHRONO --------------------
MARGE_ECRAN = 6
TEXTE_CHRONO = "CHRONO"
CHRONO_X = 6
CHRONO_Y = 9
CHRONO_ECHELLE = 1
CHRONO_ESPACE_PX = 1
SYMBOLE_ESPACE_APRES_TEXTE = 5
# ------------------------- CHRONO (5 BOÎTES) ------------------------
X_BOITE0 = MARGE_ECRAN
Y_BOITE0 = OLED_H - MARGE_ECRAN - 30
LARGEUR_BOITE = 21
HAUTEUR_BOITE = 30
ECART_ENTRE_BOITES = 2
ECART_AVANT_POINTS = 2
ECART_APRES_POINTS = 2
LARGEUR_POINTS = 2
ECART_VERTICAL_POINTS = 8
ECART_INTERNE_DANS_BOITE5 = 2
LARGEUR_BOITE5 = 2 * LARGEUR_BOITE + ECART_INTERNE_DANS_BOITE5
# ------------------------- CHIFFRES 7-seg ---------------------------
SEG_EPAISSEUR = 3
SEG_MARGE = 2
MAP_7SEG = {
'0': 0b1111110, '1': 0b0110000, '2': 0b1101101, '3': 0b1111001,
'4': 0b0110011, '5': 0b1011011, '6': 0b1011111, '7': 0b1110000,
'8': 0b1111111, '9': 0b1111011,
}
# ------------------------- BOUTONS (polling sur cœur 1) -------------
BP_AM_PIN = 14 # Start/Stop (A/M)
BP_RAZ_PIN = 15 # RAZ (seulement à l'arrêt)
DEBOUNCE_MS = 20
# ------------------------- BUZZER (cœur 0) --------------------------
BUZZER_PIN = 28
BEEP_MS = 1
buzzer = Pin(BUZZER_PIN, Pin.OUT); buzzer.value(0)
_beep_until = None
def beep_now(now_ms=None, dur_ms=BEEP_MS):
global _beep_until
now = time.ticks_ms() if now_ms is None else now_ms
buzzer.value(1); _beep_until = time.ticks_add(now, dur_ms)
def _service_beep(now_ms=None):
global _beep_until
if _beep_until is None: return
now = time.ticks_ms() if now_ms is None else now_ms
if time.ticks_diff(now, _beep_until) >= 0:
buzzer.value(0); _beep_until = None
# ------------------------- TEMPOS (cœur 0) --------------------------
PERIODE_RAFRAICH_MS = 50
PERIODE_BATT_MS = 1000
SPLASH_DUREE_MS = 2000
BLINK_PERIODE_MS = 500
# ------------------------- ADC VSYS/3 (cœur 0) ----------------------
ADC_VSYS_CH = 29 # ADC3 (GPIO29)
FACTEUR_ADC = 3.3 / 65535.0
FACTEUR_VSYS = 3.0 # VSYS/3 → ×3
USB_VSYS_MIN = 4.5
def init_adc_vsys(): return ADC(ADC_VSYS_CH)
def lire_vsys(adc_vsys):
brut = adc_vsys.read_u16()
return (brut * FACTEUR_ADC) * FACTEUR_VSYS
# ------------------------- Température interne (cœur 0) -------------
ADC_TEMP_CH = 4
NB_ECH_TEMP = 8
ADC_VREF = 3.3
def init_adc_temp(): return ADC(ADC_TEMP_CH)
def lire_temp_c(adc_temp):
total = 0
for _ in range(NB_ECH_TEMP):
total += adc_temp.read_u16()
moy = total / NB_ECH_TEMP
v = (moy / 65535.0) * ADC_VREF
return 27.0 - (v - 0.706) / 0.001721 # °C (datasheet)
# ------------------------- Détection charge (cœur 0) ----------------
PIN_CHARGE_DET = 13 # ACTIF BAS (LED CHRG ON)
charge_det = Pin(PIN_CHARGE_DET, Pin.IN, Pin.PULL_UP)
def charge_en_cours(): return charge_det.value() == 0
# ========================= OUTILS DESSIN (cœur 0) ===================
def rect_plein(oled, x, y, w, h, c=1):
for yy in range(y, y + h): oled.hline(x, yy, w, c)
FONT_5x7 = {
'A':[0b01110,0b10001,0b10001,0b11111,0b10001,0b10001,0b10001],
'B':[0b11110,0b10001,0b11110,0b10001,0b10001,0b10001,0b11110],
'C':[0b01110,0b10001,0b10000,0b10000,0b10000,0b10001,0b01110],
'D':[0b11100,0b10010,0b10001,0b10001,0b10001,0b10010,0b11100],
'E':[0b11111,0b10000,0b11110,0b10000,0b10000,0b10000,0b11111],
'G':[0b01110,0b10001,0b10000,0b10111,0b10001,0b10001,0b01110],
'H':[0b10001,0b10001,0b10001,0b11111,0b10001,0b10001,0b10001],
'I':[0b11111,0b00100,0b00100,0b00100,0b00100,0b00100,0b11111],
'L':[0b10000,0b10000,0b10000,0b10000,0b10000,0b10000,0b11111],
'N':[0b10001,0b11001,0b10101,0b10011,0b10001,0b10001,0b10001],
'O':[0b01110,0b10001,0b10001,0b10001,0b10001,0b10001,0b01110],
'R':[0b11110,0b10001,0b10001,0b11110,0b10100,0b10010,0b10001],
'T':[0b11111,0b00100,0b00100,0b00100,0b00100,0b00100,0b00100],
'0':[0b01110,0b10001,0b10011,0b10101,0b11001,0b10001,0b01110],
'1':[0b00100,0b01100,0b00100,0b00100,0b00100,0b00100,0b01110],
'2':[0b01110,0b10001,0b00001,0b00110,0b11000,0b10000,0b11111],
'3':[0b11110,0b00001,0b00001,0b01110,0b00001,0b00001,0b11110],
'4':[0b00010,0b00110,0b01010,0b10010,0b11111,0b00010,0b00010],
'5':[0b11111,0b10000,0b11110,0b00001,0b00001,0b10001,0b01110],
'6':[0b00110,0b01000,0b10000,0b11110,0b10001,0b10001,0b01110],
'7':[0b11111,0b00001,0b00010,0b00100,0b01000,0b10000,0b10000],
'8':[0b01110,0b10001,0b10001,0b01110,0b10001,0b10001,0b01110],
'9':[0b01110,0b10001,0b10001,0b01111,0b00001,0b00010,0b01100],
'.':[0b00000,0b00000,0b00000,0b00000,0b00000,0b01100,0b01100],
'-':[0b00000,0b00000,0b00000,0b01110,0b00000,0b00000,0b00000],
'°':[0b01110,0b10001,0b10001,0b01110,0b00000,0b00000,0b00000],
'C2':[0b01110,0b10001,0b10000,0b10000,0b10000,0b10001,0b01110],
'V':[0b10001,0b10001,0b10001,0b10001,0b01010,0b01010,0b00100],
' ': [0,0,0,0,0,0,0],
}
def dessiner_car_5x7(oled, x, y, car, echelle=1, couleur=1):
key = car if car in FONT_5x7 else ('C2' if car=='C' else ' ')
matrice = FONT_5x7[key]
for lig, bits in enumerate(matrice):
for col in range(5):
if (bits >> (4 - col)) & 1:
rect_plein(oled, x + col*echelle, y + lig*echelle, echelle, echelle, couleur)
def dessiner_texte_5x7(oled, x, y, texte, echelle=1, esp=1, couleur=1):
cx = x; pas = 5*echelle + esp*echelle
for ch in texte:
dessiner_car_5x7(oled, cx, y, ch, echelle, couleur); cx += pas
return cx
def mesurer_texte_5x7(texte, echelle=1, esp=1):
if not texte: return 0
n = len(texte); return n*(5*echelle) + (n-1)*(esp*echelle)
def dessiner_triangle_7(oled, x, y, c=1):
for dy, w in enumerate([1,2,3,4,3,2,1]): rect_plein(oled, x, y+dy, w, 1, c)
def dessiner_carre_7(oled, x, y, c=1):
rect_plein(oled, x, y, 7, 7, c)
# ------------------------- Batterie (v1.6.2) ------------------------
NB_BARRES = 5
LARGEUR_BARRE = 6
ECART_BARRES = 2
MARGE_INTERNE_X = 3
MARGE_INTERNE_Y = 3
HAUTEUR_INTERNE = 10
LARGEUR_INTERNE = NB_BARRES * LARGEUR_BARRE + (NB_BARRES - 1) * ECART_BARRES
LARGEUR_CORPS = LARGEUR_INTERNE + 2 * MARGE_INTERNE_X
HAUTEUR_CORPS = HAUTEUR_INTERNE + 2 * MARGE_INTERNE_Y
X_BATT = OLED_W - MARGE_ECRAN - LARGEUR_CORPS
Y_BATT = MARGE_ECRAN
BORNE_LARGEUR = 3
BORNE_HAUTEUR = 6
ECLAIR_BITMAP = [
"0000000011111",
"0000000111110",
"0000001111100",
"0000011111000",
"0000111110000",
"0001111100000",
"0000001111100",
"0000011111000",
"0000111100000",
"0001110000000",
"0011100000000",
"0110000000000",
"1000000000000",
]
ECLAIR_W = len(ECLAIR_BITMAP[0]); ECLAIR_H = len(ECLAIR_BITMAP)
ECLAIR_MARGE_GAUCHE = 2
def dessiner_contour_batterie(oled, x, y, w, h): oled.rect(x, y, w, h, 1)
def dessiner_borne_plus(oled, x_corps, y_corps, h_corps):
x_b = x_corps - BORNE_LARGEUR - 1
y_b = y_corps + h_corps // 2 - BORNE_HAUTEUR // 2
rect_plein(oled, x_b, y_b, BORNE_LARGEUR, BORNE_HAUTEUR, 1)
def dessiner_barres(oled, x_corps, y_corps, niveau):
x0 = x_corps + MARGE_INTERNE_X; y0 = y_corps + MARGE_INTERNE_Y
for i in range(NB_BARRES):
x_barre = x0 + i * (LARGEUR_BARRE + ECART_BARRES)
on = (i >= NB_BARRES - niveau)
rect_plein(oled, x_barre, y0, LARGEUR_BARRE, HAUTEUR_INTERNE, 1 if on else 0)
def dessiner_eclair_bitmap(oled, x_batt, y_batt):
x_gauche_borne = x_batt - 1
x_eclair = x_gauche_borne - BORNE_LARGEUR - ECLAIR_MARGE_GAUCHE - ECLAIR_W
y_eclair = y_batt + (HAUTEUR_CORPS - ECLAIR_H) // 2
for dy, ligne in enumerate(ECLAIR_BITMAP):
yy = y_eclair + dy
for dx, ch in enumerate(ligne):
if ch == '1': oled.pixel(x_eclair + dx, yy, 1)
def dessiner_batterie(oled, x, y, niveau, eclair=False):
dessiner_contour_batterie(oled, x, y, LARGEUR_CORPS, HAUTEUR_CORPS)
dessiner_borne_plus(oled, x, y, HAUTEUR_CORPS)
dessiner_barres(oled, x, y, niveau)
if eclair: dessiner_eclair_bitmap(oled, x, y)
# ------------------------- Niveaux de barres ------------------------
def niveau_barres_pour_vsys(v):
if v >= 4.20: return 5
if v >= 4.00: return 4
if v >= 3.85: return 3
if v >= 3.75: return 2
if v >= 3.60: return 1
return 0
# ------------------------- Messages / DIAG --------------------------
def afficher_message_recharger(oled):
texte1 = "RECHARGER"; texte2 = "LA BATTERIE"
w1 = mesurer_texte_5x7(texte1, 1, 1)
w2 = mesurer_texte_5x7(texte2, 1, 1)
x1 = (OLED_W - w1) // 2; x2 = (OLED_W - w2) // 2
y_base = Y_BOITE0 + 6
dessiner_texte_5x7(oled, x1, y_base, texte1, 1, 1, 1)
dessiner_texte_5x7(oled, x2, y_base + 10, texte2, 1, 1, 1)
def afficher_vsys_dans_batterie(oled, vsys_v, eclair):
dessiner_batterie(oled, X_BATT, Y_BATT, 0, eclair=eclair)
txt = "{:.2f}V".format(vsys_v)
tw = mesurer_texte_5x7(txt, 1, 1)
tx = X_BATT + (LARGEUR_CORPS - tw)//2
ty = Y_BATT + (HAUTEUR_CORPS - 7)//2
dessiner_texte_5x7(oled, tx, ty, txt, 1, 1, 1)
def afficher_temperature_gauche(oled, temp_c):
txt = "{:.1f}°C".format(temp_c)
dessiner_texte_5x7(oled, CHRONO_X, CHRONO_Y, txt, 1, 1, 1)
# ========================= Chrono 7-seg =============================
def seg_rects(x, y, w, h, ep, marge):
xi = x + marge; yi = y + marge
wi = max(1, w - 2*marge); hi = max(3, h - 2*marge)
e = max(1, min(ep, hi // 4))
a = (xi + e, yi, wi - 2*e, e)
g = (xi + e, yi + (hi//2) - e//2, wi - 2*e, e)
d = (xi + e, yi + hi - e, wi - 2*e, e)
b = (xi + wi - e, yi + e, e, (hi//2) - e)
c = (xi + wi - e, yi + (hi//2) + e//2, e, (hi//2) - e)
f = (xi, yi + e, e, (hi//2) - e)
eS= (xi, yi + (hi//2) + e//2, e, (hi//2) - e)
return [a,b,c,d,eS,f,g]
def dessiner_digit(oled, x, y, w, h, ch):
code = MAP_7SEG.get(ch, 0)
for i, (rx, ry, rw, rh) in enumerate(seg_rects(x, y, w, h, SEG_EPAISSEUR, SEG_MARGE)):
if (code >> (6 - i)) & 1: rect_plein(oled, rx, ry, rw, rh, 1)
def dessiner_points(oled, x, y, w, h):
cx = x + w // 2; y_centre = y + h // 2
y1 = y_centre - ECART_VERTICAL_POINTS // 2
y2 = y_centre + ECART_VERTICAL_POINTS // 2
rect_plein(oled, cx - 1, y1 - 1, LARGEUR_POINTS, LARGEUR_POINTS, 1)
rect_plein(oled, cx - 1, y2 - 1, LARGEUR_POINTS, LARGEUR_POINTS, 1)
def dessiner_chrono(oled, minutes_str, milli_str):
x = X_BOITE0; y = Y_BOITE0; h = HAUTEUR_BOITE
dessiner_digit(oled, x, y, LARGEUR_BOITE, h, minutes_str[0]); x += LARGEUR_BOITE + ECART_ENTRE_BOITES
dessiner_digit(oled, x, y, LARGEUR_BOITE, h, minutes_str[1]); x += LARGEUR_BOITE + ECART_AVANT_POINTS
LARGEUR_BOITE_POINTS = max(LARGEUR_POINTS + 2, 5)
dessiner_points(oled, x, y, LARGEUR_BOITE_POINTS, h)
x += LARGEUR_BOITE_POINTS + ECART_APRES_POINTS
dessiner_digit(oled, x, y, LARGEUR_BOITE, h, milli_str[0]); x += LARGEUR_BOITE + ECART_ENTRE_BOITES
w5 = LARGEUR_BOITE5; w_half = (w5 - ECART_INTERNE_DANS_BOITE5) // 2
dessiner_digit(oled, x, y, w_half, h, milli_str[1])
dessiner_digit(oled, x + w_half + ECART_INTERNE_DANS_BOITE5, y, w_half, h, milli_str[2])
# ========================= OLED / SPLASH (cœur 0) ===================
def init_oled():
i2c = I2C(0, sda=Pin(I2C_SDA_PIN), scl=Pin(I2C_SCL_PIN), freq=I2C_FREQ)
oled = SSD1306_I2C(OLED_W, OLED_H, i2c, addr=I2C_ADDR)
oled.fill(0); oled.show()
return oled
def splash_logo(oled):
oled.fill(1); draw_logo(oled, x0=5, y0=2); oled.show()
time.sleep_ms(SPLASH_DUREE_MS)
oled.fill(0); oled.show()
# ========================= ETAT PARTAGÉ (mutex) =====================
# etat: 0=IDLE,1=RUN,2=STOPPED — accum_ms & start_ts gérés par cœur 1
ETAT_IDLE, ETAT_RUN, ETAT_STOPPED = 0, 1, 2
_state_lock = _thread.allocate_lock()
_state = {
"etat": ETAT_IDLE,
"accum_ms": 0,
"start_ts": None,
"beep_req": False, # demandé par cœur 1, servi par cœur 0
}
def set_state(**kwargs):
with _state_lock:
_state.update(kwargs)
def get_state():
with _state_lock:
return _state["etat"], _state["accum_ms"], _state["start_ts"], _state["beep_req"]
# ========================= THREAD CHRONO (cœur 1) ===================
def thread_chrono():
bp_am = Pin(BP_AM_PIN, Pin.IN, Pin.PULL_UP)
bp_raz = Pin(BP_RAZ_PIN, Pin.IN, Pin.PULL_UP)
last_am = bp_am.value(); last_raz = bp_raz.value()
last_am_t = time.ticks_ms(); last_raz_t = time.ticks_ms()
# Copie locale de l'état
etat, accum_ms, start_ts, _ = get_state()
while True:
now = time.ticks_ms()
# --- lecture boutons avec anti-rebond (polling)
cur_am = bp_am.value()
cur_raz = bp_raz.value()
# A/M : front descendant
if last_am == 1 and cur_am == 0 and time.ticks_diff(now, last_am_t) >= DEBOUNCE_MS:
last_am_t = now
if etat == ETAT_RUN:
# STOP → figer
if start_ts is not None:
accum_ms += time.ticks_diff(now, start_ts)
start_ts = None
etat = ETAT_STOPPED
set_state(etat=etat, accum_ms=accum_ms, start_ts=start_ts, beep_req=True)
elif etat == ETAT_IDLE:
# START depuis IDLE
start_ts = now
etat = ETAT_RUN
set_state(etat=etat, accum_ms=accum_ms, start_ts=start_ts, beep_req=True)
elif etat == ETAT_STOPPED:
# Reprise interdite → rien
pass
# RAZ : front descendant — seulement si pas en RUN
if last_raz == 1 and cur_raz == 0 and time.ticks_diff(now, last_raz_t) >= DEBOUNCE_MS:
last_raz_t = now
if etat != ETAT_RUN:
accum_ms = 0
start_ts = None
etat = ETAT_IDLE
set_state(etat=etat, accum_ms=accum_ms, start_ts=start_ts, beep_req=True)
last_am = cur_am; last_raz = cur_raz
# --- entretien RUN (pas de rendu ici) ---
if etat == ETAT_RUN and start_ts is None:
# Sécurité (ne devrait pas arriver)
start_ts = now
set_state(start_ts=start_ts)
time.sleep_ms(3)
# ========================= MAIN (cœur 0) ============================
def main():
oled = init_oled()
adc_vsys = init_adc_vsys()
adc_temp = init_adc_temp()
# Splash
splash_logo(oled)
# ---------- MODE DIAG : RAZ maintenu au boot (comme v2.2.1) ----------
bp_raz_boot = Pin(BP_RAZ_PIN, Pin.IN, Pin.PULL_UP)
bp_am_boot = Pin(BP_AM_PIN, Pin.IN, Pin.PULL_UP)
if bp_raz_boot.value() == 0:
time.sleep_ms(50)
if bp_raz_boot.value() == 0:
while True:
vsys = lire_vsys(adc_vsys)
en_charge = charge_en_cours()
temp_c = lire_temp_c(adc_temp)
oled.fill(0)
afficher_temperature_gauche(oled, temp_c) # remplace CHRONO
afficher_vsys_dans_batterie(oled, vsys, eclair=en_charge)
oled.show()
# sortie par A/M
if bp_am_boot.value() == 0:
time.sleep_ms(20)
if bp_am_boot.value() == 0:
beep_now()
break
time.sleep_ms(200)
# ---------- Lancer le cœur 1 (logique chrono + boutons) ----------
_thread.start_new_thread(thread_chrono, ())
# ---------- Boucle affichage (cœur 0) ----------
t_next_draw = time.ticks_add(time.ticks_ms(), PERIODE_RAFRAICH_MS)
t_next_batt = time.ticks_add(time.ticks_ms(), PERIODE_BATT_MS)
blink_on = True
t_next_blink = time.ticks_add(time.ticks_ms(), BLINK_PERIODE_MS)
vsys = lire_vsys(adc_vsys)
usb_present = (vsys >= USB_VSYS_MIN)
en_charge = charge_en_cours()
niveau = niveau_barres_pour_vsys(vsys)
while True:
now = time.ticks_ms()
# service buzzer
_service_beep(now)
# Beep demandé par cœur 1 ?
etat, accum_ms, start_ts, beep_req = get_state()
if beep_req:
beep_now(now)
set_state(beep_req=False)
# Mesures périodiques (batterie / charge)
if time.ticks_diff(now, t_next_batt) >= 0:
t_next_batt = time.ticks_add(t_next_batt, PERIODE_BATT_MS)
vsys = lire_vsys(adc_vsys)
usb_present = (vsys >= USB_VSYS_MIN)
en_charge = charge_en_cours()
niveau = niveau_barres_pour_vsys(vsys)
# Clignotement batterie vide
if time.ticks_diff(now, t_next_blink) >= 0:
t_next_blink = time.ticks_add(t_next_blink, BLINK_PERIODE_MS)
blink_on = not blink_on
# Rendu périodique
if time.ticks_diff(now, t_next_draw) >= 0:
t_next_draw = time.ticks_add(t_next_draw, PERIODE_RAFRAICH_MS)
# Calcul chrono (côté affichage pour une seule source de vérité)
if etat == ETAT_RUN and start_ts is not None:
elapsed = accum_ms + time.ticks_diff(now, start_ts)
else:
elapsed = accum_ms
elapsed = max(0, elapsed) % 100000
minutes = (elapsed // 1000) % 100
milli = elapsed % 1000
min_str = f"{minutes:02d}"
milli_str = f"{milli:03d}"
batterie_critique = (niveau == 0) and (not usb_present) and (not en_charge)
if batterie_critique and etat == ETAT_RUN:
# Force arrêt propre côté état partagé
if start_ts is not None:
accum_ms += time.ticks_diff(now, start_ts)
set_state(etat=ETAT_STOPPED, accum_ms=accum_ms, start_ts=None)
# DESSIN (identique v2.2.1)
oled.fill(0)
# Bandeau CHRONO + symbole
x_fin_texte = dessiner_texte_5x7(oled, CHRONO_X, CHRONO_Y, TEXTE_CHRONO, 1, 1, 1)
x_sym = x_fin_texte + SYMBOLE_ESPACE_APRES_TEXTE
y_sym = CHRONO_Y
if etat == ETAT_RUN:
dessiner_triangle_7(oled, x_sym, y_sym, 1)
elif etat == ETAT_STOPPED:
dessiner_carre_7(oled, x_sym, y_sym, 1)
if batterie_critique:
afficher_message_recharger(oled)
if blink_on:
dessiner_batterie(oled, X_BATT, Y_BATT, 0, eclair=en_charge)
else:
dessiner_chrono(oled, min_str, milli_str)
dessiner_batterie(oled, X_BATT, Y_BATT, niveau, eclair=en_charge)
oled.show()
time.sleep_ms(3)
# ------------------------- LANCEMENT --------------------------------
if __name__ == "__main__":
main()
Video des tests avant mise en boîtier
Les essais du prototype câblé sur une carte PICO avec des borniers à vis et des fils Dupont
Montage du chronomètre
Boîtier en 3D pour le Chronomètre à Raspberry Pi PICO

Le Boîtier du Chronomètre. Vous avez le fichier 3mf pour pouvoir l’adepter si besoin.

Le dessous du boitier c est une plaque fixée par 2 vis

J’ai fait un support pour l’écran OLED, ça permet d avoir une facade de qualité.

Les vis sont dans des inserts métalliques ce qui garantit la solisité. On voit l emplacement du buzzer en haut du boitier et celui de l’interrupteur A/M sur la gauche. Un coup de fer pour écraser le PETG et l’interrupteur est fixé. j’ai mis un coup de résine UV par dessus pour que ça tienne dans la durée.

Les inserts métalliques en place (vis de 2,5mm)

Le Chargeur de batterie en place pour les essais mais pas encore câblé. j ai dû un limer le support sur la droite. La aussi un coup de colle pour fixer le module.

L’accès à la prise de recharge de la batterie, sur le côté du boîtier.
Vous pouvez télécharger les fichiers 3D du boîtier en cliquant sur ce lien.
Montage sur table

L’ensemble des composants du chronomètre.
La partie électronique.

Ici le câblage est fait, l’ensemble est testé avant de tout caser dans le boîtier.
Intégration dans le boîtier

Mise en place de l’écran dans son support. J’ai vité les picots d’origine et soudé directement des fils sur les pastilles.

Oui je sais c est un peu « foutrouille » et je n’ai pas prévu de support pour la diode idéale et l’optocoupleur, ils seront fixés sur la batterie avec le morceau d’adhésif double face qu’on voit sur l’image.

Et voilà, la simulation 3D a servi, tout tient dans la boîte, pas besoin de chausse-pied pour tout faire rentrer. J’ai été généreux pour la longueur des fils, mais bon, c’est un proto et sans doute le seul exemplaire que je ferai…

Et c’est parti, tout fonctionne, je teste la durée de vie de la batterie (le cahier des charges disait 2 ou 3 heures.

Après une charge complète de la batterie, le chrono tourne depuis… 19H et il reste 1 barre… On va dire que c’est bon 😀 !
Vidéo
⏱️ Un chronomètre précis jusqu’au 1/1000e de seconde
Si le 1/1000e peut sembler superflu pour un usage courant, il devient précieux en physique scolaire. Il permet par exemple de mesurer le temps de chute d’un objet avec une finesse suffisante pour vérifier les lois du mouvement (gravité, vitesse, accélération). Avec deux capteurs (départ/arrivée) reliés au Raspberry Pi Pico — fourches IR, barrières optiques ou microcontacts — on obtient des mesures cohérentes au millième de seconde près.
Ce niveau de précision ouvre la porte à des activités pédagogiques motivantes : calcul de g à partir du temps de chute, comparaison de formes d’objets, étude des frottements, etc. Votre chronomètre 1/1000 devient alors un instrument de mesure accessible, reproductible et parfait pour un atelier FabLab.
🧪 Atelier : mesurer la chute d’une bille au 1/1000e
But : mesurer le temps de chute entre deux barrières optiques (ou fourches IR) espacées d’une distance connue d, puis calculer la vitesse moyenne et estimer l’accélération de la pesanteur.
Matériel
- 2 capteurs de barrière IR (actifs bas), ou 1 fourche IR + 1 microcontact d’arrivée.
- Raspberry Pi Pico (MicroPython) + câbles Dupont.
- Support vertical (règle, tige) avec repères à d = 0,20 m (par exemple).
Câblage (exemple)
- Capteur Départ → GPIO16 (GP16), pull-up interne.
- Capteur Arrivée → GPIO17 (GP17), pull-up interne.
- Les capteurs sortent à 0 quand le faisceau est coupé (actif bas).
Formules utiles
- Temps mesuré entre capteurs :
t(en secondes). - Vitesse moyenne entre capteurs :
v = d / t. - Si le premier capteur marque le départ de la chute (v≈0 à l’instant t0) et le second est à distance H du premier :
g ≈ 2·H / t²(approximation chute libre sans frottements).
Code MicroPython (interruptions, résolution au millième)
"""
Atelier chute libre - Chrono 1/1000 s (Pico / MicroPython)
Auteur : framboise314
Licence : MIT
GPIO : GP16 = départ (barrière IR 1, actif bas)
GP17 = arrivée (barrière IR 2, actif bas)
"""
from machine import Pin
import utime as time
PIN_START = 16
PIN_STOP = 17
start_pin = Pin(PIN_START, Pin.IN, Pin.PULL_UP)
stop_pin = Pin(PIN_STOP, Pin.IN, Pin.PULL_UP)
t_start_us = None
t_stop_us = None
mesure_prete = False
def on_start(pin):
global t_start_us, t_stop_us, mesure_prete
# Déclenchement sur front descendant (faisceau coupé)
if t_start_us is None: # ignore si déjà armé
t_start_us = time.ticks_us()
stop_pin.irq(trigger=Pin.IRQ_FALLING, handler=on_stop) # n’arme l’arrivée qu’après un vrai départ
def on_stop(pin):
global t_start_us, t_stop_us, mesure_prete
if t_start_us is not None and t_stop_us is None:
t_stop_us = time.ticks_us()
mesure_prete = True
# désactive les IRQ pour éviter les rebonds multiples
start_pin.irq(handler=None)
stop_pin.irq(handler=None)
def armer_mesure():
"""Prépare une nouvelle mesure"""
global t_start_us, t_stop_us, mesure_prete
t_start_us = None
t_stop_us = None
mesure_prete = False
# Armement : départ seulement
start_pin.irq(trigger=Pin.IRQ_FALLING, handler=on_start)
stop_pin.irq(handler=None)
def afficher_resultat(dt_us, distance_m=0.20):
t_s = dt_us / 1_000_000.0
v = distance_m / t_s
print("Δt = {:.3f} ms | t = {:.6f} s | v_moy = {:.3f} m/s".format(dt_us/1000.0, t_s, v))
# --- Boucle principale ---
print("Mesure de temps entre 2 capteurs (GP16 → GP17). Coupez le faisceau...")
armer_mesure()
try:
while True:
if mesure_prete:
dt = time.ticks_diff(t_stop_us, t_start_us) # µs, robuste au rollover
afficher_resultat(dt, distance_m=0.20) # adapte la distance à ton montage
time.sleep_ms(800)
print("— Nouvelle mesure —")
armer_mesure()
time.sleep_ms(5)
except KeyboardInterrupt:
print("Arrêt.")
Déroulé rapide
- Armer la mesure → couper la première barrière (départ).
- La bille franchit la seconde barrière (arrivée) → le chrono s’arrête.
- Lire
Δt(millisecondes) ett(secondes), puis calculervet éventuellementg.
Variantes : une seule barrière + plaque d’arrivée à contact ; ou plusieurs barrières en série pour mesurer des vitesses successives.
Comprendre le fonctionnement (interruptions, armement, réarmement)
Ce programme mesure le temps de chute entre deux capteurs reliés au Raspberry Pi Pico, avec une précision au 1/1000e de seconde. Il s’appuie sur des interruptions (IRQ) pour réagir instantanément aux événements, sans “scruter” les entrées en boucle.
1) Les interruptions : réagir immédiatement aux capteurs
Chaque capteur déclenche automatiquement une fonction quand son état change :
- Capteur de départ → appelle
on_start()lors de la coupure du faisceau. - Capteur d’arrivée → appelle
on_stop()lors de la coupure du faisceau.
Cette mécanique garantit un horodatage précis au moment exact où l’objet franchit chaque capteur.
2) Armement de la mesure
Au démarrage (et avant chaque nouvel essai), la fonction armer_mesure() :
- réinitialise les variables de temps (
t_start_us,t_stop_us), - active uniquement l’interruption du capteur de départ,
- désactive l’interruption du capteur d’arrivée,
- affiche un message du type : « Mesure armée… Coupez le faisceau de départ. »
À ce stade, le système attend le départ et ignore toute “fausse arrivée”.
3) Déclenchement du chrono (fonction on_start())
Lorsque la bille coupe le premier faisceau :
on_start()enregistre l’horodatage de départ en µs (t_start_us),- puis active l’interruption du capteur d’arrivée (via
stop_pin.irq(...)).
Le capteur de départ n’est pas “coupé” explicitement, mais il n’est pas réarmé tant qu’une nouvelle mesure n’est pas demandée. Il ne peut donc plus relancer le chrono pendant l’essai en cours.
4) Arrêt et calcul du temps (fonction on_stop())
Quand la bille coupe le second faisceau :
on_stop()enregistre l’horodatage d’arrivée (t_stop_us),- désactive les deux interruptions (
start_pinetstop_pin) pour figer la mesure, - calcule
Δt = t_stop_us − t_start_us(µs, robuste au “rollover”), - affiche le résultat (ms et s) et, si besoin, la vitesse moyenne
v = d / t.
5) Réarmement automatique
Après un court délai (≈ 0,8 s), le programme appelle à nouveau armer_mesure(). Le système revient alors dans l’état « prêt », avec seul le capteur de départ actif. Vous pouvez enchaîner les essais sans redémarrer le Pico.
6) Résumé séquentiel
| Étape | Capteur départ | Capteur arrivée | Action du programme |
|---|---|---|---|
| Armement | Actif (IRQ en attente) | Inactif | Attente d’un départ valide |
| Départ | Déclenche on_start() |
Activé | Enregistre t₀, active l’arrivée |
| Arrivée | Inactif | Déclenche on_stop() |
Enregistre t₁, calcule Δt, fige la mesure |
| Réarmement | Réactivé | Inactif | Prêt pour un nouvel essai |
Pourquoi cette architecture est pédagogique ?
- Elle montre la différence entre polling et interruptions (réactivité, précision temporelle).
- Elle formalise un cycle clair : armement → départ → arrivée → réarmement.
- Elle permet d’exploiter les résultats en physique : temps, vitesse moyenne, estimation de g.
Conclusion
Ce chronomètre scolaire autonome à base de Raspberry Pi Pico illustre parfaitement ce que la philosophie maker permet de réaliser : un outil simple, robuste et précis, conçu pour répondre à un besoin concret tout en restant ouvert à l’expérimentation. Grâce au microcontrôleur RP2040, à un écran OLED 128×64 lisible et à une batterie rechargeable, ce projet DIY réunit toutes les qualités d’un véritable prototype pédagogique : autonomie, fiabilité, et plaisir de comprendre comment les choses fonctionnent.
Que vous soyez enseignant, animateur de FabLab ou passionné d’électronique, ce projet Raspberry Pi Pico vous permettra de combiner apprentissage de la programmation, électronique embarquée et conception 3D. Il peut aussi servir de base à d’autres réalisations : minuteur de laboratoire, compteur de tours, timer d’atelier ou même jeu ??? … Les possibilités sont nombreuses !
En travaillant pas à pas, du schéma à l’impression 3D du boîtier, on redécouvre le plaisir de concevoir un objet utile de A à Z. Et si le 1/1000e de seconde semble superflu, il rappelle surtout la précision et la puissance qu’un petit microcontrôleur RP2040 peut offrir entre des mains curieuses.
👉 Vous trouverez dans cet article tous les fichiers 3D, le code source et les ressources pour reproduire ce chronomètre autonome OLED Raspberry Pi Pico. N’hésitez pas à l’adapter, à le partager, et à le faire évoluer : c’est ainsi que l’esprit maker continue de vivre !








Bonjour François ,
Très beau projet conçu de A à Z (avec boitier) .
J’aurais une question : Sur le Pico RP2040 , il semble y avoir une horloge RTC intégré (section 4.8 RTC , page 548 du Datasheet : https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf) or celle-ci n’a pas été reporté à l’identique sur le Pico2 RP2350 (section 12.10.2 Changes from RP2040 , page 1198 du Datasheet : https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf ) du moins le fonctionnement n’est pas le même , qui semble être un comptage de micro-secondes stockés dans un mot de 64bits , au lieu d’un adressage décimal facilement interprétable par un humain , comme sur la plupart des RTC externes (i2C / SPI) .
Savez vous où trouver des infos sur ces conversions , qui ne sont pas expliquées dans le Datasheet !?
Bonjour François,
Merci pour ce tutoriel, il manque juste le cout de la chose, je vais tenter de le réaliser, mais sans boitier car le n’ai pas d’imprimante 3D.
Cordialement
Bonjour Francis
effectivement, ça depend de l’endroit ou vous trouvez le matériel.
Avec les liens (qui fonctionnent en 11/2025 mais seront sans doute « morts » plus tard)
on est à moins de 20 €
merci pour le retour
cdt
francois
Bonjour Francois
Est-ce que tu aurais un lien ou le nom du ‘breadbord’ utilisé dans la vidéo de test ?
Merci
p.s. : il n’y a pas de lien su l’optocoupleur
Bonjour
pour l optocoupleur c est https://s.click.aliexpress.com/e/_c3OHUZgp
la breadboard je pense que c est la carte support du PICO avec le bornier à vis ?
Celui que j ai utilisé ne semble plus dispo
j ai trouvé celui ci https://s.click.aliexpress.com/e/_c3cucbaz il devrait faire l affaire
cdt
francois
Bonjour,
Félicitation pour ce beau projet.
Je cherche a faire un système de chronométrage pour le sprint avec des barrières à faisceau laser. Je pense me baser sur votre projet concernant la partie alimentation, charge et batterie.
La partie détection de passage sera composée de paires de émetteur / récepteur.Il peut y avoir jusqu’à 4 paires pour mesurer le temps de passage à des distances de sprint définies.
Chaque émetteur sera un boitier comprenant batterie, charge USB, écran, Raspberry Pi Pico( affichage de la batterie restante), et un émetteur laser KY-008
Chaque récepteur sera un boitier identique mais avec un module récepteur photosensible KY-008, qui pilotera le bouton poussoir une télécommande RF via le Raspberry
cf : https://www.manomano.fr/p/recepteur-a-distance-pour-portail-de-garage-433-868mhz-rx-multi-300-900mhz-acdc-9-30v-recepteur-avec-telecommande-87232352
Le récepteur final sera un boitier comprenant batterie, charge USB, écran, Raspberry Pi Pico ainsi qu’un module de réception RF relié aux télécommandes.
La première impulsion envoyée par une télécommande déclenchera le chrono sur le Raspberry, et chaque impulsion supplémentaire affichera un temps de passage.
J’espère être assez clair.
Ma question est comment alimenter les émetteur / récepteur optique en 5V, ainsi que mon récepteur RF en 9V à 30V depuis la batterie ?
Mon domaine est le développement mais par l’electricité / électronique donc je sèche un peu la dessus.
Merci d’avance.
Bonjour Manu,
Merci pour votre message et pour l’intérêt porté au projet 👍
VOtre besoin est bien compris, et l’architecture globale est cohérente. Je vous fais un retour point par point, surtout sur la partie alimentation, et j’attire aussi votre attention sur un point critique côté détection optique.
– Alimentation des émetteurs / récepteurs optiques en 5 V
Pour cette partie, la solution la plus simple et la plus robuste est effectivement :
Batterie Li-ion / LiPo 3,7 V (1S)
Module de charge USB (type TP4056 avec protection)
Convertisseur DC/DC step-up 5 V (MT3608, ou équivalent)
C’est une architecture classique, fiable, compacte, et parfaitement adaptée au Pico, à l’écran et aux modules optiques.
Avantage : simplicité, recharge USB, et pas de gestion complexe des cellules.
– Alimentation du module RF (9 V à 30 V)
Là, on change de catégorie.
Deux approches possibles :
Option A – la plus propre électriquement
Pack 3 × 18650 en série (3S) → ~11,1 V nominal
Chargeur BMS 3S dédié
Tension directement compatible avec ton module RF (9–30 V)
C’est la solution que je privilégierais si le module RF est énergivore ou sensible à la stabilité d’alim.
Option B – une seule batterie + conversion
Batterie 3,7 V (1S)
Chargeur USB
Convertisseur boost haute tension réglé à ~12 V
Ça fonctionne, mais c’est plus délicat :
rendement plus faible
bruit possible
attention aux pointes de courant
=> Pour un système terrain, robuste et répétable, le 3S avec BMS est plus
⚠️ Point d’attention important : les modules laser KY-008
Je me permets d’insister là-dessus, car j’ai un retour terrain très concret.
J’ai utilisé des KY-008 (émetteur + récepteur) sur un stand avec un jeu de tir laser.
Même avec le récepteur placé au fond d’un tube noir, le fonctionnement était très dépendant de la lumière ambiante.
En pratique :
sensibilité à réajuster fréquemment
comportement parfois erratique selon l’éclairage
soleil direct = conditions difficiles
=> Avant d’aller plus loin, je vous conseille vraiment de valider cette solution :
en extérieur
en plein soleil
sur plusieurs distances
avec des passages rapides (sprint réel)
Si ça passe, parfait.
Sinon, il faudra envisager soit :
– une autre techno optique (barrières IR industrielles)
– soit un traitement plus robuste côté réception
Bonne continuation dans le projet,
François
Merci pour ces réponses.
C’est plus clair pour la partie alimentation.
J’ai lu que les modules de charge TP4056 chauffait pas mal.
Avez vous rencontré ce cas dans vos montages ?
Effectivement, je ne connait pas encore la consommation du module RF, mais des 18650 en série (3S) dédiée me semble une bonne idée. Le Pico ayant sa propre alim (lipo).
Concernant la partie optique, je vais faire des tests à blanc avec une paire de module pour évaluer si le type de capteur est le bon dans mon projet.
Ce projet n’étant dédié que pour la phase entrainement des sprinteurs, les temps et vitesse de passage entre les cellules) ne seront mesurés que athlète par athlète, donc la distance entre l’émetteur et le récepteur sera de 1m a 1m50.
Quand vous parlez de réglage de sensibilité, vous parlez de quoi ?
Je croyais que le lecture de la sortie OUT par le pico ne restutait que deux valeur HIGH ou LOW.
Cordialement.
Manu
Bonjour Manu,
Très bonne question, et elle est importante 👍
Il y a effectivement un petit point de fonctionnement à clarifier entre le Pico et le module récepteur laser.
Comment fonctionne réellement le couple laser / récepteur
Le système KY-008 ne fonctionne pas comme un capteur « intelligent » piloté par le Pico.
Le laser émet en continu.
Le récepteur est un module autonome qui contient :
un photorécepteur (sensible à la lumière en général, pas uniquement au laser),
un comparateur,
et un potentiomètre de réglage de sensibilité.
C’est ce module récepteur qui fait tout le travail d’analyse.
👉 Le Raspberry Pi Pico n’analyse rien ici :
il est simplement configuré en entrée logique, et il lit l’état de sortie du module.
À quoi sert le potentiomètre de réglage
Même si la sortie du module est bien un signal HIGH / LOW,
le seuil de déclenchement dépend du réglage du potentiomètre.
Ce réglage permet de décider à partir de quelle quantité de lumière reçue le module considère que le laser est “vu” ou “coupé”
Et c’est là que la lumière ambiante entre en jeu :
lumière faible → réglage assez tolérant
lumière forte / soleil → le récepteur reçoit déjà beaucoup de lumière avant même le laser
Dans ce cas :
soit il déclenche trop facilement
soit il ne voit plus correctement la coupure du faisceau
soit il devient instable
C’est ce réglage-là que je mentionnais quand je parlais de « sensibilité »,
pas un réglage logiciel dans le Pico.
Pourquoi je recommande des tests terrain
À 1 m ou 1,5 m, en entraînement, ça peut très bien fonctionner,
mais ça dépendra énormément :
de l’éclairage (intérieur / extérieur)
de l’orientation
du réglage fin du potentiomètre
de la répétabilité du déclenchement sur des passages rapides
D’où mon conseil de valider une paire complète en conditions réelles avant d’industrialiser le reste
Bonne continuation dans le projet,
François 🙂
effectivement je viens de voir ce module (c est celui que vous avez ?)
et sur le recepteur il n y a pas de potentiomètre de réglage de sensibilité. Ce n ‘est pas celui que j ai utilisé. Je renouvelle le conseil : essayez sérieusement l’ensemble dans les conditions réelles (extérieur au soleil) avant de valider le choix !