Mesurer c’est bien, enregistrer et visualiser c’est mieux. Ce chapitre couvre la chaîne complète : acquisition des données par l’ESP32, transmission (WiFi, MQTT), stockage (fichier local, base de données, cloud), et visualisation (tableaux de bord, graphiques). L’objectif : transformer des flux de nombres bruts en informations exploitables pour piloter son jardin.
[Capteurs] → [ESP32] → [WiFi] → [Broker MQTT / API HTTP]
│
▼
[Base de données]
(SQLite / InfluxDB)
│
▼
[Visualisation]
(Grafana / Matplotlib / Web)
Deux approches coexistent :
| Approche | Avantages | Inconvénients |
|---|---|---|
| Locale (fichier CSV sur carte SD) | Autonome, pas de réseau | Pas de consultation à distance |
| Connectée (WiFi + serveur) | Temps réel, alertes, historique | Dépendance réseau et serveur |
Un bon système combine les deux : stockage local en tampon + envoi réseau quand disponible.
L’ESP32 dispose de 4 Mo de flash, dont une partie est formatée en LittleFS (anciennement SPIFFS). On peut y écrire des fichiers CSV :
# Écriture d'une mesure sur le système de fichiers
import time, os
def enregistrer_mesure(fichier, zone, humidite,
temperature, action):
t = time.localtime()
ligne = "{:04d}-{:02d}-{:02d},{:02d}:{:02d}:{:02d}," \
"{},{:.1f},{:.1f},{}\n".format(
*t[:6], zone, humidite, temperature, action)
with open(fichier, "a") as f:
f.write(ligne)
Capacité : un fichier CSV avec 6 champs × 50 caractères/ligne ≈ 50 octets/ligne. Avec 2 Mo disponibles, on peut stocker environ 40 000 lignes, soit ~400 jours à 100 mesures/jour.
Pour éviter de saturer la mémoire :
def rotation_log(fichier, taille_max=500_000):
"""Supprime le fichier si > 500 Ko"""
try:
stat = os.stat(fichier)
if stat[6] > taille_max:
os.rename(fichier, fichier + ".old")
except OSError:
pass # fichier n'existe pas encore
Pour un stockage plus important, une carte micro-SD connectée en SPI offre des Go de capacité :
from machine import SPI, Pin
import sdcard, os
spi = SPI(1, baudrate=1_000_000,
sck=Pin(18), mosi=Pin(23), miso=Pin(19))
cs = Pin(5, Pin.OUT)
sd = sdcard.SDCard(spi, cs)
os.mount(sd, "/sd")
# Écriture identique : open("/sd/data.csv", "a")
import network, time
def connecter_wifi(ssid, password, timeout=15):
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(ssid, password)
debut = time.time()
while not wlan.isconnected():
if time.time() - debut > timeout:
raise RuntimeError("WiFi timeout")
time.sleep(0.5)
print("IP:", wlan.ifconfig()[0])
return wlan
Point pratique : la connexion WiFi consomme ~240 mA. Pour maximiser l’autonomie, connectez-vous uniquement pour envoyer les données, puis repassez en deep sleep.
L’approche la plus simple est un POST HTTP vers un serveur Flask ou FastAPI :
import urequests, json
def envoyer_donnees(url, donnees):
"""Envoie un dict de mesures en JSON"""
headers = {"Content-Type": "application/json"}
r = urequests.post(url, data=json.dumps(donnees),
headers=headers)
ok = r.status_code == 200
r.close()
return ok
MQTT (Message Queuing Telemetry Transport) est le protocole standard pour l’IoT. Il est léger, fiable, et parfaitement adapté aux microcontrôleurs.
| Concept | Description |
|---|---|
| Broker | Serveur central qui relaie les messages (ex : Mosquitto) |
| Topic | Canal de communication hiérarchique (ex : jardin/potager/humidite) |
| Publish | Envoyer un message sur un topic |
| Subscribe | Écouter un topic pour recevoir les messages |
| QoS | Qualité de service : 0 (au plus une fois), 1 (au moins une fois), 2 (exactement une fois) |
| Retain | Le broker conserve le dernier message pour les nouveaux abonnés |
jardin/
├── potager/
│ ├── humidite → 45.2
│ ├── temperature → 22.1
│ ├── arrosage/status → ON
│ └── arrosage/volume → 12.5
├── pelouse/
│ ├── humidite → 38.7
│ └── temperature → 21.8
├── meteo/
│ ├── temperature → 24.3
│ ├── humidite_air → 62
│ └── pluie → false
└── systeme/
├── batterie → 85
├── wifi_rssi → -67
└── status → OK
from umqtt.simple import MQTTClient
import json
def publier_mqtt(broker, client_id, topic, donnees):
client = MQTTClient(client_id, broker)
client.connect()
payload = json.dumps(donnees)
client.publish(topic, payload, retain=True)
client.disconnect()
Équation clé — Bande passante MQTT :
Débit = (taille_header + taille_payload) × fréquence_publication × nombre_capteurs
Header MQTT ≈ 5 octets. Payload JSON typique ≈ 50 octets. À 1 msg/min pour 5 capteurs : 55 × 5 / 60 ≈ 4,6 octets/s — négligeable.
Côté serveur (Raspberry Pi, PC, VPS), un script Python reçoit et stocke les données :
# code/serveur_donnees.py (extrait)
from flask import Flask, request, jsonify
import sqlite3, datetime
app = Flask(__name__)
@app.route("/api/mesure", methods=["POST"])
def recevoir_mesure():
data = request.json
conn = sqlite3.connect("jardin.db")
conn.execute(
"INSERT INTO mesures VALUES (?,?,?,?,?)",
(datetime.datetime.now().isoformat(),
data["zone"], data["humidite"],
data["temperature"], data.get("action", ""))
)
conn.commit()
conn.close()
return jsonify({"status": "ok"})
# code/mqtt_receiver.py (extrait)
import paho.mqtt.client as mqtt
import sqlite3, json, datetime
def on_message(client, userdata, msg):
data = json.loads(msg.payload)
topic_parts = msg.topic.split("/")
zone = topic_parts[1] if len(topic_parts) > 1 else "inconnu"
conn = sqlite3.connect("jardin.db")
conn.execute(
"INSERT INTO mesures VALUES (?,?,?,?,?)",
(datetime.datetime.now().isoformat(),
zone, data.get("humidite"),
data.get("temperature"), "")
)
conn.commit()
conn.close()
Parfait pour un projet personnel. Schéma de la table principale :
CREATE TABLE mesures (
timestamp TEXT NOT NULL,
zone TEXT NOT NULL,
humidite REAL,
temperature REAL,
action TEXT,
volume_eau REAL DEFAULT 0
);
CREATE INDEX idx_mesures_time ON mesures(timestamp);
CREATE INDEX idx_mesures_zone ON mesures(zone);
Pour des volumes plus importants ou une rétention configurée, InfluxDB est une base de données optimisée pour les séries temporelles :
from influxdb_client import InfluxDBClient, Point
client = InfluxDBClient(
url="http://localhost:8086",
token="mon_token", org="jardin")
write_api = client.write_api()
point = (Point("sol")
.tag("zone", "potager")
.field("humidite", 45.2)
.field("temperature", 22.1))
write_api.write(bucket="jardin", record=point)
Avantages d’InfluxDB :
# code/visualisation_humidite.py (extrait)
import sqlite3
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import datetime
conn = sqlite3.connect("jardin.db")
rows = conn.execute(
"SELECT timestamp, humidite FROM mesures "
"WHERE zone='potager' "
"ORDER BY timestamp DESC LIMIT 1008"
).fetchall()
conn.close()
temps = [datetime.fromisoformat(r[0]) for r in rows]
hum = [r[1] for r in rows]
Le fichier complet code/visualisation_humidite.py génère un graphique avec zones de confort, seuils d’arrosage et annotations des événements d’irrigation.
fig, axes = plt.subplots(3, 1, figsize=(12, 8),
sharex=True)
axes[0].plot(temps, humidite, "b-", label="Humidité (%)")
axes[0].axhspan(40, 70, alpha=0.1, color="green",
label="Zone optimale")
axes[1].plot(temps, temperature, "r-",
label="Température (°C)")
axes[2].bar(temps_arrosage, volumes, width=0.02,
color="cyan", label="Arrosage (L)")
import numpy as np
def stats_zone(conn, zone, jours=7):
rows = conn.execute(
"SELECT humidite, temperature FROM mesures "
"WHERE zone=? AND timestamp > datetime("
"'now', ?)", (zone, f"-{jours} days")
).fetchall()
h = np.array([r[0] for r in rows if r[0]])
t = np.array([r[1] for r in rows if r[1]])
return {
"humidite_moy": np.mean(h),
"humidite_std": np.std(h),
"temp_moy": np.mean(t),
"temp_min": np.min(t),
"temp_max": np.max(t),
}
Grafana est un outil open-source de visualisation qui se connecte directement à InfluxDB, SQLite (via plugin), ou d’autres sources.
# Sur Raspberry Pi ou serveur Linux
sudo apt install -y grafana
sudo systemctl enable grafana-server
sudo systemctl start grafana-server
# Interface web : http://localhost:3000
Un dashboard Grafana pour le jardin pourrait contenir :
| Panel | Type | Données |
|---|---|---|
| Humidité temps réel | Gauge | Dernière valeur par zone |
| Historique humidité | Time series | 7 derniers jours |
| Température sol/air | Time series | Superposition sol + air |
| Volume d’eau quotidien | Bar chart | Somme par jour |
| Batterie ESP32 | Gauge | Niveau en % |
| Alertes | Table | Événements anormaux |
| Statistiques | Stat | Min/Max/Moy de la semaine |
Grafana peut envoyer des alertes par email, Telegram ou webhook :
Avec un historique de plusieurs mois, on peut analyser les cycles :
# code/analyse_saisonniere.py (extrait)
import pandas as pd
df = pd.read_sql(
"SELECT * FROM mesures", conn,
parse_dates=["timestamp"])
df.set_index("timestamp", inplace=True)
# Moyenne journalière
quotidien = df.groupby("zone").resample("D").mean()
# Moyenne mobile sur 7 jours
quotidien["hum_lissee"] = (
quotidien["humidite"].rolling(7).mean())
L’analyse des corrélations entre variables révèle les dynamiques du jardin :
r(humidité, température) : corrélation négative attendue (plus chaud → sol plus sec)
r(humidité, arrosage) : corrélation positive avec un décalage temporel (lag)
# Matrice de corrélation
corr = df[["humidite", "temperature",
"volume_eau"]].corr()
print(corr)
Une mesure est anomale si elle s’écarte de plus de 3 écarts-types de la moyenne mobile :
Anomalie si : x - μ_mobile > 3σ_mobile
def detecter_anomalies(serie, fenetre=48):
"""Détection par z-score sur moyenne mobile"""
mu = serie.rolling(fenetre).mean()
sigma = serie.rolling(fenetre).std()
z = (serie - mu) / sigma
return z.abs() > 3
df.to_csv("export_jardin_2026.csv", index=True)
# code/rapport_hebdo.py (extrait)
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.image import MIMEImage
def envoyer_rapport(destinataire, graphique_path):
msg = MIMEMultipart()
msg["Subject"] = "Rapport Jardin - Semaine"
msg["To"] = destinataire
with open(graphique_path, "rb") as f:
img = MIMEImage(f.read())
msg.attach(img)
# ... envoi via SMTP
Le serveur Flask peut aussi exposer les données pour une application mobile ou un site web :
@app.route("/api/derniere/<zone>")
def derniere_mesure(zone):
conn = sqlite3.connect("jardin.db")
row = conn.execute(
"SELECT * FROM mesures "
"WHERE zone=? ORDER BY timestamp DESC "
"LIMIT 1", (zone,)
).fetchone()
conn.close()
return jsonify({
"timestamp": row[0], "zone": row[1],
"humidite": row[2], "temperature": row[3]
})
| Couche | Technologie | Complexité |
|---|---|---|
| Stockage local | Fichier CSV sur flash/SD | ★☆☆ |
| Transmission | HTTP POST ou MQTT | ★★☆ |
| Base de données | SQLite (simple) ou InfluxDB (avancé) | ★★☆ / ★★★ |
| Visualisation | Matplotlib (scripts) ou Grafana (dashboard) | ★★☆ / ★★★ |
| Analyse | Pandas + NumPy | ★★☆ |
| Alertes | Grafana ou script Python | ★★☆ |
| API | Flask / FastAPI | ★★☆ |
💡 Point pratique : Pour démarrer, un simple fichier CSV sur la flash de l’ESP32 + un script Matplotlib suffit. Ajoutez MQTT + InfluxDB + Grafana quand vous avez besoin de surveillance temps réel ou d’historique long terme.
| ← Précédent : Irrigation Automatique | Suivant : Projet Intégré — Le Jardin Connecté → |