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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
""" 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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
""" 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
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
""" 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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
""" 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.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 |
# ===================================================================== # 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)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
""" 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 !







