Projet DIY

Station météo Raspberry Pi : BME280, CSV et Flask

Construisez une station météo Raspberry Pi avec capteur BME280, stockage CSV, dashboard Flask, graphiques Chart.js et services systemd.

Construisez une station météo locale sur Raspberry Pi OS avec un breakout BME280 I2C 3,3 V. Cette recette mesure température, humidité et pression, enregistre les relevés horodatés en UTC dans un CSV et affiche un échantillon des dernières 24 heures dans deux graphiques Flask. Elle nécessite Python 3.10 ou supérieur, apt, systemd et s’inscrit dans le périmètre des Raspberry Pi à embase GPIO 40 points ; certains modèles Zero nécessitent d’y souder des broches, tandis que les anciens modèles à 26 broches et les Pico ou Pico W sont hors périmètre.

Limite de validation : les commandes et contrôles nécessaires sont fournis, mais aucun résultat d’essai complet sur une plateforme précisément identifiée n’est présenté. Le dashboard affiche les nouveaux relevés lorsque la page est rechargée.

Prérequis. Raspberry Pi sous Raspberry Pi OS avec embase GPIO 40 points, Python ≥ 3.10 et breakout BME280 compatible I2C 3,3 V. Certains modèles Zero demandent la soudure de broches ; les anciens modèles à 26 broches sont hors périmètre de ce guide.

Matériel nécessaire pour une station météo Raspberry Pi

Cette recette nécessite un Raspberry Pi dans le périmètre du guide, un breakout BME280 I2C 3,3 V, quatre câbles, une alimentation et une carte microSD.

Composant Rôle Point de vigilance
Raspberry Pi sous Raspberry Pi OS Acquisition, stockage et dashboard Embase GPIO 40 points, éventuellement à souder sur certains Zero, Python ≥ 3.10, réseau et systemd
Breakout BME280 I2C 3,3 V Température, humidité et pression Vérifier son schéma, CSB, SDO et les composants intégrés
4 câbles Dupont 3,3 V, masse, SDA et SCL Connecteurs adaptés au breakout
Carte microSD et alimentation Système et données Alimentation adaptée au modèle de Pi

Si vous n’avez pas encore la carte, consultez le guide pour choisir un Raspberry Pi adapté au projet.

BME280, AM2302 ou DS18B20 : lequel choisir ?

Le BME280 est le seul de ces trois capteurs à réunir température, humidité et pression dans un composant compatible I2C.

Capteur Mesures Précision T° annoncée Interface Usage pertinent
BME280 Température, humidité, pression ±0,5 °C entre 0 et 65 °C I2C ou SPI Station météo compacte
AM2302 Température, humidité Courbes d’erreur typique et maximale dans le manuel Protocole numérique propriétaire à un fil Mesure simple sans pression
DS18B20 Température ±0,5 °C de −10 à +85 °C 1-Wire Sonde déportée ou étanche

Les caractéristiques annoncées proviennent des documentations primaires du BME280 de Bosch, de l’AM2302 d’Aosong et du DS18B20 d’Analog Devices. La précision du composant ne garantit pas celle de la station assemblée : la chaleur du Pi, le boîtier, le soleil et la circulation d’air influencent la mesure.

Câbler le BME280 en I2C sur le Raspberry Pi

Quatre fils relient un breakout BME280 I2C 3,3 V aux broches d’alimentation, de masse, SDA et SCL du Raspberry Pi.

Breakout BME280 Raspberry Pi Fonction
VIN ou VCC Pin 1, 3,3 V Alimentation
GND Pin 6, GND Masse
SDA Pin 3, GPIO2 Données I2C
SCL Pin 5, GPIO3 Horloge I2C

GPIO2 et GPIO3 possèdent des résistances de rappel fixes sur le Raspberry Pi. Consultez néanmoins le schéma du breakout avant d’ajouter des résistances ou de connecter ses broches : selon la fiche Bosch, un BME280 nu demande notamment VDD, VDDIO, des condensateurs de découplage, CSB au niveau approprié pour l’I2C et un niveau défini sur SDO.

Activez I2C dans sudo raspi-config, sous Interface Options.

Attention avant le scan I2C. Le manuel officiel d’i2cdetect avertit que ses commandes SMBus arbitraires peuvent perturber le bus et entraîner une perte de données. Effectuez ce scan sur un bus simple, après vérification du câblage, et arrêtez les autres applications qui utilisent le bus I2C.

