Tests · Embarqué

Tests Électronique

Tester du code Python sur systèmes embarqués et capteurs : mocker GPIO, I2C, UART et stratégies de test sans matériel.

🔌 Raspberry Pi · Capteurs · Protocoles

Tester du code
embarqué sans matériel

Quand ton code parle à des capteurs, des GPIO ou des bus I2C, tu ne peux pas dépendre du matériel pour tes tests. Cette page montre comment mocker toute la couche électronique pour des tests fiables, rapides et portables.

Contexte

Pourquoi mocker le matériel ?

Les tests de code embarqué posent des défis uniques : le matériel n'est pas toujours disponible, les capteurs peuvent retourner des valeurs imprévisibles, et certains scénarios (capteur en panne, valeur hors plage) sont difficiles à reproduire physiquement.

🔒 Pas de Raspberry Pi requis

Les tests s'exécutent sur n'importe quelle machine — CI/CD, laptop Windows, macOS. Pas besoin du hardware.

💥 Simuler les pannes

Tester ce qui se passe quand un capteur retourne des valeurs erronées, se déconnecte, ou sort de sa plage de mesure.

⚡ Tests instantanés

Pas d'attente pour stabiliser un capteur de température ou pour une mesure ultrasonique. Les tests sont déterministes et rapides.

🔁 Reproductibilité

Les mocks retournent exactement les mêmes valeurs à chaque run. Plus de tests flottants dus aux variations de l'environnement.

⚠️

Stratégie : Séparer toujours la logique métier (calculs, décisions, alertes) des appels matériels (lire GPIO, écrire sur I2C). Les tests unitaires couvrent la logique ; les tests d'intégration, eux, nécessitent le vrai matériel.

01 — GPIO

Mocker RPi.GPIO

RPi.GPIO n'existe que sur Raspberry Pi. Pour tester sur d'autres machines, on le remplace entièrement par un mock. C'est le cas de mock le plus courant en électronique Python.

Architecture recommandée — couche d'abstraction

🏗️

Bonne pratique : Ne jamais appeler RPi.GPIO directement dans la logique métier. Créer une classe GpioDriver qui encapsule tous les accès matériels. Les tests mockent cette classe, pas GPIO directement.

gpio_driver.py — couche d'abstraction
# gpio_driver.py — encapsule tous les accès GPIO
try:
    import RPi.GPIO as GPIO
except ImportError:
    GPIO = None  # Mode développement (pas de RPi)

class GpioDriver:
    BCM = 11   # constantes GPIO
    OUT = 0
    IN  = 1
    HIGH = True
    LOW  = False

    def __init__(self):
        if GPIO:
            GPIO.setmode(GPIO.BCM)
            GPIO.setwarnings(False)

    def configurer_pin(self, pin: int, mode: int) -> None:
        if GPIO: GPIO.setup(pin, mode)

    def ecrire(self, pin: int, etat: bool) -> None:
        if GPIO: GPIO.output(pin, etat)

    def lire(self, pin: int) -> bool:
        if GPIO: return GPIO.input(pin)
        raise RuntimeError("GPIO non disponible")

    def cleanup(self) -> None:
        if GPIO: GPIO.cleanup()

# systeme_led.py — logique métier (testable)
class SystemeLED:
    def __init__(self, gpio: GpioDriver, pin_led: int):
        self.gpio = gpio
        self.pin = pin_led
        gpio.configurer_pin(pin_led, GpioDriver.OUT)

    def allumer(self) -> None:
        self.gpio.ecrire(self.pin, GpioDriver.HIGH)

    def eteindre(self) -> None:
        self.gpio.ecrire(self.pin, GpioDriver.LOW)

    def basculer(self) -> None:
        etat_actuel = self.gpio.lire(self.pin)
        self.gpio.ecrire(self.pin, not etat_actuel)

Tester avec MagicMock

test_gpio.py
import pytest
from unittest.mock import MagicMock, patch, call

