Publié le 4 novembre 2025 - par

Construire un chronomètre scolaire autonome avec Raspberry Pi Pico et écran OLED

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 :

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

Module de charge TP4056 avec connecteur USB-C, utilisé pour recharger une batterie Li-Ion 3,7 V jusqu’à 1 A, avec double indicateur LED rouge/vert.

 

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

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

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 Lock cré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

  1. Armer la mesure → couper la première barrière (départ).
  2. La bille franchit la seconde barrière (arrivée) → le chrono s’arrête.
  3. Lire Δt (millisecondes) et t (secondes), puis calculer v et éventuellement g.

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_pin et stop_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 !

À propos François MOCQ

Électronicien d'origine, devenu informaticien, et passionné de nouvelles technologies, formateur en maintenance informatique puis en Réseau et Télécommunications. Dès son arrivée sur le marché, le potentiel offert par Raspberry Pi m’a enthousiasmé j'ai rapidement créé un blog dédié à ce nano-ordinateur (www.framboise314.fr) pour partager cette passion. Auteur de plusieurs livres sur le Raspberry Pi publiés aux Editions ENI.

9 réflexions au sujet de « Construire un chronomètre scolaire autonome avec Raspberry Pi Pico et écran OLED »

  1. msg

    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 !?

     

    Répondre
  2. francis

    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

    Répondre
    1. François MOCQ Auteur de l’article

      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

      Répondre
  3. francis

    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

    Répondre
  4. Manu

    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.

    Répondre
    1. François MOCQ Auteur de l’article

      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

      Répondre
      1. Manu

        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

        Répondre
  5. François MOCQ Auteur de l’article

    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 ?)
    Module laser HY-008
    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 !

    Répondre

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur la façon dont les données de vos commentaires sont traitées.