sudo apt update
sudo apt install i2c-tools -y
sudo i2cdetect -y 1

Repérez une adresse 76 ou 77. i2cdetect indique qu’une adresse est occupée, mais n’identifie pas à lui seul le composant ; le pilote Adafruit vérifie ensuite le chip ID du BME280. Conservez l’adresse détectée pour les tests et le service systemd.

Placement extérieur. Aucun indice IP n’apparaît dans la fiche technique ni dans les instructions de montage Bosch citées. Bosch demande de protéger l’ouverture du capteur contre les liquides et la lumière directe. Utilisez un abri ventilé protégé de la pluie et du soleil, loin des sources de chaleur.

Installer Blinka et les dépendances Python

Sur Raspberry Pi OS Bookworm ou ultérieur, un environnement virtuel créé avec --system-site-packages conserve l’accès aux paquets Python installés par APT. Le backend matériel utilisé par Blinka dépend du modèle de Raspberry Pi, du système d’exploitation et des bibliothèques installées.

python3 --version

METEO_USER="$(id -un)"
METEO_GROUP="$(id -gn)"

sudo apt install \
  python3-venv \
  python3-pip \
  libgpiod-dev \
  python3-libgpiod \
  -y

sudo usermod -aG i2c,gpio "$METEO_USER"
sudo install -d -o "$METEO_USER" -g "$METEO_GROUP" /opt/meteo
sudo install -d -o "$METEO_USER" -g "$METEO_GROUP" /var/lib/meteo

python3 -m venv --system-site-packages /opt/meteo/venv
/opt/meteo/venv/bin/pip install \
  adafruit-circuitpython-bme280==2.6.32 \
  Flask==3.1.3 \
  gunicorn==26.0.0
/opt/meteo/venv/bin/pip freeze --local \
  > /opt/meteo/requirements.txt

Les versions sont documentées par Adafruit CircuitPython BME280 2.6.32, les versions officielles de Flask et les métadonnées de Gunicorn 26.0.0. Reconnectez-vous ou redémarrez pour appliquer les groupes i2c et gpio.

Portée du fichier de dépendances. pip freeze --local enregistre les paquets Python installés localement dans le venv ; il n’inclut ni les paquets installés par APT, comme python3-libgpiod, ni l’appartenance aux groupes Unix i2c et gpio.

Enregistrer les mesures du BME280 dans un CSV

À un relevé théorique par minute, 30 jours représentent 43 200 relevés, avant le temps de lecture, d’écriture et les redémarrages.

Créez /opt/meteo/meteo.py :

#!/usr/bin/env python3

import csv
import os
from datetime import datetime, timezone
from time import sleep

import board
import adafruit_bme280.basic as adafruit_bme280

CSV_FILE = "/var/lib/meteo/data.csv"
ADDRESS = int(os.environ.get("BME280_ADDRESS", "0x76"), 0)

i2c = board.I2C()
bme = adafruit_bme280.Adafruit_BME280_I2C(i2c, address=ADDRESS)

os.makedirs(os.path.dirname(CSV_FILE), exist_ok=True)

if not os.path.exists(CSV_FILE) or os.path.getsize(CSV_FILE) == 0:
    with open(CSV_FILE, "w", newline="", encoding="utf-8") as output:
        csv.writer(output).writerow(
            ["timestamp", "temp_c", "humidity_pct", "pressure_hpa"]
        )

print(f"Station démarrée avec le BME280 à {ADDRESS:#04x}.", flush=True)

while True:
    timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
    temperature = round(bme.temperature, 1)
    humidity = round(bme.relative_humidity, 1)
    pressure = round(bme.pressure, 1)

    with open(CSV_FILE, "a", newline="", encoding="utf-8") as output:
        csv.writer(output).writerow(
            [timestamp, temperature, humidity, pressure]
        )

    print(
        f"[{timestamp}] {temperature} °C | "
        f"{humidity} % HR | {pressure} hPa",
        flush=True,
    )
    sleep(60)

Chaque cycle comprend les lectures, l’écriture et au moins 60 secondes de sommeil. Le pilote officiel Adafruit peut déclencher des mesures forcées distinctes lors des accès successifs aux propriétés : les trois valeurs ne constituent donc pas nécessairement une acquisition atomique unique.

sea_level_pressure sert au calcul de l’altitude sans transformer bme.pressure en pression ramenée au niveau de la mer ; le CSV enregistre la pression mesurée.