# ── Fixture : GPIO mocké ───────────────────────────
@pytest.fixture
def gpio_mock():
    """GPIO driver complètement simulé."""
    mock = MagicMock(spec=GpioDriver)
    mock.lire.return_value = False   # LOW par défaut
    return mock

@pytest.fixture
def led(gpio_mock):
    return SystemeLED(gpio_mock, pin_led=17)

# ── Tests ─────────────────────────────────────────
def test_init_configure_pin(gpio_mock, led):
    # La pin doit être configurée en sortie à l'init
    gpio_mock.configurer_pin.assert_called_once_with(17, GpioDriver.OUT)

def test_allumer_envoie_high(gpio_mock, led):
    led.allumer()
    gpio_mock.ecrire.assert_called_once_with(17, GpioDriver.HIGH)

def test_eteindre_envoie_low(gpio_mock, led):
    led.eteindre()
    gpio_mock.ecrire.assert_called_once_with(17, GpioDriver.LOW)

def test_basculer_depuis_low(gpio_mock, led):
    gpio_mock.lire.return_value = False  # LED éteinte
    led.basculer()
    gpio_mock.ecrire.assert_called_once_with(17, True)  # doit s'allumer

def test_basculer_depuis_high(gpio_mock, led):
    gpio_mock.lire.return_value = True   # LED allumée
    led.basculer()
    gpio_mock.ecrire.assert_called_once_with(17, False) # doit s'éteindre

# ── Mocker RPi.GPIO directement (alternative) ─────
def test_rpi_gpio_direct():
    # Pour du code qui importe RPi.GPIO directement
    with patch("systeme_led.GPIO") as mock_gpio:
        mock_gpio.BCM = 11
        mock_gpio.OUT = 0
        mock_gpio.input.return_value = 0  # LOW
        # … instancier et tester
02 — Capteurs

Mocker les capteurs physiques

Température, humidité, distance ultrasonique — chaque capteur a ses caractéristiques, sa plage valide et ses modes de défaillance. Les mocks permettent de simuler tous ces scénarios sans matériel.

Capteur DHT22 — Température & Humidité

capteur_dht.py + test_capteur_dht.py
# ── capteur_dht.py ────────────────────────────────
import adafruit_dht
import board

class LecteurDHT22:
    """Lecture température et humidité via DHT22."""
    TEMP_MIN, TEMP_MAX = -40.0, 80.0   # plage capteur
    HUM_MIN, HUM_MAX   = 0.0, 100.0

    def __init__(self, pin=board.D4):
        self._dht = adafruit_dht.DHT22(pin)

    def lire(self) -> dict:
        temp = self._dht.temperature
        hum  = self._dht.humidity
        if temp is None or hum is None:
            raise IOError("Lecture DHT22 échouée (None)")
        return {"temperature": temp, "humidite": hum}

    def est_valide(self, lecture: dict) -> bool:
        t = lecture["temperature"]
        h = lecture["humidite"]
        return (self.TEMP_MIN <= t <= self.TEMP_MAX and
                self.HUM_MIN  <= h <= self.HUM_MAX)

# ── test_capteur_dht.py ───────────────────────────
from unittest.mock import patch, MagicMock, PropertyMock
import pytest

@pytest.fixture
def mock_dht():
    with patch("capteur_dht.adafruit_dht.DHT22") as mock_cls:
        instance = mock_cls.return_value
        # DHT22 expose temperature et humidity comme propriétés
        type(instance).temperature = PropertyMock(return_value=22.5)
        type(instance).humidity    = PropertyMock(return_value=55.0)
        yield instance

def test_lecture_normale(mock_dht):
    capteur = LecteurDHT22()
    lecture = capteur.lire()
    assert lecture["temperature"] == 22.5
    assert lecture["humidite"] == 55.0

