Projet en cours de développement — Version 2.1 — Avril 2026  |  PCB non encore fabriqué — Validation métrologique à venir

Colorimètre / Luxmètre Professionnel OPT4048

Réf : ECO-COLORIM-01  |  Version 2.1 — Avril 2026  |  En cours de développement

Instrument de mesure colorimétrique et photométrique basé sur le capteur OPT4048 de Texas Instruments. Mesure tristimulus XYZ directe, calcul CCT, coordonnées CIE 1931, plage dynamique 26 bits. ESP32-WROOM-32 + OLED 128×64 + microSD + CH340C (USB-UART auto-reset) + WiFi. PCB 70×50 mm 2 couches.

26 bits
Plage dynamique
2,15 mlux
Résolution minimum
144 klux
Mesure maximum
±5%
Précision typique
>10h
Autonomie batterie
~31€
Coût total estimé

Vue d'ensemble du projet

⚠️ Statut : En cours de développement

Le circuit est conçu et documenté. La fabrication PCB et la validation métrologique sont à venir. Le firmware est fonctionnel sur breadboard.

Pourquoi ce projet ?

Ce projet combine précision professionnelle et accessibilité DIY pour créer un outil polyvalent de caractérisation de la lumière. Le capteur OPT4048 de Texas Instruments offre un matching exact des courbes CIE 1931 grâce à ses 4 canaux XYZ+W — une mesure tristimulus directe, sans conversion approximative depuis un espace RGB.

La version 2.1 ajoute le bridge USB-UART CH340C avec circuit auto-reset à transistors croisés (BC847), rendant la programmation USB native sans manipulations manuelles des strapping pins.

Fonctionnalités principales

  • Colorimétrie CIE 1931 : coordonnées chromatiques (x,y) et (u',v'), espace CIE Lab, ΔE entre échantillons.
  • CCT double méthode : McCamy (rapide) et Hernández-Andrés (précise, Newton-Raphson sur locus de Planck).
  • Photométrie : éclairement de 2,15 mlux à 144 klux — plage dynamique 67 millions:1.
  • Interface web WiFi : point d'accès intégré, visualisation temps réel, streaming 500 ms, aperçu sRGB.
  • Datalogging microSD : CSV horodaté — XYZ, W, lux, x, y, u', v', CCT×2, Lab, classification source.
  • Programmation USB native : CH340C + auto-reset — flash depuis Arduino IDE / PlatformIO sans intervention manuelle.

Capteur OPT4048 — Pourquoi ce choix ?

Canaux XYZ natifs — Matching CIE 1931 exact

Contrairement aux capteurs RGBW comme l'OPT4060, l'OPT4048 dispose de 4 canaux dont les réponses spectrales correspondent directement aux courbes de l'observateur CIE 1931 (X, Y, Z + W). Le calcul de CCT et des coordonnées chromatiques est donc direct, sans approximation par conversion RGB→XYZ.

📡Caractéristiques capteur

  • PackageSOT-5X3 (WSON-8)
  • CanauxX, Y, Z + W (CIE 1931)
  • Résolution2,15 mlux min.
  • Plage2,15 mlux – 144 klux
  • Dynamique26 bits (67M:1)
  • Temps intégration600 µs – 800 ms (12 modes)
  • InterfaceI²C (4 adresses 0x44–0x47)
  • Conso. active24 µA actif / 2 µA veille
  • Rejection IRExcellente (850/940 nm)
  • Prix~4,50 € (Mouser/Digi-Key)

🔬OPT4048 vs OPT4060

  • OPT4048 — CanauxXYZ + W (tristimulus)
  • OPT4048 — Matching CIEExcellent (direct)
  • OPT4048 — CCTDirect XYZ→CCT
  • OPT4048 — UsageColorimétrie scientifique
  • OPT4060 — CanauxRGBW (approx.)
  • OPT4060 — CCTIndirect RGB→XYZ→CCT
  • OPT4060 — UsageDétection couleur simple

Règle : OPT4048 si colorimétrie scientifique, CCT précis, mesure photopique. OPT4060 si détection couleur RGB simple ou budget contraint.

Circuit OPT4048 — Connexions

              +3.3V
                |
              [100nF C2]
                |
  ┌─────────────┴────────────────────────┐
  │           OPT4048                    │
  │  (Package SOT-5X3 / WSON-8)         │
  │                                      │
  │  1. VDD  ──────────────── +3.3V     │
  │  2. GND  ──────────────── GND       │
  │  3. SDA  ──[4,7kΩ R1]── +3.3V      ├──── MCU SDA (GPIO21)
  │  4. SCL  ──[4,7kΩ R2]── +3.3V      ├──── MCU SCL (GPIO22)
  │  5. INT  ──[10kΩ R3]─── +3.3V      ├──── MCU GPIO33
  │  6. ADDR ──────────────── GND       │  ← adresse 0x44
  │  7. NC                              │
  │  8. GND  ──────────────── GND       │
  └─────────────────────────────────────┘

  Config adresses I²C (pin ADDR) :
    ADDR → GND  →  0x44  ← retenue
    ADDR → VDD  →  0x45
    ADDR → SDA  →  0x46
    ADDR → SCL  →  0x47

Architecture Matérielle

ESP32-WROOM-32 + OPT4048 + OLED + microSD + CH340C — PCB 70×50 mm 2 couches

Toute la chaîne de mesure tient sur un PCB compact de 70×50 mm. Le connecteur USB-C assure à la fois l'alimentation/charge et la programmation via le bridge CH340C. L'OPT4048 est positionné en bord de carte avec dégagement mécanique 5 mm.

📌Affectation GPIO ESP32

  • GPIO 21I²C SDA (OPT4048 + OLED)
  • GPIO 22I²C SCL (OPT4048 + OLED)
  • GPIO 33INT OPT4048 (data ready)
  • GPIO 18SPI CLK microSD
  • GPIO 19SPI MISO microSD
  • GPIO 23SPI MOSI microSD
  • GPIO 4SD CS (pull-up 10kΩ)
  • GPIO 25BTN1 MODE
  • GPIO 13BTN2 SELECT (pull-up 10kΩ)
  • GPIO 14BTN3 HOLD (pull-up 10kΩ)
  • GPIO 34ADC batterie (input-only)
  • GPIO 2LED status bleue
  • GPIO 26LED charge rouge
  • GPIO 1 / 3U0TXD/RXD → CH340C + J3
  • GPIO 0BOOT → Q1 auto-reset