Afficher les dernières 24 heures avec Flask

Le dashboard Flask filtre une fenêtre glissante de 24 heures et affiche un relevé sur dix, plus le dernier relevé.

Créez /opt/meteo/dashboard.py :

#!/usr/bin/env python3

import csv
import math
import os
from datetime import datetime, timedelta, timezone

from flask import Flask, render_template_string

app = Flask(__name__)

CSV_FILE = "/var/lib/meteo/data.csv"
CHUNK_SIZE = 64 * 1024
ACQUISITION_INTERVAL_SECONDS = 60
STALE_AFTER_SECONDS = 2 * ACQUISITION_INTERVAL_SECONDS

TEMPLATE = """<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Station météo Raspberry Pi</title>
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
  <style>
    body{font-family:system-ui,sans-serif;max-width:900px;margin:2rem auto;
    padding:0 1rem;background:#0b0f17;color:#e5e7eb}
    header{font-size:1.8rem;font-weight:700;color:#f97316;margin-bottom:1rem}
    .last{display:flex;flex-wrap:wrap;gap:2rem;padding:1rem 1.5rem;
    background:#161b22;border-left:3px solid #f97316;border-radius:6px}
    .last strong{font-size:1.35rem;color:#fff}
    .last span{display:block;color:#8b949e;font-size:.85rem}
    .status{flex-basis:100%;font-size:.9rem}
    .status.stale{color:#fca5a5;font-weight:700}
    canvas{background:#161b22;border-radius:6px;margin-top:1.5rem;padding:1rem}
  </style>
</head>
<body>
  <header>Station météo Raspberry Pi</header>
  <div class="last">
    <div><strong>{{ last.temp }}</strong><span>Température °C</span></div>
    <div><strong>{{ last.hum }}</strong><span>Humidité %</span></div>
    <div><strong>{{ last.pres }}</strong><span>Pression hPa</span></div>
    <div class="status{% if last.stale %} stale{% endif %}">
      Dernier relevé : {{ last.timestamp }}
      {% if last.stale %} — mesure trop ancienne{% endif %}
    </div>
  </div>
  <canvas id="weatherChart"></canvas>
  <canvas id="pressureChart"></canvas>
  <script>
    const weatherData = {{ chart_data | tojson }};

    new Chart(document.getElementById("weatherChart"), {
      type: "line",
      data: {
        labels: weatherData.labels,
        datasets: [
          {label:"Température °C",data:weatherData.temp,
           borderColor:"#f97316",pointRadius:0,yAxisID:"temperature"},
          {label:"Humidité %",data:weatherData.hum,
           borderColor:"#22d3ee",pointRadius:0,yAxisID:"humidity"}
        ]
      },
      options: {
        responsive: true,
        scales: {
          temperature:{type:"linear",position:"left"},
          humidity:{type:"linear",position:"right",
                    grid:{drawOnChartArea:false}}
        }
      }
    });

    new Chart(document.getElementById("pressureChart"), {
      type: "line",
      data: {
        labels: weatherData.labels,
        datasets: [
          {label:"Pression hPa",data:weatherData.pres,
           borderColor:"#a78bfa",pointRadius:0}
        ]
      },
      options: {responsive:true}
    });
  </script>
</body>
</html>"""

def parse_line(line):
    try:
        fields = next(csv.reader([line], strict=True), [])
    except csv.Error:
        return None

    if not fields or fields[0] == "timestamp" or len(fields) != 4:
        return None

    try:
        timestamp = datetime.fromisoformat(fields[0])
        temperature = float(fields[1])
        humidity = float(fields[2])
        pressure = float(fields[3])
    except (ValueError, TypeError):
        return None

    if timestamp.tzinfo is None:
        return None

    values = (temperature, humidity, pressure)
    if not all(math.isfinite(value) for value in values):
        return None

    return {
        "timestamp": timestamp,
        "temp": temperature,
        "hum": humidity,
        "pres": pressure,
    }