def test_lecture_echouee_none(mock_dht):
    # Simuler une lecture ratée (common avec DHT22)
    type(mock_dht).temperature = PropertyMock(return_value=None)
    capteur = LecteurDHT22()
    with pytest.raises(IOError, match="None"):
        capteur.lire()

@pytest.mark.parametrize("temp, hum, valide", [
    (22.5,  55.0,  True),   # normal
    (-40.0, 0.0,   True),   # limite basse exacte
    (80.0,  100.0, True),   # limite haute exacte
    (-40.1, 50.0,  False),  # temp trop basse
    (80.1,  50.0,  False),  # temp trop haute
    (25.0,  -0.1,  False),  # humidité négative
    (25.0,  100.1, False),  # humidité > 100%
])
def test_validation_limites(mock_dht, temp, hum, valide):
    type(mock_dht).temperature = PropertyMock(return_value=temp)
    type(mock_dht).humidity    = PropertyMock(return_value=hum)
    capteur = LecteurDHT22()
    lecture = {"temperature": temp, "humidite": hum}
    assert capteur.est_valide(lecture) is valide

Capteur HC-SR04 — Distance Ultrasonique

capteur_distance.py + test_distance.py
# ── capteur_distance.py ───────────────────────────
import time
import from gpiozero import DistanceSensor

class CapteurUltrason:
    DISTANCE_MAX_CM = 400.0
    DISTANCE_MIN_CM = 2.0
    SEUIL_ALERTE_CM = 30.0

    def __init__(self, trigger_pin=23, echo_pin=24):
        self._sensor = DistanceSensor(echo=echo_pin, trigger=trigger_pin,
                                        max_distance=4)

    def distance_cm(self) -> float:
        # DistanceSensor.distance retourne des mètres
        return self._sensor.distance * 100

    def objet_detecte(self) -> bool:
        return self.distance_cm() <= self.SEUIL_ALERTE_CM

    def mesure_moyenne(self, n: int = 5) -> float:
        mesures = [self.distance_cm() for _ in range(n)]
        return sum(mesures) / n

# ── test_distance.py ──────────────────────────────
from unittest.mock import patch, MagicMock, PropertyMock

@pytest.fixture
def mock_sensor():
    with patch("capteur_distance.DistanceSensor") as mock_cls:
        instance = mock_cls.return_value
        # distance est une propriété → PropertyMock
        type(instance).distance = PropertyMock(return_value=0.25)  # 25cm
        yield instance

def test_distance_cm_conversion(mock_sensor):
    capteur = CapteurUltrason()
    assert capteur.distance_cm() == 25.0  # 0.25m → 25cm

def test_objet_detecte_sous_seuil(mock_sensor):
    type(mock_sensor).distance = PropertyMock(return_value=0.20)  # 20cm
    capteur = CapteurUltrason()
    assert capteur.objet_detecte() is True   # 20 < 30 → détecté

def test_objet_non_detecte_sur_seuil(mock_sensor):
    type(mock_sensor).distance = PropertyMock(return_value=0.30)  # 30cm
    capteur = CapteurUltrason()
    assert capteur.objet_detecte() is True   # 30 <= 30 → détecté

def test_mesure_moyenne_5_lectures(mock_sensor):
    # Simule 5 lectures différentes
    valeurs = [0.23, 0.25, 0.24, 0.26, 0.22]
    type(mock_sensor).distance = PropertyMock(side_effect=valeurs)
    capteur = CapteurUltrason()
    moyenne = capteur.mesure_moyenne(n=5)
    assert moyenne == pytest.approx(24.0, abs=0.01)  # (23+25+24+26+22)/5
03 — Protocoles

Mocker I2C, SPI & UART

I2C, SPI et UART sont les bus de communication de l'électronique embarquée. Chaque protocole a sa bibliothèque Python et ses particularités à mocker.

🔵 I2C smbus2 / adafruit

Bus série 2 fils (SDA + SCL). Adresse 7 bits par esclave. Couramment utilisé pour BMP280 (pression), SSD1306 (écran OLED), MPU6050 (gyroscope).