Alimentation

  • EntréeUSB-C J1 — 5 V
  • ChargeurTP4056 — 1 A
  • Batterie18650 Li-ion 3,0–4,2 V
  • Rail 3,3 VAMS1117-3.3 — 800 mA
  • ProtectionDiode Schottky SS34
  • Conso. max~500 mA (WiFi actif)
  • Conso. veille~30 mA (light sleep)
  • Autonomie>10 h (2500 mAh, sans WiFi)

🗂️Bus I²C & adresses

  • OPT40480x44 (ADDR → GND)
  • OLED SSD13060x3C (SA0 → GND)
  • Pull-ups SDA/SCL4,7 kΩ (R1, R2) côté OPT
  • Pull-up INT10 kΩ (R3) → GPIO 33
  • Adresses dispo.0x44–0x47 (jusqu'à 4 capteurs)

Note : GPIO 19 exclusivement MISO microSD. L'INT OPT4048 a été déplacé sur GPIO 33 pour éviter le conflit initial.

🖥️PCB 2 couches

  • Dimensions70 × 50 mm
  • CouchesTop (signaux) / Bottom (GND)
  • Cuivre35 µm (1 oz)
  • Viaperçage 0,3 mm / anneau 0,6 mm
  • FinitionHASL sans plomb, FR4 1,6 mm
  • Zone USBCH340C + connecteur + Q1/Q2 groupés en bord PCB
  • FabricantJLCPCB / PCBWay (~2 €/u lot 5)

USB-UART CH340C + Circuit Auto-Reset — Nouveauté v2.1

Programmation USB native — sans manipulation manuelle

La v2.1 intègre le bridge CH340C (sans quartz externe — régulateur 3,3 V interne) et un circuit auto-reset à transistors croisés NPN (BC847). L'ESP32 entre automatiquement en bootloader lors du flash depuis Arduino IDE, PlatformIO ou esptool — exactement comme une carte DevKit classique.

Schéma bloc — Alimentation + USB

USB-C (J1)
   │
   ├── D+ / D─ ────► CH340C (USB-UART) ────► ESP32 UART0 (GPIO1/GPIO3)
   │                       │
   │              [Auto-Reset Q1/Q2] ────► EN + GPIO0
   │
   └── VBUS 5V ──► D1 (SS34) ──► VSYS
                                    │
                       ┌────────────┴──────────────┐
                       │                           │
                  TP4056 (chargeur)          AMS1117-3.3
                       │                           │
                  18650 Li-ion               Rail 3,3 V ──► ESP32 / OPT4048 / OLED / SD / CH340C(V3)

Circuit auto-reset — Transistors croisés BC847

  RTS# ──[10kΩ R9]──► Base Q1(BC847)   Collecteur Q1 ──► GPIO0 (BOOT)
                        Émetteur Q1 → GND              │
  DTR# ──────────────► Collecteur Q1                [10kΩ R11] → +3.3V

  DTR# ──[10kΩ R10]──► Base Q2(BC847)   Collecteur Q2 ──► EN (RESET)
                        Émetteur Q2 → GND              │
  RTS# ──────────────► Collecteur Q2                [10kΩ] → +3.3V + [100nF C6] → GND
① Reset : DTR=LOW, RTS=HIGH → Q2 conduit → EN=LOW (ESP32 reset)
② Boot : DTR=HIGH, RTS=LOW → Q1 conduit → GPIO0=LOW + EN relâché → ESP32 démarre en bootloader
③ Normal : DTR=HIGH, RTS=HIGH → Q1 et Q2 bloqués → fonctionnement normal

🔌CH340C — Caractéristiques

  • PackageSOP-16
  • InterfaceUSB Full Speed (12 Mbit/s)
  • UART max2 Mbit/s
  • Quartz externeNon requis (oscillateur interne)
  • Régulateur V33,3 V interne — [100nF C7]
  • AlimentationVBUS 5 V + [100nF C8]
  • DriversWindows 10/11 natifs, Linux, macOS
  • Prix~0,30 € (LCSC)

⚙️Composants auto-reset

  • Q1, Q2BC847 NPN SOT-23 (~0,02 €)
  • R9, R1010 kΩ base Q1/Q2 (0402)
  • R1110 kΩ pull-up GPIO0 (0402)
  • C6100 nF filtre EN/RESET (0402)
  • Connexion DTRCH340C.DTR# → R10.1 + Q1.C
  • Connexion RTSCH340C.RTS# → R9.1 + Q2.C

Firmware ESP32 — Architecture logicielle

🧮

Colorimétrie CIE 1931

Calcul (x,y) et (u',v'), conversion CIE Lab. Distance ΔE (CIE76) pour comparaison d'échantillons. Seuil perception humaine ≈ 2,3 ΔE.

🌡️

CCT double méthode

McCamy (immédiat, ±2%), Hernández-Andrés avec itération Newton-Raphson sur locus de Planck. Plage 1 667–25 000 K.

📶

Interface Web WiFi

Point d'accès "Colorimeter" (192.168.4.1). Streaming 500 ms, aperçu sRGB calculé depuis XYZ, téléchargement log CSV.

💾

Datalogging microSD

CSV avec 16 colonnes : timestamp, lux, X, Y, Z, W, x, y, u', v', CCT×2 (McCamy+précis), Lab(L,a,b), classification source.

🎯

Calibration EEPROM

Facteur lux + gains XYZ sauvegardés en EEPROM (magic 0xCAFECAFE). Moyennage N=100 mesures. Commande interactive série 'c'.

🔬

Tests intégrés

Répétabilité (CV sur N=50), calibration écran D65, binning LED (Warm/Neutral/Cool/Daylight). Commandes série 'm', 't', 'b', 'v'.

Bibliothèques Arduino requises

SparkFun_OPT4048 Adafruit_SSD1306 Adafruit GFX Library SD (built-in) WiFi (built-in ESP32) EEPROM (built-in)

Classification automatique de la source lumineuse

🕯️ Bougie < 2 000 K 💡 Incandescent 2–3 000 K 🔆 Halogène 3–4 000 K ☀️ Fluor chaud 4–5 000 K 🌤️ Lumière jour 5–6 500 K ☁️ Ciel couvert 6,5–10 000 K 🌀 Ciel bleu > 10 000 K

Code Arduino — Firmware complet

💻 colorimetre_opt4048.ino v2.1 — Avril 2026 En cours de développement
Bibliothèques : SparkFun_OPT4048 Adafruit_SSD1306 Adafruit GFX SD / WiFi / EEPROM (built-in) | Board : esp32dev | Baud : 115200
/*
 * ============================================================
 *  Colorimètre / Luxmètre Professionnel OPT4048
 *  Version 2.1 — Avril 2026 — ECOPHOT
 * ============================================================
 *  Matériel :
 *    - ESP32-WROOM-32
 *    - Capteur OPT4048 (I²C 0x44)
 *    - OLED SSD1306 128×64 (I²C 0x3C)
 *    - MicroSD (SPI : CLK=18, MISO=19, MOSI=23, CS=4)
 *    - CH340C USB-UART (programmation auto-reset)
 *    - Batterie 18650 + TP4056 + AMS1117-3.3
 *
 *  Bibliothèques requises (Library Manager) :
 *    - SparkFun_OPT4048          (SparkFun Electronics)
 *    - Adafruit_SSD1306          (Adafruit)
 *    - Adafruit GFX Library      (Adafruit)
 *    - SD (built-in Arduino/ESP32)
 *
 *  Licence : MIT — ecophot.fr
 * ============================================================
 *
 *  Fonctionnalités :
 *    - Mesure tristimulus XYZ + W directe
 *    - Photométrie lux (2,15 mlux – 144 klux, 26 bits)
 *    - Coordonnées chromatiques (x,y) CIE 1931
 *    - CCT méthode McCamy (rapide)
 *    - CCT méthode Hernández-Andrés (précise, Newton-Raphson)
 *    - Conversion CIE Lab + calcul ΔE
 *    - Longueur d'onde dominante approximée
 *    - Classification automatique source lumineuse
 *    - Affichage OLED temps réel
 *    - Datalogging CSV sur microSD
 *    - Serveur web WiFi (point d'accès "Colorimeter")
 *    - Calibration avec sauvegarde EEPROM
 *    - Tests répétabilité / linéarité
 *
 *  GPIO assignments :
 *    GPIO 21 → I²C SDA (OPT4048 + OLED)
 *    GPIO 22 → I²C SCL (OPT4048 + OLED)
 *    GPIO 33 → INT OPT4048 (data ready, pull-up 10kΩ)
 *    GPIO 18 → SPI CLK microSD
 *    GPIO 19 → SPI MISO microSD
 *    GPIO 23 → SPI MOSI microSD
 *    GPIO  4 → SD CS (pull-up 10kΩ)
 *    GPIO 25 → BTN1 MODE
 *    GPIO 13 → BTN2 SELECT (pull-up 10kΩ)
 *    GPIO 14 → BTN3 HOLD (pull-up 10kΩ)
 *    GPIO 34 → ADC tension batterie (input-only)
 *    GPIO  2 → LED status bleue (strapping, LOW au boot)
 *    GPIO 26 → LED charge rouge
 *    GPIO  1 → U0TXD → CH340C RXD + J3 debug
 *    GPIO  3 → U0RXD → CH340C TXD + J3 debug
 *    GPIO  0 → BOOT (contrôlé par circuit auto-reset Q1/Q2)
 * ============================================================
 */

#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <WiFi.h>
#include <WebServer.h>
#include <EEPROM.h>
#include <math.h>

#include <SparkFun_OPT4048.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_GFX.h>

// ============================================================
//  Définitions matérielles
// ============================================================
#define SD_CS_PIN     4
#define OPT_INT_PIN  33
#define BTN1_MODE    25
#define BTN2_SELECT  13
#define BTN3_HOLD    14
#define LED_STATUS    2
#define LED_CHARGE   26
#define BAT_ADC_PIN  34

// OLED
#define SCREEN_WIDTH  128
#define SCREEN_HEIGHT  64
#define OLED_RESET     -1

// WiFi AP
#define WIFI_SSID     "Colorimeter"
#define WIFI_PASSWORD "color123"

// EEPROM
#define EEPROM_SIZE   64
#define CAL_ADDR       0   // Adresse calibration dans EEPROM

// ============================================================
//  Structures de données
// ============================================================
struct ColorMeasurement {
    float X, Y, Z, W;
    float lux;
    float x, y;
    float u_prime, v_prime;
    float CCT;
    float dominant_wavelength;
    float purity;
    uint32_t timestamp;
};

struct LabColor {
    float L;   // Luminosité (0–100)
    float a;   // Rouge↔Vert (−128 à +127)
    float b;   // Jaune↔Bleu (−128 à +127)
};

struct CalibrationData {
    float lux_factor;
    float X_gain, Y_gain, Z_gain;
    float X_offset, Y_offset, Z_offset;
    uint32_t magic;   // 0xCAFECAFE si données valides
};

// ============================================================
//  Variables globales
// ============================================================
OPT4048         colorSensor;
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
WebServer       server(80);
CalibrationData cal;
bool            sdReady    = false;
bool            wifiActive = false;

// ============================================================
//  Calibration — EEPROM
// ============================================================
void loadCalibration() {
    EEPROM.begin(EEPROM_SIZE);
    EEPROM.get(CAL_ADDR, cal);
    if (cal.magic != 0xCAFECAFE) {
        // Valeurs par défaut (pas de calibration)
        cal.lux_factor = 1.0f;
        cal.X_gain = cal.Y_gain = cal.Z_gain = 1.0f;
        cal.X_offset = cal.Y_offset = cal.Z_offset = 0.0f;
    }
}

void saveCalibration() {
    cal.magic = 0xCAFECAFE;
    EEPROM.put(CAL_ADDR, cal);
    EEPROM.commit();
}

// ============================================================
//  Initialisation capteur OPT4048
// ============================================================
void initOPT4048() {
    Wire.begin(21, 22);  // SDA=GPIO21, SCL=GPIO22

    if (!colorSensor.begin(0x44)) {
        Serial.println("[OPT4048] ERREUR: capteur non détecté!");
        display.clearDisplay();
        display.setCursor(0, 0);
        display.println("OPT4048 absent!");
        display.display();
        while (1) { delay(500); digitalWrite(LED_STATUS, !digitalRead(LED_STATUS)); }
    }

    colorSensor.setConversionTime(OPT4048_CONVERSION_TIME_100MS);
    colorSensor.setRange(OPT4048_RANGE_AUTO);
    colorSensor.setOperatingMode(OPT4048_CONTINUOUS_MODE);

    // Interruption data ready sur GPIO33
    colorSensor.setInterruptConfig(
        OPT4048_CHANNEL_Y,
        OPT4048_INT_THRESHOLD_HIGH,
        1000
    );

    Serial.println("[OPT4048] Initialisé OK — adresse 0x44");
}

// ============================================================
//  Acquisition mesure brute
// ============================================================
ColorMeasurement acquireMeasurement() {
    ColorMeasurement data;

    data.X = (colorSensor.getX() + cal.X_offset) * cal.X_gain;
    data.Y = (colorSensor.getY() + cal.Y_offset) * cal.Y_gain;
    data.Z = (colorSensor.getZ() + cal.Z_offset) * cal.Z_gain;
    data.W = colorSensor.getW();
    data.lux = colorSensor.getLux() * cal.lux_factor;
    data.timestamp = millis();

    return data;
}

// ============================================================
//  Calculs colorimétriques
// ============================================================

// Coordonnées chromatiques xy (CIE 1931)
void calculateChromaticity(ColorMeasurement &data) {
    float sum = data.X + data.Y + data.Z;
    if (sum > 0.001f) {
        data.x = data.X / sum;
        data.y = data.Y / sum;
    } else {
        data.x = 0.3333f;
        data.y = 0.3333f;
    }

    // Coordonnées u'v' (CIE 1976)
    float denom = -2.0f * data.x + 12.0f * data.y + 3.0f;
    if (fabs(denom) > 0.0001f) {
        data.u_prime = 4.0f * data.x / denom;
        data.v_prime = 9.0f * data.y / denom;
    } else {
        data.u_prime = 0.2105f;
        data.v_prime = 0.4737f;
    }
}

// Locus de Planck (chromacité du corps noir à température T)
void plankianLocus(float T, float &x, float &y) {
    if (T >= 1667.0f && T <= 4000.0f) {
        x = -0.2661239e9f / (T*T*T)
            - 0.2343589e6f / (T*T)
            + 0.8776956e3f / T
            + 0.179910f;
    } else if (T > 4000.0f && T <= 25000.0f) {
        x = -3.0258469e9f / (T*T*T)
            + 2.1070379e6f / (T*T)
            + 0.2226347e3f / T
            + 0.240390f;
    } else {
        x = 0.3333f;
        y = 0.3333f;
        return;
    }

    if (T >= 1667.0f && T <= 2222.0f) {
        y = -1.1063814f*x*x*x - 1.34811020f*x*x + 2.18555832f*x - 0.20219683f;
    } else if (T > 2222.0f && T <= 4000.0f) {
        y = -0.9549476f*x*x*x - 1.37418593f*x*x + 2.09137015f*x - 0.16748867f;
    } else {
        y =  3.0817580f*x*x*x - 5.87338670f*x*x + 3.75112997f*x - 0.37001483f;
    }
}

// CCT — méthode McCamy (rapide, ±2% sur 2 000–12 000 K)
float calculateCCT_McCamy(float x, float y) {
    float n = (x - 0.3320f) / (y - 0.1858f);
    return -449.0f * n*n*n
           + 3525.0f * n*n
           - 6823.3f * n
           + 5520.33f;
}

// CCT — méthode Hernández-Andrés avec itération Newton-Raphson (précise)
float calculateCCT_Accurate(float x, float y) {
    const float xe = 0.3366f, ye = 0.1735f;
    const float A0 = -949.86315f, A1 = 6253.80338f, t1 = 0.92159f;
    const float A2 = 28.70599f,   t2 = 0.20039f;
    const float A3 = 0.00004f,    t3 = 0.07125f;

    float n = (x - xe) / (y - ye);
    float CCT = A0 + A1*expf(-n/t1) + A2*expf(-n/t2) + A3*expf(-n/t3);

    // Itération Newton-Raphson (max 5 passes)
    for (int i = 0; i < 5; i++) {
        float xc, yc, xc1, yc1, xc2, yc2;
        plankianLocus(CCT,      xc,  yc);
        plankianLocus(CCT + 10, xc1, yc1);
        plankianLocus(CCT - 10, xc2, yc2);

        float dx = x - xc, dy = y - yc;
        if (sqrtf(dx*dx + dy*dy) < 0.0001f) break;

        float dxdT = (xc1 - xc2) / 20.0f;
        float dydT = (yc1 - yc2) / 20.0f;
        float denom = dxdT*dxdT + dydT*dydT;
        if (denom > 0) CCT += (dx*dxdT + dy*dydT) / denom;
    }
    return CCT;
}

// Conversion XYZ → CIE Lab (illuminant D65)
LabColor XYZ_to_Lab(float X, float Y, float Z) {
    const float Xn = 0.95047f, Yn = 1.00000f, Zn = 1.08883f;
    const float delta = 6.0f / 29.0f;
    const float delta3 = delta * delta * delta;

    auto f = [&](float t) -> float {
        return (t > delta3) ? cbrtf(t) : (t / (3.0f * delta * delta) + 4.0f / 29.0f);
    };

    float fx = f(X / Xn);
    float fy = f(Y / Yn);
    float fz = f(Z / Zn);

    LabColor lab;
    lab.L =  116.0f * fy - 16.0f;
    lab.a =  500.0f * (fx - fy);
    lab.b =  200.0f * (fy - fz);
    return lab;
}

// Distance colorimétrique ΔE (CIE76)
float deltaE(LabColor lab1, LabColor lab2) {
    float dL = lab1.L - lab2.L;
    float da = lab1.a - lab2.a;
    float db = lab1.b - lab2.b;
    return sqrtf(dL*dL + da*da + db*db);
}

// Longueur d'onde dominante approchée (nm)
float calculateDominantWavelength(float x, float y) {
    const float xe = 0.3333f, ye = 0.3333f;
    float theta = atan2f(y - ye, x - xe);
    float lambda;
    if (theta > 0 && theta < 1.0f)
        lambda = 380.0f + (700.0f - 380.0f) * (theta / 1.0f);
    else if (theta < 0)
        lambda = 490.0f + fabsf(theta) * 50.0f;
    else
        lambda = 570.0f + (theta - 1.0f) * 50.0f;
    return lambda;
}

// Classification de la source lumineuse
String classifyLightSource(float CCT) {
    if (CCT < 2000)       return "Bougie";
    else if (CCT < 3000)  return "Incandescent";
    else if (CCT < 4000)  return "Halogene";
    else if (CCT < 5000)  return "Fluor chaud";
    else if (CCT < 6500)  return "Lumiere jour";
    else if (CCT < 10000) return "Ciel couvert";
    else                  return "Ciel bleu";
}

// ============================================================
//  Affichage OLED
// ============================================================
void displayMeasurement(ColorMeasurement &data) {
    display.clearDisplay();
    display.setTextSize(1);
    display.setTextColor(SSD1306_WHITE);

    display.setCursor(0, 0);
    display.print("Lux: ");
    display.print(data.lux, 1);

    display.setCursor(0, 10);
    display.print("CCT: ");
    display.print((int)data.CCT);
    display.print(" K");

    display.setCursor(0, 20);
    display.print("x:");
    display.print(data.x, 4);
    display.setCursor(64, 20);
    display.print("y:");
    display.print(data.y, 4);

    display.setCursor(0, 30);
    display.print("X:");
    display.print((int)data.X);
    display.setCursor(42, 30);
    display.print("Y:");
    display.print((int)data.Y);
    display.setCursor(84, 30);
    display.print("Z:");
    display.print((int)data.Z);

    display.setCursor(0, 40);
    display.print(classifyLightSource(data.CCT));

    display.setCursor(0, 52);
    display.print("t:");
    display.print(data.timestamp / 1000);
    display.print("s");

    display.display();
}

// ============================================================
//  Datalogging microSD
// ============================================================
void initSDCard() {
    SPI.begin(18, 19, 23, SD_CS_PIN);
    if (!SD.begin(SD_CS_PIN)) {
        Serial.println("[SD] Erreur initialisation");
        sdReady = false;
        return;
    }
    sdReady = true;

    // Créer l'en-tête CSV si le fichier n'existe pas
    if (!SD.exists("/colorlog.csv")) {
        File f = SD.open("/colorlog.csv", FILE_WRITE);
        if (f) {
            f.println("timestamp_ms,lux,X,Y,Z,W,x,y,u,v,CCT_McCamy,CCT_Precise,Lab_L,Lab_a,Lab_b,source");
            f.close();
        }
    }
    Serial.println("[SD] Carte OK");
}

void logToSD(ColorMeasurement &data) {
    if (!sdReady) return;
    File f = SD.open("/colorlog.csv", FILE_APPEND);
    if (!f) return;

    LabColor lab = XYZ_to_Lab(data.X, data.Y, data.Z);
    float cct_acc = calculateCCT_Accurate(data.x, data.y);

    f.print(data.timestamp);         f.print(",");
    f.print(data.lux, 4);            f.print(",");
    f.print(data.X, 4);              f.print(",");
    f.print(data.Y, 4);              f.print(",");
    f.print(data.Z, 4);              f.print(",");
    f.print(data.W, 4);              f.print(",");
    f.print(data.x, 5);              f.print(",");
    f.print(data.y, 5);              f.print(",");
    f.print(data.u_prime, 5);        f.print(",");
    f.print(data.v_prime, 5);        f.print(",");
    f.print(data.CCT, 1);            f.print(",");
    f.print(cct_acc, 1);             f.print(",");
    f.print(lab.L, 2);               f.print(",");
    f.print(lab.a, 2);               f.print(",");
    f.print(lab.b, 2);               f.print(",");
    f.println(classifyLightSource(data.CCT));

    f.close();
}

// ============================================================
//  Serveur Web WiFi
// ============================================================
ColorMeasurement lastMeasure;

void handleRoot() {
    String html = R"rawhtml(<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Colorimetre OPT4048</title>
<style>
 body{font-family:Arial,sans-serif;max-width:800px;margin:30px auto;padding:0 16px;background:#f5f5f5}
 h1{color:#1a73e8;text-align:center}
 .card{background:#fff;border-radius:12px;padding:20px;margin:16px 0;box-shadow:0 2px 8px rgba(0,0,0,.1)}
 .val{font-size:2rem;font-weight:bold;color:#1a73e8}
 .label{font-size:.85rem;color:#666;margin-bottom:4px}
 .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
 #colorBox{width:100%;height:100px;border-radius:10px;border:1px solid #ddd;margin:12px 0;transition:background .5s}
 button{padding:12px 28px;font-size:1rem;cursor:pointer;background:#1a73e8;color:#fff;border:none;border-radius:8px;margin:4px}
 button:hover{background:#1557b0}
 .stream-on{background:#e53935 !important}
 .source{font-size:1.1rem;font-weight:bold;color:#333;text-align:center;padding:8px}
 .status{font-size:.8rem;color:#aaa;text-align:center}
</style>
</head>
<body>
<h1>Colorimetre Professionnel — OPT4048</h1>
<div style="text-align:center">
 <button onclick="measure()">Mesurer</button>
 <button id="btnStream" onclick="toggleStream()">Stream (500ms)</button>
</div>
<div id="colorBox"></div>
<div class="card source" id="source">--</div>
<div class="card">
 <div class="grid">
  <div><div class="label">Lux</div><div class="val" id="lux">--</div></div>
  <div><div class="label">CCT</div><div class="val" id="cct">-- K</div></div>
  <div><div class="label">x</div><div class="val" id="cx">--</div></div>
  <div><div class="label">y</div><div class="val" id="cy">--</div></div>
 </div>
</div>
<div class="card">
 <div class="grid">
  <div><div class="label">X</div><div class="val" id="X">--</div></div>
  <div><div class="label">Y</div><div class="val" id="Y">--</div></div>
  <div><div class="label">Z</div><div class="val" id="Z">--</div></div>
  <div><div class="label">W</div><div class="val" id="W">--</div></div>
 </div>
</div>
<div class="card">
 <div class="grid">
  <div><div class="label">u'</div><div class="val" id="u">--</div></div>
  <div><div class="label">v'</div><div class="val" id="v">--</div></div>
 </div>
</div>
<div class="status" id="status">Prêt</div>
<script>
function measure(){
 document.getElementById('status').textContent='Mesure en cours...';
 fetch('/measure').then(r=>r.json()).then(d=>{
  document.getElementById('lux').textContent=d.lux.toFixed(2);
  document.getElementById('cct').textContent=Math.round(d.CCT)+' K';
  document.getElementById('cx').textContent=d.x.toFixed(4);
  document.getElementById('cy').textContent=d.y.toFixed(4);
  document.getElementById('X').textContent=d.X.toFixed(2);
  document.getElementById('Y').textContent=d.Y.toFixed(2);
  document.getElementById('Z').textContent=d.Z.toFixed(2);
  document.getElementById('W').textContent=d.W.toFixed(2);
  document.getElementById('u').textContent=d.u.toFixed(4);
  document.getElementById('v').textContent=d.v.toFixed(4);
  document.getElementById('source').textContent=d.source;
  let r=3.2406*d.X-1.5372*d.Y-0.4986*d.Z;
  let g=-0.9689*d.X+1.8758*d.Y+0.0415*d.Z;
  let b=0.0557*d.X-0.2040*d.Y+1.0570*d.Z;
  r=Math.min(255,Math.round(Math.pow(Math.max(0,r),1/2.2)*255));
  g=Math.min(255,Math.round(Math.pow(Math.max(0,g),1/2.2)*255));
  b=Math.min(255,Math.round(Math.pow(Math.max(0,b),1/2.2)*255));
  document.getElementById('colorBox').style.background='rgb('+r+','+g+','+b+')';
  document.getElementById('status').textContent='Mesure OK — '+new Date().toLocaleTimeString();
 }).catch(e=>document.getElementById('status').textContent='Erreur: '+e);
}
let streaming=false,iv;
function toggleStream(){
 streaming=!streaming;
 const btn=document.getElementById('btnStream');
 if(streaming){iv=setInterval(measure,500);btn.textContent='Arrêter';btn.classList.add('stream-on');}
 else{clearInterval(iv);btn.textContent='Stream (500ms)';btn.classList.remove('stream-on');}
}
</script>
</body>
</html>)rawhtml";

    server.send(200, "text/html", html);
}

void handleMeasure() {
    ColorMeasurement data = acquireMeasurement();
    calculateChromaticity(data);
    data.CCT = calculateCCT_McCamy(data.x, data.y);
    data.dominant_wavelength = calculateDominantWavelength(data.x, data.y);
    lastMeasure = data;

    String json = "{";
    json += "\"lux\":"  + String(data.lux, 3)      + ",";
    json += "\"X\":"    + String(data.X, 3)         + ",";
    json += "\"Y\":"    + String(data.Y, 3)         + ",";
    json += "\"Z\":"    + String(data.Z, 3)         + ",";
    json += "\"W\":"    + String(data.W, 3)         + ",";
    json += "\"x\":"    + String(data.x, 5)         + ",";
    json += "\"y\":"    + String(data.y, 5)         + ",";
    json += "\"u\":"    + String(data.u_prime, 5)   + ",";
    json += "\"v\":"    + String(data.v_prime, 5)   + ",";
    json += "\"CCT\":"  + String(data.CCT, 1)       + ",";
    json += "\"source\":\"" + classifyLightSource(data.CCT) + "\"";
    json += "}";

    server.send(200, "application/json", json);
    logToSD(data);
}

void handleLog() {
    if (!sdReady || !SD.exists("/colorlog.csv")) {
        server.send(404, "text/plain", "Pas de log disponible");
        return;
    }
    File f = SD.open("/colorlog.csv", FILE_READ);
    server.streamFile(f, "text/csv");
    f.close();
}

void setupWebServer() {
    WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
    server.on("/",       handleRoot);
    server.on("/measure",handleMeasure);
    server.on("/log.csv",handleLog);
    server.begin();
    wifiActive = true;
    Serial.print("[WiFi] AP: ");
    Serial.print(WIFI_SSID);
    Serial.print(" — IP: ");
    Serial.println(WiFi.softAPIP());
}

// ============================================================
//  Calibration interactive (via port série)
// ============================================================
float readFloatSerial() {
    while (!Serial.available()) delay(10);
    return Serial.parseFloat();
}

void performCalibration() {
    Serial.println("\n=== CALIBRATION OPT4048 ===");
    Serial.println("Placer sous source de référence calibrée...");
    delay(5000);

    float Xs = 0, Ys = 0, Zs = 0;
    const int N = 100;
    for (int i = 0; i < N; i++) {
        ColorMeasurement d = acquireMeasurement();
        Xs += d.X; Ys += d.Y; Zs += d.Z;
        delay(10);
    }
    Xs /= N; Ys /= N; Zs /= N;

    Serial.print("Entrer lux référence: ");
    float lux_ref = readFloatSerial();
    cal.lux_factor = lux_ref / (Ys > 0 ? Ys : 1.0f);

    Serial.print("\nEntrer CCT référence (K): ");
    float cct_ref = readFloatSerial();
    (void)cct_ref;  // utilisé pour ajustement colorimétrique futur

    saveCalibration();
    Serial.println("\n[CAL] Calibration sauvegardée en EEPROM.");
}

// ============================================================
//  Tests de validation
// ============================================================
void testRepeatability() {
    const int N = 50;
    float values[N];
    Serial.println("[TEST] Répétabilité — N=50 mesures lux");

    for (int i = 0; i < N; i++) {
        ColorMeasurement d = acquireMeasurement();
        calculateChromaticity(d);
        d.CCT = calculateCCT_McCamy(d.x, d.y);
        values[i] = d.lux;
        delay(100);
    }

    float mean = 0;
    for (int i = 0; i < N; i++) mean += values[i];
    mean /= N;

    float variance = 0;
    for (int i = 0; i < N; i++) variance += (values[i] - mean) * (values[i] - mean);
    float std_dev = sqrtf(variance / N);
    float cv = (mean > 0) ? (std_dev / mean * 100.0f) : 0;

    Serial.printf("[TEST] Moyenne=%.2f lux  Ecart-type=%.4f  CV=%.3f%%\n",
                  mean, std_dev, cv);
}

// ============================================================
//  Application — Calibration écran (D65)
// ============================================================
void calibrateMonitor() {
    Serial.println("[MONITOR] Calibration — Mode D65");
    Serial.println("  Afficher un blanc 100% sur l'écran...");
    delay(3000);

    ColorMeasurement white = acquireMeasurement();
    calculateChromaticity(white);

    const float x_target = 0.3127f;
    const float y_target = 0.3290f;
    float dx = x_target - white.x;
    float dy = y_target - white.y;

    Serial.printf("[MONITOR] Blanc mesuré : x=%.4f  y=%.4f\n", white.x, white.y);
    Serial.printf("[MONITOR] Écart D65    : dx=%.4f  dy=%.4f\n", dx, dy);

    if      (dx >  0.01f) Serial.println("[MONITOR] → Augmenter gain ROUGE");
    else if (dx < -0.01f) Serial.println("[MONITOR] → Réduire gain ROUGE");
    if      (dy >  0.01f) Serial.println("[MONITOR] → Augmenter gain VERT");
    else if (dy < -0.01f) Serial.println("[MONITOR] → Réduire gain VERT");
}

// ============================================================
//  Binning LED
// ============================================================
enum LEDBin { WARM_WHITE, NEUTRAL_WHITE, COOL_WHITE, DAYLIGHT, OUT_OF_SPEC };

LEDBin classifyLED(float CCT) {
    if (CCT >= 2700 && CCT <= 3000) return WARM_WHITE;
    if (CCT >= 4000 && CCT <= 4500) return NEUTRAL_WHITE;
    if (CCT >= 5000 && CCT <= 6500) return COOL_WHITE;
    if (CCT >  6500)                return DAYLIGHT;
    return OUT_OF_SPEC;
}

String ledBinName(LEDBin bin) {
    switch (bin) {
        case WARM_WHITE:    return "Warm White (2700-3000K)";
        case NEUTRAL_WHITE: return "Neutral White (4000-4500K)";
        case COOL_WHITE:    return "Cool White (5000-6500K)";
        case DAYLIGHT:      return "Daylight (6500K+)";
        default:            return "Hors spec";
    }
}

// ============================================================
//  Lecture tension batterie
// ============================================================
float readBattery() {
    // Pont diviseur 100kΩ/100kΩ → Vbat/2 sur GPIO34
    // ADC ESP32 12 bits, Vref≈3.3V (non linéaire, calibration recommandée)
    int raw = analogRead(BAT_ADC_PIN);
    float v = (raw / 4095.0f) * 3.3f * 2.0f;
    return v;
}

// ============================================================
//  Setup
// ============================================================
void setup() {
    Serial.begin(115200);
    Serial.println("\n=== Colorimetre OPT4048 v2.1 — ECOPHOT ===");

    // GPIO
    pinMode(LED_STATUS,  OUTPUT);
    pinMode(LED_CHARGE,  OUTPUT);
    pinMode(BTN1_MODE,   INPUT_PULLUP);
    pinMode(BTN2_SELECT, INPUT_PULLUP);
    pinMode(BTN3_HOLD,   INPUT_PULLUP);
    digitalWrite(LED_STATUS, LOW);

    // EEPROM & calibration
    loadCalibration();

    // OLED
    if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
        Serial.println("[OLED] ERREUR initialisation!");
    } else {
        display.clearDisplay();
        display.setTextSize(1);
        display.setTextColor(SSD1306_WHITE);
        display.setCursor(0, 0);
        display.println("OPT4048 v2.1");
        display.println("ECOPHOT");
        display.println("Initialisation...");
        display.display();
    }

    // OPT4048
    initOPT4048();
    digitalWrite(LED_STATUS, HIGH);

    // MicroSD
    initSDCard();

    // WiFi + serveur web
    setupWebServer();

    display.clearDisplay();
    display.setCursor(0, 0);
    display.println("Pret!");
    display.print("WiFi: ");
    display.println(WIFI_SSID);
    display.print("IP: ");
    display.println(WiFi.softAPIP());
    display.display();

    Serial.println("[INIT] Démarrage complet. Prêt.");
}

// ============================================================
//  Loop principal
// ============================================================
void loop() {
    server.handleClient();

    static uint32_t lastMeasMs = 0;
    static uint32_t lastDispMs = 0;

    // Mesure toutes les 500 ms
    if (millis() - lastMeasMs > 500) {
        lastMeasMs = millis();

        lastMeasure = acquireMeasurement();
        calculateChromaticity(lastMeasure);
        lastMeasure.CCT = calculateCCT_McCamy(lastMeasure.x, lastMeasure.y);
        lastMeasure.dominant_wavelength = calculateDominantWavelength(lastMeasure.x, lastMeasure.y);
    }

    // Affichage OLED toutes les 300 ms
    if (millis() - lastDispMs > 300) {
        lastDispMs = millis();
        displayMeasurement(lastMeasure);
    }

    // Bouton MODE (GPIO25) — log manuel + Serial
    if (digitalRead(BTN1_MODE) == LOW) {
        delay(50);
        if (digitalRead(BTN1_MODE) == LOW) {
            logToSD(lastMeasure);
            LabColor lab = XYZ_to_Lab(lastMeasure.X, lastMeasure.Y, lastMeasure.Z);
            float cct_acc = calculateCCT_Accurate(lastMeasure.x, lastMeasure.y);
            Serial.printf("[LOG] lux=%.2f  CCT_mc=%.0f K  CCT_acc=%.0f K  x=%.4f  y=%.4f  "
                          "X=%.2f Y=%.2f Z=%.2f  Lab(%.1f,%.1f,%.1f)  src=%s\n",
                          lastMeasure.lux, lastMeasure.CCT, cct_acc,
                          lastMeasure.x, lastMeasure.y,
                          lastMeasure.X, lastMeasure.Y, lastMeasure.Z,
                          lab.L, lab.a, lab.b,
                          classifyLightSource(lastMeasure.CCT).c_str());
            while (digitalRead(BTN1_MODE) == LOW) delay(10); // attendre relâche
        }
    }

    // Bouton SELECT (GPIO13) — test répétabilité sur appui long (>2s)
    if (digitalRead(BTN2_SELECT) == LOW) {
        uint32_t t0 = millis();
        while (digitalRead(BTN2_SELECT) == LOW) delay(10);
        if (millis() - t0 > 2000) testRepeatability();
    }

    // Commandes série interactive
    if (Serial.available()) {
        char c = Serial.read();
        if (c == 'c' || c == 'C') performCalibration();
        if (c == 'm' || c == 'M') calibrateMonitor();
        if (c == 't' || c == 'T') testRepeatability();
        if (c == 'b' || c == 'B') {
            // Binning LED sur mesure courante
            LEDBin bin = classifyLED(lastMeasure.CCT);
            Serial.print("[BIN] ");
            Serial.println(ledBinName(bin));
        }
        if (c == 'v' || c == 'V') {
            Serial.printf("[BAT] Vbat=%.2f V\n", readBattery());
        }
        if (c == '?') {
            Serial.println("Commandes: c=Calibrer  m=MonitorD65  t=TestRep  b=Binning  v=Vbat");
        }
    }
}

            

Commandes série interactives (115200 baud)

c / C — Lancer procédure de calibration
m / M — Calibration écran D65
t / T — Test répétabilité N=50
b / B — Binning LED (Warm/Cool/…)
v / V — Lire tension batterie
? — Aide commandes

BTN1 (GPIO25) : log manuel + affichage série  |  BTN2 (GPIO13) appui long >2 s : test répétabilité

Applications Pratiques

🖥️Calibration d'écrans

Mesure du point blanc (D65 cible : x=0,3127, y=0,3290). Guidance automatique R/G/B. Commande 'm' en série.

💡Test & binning LED

Classification Warm White (2700–3000K) / Neutral (4000–4500K) / Cool White (5000–6500K) / Daylight. Commande 'b'.

🏢Ambiance lumineuse

Cartographie lux/CCT en bureaux, serres, studios. Datalogging continu microSD. Vérification EN 12464.

🎨Comparaison couleurs

ΔE CIE76 entre deux échantillons. Seuil perception humaine ≈ 2,3 ΔE. Peintures, tissus, plastiques.

🌱Horticulture

Mesure spectre lumineux LED de culture. Suivi ratio B/R, intensité PAR approximée, journalisation automatique.

📸Photographie

Mesure objective CCT des sources studio. Équilibrage des blancs précis. Comparaison flashs et lumières continues.

Bill of Materials v2.1 — Budget estimé ~31 €

Réf. Composant Description Qté Package Prix
U1OPT4048DTSRCapteur colorimétrique XYZ+W1WSON-8~4,50 €
U2ESP32-WROOM-32Module WiFi/BT + MCU 240 MHz1Module SMD~3,50 €
U3AMS1117-3.3Régulateur LDO 3,3 V / 800 mA1SOT-223~0,15 €
U4TP4056Chargeur Li-ion 1 A1SOP-8~0,20 €
U5SSD1306 moduleOLED 128×64 I²C1Module 4 pins~3,00 €
U6 NEWCH340CBridge USB-UART 3,3 V (sans quartz)1SOP-16~0,30 €
D1SS34Diode Schottky protection VBUS1SMA (DO-214AC)~0,10 €
D2, D3LED 0805Indicateurs charge / status20805~0,05 €
J1USB-C femelleAlimentation + programmation1SMD 16 pins~0,50 €
J2JST-SH 4pConnecteur OLED I²C1Pas 1,0 mm~0,30 €
J3Pin Header 4pUART debug (2,54 mm)1THT~0,10 €
J4MicroSD TFSocket push-push avec détection19+2 pins~0,60 €
J5BH-18650Support batterie1THT/SMD~0,80 €
SW1–3Bouton 6×6 mmMODE / SELECT / HOLD3THT 4 pins~0,15 €
SW4Bouton RESETReset ESP321THT 4 pins~0,05 €
Q1, Q2 NEWBC847NPN auto-reset (transistors croisés)2SOT-23~0,04 €
R1, R24,7 kΩ ±1%Pull-up I²C SDA/SCL20402~0,02 €
R3, R4, R5, R6, R9, R10, R1110 kΩPull-ups divers + bases Q1/Q270402~0,07 €
R7, R868 ΩSérie LEDs20402~0,02 €
C1, C4, C510 µF / 10 VDécouplage ESP32 + AMS111730805 X5R~0,15 €
C2, C3, C6, C7, C8100 nFDécouplage divers + CH340C50402 X7R~0,05 €
PCB 70×50 mm 2LHASL sans plomb, FR4 1,6 mm (lot 5)5~2,00 €/u
Total PCB + composants (hors batterie et boîtier) ~23–26 €
Total complet (avec batterie 18650 + boîtier impression 3D) ~31 €

NEW = composants ajoutés en v2.1 par rapport à v2.0

Évolutions Futures

🔧Hardware

  • Array 3×3 capteurs — cartographie spatiale
  • LED de référence interne calibrée (auto-cal)
  • Intégration sphère intégrante
  • USB-C PD + alimentation stabilisée
  • RTC pour horodatage précis

💻Software

  • Calcul CRI (Color Rendering Index) approximé
  • Analyse temporelle — flicker, modulation
  • Machine learning classification sources
  • Compensation température capteur
  • Application smartphone Bluetooth

🔬Applications avancées

  • Spectrophotomètre 11 bandes (+ AS7341)
  • Chromamètre vidéo (monitoring temps réel)
  • Photobiomodulation — doses thérapeutiques
  • Agriculture de précision — NDVI approximatif

Projet open source — MIT License

Firmware Arduino, schémas KiCad 7/8 et documentation disponibles.
Intéressé par une collaboration, un test ou une commande groupée de PCB ?

Nous Contacter →

🔓 Open source (MIT)  •  🧪 Validation métrologique à venir  •  📐 PCB KiCad 7/8  •  ⚙️ CH340C auto-reset v2.1