def recent_rows():
    if not os.path.isfile(CSV_FILE):
        return [], None

    now = datetime.now(timezone.utc)
    cutoff = now - timedelta(hours=24)
    blocks = []
    position = os.path.getsize(CSV_FILE)
    reached_cutoff = False

    try:
        with open(CSV_FILE, "rb") as source:
            while position > 0 and not reached_cutoff:
                size = min(CHUNK_SIZE, position)
                position -= size
                source.seek(position)
                blocks.insert(0, source.read(size))

                text = b"".join(blocks).decode("utf-8", errors="replace")
                for line in text.splitlines():
                    row = parse_line(line)
                    if row and row["timestamp"] < cutoff:
                        reached_cutoff = True
                        break
    except OSError:
        return [], None

    text = b"".join(blocks).decode("utf-8", errors="replace")
    valid_rows = []

    for line in text.splitlines():
        row = parse_line(line)
        if row and row["timestamp"] <= now:
            valid_rows.append(row)

    valid_rows.sort(key=lambda row: row["timestamp"])
    latest = valid_rows[-1] if valid_rows else None
    rows = [
        row for row in valid_rows
        if cutoff <= row["timestamp"] <= now
    ]
    return rows, latest

@app.get("/")
def index():
    rows, latest = recent_rows()
    sampled = rows[::10]

    if rows and (
        not sampled
        or sampled[-1]["timestamp"] != rows[-1]["timestamp"]
    ):
        sampled.append(rows[-1])

    chart_data = {
        "labels": [
            row["timestamp"].astimezone().strftime("%H:%M")
            for row in sampled
        ],
        "temp": [row["temp"] for row in sampled],
        "hum": [row["hum"] for row in sampled],
        "pres": [row["pres"] for row in sampled],
    }

    if latest:
        local_timestamp = latest["timestamp"].astimezone()
        age_seconds = (
            datetime.now(timezone.utc)
            - latest["timestamp"].astimezone(timezone.utc)
        ).total_seconds()
        last = {
            "temp": latest["temp"],
            "hum": latest["hum"],
            "pres": latest["pres"],
            "timestamp": local_timestamp.strftime(
                "%d/%m/%Y à %H:%M:%S %Z (%z)"
            ),
            "stale": age_seconds > STALE_AFTER_SECONDS,
        }
    else:
        last = {
            "temp": "—",
            "hum": "—",
            "pres": "—",
            "timestamp": "aucun relevé valide",
            "stale": True,
        }

    return render_template_string(
        TEMPLATE,
        last=last,
        chart_data=chart_data,
    )

L’application affiche la dernière ligne valide dans le fuseau local du Pi et signale toute mesure datant de plus de 120 secondes. Le parseur rejette les lignes sans quatre champs, les dates sans fuseau et les nombres non finis.

La lecture depuis la fin du fichier suppose des lignes chronologiques et une horloge synchronisée. Si le CSV est importé, modifié ou produit avec une horloge incorrecte, supprimez l’arrêt anticipé ou retriez toutes les lignes avant filtrage.

Dépendance réseau. Le CDN est une méthode d’installation documentée par Chart.js. Ici, chart.js@4 fixe seulement la version majeure et dépend de jsDelivr ; sur un réseau isolé, choisissez une version exacte et servez-la localement.

Tester Blinka, le BME280 et Gunicorn

Trois tests manuels isolent les erreurs d’accès GPIO, de lecture du capteur et du serveur web avant l’activation de systemd.

Après reconnexion pour appliquer les groupes i2c et gpio, vérifiez d’abord que Blinka peut créer le bus I2C :

/opt/meteo/venv/bin/python -c \
  "import board; i2c = board.I2C(); print('Bus I2C accessible')"

La sortie attendue est Bus I2C accessible. Testez ensuite le lecteur avec l’adresse détectée :

BME280_ADDRESS=0x76 /opt/meteo/venv/bin/python /opt/meteo/meteo.py

La sortie doit commencer par Station démarrée avec le BME280 à 0x76., puis afficher les trois mesures. Utilisez 0x77 si nécessaire, attendez un relevé et arrêtez avec Ctrl+C.

Lancez ensuite Gunicorn sur l’interface locale :

cd /opt/meteo
/opt/meteo/venv/bin/gunicorn --workers 1 --bind 127.0.0.1:5000 dashboard:app

Dans un second terminal, vérifiez uniquement la réponse HTTP de Gunicorn :

curl -fsS http://127.0.0.1:5000/ >/dev/null \
  && echo "Dashboard accessible"

La sortie attendue est Dashboard accessible. Ouvrez ensuite http://127.0.0.1:5000 dans un navigateur pour confirmer le chargement de Chart.js et le rendu des deux graphiques, puis arrêtez Gunicorn avec Ctrl+C.

Démarrer la station météo avec systemd

Deux unités systemd exécutent séparément l’acquisition BME280 et le dashboard Gunicorn au démarrage.