🟣 SPI spidev / gpiozero

Bus 4 fils (MOSI, MISO, CLK, CS). Plus rapide qu'I2C. Utilisé pour MCP3008 (ADC), MAX31855 (thermocouple), écrans TFT.

I2C — Capteur BMP280 (Pression & Altitude)

capteur_bmp280.py + test_bmp280.py
# ── capteur_bmp280.py ─────────────────────────────
import smbus2, bmp280

class CapteurBMP280:
    ADRESSE_I2C = 0x76
    PRESSION_MER_HPA = 1013.25

    def __init__(self, bus_num=1):
        self._bus = smbus2.SMBus(bus_num)
        self._bmp = bmp280.BMP280(i2c_dev=self._bus)

    def lire(self) -> dict:
        return {
            "temperature": self._bmp.get_temperature(),
            "pression":    self._bmp.get_pressure(),
        }

    def altitude(self) -> float:
        # Formule barométrique simplifiée
        p = self._bmp.get_pressure()
        return 44330 * (1.0 - (p / self.PRESSION_MER_HPA) ** (1 / 5.255))

# ── test_bmp280.py ────────────────────────────────
from unittest.mock import patch, MagicMock

@pytest.fixture
def mock_bmp():
    with patch("capteur_bmp280.smbus2.SMBus"), \
         patch("capteur_bmp280.bmp280.BMP280") as mock_cls:
        instance = mock_cls.return_value
        instance.get_temperature.return_value = 23.4
        instance.get_pressure.return_value    = 1013.25
        yield instance

def test_lecture_bmp280(mock_bmp):
    capteur = CapteurBMP280()
    lecture = capteur.lire()
    assert lecture["temperature"] == 23.4
    assert lecture["pression"] == 1013.25

def test_altitude_au_niveau_mer(mock_bmp):
    mock_bmp.get_pressure.return_value = 1013.25  # pression mer
    capteur = CapteurBMP280()
    assert capteur.altitude() == pytest.approx(0.0, abs=0.1)

@pytest.mark.parametrize("pression_hpa, altitude_attendue_m", [
    (1013.25, 0),       # niveau de la mer
    (954.6,  500),      # 500m d'altitude
    (899.3,  1000),     # 1000m
    (795.0,  2000),     # 2000m — Alpes
])
def test_altitude_calcul(mock_bmp, pression_hpa, altitude_attendue_m):
    mock_bmp.get_pressure.return_value = pression_hpa
    capteur = CapteurBMP280()
    assert capteur.altitude() == pytest.approx(altitude_attendue_m, abs=5)

I2C bas niveau — smbus2 directement

test_i2c_bas_niveau.py
# ── Mocker des lectures de registres I2C bruts ────
# Code : bus.read_i2c_block_data(addr, reg, 6)
# Retourne 6 octets bruts depuis un registre

from unittest.mock import patch, MagicMock

def test_lecture_registres_bruts():
    with patch("smbus2.SMBus") as mock_bus_cls:
        mock_bus = mock_bus_cls.return_value.__enter__.return_value

        # Simuler 6 octets de données brutes (réponse capteur)
        mock_bus.read_i2c_block_data.return_value = [
            0x59, 0xD8,   # temp MSB/LSB
            0x4E, 0x20,   # pression MSB/LSB
            0x00, 0x00    # réservé
        ]

        with smbus2.SMBus(1) as bus:
            data = bus.read_i2c_block_data(0x76, 0xF7, 6)

        assert data[0] == 0x59
        mock_bus.read_i2c_block_data.assert_called_with(0x76, 0xF7, 6)

def test_ecriture_registre_config():
    with patch("smbus2.SMBus") as mock_bus_cls:
        mock_bus = mock_bus_cls.return_value

        configurer_capteur(0x76)  # écrit dans le registre 0xF5

        # Vérifier que la bonne config a été envoyée
        mock_bus.write_byte_data.assert_called_with(0x76, 0xF5, 0xA0)