Créez /etc/systemd/system/meteo-reader@.service :

[Unit]
Description=Station météo - acquisition BME280
After=local-fs.target

[Service]
Type=simple
User=%i
SupplementaryGroups=i2c gpio
WorkingDirectory=/opt/meteo
Environment=PYTHONUNBUFFERED=1
Environment=BME280_ADDRESS=0x76
ExecStart=/opt/meteo/venv/bin/python /opt/meteo/meteo.py
Restart=on-failure
RestartSec=5
StateDirectory=meteo
UMask=0027
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target

Remplacez 0x76 par 0x77 si cette adresse a été détectée.

Créez /etc/systemd/system/meteo-dashboard@.service :

[Unit]
Description=Station météo - dashboard Gunicorn
Wants=network-online.target
After=network-online.target meteo-reader@%i.service

[Service]
Type=simple
User=%i
WorkingDirectory=/opt/meteo
ExecStart=/opt/meteo/venv/bin/gunicorn --workers 1 --bind 0.0.0.0:5000 dashboard:app
Restart=on-failure
RestartSec=5
StateDirectory=meteo
UMask=0027
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target

Accès réseau. 0.0.0.0:5000 expose le dashboard sur toutes les interfaces du Pi sans authentification. Réservez-le à un LAN ou VPN de confiance et ne redirigez pas le port 5000 depuis Internet.

Activez les services après les tests manuels :

METEO_USER="$(id -un)"

sudo systemctl daemon-reload
sudo systemctl enable --now "meteo-reader@${METEO_USER}.service"
sudo systemctl enable --now "meteo-dashboard@${METEO_USER}.service"

systemctl status "meteo-reader@${METEO_USER}.service"
systemctl status "meteo-dashboard@${METEO_USER}.service"

Ouvrez http://ADRESSE_IP_DU_PI:5000 depuis le LAN ou le VPN. Pour administrer le Pi sans écran, suivez le guide SSH headless sur Raspberry Pi. Un nom en .local dépend de mDNS et n’est pas garanti à travers un VPN.

Valider la station après redémarrage

La validation exige deux services actifs, une ligne CSV récente, une réponse HTTP de Gunicorn et deux graphiques rendus dans un navigateur.

Élément Contrôle Résultat attendu
Raspberry Pi et OS Identifier la plateforme Raspberry Pi OS et architecture attendue
Python et systemd Contrôler après redémarrage Version de Python et deux unités actives
Breakout et adresse I2C Vérifier le module Référence, schéma et adresse déjà détectée
CSV et réponse HTTP Contrôler les données et Gunicorn Ligne récente et réponse HTTP valide
Chart.js et graphiques Ouvrir le dashboard dans un navigateur Chart.js chargé et deux graphiques rendus

Consignez la plateforme, puis redémarrez :

cat /etc/os-release
uname -m
python3 --version
sudo reboot

Après reconnexion, contrôlez les services, les données et la réponse HTTP :

METEO_USER="$(id -un)"

systemctl is-active "meteo-reader@${METEO_USER}.service"
systemctl is-active "meteo-dashboard@${METEO_USER}.service"

/opt/meteo/venv/bin/pip check

tail -n 5 /var/lib/meteo/data.csv
curl -fsS http://127.0.0.1:5000/ >/dev/null \
  && echo "Dashboard accessible"

journalctl -u "meteo-reader@${METEO_USER}.service" -b --no-pager -n 30
journalctl -u "meteo-dashboard@${METEO_USER}.service" -b --no-pager -n 30

Ouvrez ensuite http://ADRESSE_IP_DU_PI:5000 dans un navigateur pour confirmer le chargement de Chart.js et le rendu des deux graphiques.

Comment vérifier la réussite. pip check doit indiquer qu’aucune dépendance installée n’est incompatible ; le lecteur doit ajouter une ligne valide au CSV et les deux services doivent être active après redémarrage. curl vérifie seulement la réponse HTTP de Gunicorn ; le navigateur confirme le chargement de Chart.js et le rendu des deux graphiques.

Autres projets DIY

Projet DIY

Robot 2 roues autonome avec Raspberry Pi : le tutoriel complet

Le projet parfait pour démarrer en robotique DIY : construire un petit robot mobile qui évite les obstacles, avec un Raspberry Pi, un module L298N, deux moteurs et un capteur ultrason. Matériel, câblage, code Python commenté.