SPI — ADC MCP3008

adc_spi.py + test_adc.py
# ── adc_spi.py ────────────────────────────────────
import spidev

class MCP3008:
    """ADC 10 bits SPI — 8 canaux, 0-1023"""
    RESOLUTION = 1023
    VREF = 3.3

    def __init__(self, bus=0, device=0):
        self._spi = spidev.SpiDev()
        self._spi.open(bus, device)
        self._spi.max_speed_hz = 1_350_000

    def lire_canal(self, canal: int) -> int:
        if not 0 <= canal <= 7:
            raise ValueError(f"Canal {canal} invalide (0-7)")
        cmd = [0x01, (0x08 | canal) << 4, 0x00]
        reponse = self._spi.xfer2(cmd)
        return ((reponse[1] & 0x03) << 8) | reponse[2]

    def tension_v(self, canal: int) -> float:
        brut = self.lire_canal(canal)
        return (brut / self.RESOLUTION) * self.VREF

# ── test_adc.py ───────────────────────────────────
@pytest.fixture
def mock_spi():
    with patch("adc_spi.spidev.SpiDev") as mock_cls:
        instance = mock_cls.return_value
        # xfer2 simule la réponse SPI : valeur brute 512 (≈1.65V)
        instance.xfer2.return_value = [0x00, 0x02, 0x00]  # → 512
        yield instance

def test_lecture_canal_0(mock_spi):
    adc = MCP3008()
    valeur = adc.lire_canal(0)
    assert valeur == 512
    mock_spi.xfer2.assert_called_once()

def test_tension_milieu_plage(mock_spi):
    adc = MCP3008()
    tension = adc.tension_v(0)
    assert tension == pytest.approx(1.65, abs=0.01)  # 512/1023 * 3.3

def test_canal_invalide(mock_spi):
    adc = MCP3008()
    with pytest.raises(ValueError, match="invalide"):
        adc.lire_canal(8)   # canal 8 n'existe pas sur MCP3008

UART / Serial — pyserial

uart_gps.py + test_uart.py
# ── uart_gps.py ───────────────────────────────────
import serial

class LecteurGPS:
    """Lit les trames NMEA depuis un GPS sur UART."""

    def __init__(self, port="/dev/ttyAMA0", baudrate=9600):
        self._serial = serial.Serial(port, baudrate, timeout=1)

    def lire_trame(self) -> str:
        ligne = self._serial.readline().decode("utf-8").strip()
        if not ligne.startswith("$"):
            raise ValueError(f"Trame NMEA invalide : {ligne!r}")
        return ligne

    def parser_gprmc(self, trame: str) -> dict:
        if not trame.startswith("$GPRMC"):
            raise ValueError("Pas une trame GPRMC")
        champs = trame.split(",")
        return {
            "heure":    champs[1],
            "validite": champs[2],  # "A"=actif, "V"=invalide
            "latitude": float(champs[3]) if champs[3] else None,
            "longitude": float(champs[5]) if champs[5] else None,
        }

# ── test_uart.py ──────────────────────────────────
@pytest.fixture
def mock_serial():
    with patch("uart_gps.serial.Serial") as mock_cls:
        instance = mock_cls.return_value
        yield instance

def test_lire_trame_valide(mock_serial):
    trame = "$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"
    mock_serial.readline.return_value = (trame + "\r\n").encode()

    gps = LecteurGPS()
    resultat = gps.lire_trame()
    assert resultat.startswith("$GPRMC")

def test_trame_corrompue(mock_serial):
    mock_serial.readline.return_value = b"CORRUPTED_DATA\r\n"
    gps = LecteurGPS()
    with pytest.raises(ValueError, match="invalide"):
        gps.lire_trame()

def test_parser_gprmc_complet(mock_serial):
    trame = "$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"
    gps = LecteurGPS()
    parsed = gps.parser_gprmc(trame)
    assert parsed["validite"] == "A"
    assert parsed["latitude"] == pytest.approx(4807.038)

def test_connexion_serie_perdue(mock_serial):
    # Simuler une déconnexion UART
    mock_serial.readline.side_effect = serial.SerialException("Port déconnecté")
    gps = LecteurGPS()
    with pytest.raises(serial.SerialException):
        gps.lire_trame()
04 — Tests aux limites

Tester les valeurs limites des capteurs

Les capteurs ont des plages de fonctionnement précises. Tester aux limites exactes, en-dessous et au-dessus — c'est là que les bugs se cachent. Cette technique s'appelle Boundary Value Analysis (BVA).

Plages de référence — capteurs courants

CapteurMesureMinMaxRésolutionBibliothèque
DHT22Température-40°C+80°C0.1°Cadafruit_dht
DHT22Humidité0%100%0.1%adafruit_dht
BMP280Pression300 hPa1100 hPa0.18 Pasmbus2 / bmp280
BMP280Température-40°C+85°C0.01°Csmbus2 / bmp280
HC-SR04Distance2 cm400 cm0.3 cmgpiozero
MCP3008Tension (ADC)0 VVREF (3.3V)10 bitsspidev
MPU6050Accélération-16g+16g16 bitssmbus2

Stratégie BVA — Boundary Value Analysis

📐

Pour chaque limite : tester la valeur juste en-dessous (min - ε), exactement à la limite (min), et juste au-dessus (min + ε). Idem pour la limite haute. C'est la technique la plus efficace pour trouver les off-by-one errors.

test_limites_complet.py
import pytest
from unittest.mock import MagicMock

class SystemeAlerte:
    """Déclenche une alerte si température hors plage sécurisée."""
    TEMP_SECURITE_MIN = 10.0
    TEMP_SECURITE_MAX = 35.0
    TAUX_HUMIDITE_MAX = 85.0

    def __init__(self, capteur, notificateur):
        self.capteur = capteur
        self.notificateur = notificateur

    def verifier(self) -> str:
        data = self.capteur.lire()
        t, h = data["temperature"], data["humidite"]

        if t < self.TEMP_SECURITE_MIN:
            self.notificateur.alerter(f"FROID: {t}°C")
            return "alerte_froid"
        if t > self.TEMP_SECURITE_MAX:
            self.notificateur.alerter(f"CHAUD: {t}°C")
            return "alerte_chaud"
        if h > self.TAUX_HUMIDITE_MAX:
            self.notificateur.alerter(f"HUMIDE: {h}%")
            return "alerte_humidite"
        return "ok"

# ── Fixtures réutilisables ────────────────────────
@pytest.fixture
def capteur_mock():
    return MagicMock()

@pytest.fixture
def notif_mock():
    return MagicMock()

@pytest.fixture
def systeme(capteur_mock, notif_mock):
    return SystemeAlerte(capteur_mock, notif_mock)

# ════════════════════════════════════════════════
# BVA — Température (limites 10°C et 35°C)
# ════════════════════════════════════════════════
@pytest.mark.parametrize("temp, hum, resultat_attendu, alerte_attendue", [
    # ── Zone froide ──────────────────────────────
    (9.9,  50.0, "alerte_froid", True),   # juste sous le min → alerte
    (10.0, 50.0, "ok",           False),  # exactement le min → OK
    (10.1, 50.0, "ok",           False),  # juste au-dessus → OK
    # ── Zone normale ─────────────────────────────
    (22.5, 50.0, "ok",           False),  # valeur typique
    # ── Zone chaude ──────────────────────────────
    (34.9, 50.0, "ok",           False),  # juste sous le max → OK
    (35.0, 50.0, "ok",           False),  # exactement le max → OK
    (35.1, 50.0, "alerte_chaud", True),   # dépasse → alerte
    # ── Humidité ─────────────────────────────────
    (22.5, 85.0, "ok",             False), # exactement le max → OK
    (22.5, 85.1, "alerte_humidite", True),  # dépasse → alerte
    # ── Valeurs extrêmes capteur ─────────────────
    (-40.0, 0.0,  "alerte_froid", True),   # min physique DHT22
    (80.0,  100.0,"alerte_chaud", True),   # max physique DHT22
])
def test_bva_temperature(systeme, capteur_mock, notif_mock,
                         temp, hum, resultat_attendu, alerte_attendue):
    capteur_mock.lire.return_value = {"temperature": temp, "humidite": hum}

    resultat = systeme.verifier()

    assert resultat == resultat_attendu
    if alerte_attendue:
        notif_mock.alerter.assert_called_once()
    else:
        notif_mock.alerter.assert_not_called()

# ════════════════════════════════════════════════
# Tester les pannes matérielles
# ════════════════════════════════════════════════
def test_capteur_deconnecte(systeme, capteur_mock):
    capteur_mock.lire.side_effect = IOError("Capteur déconnecté")
    with pytest.raises(IOError):
        systeme.verifier()

def test_capteur_instable(systeme, capteur_mock, notif_mock):
    # Simule un capteur qui échoue puis réussit
    capteur_mock.lire.side_effect = [
        IOError("Timeout"),
        {"temperature": 22.5, "humidite": 50.0}
    ]
    with pytest.raises(IOError):
        systeme.verifier()   # 1er appel → IOError
    resultat = systeme.verifier()  # 2ème appel → OK
    assert resultat == "ok"

def test_lecture_parasite_nan(systeme, capteur_mock):
    # Certains capteurs retournent NaN ou inf en cas de perturbation
    import math
    capteur_mock.lire.return_value = {
        "temperature": float("nan"),
        "humidite": 50.0
    }
    # NaN comparisons retournent toujours False
    # Le système doit gérer ce cas
    resultat = systeme.verifier()
    assert resultat != "ok"  # NaN ne doit pas passer comme "normal"

Tests de robustesse — scénarios avancés

test_robustesse.py
# ── Drift / dérive lente du capteur ───────────────
def test_derive_capteur(capteur_mock):
    """Simule une dérive progressive (capteur qui vieillit)."""
    # Température qui monte de 0.5°C à chaque lecture
    valeurs = [{"temperature": 20.0 + i*0.5, "humidite": 50.0}
               for i in range(10)]
    capteur_mock.lire.side_effect = valeurs

    lectures = [capteur_mock.lire() for _ in range(10)]
    temps = [l["temperature"] for l in lectures]
    assert temps[0] == 20.0
    assert temps[-1] == 24.5

# ── Bruit aléatoire contrôlé ──────────────────────
def test_lissage_avec_bruit(capteur_mock):
    """Teste que la moyenne sur 10 mesures bruitées converge."""
    import random; random.seed(42)
    valeur_reelle = 23.0
    bruit = [{"temperature": valeur_reelle + random.uniform(-0.5, 0.5),
              "humidite": 50.0} for _ in range(10)]
    capteur_mock.lire.side_effect = bruit

    moyenne = sum(capteur_mock.lire()["temperature"] for _ in range(10)) / 10
    assert moyenne == pytest.approx(valeur_reelle, abs=0.3)

# ── Test de timeout réseau / I2C ──────────────────
def test_retry_apres_timeout(capteur_mock):
    """Le système doit réessayer 3 fois avant d'abandonner."""
    capteur_mock.lire.side_effect = [
        TimeoutError(), TimeoutError(),
        {"temperature": 22.0, "humidite": 55.0}
    ]
    def lire_avec_retry(capteur, max_retries=3):
        for tentative in range(max_retries):
            try:
                return capteur.lire()
            except TimeoutError:
                if tentative == max_retries - 1: raise
        return None

    resultat = lire_avec_retry(capteur_mock)
    assert resultat["temperature"] == 22.0
    assert capteur_mock.lire.call_count == 3  # 2 échecs + 1 succès