Cam OV3660 avec ESP32-S3 sur Add on MJPEG camera de Home Assistant

Codé avec ESP-IDF 5.5.1 sur platformio.
ci-dessous le main.c si ça vous intéresse je pourrai rajouter les autres fichiers nécessaire au projet avec PlateformIO
Il reste encore des choses à faire mais toutes les fonctions marchent parfaitement(stream fluide et de qualité mais poussé au max, détection de mouvement et capture, enregistrement sur carte SD), je pense ajouter une page html au serveur pour pouvoir modifier dynamiquement les paramètres de la caméra et je n’ai pas encore testé si l’AP fonctionnait bien.

Résumé & Guide de Démarrage

:pushpin: Présentation du Projet

Ce projet est un firmware haute performance pour ESP32-S3 avec caméra OV3660, conçu spécifiquement pour s’intégrer parfaitement à Home Assistant. Il transforme le module en une caméra IP intelligente capable de streaming fluide, de détection de mouvement et d’enregistrement autonome sur carte SD.

:rocket: Fonctionnalités Clés

  • Streaming HD Fluide : Vidéo MJPEG 720p (1280x720) à 15 FPS avec qualité adaptative.
  • Architecture Dual-Core : Capture vidéo isolée sur le Cœur 0, Gestion WiFi/HTTP sur le Cœur 1 pour une stabilité maximale.
  • Détection de Mouvement : Analyse d’image en temps réel avec pré-enregistrement (buffer circulaire) pour ne rien rater de l’action.
  • Enregistrement Autonome : Sauvegarde automatique des séquences vidéo (format AVI) sur carte SD lors d’une détection.
  • Intégration Home Assistant : Configuration YAML simple, découverte mDNS, et API REST pour les capteurs d’état.

:open_book: Mode d’Emploi Rapide

  1. Premier Démarrage : Si le WiFi n’est pas configuré, connectez-vous au réseau ESP32-CAM-SETUP et allez sur http://192.168.4.1/setup pour entrer vos identifiants.
  2. Accès Direct :
  • Flux Vidéo : http://<IP_CAM>/stream
  • Photo Instantanée : http://<IP_CAM>/snapshot
  • État Système (JSON) : http://<IP_CAM>/api/status

:house: Configuration Home Assistant (Interface UI)

L’intégration utilise désormais le module officiel MJPEG IP Camera et se configure directement via l’interface graphique (plus besoin d’éditer le fichier configuration.yaml).

  1. Allez dans Paramètres > Appareils et services.
  2. Cliquez sur le bouton + AJOUTER UNE INTÉGRATION.
  3. Recherchez et sélectionnez MJPEG IP Camera.
  4. Remplissez le formulaire avec les données suivantes :
  • MJPEG URL : http://<IP_CAM>/stream
  • Still Image URL : http://<IP_CAM>/snapshot
  • Username / Password : Laisser vide
  • Verify SSL : Décocher la case (False)
  1. Cliquez sur Soumettre pour terminer.

Note pour la détection de mouvement :
L’état du mouvement est disponible via l’API JSON sur http://<IP_CAM>/api/status (champ motion_detection).
Pour l’intégrer, vous pouvez utiliser une entrée REST ou un capteur binaire (nécessite encore souvent une configuration YAML avancée).

:bookmark_tabs: Documentation Technique : Caméra ESP32-S3 HD

Projet : Surveillance HD Optimisée & Détection de Mouvement
Date : 04 Décembre 2025
Version : Stable (Q18 / 15 FPS / Smart Stream)

:hammer_and_wrench: Vue d’ensemble Hardware

Composant Spécification Détails
Microcontrôleur ESP32-S3 Dual-Core 240MHz (Xtensa LX7)
Flash 16 MB Stockage Firmware, SPIFFS (Web) & NVS (WiFi)
PSRAM 8 MB Mémoire RAM Externe (Critique pour la HD et les Buffers)
Capteur OV3660 3MP, Interface DVP 8-bit
Stockage Carte SD Enregistrement vidéo (AVI Motion-JPEG)

:camera: Configuration Caméra (Optimisée)

Ces paramètres sont définis dans src/main.c (fonction init_camera).

Paramètre Valeur Actuelle Pourquoi ce choix ?
Résolution FRAMESIZE_HD (1280x720) Meilleur compromis détails / poids du fichier.
Qualité (Q) 18 Échelle 0-63 (0=Max). Q18 (~30KB) offre un excellent compromis détails/fluidité.
Fréquence XCLK 16 MHz Réduit de 20MHz à 16MHz pour supprimer les artefacts visuels (lignes vertes/bruit).
Framebuffers 32 Nombre d’images en tampon (PSRAM). Absorbe les latences WiFi sans perdre de frames.
Pixel Format PIXFORMAT_JPEG Compression matérielle indispensable pour le streaming vidéo.

:brain: Architecture Dual-Core (FreeRTOS)

Le système utilise le multitâche asymétrique pour garantir que la capture d’image n’est jamais bloquée par le réseau.

Caractéristique :high_voltage: Core 0 (Capture & Save) :globe_with_meridians: Core 1 (Réseau & Stream)
Tâches Principales camera_capture_task
task_save_motion_video httpd (Serveur Web)
Stack WiFi (LwIP)
Priorité Capture: Haute (24)
Save: Basse (1) Standard (Géré par l’OS)
Responsabilité Capture I2S → Queue
Écriture SD (Background) Lecture Queue → Envoi TCP/IP
Gestion Portail WiFi
Isolation Totale (Ne gère pas le WiFi) Gère toute la stack IP et les interruptions WiFi

:glowing_star: Nouveauté : Sauvegarde Non-Bloquante
L’enregistrement sur carte SD se fait désormais dans une tâche de fond sur le Core 0 avec une priorité basse. Cela permet de continuer à streamer et à capturer sans figer l’image pendant les 5-10 secondes d’écriture fichier.

:rocket: Fonctionnalités Avancées

1. Streaming Intelligent (« Smart Catch-up »)

  • Problème : Le WiFi peut avoir des micro-coupures, créant un retard (lag) qui s’accumule.
  • Solution : Si la file d’attente dépasse 4 images (~250ms de retard), le système purge automatiquement les vieilles images pour sauter directement au direct.
  • Résultat : Latence quasi-nulle (< 100ms) et fluidité maintenue.

2. Détection de Mouvement & Enregistrement

  • Pre-Record : Garde en permanence 5 secondes d’images en mémoire tampon (PSRAM).
  • Déclenchement : Analyse d’image en temps réel (différence de pixels).
  • Post-Record : Continue d’enregistrer 5 secondes après la fin du mouvement.
  • Résultat : La vidéo finale contient l’action avant qu’elle ne se produise.

3. Portail de Configuration WiFi

  • Si le WiFi configuré est introuvable, l’ESP32 crée un point d’accès : ESP32-CAM-SETUP.
  • Interface Web moderne accessible sur http://192.168.4.1/setup.
  • Permet de scanner les réseaux et de sauvegarder les identifiants en mémoire non-volatile (NVS).

:bar_chart: Performances & Statut

Métrique Valeur Observation
Framerate 15 FPS Limité volontairement pour éviter la saturation WiFi.
Poids Image ~25 - 35 KB Qualité élevée (Q18).
Latence < 100ms Ultra-réactif grâce au « Smart Catch-up ».
Stabilité :white_check_mark: Maximale Pas de crash, pas de freeze pendant la sauvegarde.
/*
 * =======================================================================================
 * PROJET : ESP32-S3 CAM - FIRMWARE OPTIMISÉ POUR HOME ASSISTANT
 * =======================================================================================
 * 
 * RÉSUMÉ DU PROJET :
 * Ce firmware transforme un module ESP32-S3 avec caméra OV3660 en une caméra IP haute
 * performance, spécifiquement optimisée pour l'intégration avec Home Assistant.
 * Il utilise une architecture double cœur pour séparer la capture vidéo (Core 0) de la
 * gestion réseau/HTTP (Core 1), garantissant un flux stable et une faible latence.
 *
 * FONCTIONNALITÉS PRINCIPALES :
 * 1. Streaming MJPEG Haute Performance : 720p (1280x720) @ 15 FPS, Qualité JPEG Q18.
 * 2. Architecture Dual-Core : Capture isolée sur Core 0, WiFi/HTTP sur Core 1.
 * 3. Détection de Mouvement : Analyse d'image en temps réel avec buffer circulaire.
 * 4. Enregistrement SD : Sauvegarde automatique des séquences de mouvement (AVI MJPEG).
 * 5. Mode Adaptatif : Ajuste le framerate (20fps en stream, 1fps en veille) pour économiser l'énergie.
 * 6. Intégration Home Assistant : Endpoints compatibles, mDNS, et configuration YAML fournie.
 * 7. Serveur Web Embarqué : Visualisation en direct, snapshots, et API JSON d'état.
 *
 * =======================================================================================
 * MODE D'EMPLOI & CONFIGURATION
 * =======================================================================================
 *
 * 1. PREMIER DÉMARRAGE (Provisioning WiFi) :
 *    - Au démarrage, si le WiFi n'est pas configuré, le module crée un Point d'Accès (AP).
 *    - Connectez-vous au WiFi : "ESP32-CAM-SETUP"
 *    - Ouvrez un navigateur sur : http://192.168.4.1/setup
 *    - Entrez votre SSID et Mot de passe. Le module redémarre et se connecte.
 *
 * 2. UTILISATION :
 *    - Stream Vidéo : http://<IP_CAM>/stream
 *    - Snapshot     : http://<IP_CAM>/snapshot
 *    - État JSON    : http://<IP_CAM>/api/status
 *    - Info Système : http://<IP_CAM>/api/info
 *
 * 3. CONFIGURATION HOME ASSISTANT (configuration.yaml) :
 *    
 *    camera:
 *      - platform: mjpeg
 *        name: "ESP32 S3 Cam"
 *        mjpeg_url: http://<IP_CAM>/stream
 *        still_image_url: http://<IP_CAM>/snapshot
 *        verify_ssl: false
 *
 *    binary_sensor:
 *      - platform: rest
 *        name: "ESP32 Cam Motion"
 *        resource: http://<IP_CAM>/api/status
 *        method: GET
 *        value_template: "{{ value_json.motion_detection }}"
 *        scan_interval: 5
 *
 * =======================================================================================
 */

#include <string.h>
#include <stdio.h>
#include <errno.h>
#include <time.h>
#include <sys/time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "esp_system.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "esp_camera.h"
#include "esp_http_server.h"
#include "esp_timer.h"
#include "mdns.h"
#include "esp_vfs_fat.h"
#include "sdmmc_cmd.h"
#include "driver/sdmmc_host.h"
#include "driver/gpio.h"
#include "esp_sntp.h"
#include "esp_spiffs.h"

// lwIP headers pour accès direct netconn
#include "lwip/sockets.h"
#include "lwip/api.h"
#include "lwip/tcp.h"
#include "lwip/priv/tcp_priv.h"  // Pour tcp_pcb
#include "lwip/priv/sockets_priv.h"  // Pour lwip_sock

#include "camera_pins.h"

static const char *TAG = "ESP32_CAM";

// ==========================
// CONFIGURATION WiFi
// ==========================
#define DEFAULT_WIFI_SSID "SSID"          // Valeur par défaut si NVS vide
#define DEFAULT_WIFI_PASS "Password"    // Valeur par défaut si NVS vide

// Variables globales WiFi
char wifi_ssid[32] = {0};
char wifi_pass[64] = {0};
bool wifi_ap_mode = false;
static char last_connected_ip[16] = "0.0.0.0";
static int wifi_connect_status = 0; // 0=Inactif, 1=Connexion, 2=Connecté, 3=Échec

// Indicateur global de sauvegarde en cours (Tâche de fond)
volatile bool is_saving = false;

// ==========================
// GESTION SPIFFS (Système de fichiers Web)
// ==========================
void init_spiffs(void) {
    esp_vfs_spiffs_conf_t conf = {
        .base_path = "/spiffs",
        .partition_label = "storage", // Doit correspondre à partitions.csv
        .max_files = 5,
        .format_if_mount_failed = true
    };

    esp_err_t ret = esp_vfs_spiffs_register(&conf);

    if (ret != ESP_OK) {
        if (ret == ESP_FAIL) {
            ESP_LOGE(TAG, "Failed to mount or format filesystem");
        } else if (ret == ESP_ERR_NOT_FOUND) {
            ESP_LOGE(TAG, "Failed to find SPIFFS partition");
        } else {
            ESP_LOGE(TAG, "Failed to initialize SPIFFS (%s)", esp_err_to_name(ret));
        }
        return;
    }

    size_t total = 0, used = 0;
    ret = esp_spiffs_info(conf.partition_label, &total, &used);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "Failed to get SPIFFS partition information (%s)", esp_err_to_name(ret));
    } else {
        ESP_LOGI(TAG, "✅ SPIFFS mounted: %d KB used / %d KB total", used/1024, total/1024);
    }
}

// ==========================
// GESTION NVS (WiFi Credentials)
// ==========================
void load_wifi_creds(void) {
    nvs_handle_t my_handle;
    esp_err_t err = nvs_open("storage", NVS_READWRITE, &my_handle);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "NVS open failed, using defaults");
        strncpy(wifi_ssid, DEFAULT_WIFI_SSID, sizeof(wifi_ssid));
        strncpy(wifi_pass, DEFAULT_WIFI_PASS, sizeof(wifi_pass));
        return;
    }
    
    size_t ssid_len = sizeof(wifi_ssid);
    size_t pass_len = sizeof(wifi_pass);
    
    if (nvs_get_str(my_handle, "ssid", wifi_ssid, &ssid_len) != ESP_OK) {
        ESP_LOGI(TAG, "No SSID in NVS, using default: %s", DEFAULT_WIFI_SSID);
        strncpy(wifi_ssid, DEFAULT_WIFI_SSID, sizeof(wifi_ssid));
        strncpy(wifi_pass, DEFAULT_WIFI_PASS, sizeof(wifi_pass));
    } else {
        nvs_get_str(my_handle, "pass", wifi_pass, &pass_len);
        ESP_LOGI(TAG, "Loaded WiFi creds from NVS: %s", wifi_ssid);
    }
    
    nvs_close(my_handle);
}

void save_wifi_creds(const char* ssid, const char* pass) {
    nvs_handle_t my_handle;
    if (nvs_open("storage", NVS_READWRITE, &my_handle) == ESP_OK) {
        nvs_set_str(my_handle, "ssid", ssid);
        nvs_set_str(my_handle, "pass", pass);
        nvs_commit(my_handle);
        nvs_close(my_handle);
        ESP_LOGI(TAG, "WiFi creds saved to NVS");
    }
}

// ==========================
// CONFIGURATION (Home Assistant)
// ==========================
#define DEVICE_NAME "ESP-Cam"
#define MDNS_HOSTNAME "esp32-cam"  // Accès via esp32-cam.local

// ==========================
// CONFIGURATION MOTION DETECTION
// ==========================
#define MOTION_DETECTION_ENABLED 1
#define MOTION_THRESHOLD 12        // Différence pixel (0-255) - Plus sensible (était 15)
#define MOTION_THRESHOLD_HIGH 30   // Seuil haute sensibilité (était 35)
#define MOTION_BLOCK_SIZE 16       // Analyse par blocs 16x16
#define MOTION_MIN_BLOCKS 4        // Minimum blocs changés - Très sensible (était 8)
#define MOTION_MIN_BLOCKS_HIGH 10  // Mouvement important (était 20)
#define MOTION_COOLDOWN_MS 1000    // Anti-rebond 1s
#define MOTION_COOLDOWN_RECORDING_MS 500  // Cooldown réduit pendant enregistrement
#define MOTION_PRE_RECORD_SEC 5    // Pré-enregistrement avant mouvement (Augmenté grâce à PSRAM)
#define MOTION_POST_RECORD_SEC 5   // Post-enregistrement après mouvement
#define MOTION_BUFFER_FRAMES 75    // Buffer circulaire (5s @ 15fps)

// ==========================
// CONFIGURATION CAMÉRA (Optimisé pour Home Assistant 2025.11)
// ==========================
#define CAM_WIDTH  1280  // HD 720p - Résolution optimale pour HA
#define CAM_HEIGHT 720   // Meilleur rendu sur dashboards modernes

// Variables globales
static httpd_handle_t camera_httpd = NULL;
static EventGroupHandle_t s_wifi_event_group;
static bool camera_ready = false;
static char device_ip[16] = "0.0.0.0";
static uint32_t frame_count = 0;
static int64_t start_time = 0;

// Variables motion detection
static uint8_t *previous_frame = NULL;
static size_t previous_frame_size = 0;
static size_t max_frame_size = 0;  // Taille max allouée pour éviter réallocations
static uint32_t motion_event_count = 0;

// Mutex pour synchronisation multi-core (protection variables partagées)
static SemaphoreHandle_t recording_mutex = NULL;

// Indicateur de flux actif (mis à jour par stream_handler)
static volatile bool stream_client_active = false;

// ⚡ ARCHITECTURE OPTIMALE (voir OPTIMIZATIONS.md Option B):
// Core 0 (APP_CPU): camera_capture_task (capture + JPEG) → envoie frames via queue
// Core 1 (PRO_CPU): WiFi + HTTP + stream_handler (reçoit frames, envoie réseau)

// File d'attente pour frames Core 0 → Core 1 (streaming)
#define STREAM_QUEUE_SIZE 16 // Tampon moyen (~1s) pour la fluidité, avec purge auto si latence
                             // 32 framebuffers caméra = max 32 frames en file
                             // Une valeur supérieure causerait un blocage : file pleine mais pas de fb libre
static QueueHandle_t stream_frame_queue = NULL;

// Queue pour frames motion detection (optionnel, désactivée actuellement)
#define MOTION_QUEUE_SIZE 4
static QueueHandle_t motion_frame_queue = NULL;

typedef struct {
    camera_fb_t *fb;  // Pointeur vers framebuffer caméra (pas de copie!)
    int64_t timestamp;
} stream_frame_t;

typedef struct {
    uint8_t *buffer;
    size_t len;
    int64_t timestamp;
} motion_frame_t;

// Buffer circulaire pour pré-enregistrement
typedef struct {
    uint8_t *buffer;
    size_t len;
    int64_t timestamp;
} frame_buffer_t;

static frame_buffer_t *circular_buffer = NULL;
static int circular_buffer_index = 0;

// Pool de buffers rotatifs pour WEAR LEVELING PSRAM
#define RECORDING_BUFFER_FRAMES 75  // 5s @ 15fps
#define MAX_RECORDING_BUFFERS 50     // Pool de 50 buffers (répartit l'usure sur 2.7MB PSRAM)
static frame_buffer_t *recording_buffers[MAX_RECORDING_BUFFERS] = {NULL};
static int current_recording_buffer_index = 0;  // Index du pool actuel
static frame_buffer_t *recording_buffer = NULL;  // Pointeur vers buffer actif
static int recording_buffer_count = 0;

static bool is_recording = false;
static int64_t recording_start_time = 0;

// Carte SD
static sdmmc_card_t *sd_card = NULL;
static bool sd_card_mounted = false;

#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT      BIT1

// Déclarations de fonctions
void start_mdns_service(void);
void start_webserver(void);

// ==========================
// INITIALISATION CARTE SD (Auto-détection multiple configs)
// ==========================

// Configurations SD connues pour ESP32-S3-CAM génériques
typedef struct {
    const char *name;
    int cmd_pin;
    int clk_pin;
    int d0_pin;
} sd_config_t;

static const sd_config_t sd_configs[] = {
    // Config 1: Freenove ESP32-S3-WROOM
    {"Freenove", 38, 39, 40},
    // Config 2: ESP32-S3-CAM générique v1 (broches basses)
    {"Generic_v1", 13, 14, 2},
    // Config 3: ESP32-S3-CAM générique v2 (broches hautes)
    {"Generic_v2", 47, 21, 48},
    // Config 4: Xiao ESP32-S3 Sense (pas de SD par défaut)
    {"Xiao", -1, -1, -1},
};

esp_err_t init_sd_card(void) {
    // ESP_LOGI(TAG, "🔍 Testing SD card configurations...");
    
    for (int i = 0; i < sizeof(sd_configs) / sizeof(sd_config_t); i++) {
        if (sd_configs[i].cmd_pin == -1) {
            // ESP_LOGI(TAG, "   [%d] %s: No SD support", i + 1, sd_configs[i].name);
            continue;
        }
        
        // ESP_LOGI(TAG, "   [%d] Trying %s config (CMD:%d CLK:%d D0:%d)...", 
        //          i + 1, sd_configs[i].name, 
        //          sd_configs[i].cmd_pin, sd_configs[i].clk_pin, sd_configs[i].d0_pin);
        
        sdmmc_host_t host = SDMMC_HOST_DEFAULT();
        host.flags = SDMMC_HOST_FLAG_1BIT;
        host.max_freq_khz = SDMMC_FREQ_DEFAULT;  // 20MHz
        
        sdmmc_slot_config_t slot_config = SDMMC_SLOT_CONFIG_DEFAULT();
        slot_config.width = 1;  // 1-bit mode
        slot_config.clk = sd_configs[i].clk_pin;
        slot_config.cmd = sd_configs[i].cmd_pin;
        slot_config.d0 = sd_configs[i].d0_pin;
        slot_config.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP;
        
        esp_vfs_fat_sdmmc_mount_config_t mount_config = {
            .format_if_mount_failed = false,
            .max_files = 5,
            .allocation_unit_size = 16 * 1024,
            .use_one_fat = false  // Support noms longs (LFN)
        };
        
        esp_err_t ret = esp_vfs_fat_sdmmc_mount("/sdcard", &host, &slot_config, &mount_config, &sd_card);
        
        if (ret == ESP_OK) {
            sdmmc_card_print_info(stdout, sd_card);
            sd_card_mounted = true;
            ESP_LOGI(TAG, "✅ SD Card mounted with %s config!", sd_configs[i].name);
            ESP_LOGI(TAG, "   Size: %.2f GB | Sectors: %u", 
                     (float)(sd_card->csd.capacity) / (1024*1024*1024/512),
                     (unsigned int)sd_card->csd.capacity);
            return ESP_OK;
        } else {
            ESP_LOGW(TAG, "   ❌ Failed: %s", esp_err_to_name(ret));
        }
    }
    
    ESP_LOGE(TAG, "❌ All SD configurations failed!");
    ESP_LOGE(TAG, "   Possible causes:");
    ESP_LOGE(TAG, "   1. No SD card inserted");
    ESP_LOGE(TAG, "   2. Card not FAT32 formatted");
    ESP_LOGE(TAG, "   3. Card >32GB (use FAT32, not exFAT)");
    ESP_LOGE(TAG, "   4. Hardware doesn't have SD slot");
    ESP_LOGE(TAG, "   Motion detection will work, but NO recording to SD");
    
    sd_card_mounted = false;
    return ESP_FAIL;
}

// ==========================
// INITIALISATION CAMÉRA
// ==========================
esp_err_t init_camera(void) {
    camera_config_t config;
    config.ledc_channel = LEDC_CHANNEL_0;
    config.ledc_timer = LEDC_TIMER_0;
    config.pin_d0 = Y2_GPIO_NUM;
    config.pin_d1 = Y3_GPIO_NUM;
    config.pin_d2 = Y4_GPIO_NUM;
    config.pin_d3 = Y5_GPIO_NUM;
    config.pin_d4 = Y6_GPIO_NUM;
    config.pin_d5 = Y7_GPIO_NUM;
    config.pin_d6 = Y8_GPIO_NUM;
    config.pin_d7 = Y9_GPIO_NUM;
    config.pin_xclk = XCLK_GPIO_NUM;
    config.pin_pclk = PCLK_GPIO_NUM;
    config.pin_vsync = VSYNC_GPIO_NUM;
    config.pin_href = HREF_GPIO_NUM;
    config.pin_sccb_sda = SIOD_GPIO_NUM;
    config.pin_sccb_scl = SIOC_GPIO_NUM;
    config.pin_pwdn = PWDN_GPIO_NUM;
    config.pin_reset = RESET_GPIO_NUM;
    config.xclk_freq_hz = 16000000;       // 16MHz (Compromis stabilité/artefacts)
    config.frame_size = FRAMESIZE_HD;     // HD 720p (1280x720)
    config.pixel_format = PIXFORMAT_JPEG; // Compression JPEG matérielle
    config.grab_mode = CAMERA_GRAB_LATEST;      // Streaming optimisé
    config.fb_location = CAMERA_FB_IN_PSRAM;  // PSRAM 8MB Octal SPI 80MHz
    config.jpeg_quality = 18;             
                                          // Note: Plus le chiffre est bas, meilleure est la qualité (0-63)
    config.fb_count = 32;                 // 32 buffers = ~3MB PSRAM (sur 8MB)
                                          // CRITIQUE: Doit être > STREAM_QUEUE_SIZE (32)
                                          // Permet au pilote de continuer à capturer même si le WiFi sature
                                          // Élimine les erreurs "cam_hal: FB-OVF"

    // Initialisation de la caméra
    esp_err_t err = esp_camera_init(&config);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "Camera init failed with error 0x%x", err);
        return err;
    }

    // ========== DEBUG: Vérification config caméra réelle ==========
    // ESP_LOGI(TAG, "========== CAMERA CONFIG DEBUG ==========");
    // ESP_LOGI(TAG, "Config demandée:");
    // ESP_LOGI(TAG, "  - JPEG Quality: %d", config.jpeg_quality);
    // ESP_LOGI(TAG, "  - Frame Size: %d (HD=11)", config.frame_size);
    // ESP_LOGI(TAG, "  - Grab Mode: %d (LATEST=0, WHEN_EMPTY=1)", config.grab_mode);
    // ESP_LOGI(TAG, "  - Pixel Format: %d (JPEG=4)", config.pixel_format);
    // ESP_LOGI(TAG, "  - FB Count: %d", config.fb_count);
    // ESP_LOGI(TAG, "  - XCLK: %d MHz", config.xclk_freq_hz / 1000000);
    
    // Configuration optimale capteur OV3660 3MP
    sensor_t *s = esp_camera_sensor_get();
    if (s != NULL) {
        // DEBUG: Lire config RÉELLE du sensor
        // ESP_LOGI(TAG, "Sensor config réelle AVANT modifications:");
        // ESP_LOGI(TAG, "  - Quality: %d", s->status.quality);
        // ESP_LOGI(TAG, "  - Framesize: %d", s->status.framesize);
        // ESP_LOGI(TAG, "  - Brightness: %d", s->status.brightness);
        // ESP_LOGI(TAG, "  - Contrast: %d", s->status.contrast);
        // ESP_LOGI(TAG, "  - Saturation: %d", s->status.saturation);
        // ESP_LOGI(TAG, "  - Sharpness: %d", s->status.sharpness);
        // ESP_LOGI(TAG, "  - Denoise: %d", s->status.denoise);
        // ESP_LOGI(TAG, "  - AWB: %d", s->status.awb);
        // ESP_LOGI(TAG, "  - AEC: %d", s->status.aec);
        // ESP_LOGI(TAG, "  - AGC: %d", s->status.agc);
        // ESP_LOGI(TAG, "  - AEC2: %d", s->status.aec2);
        // ESP_LOGI(TAG, "  - DCW: %d", s->status.dcw);
        // ESP_LOGI(TAG, "  - BPC: %d", s->status.bpc);
        // ESP_LOGI(TAG, "  - WPC: %d", s->status.wpc);
        // ESP_LOGI(TAG, "  - Raw GMA: %d", s->status.raw_gma);
        // ESP_LOGI(TAG, "  - Lenc: %d", s->status.lenc);
        // ESP_LOGI(TAG, "=========================================");
        
        // Ajustements image (optimisés pour qualité streaming HD)
        s->set_brightness(s, 0);     // Luminosité normale (-2 sombre, +2 clair)
        s->set_contrast(s, 0);       // Contraste 0 (Réduit le bruit/lignes visibles)
        s->set_saturation(s, 0);     // Saturation normale (-2 N&B, +2 vif)
        s->set_special_effect(s, 0); // Pas de filtre (1=N&B, 2=Sepia, etc.)
        
        // Balance des blancs automatique (AWB)
        s->set_whitebal(s, 1);       // AWB activée
        s->set_awb_gain(s, 1);       // Gain AWB auto
        s->set_wb_mode(s, 0);        // Auto (1=Sunny, 2=Cloudy, 3=Office, 4=Home)
        
        // Exposition automatique (AEC) - Optimisée pour streaming
        s->set_exposure_ctrl(s, 1);  // AEC activée
        s->set_aec2(s, 1);           // AEC DSP activé pour meilleure exposition
        s->set_ae_level(s, 0);       // Niveau exposition normal
        s->set_aec_value(s, 600);    // Exposition élevée pour image lumineuse
        
        // Contrôle de gain automatique (AGC) - Optimisé qualité
        s->set_gain_ctrl(s, 1);      // AGC activée
        s->set_agc_gain(s, 0);       // Gain auto
        s->set_gainceiling(s, (gainceiling_t)2);  // Gain ceiling modéré (0-6, 2=bon compromis bruit/lumière)
        
        // Corrections matérielles ACTIVÉES pour réduire les artefacts
        s->set_bpc(s, 1);            // Black Pixel Correction ON
        s->set_wpc(s, 1);            // White Pixel Correction ON
        s->set_raw_gma(s, 1);        // Correction gamma RAW
        s->set_lenc(s, 1);           // Lens Correction ON pour meilleure qualité
        
        // Orientation
        s->set_hmirror(s, 0);        // Miroir horizontal (0=normal, 1=inversé)
        s->set_vflip(s, 0);          // Flip vertical (0=normal, 1=inversé)
        
        // Qualité d'image optimale
        s->set_dcw(s, 0);            // Downscale désactivé = résolution native
        s->set_sharpness(s, -1);     // Anti-accentuation (évite amplifier artefacts JPEG)
        s->set_denoise(s, 1);        // Denoise actif niveau 1 (réduit bruit numérique)
        s->set_colorbar(s, 0);       // Barre test désactivée
    }

    camera_ready = true;
    return ESP_OK;
}

// ==========================
// DÉTECTION DE MOUVEMENT
// ==========================
bool detect_motion(camera_fb_t *fb) {
    // Allocation unique au démarrage (taille max possible pour HD JPEG ~100KB)
    if (!previous_frame) {
        max_frame_size = 128 * 1024; // 128KB fixe en PSRAM (suffisant pour Q18 HD)
        previous_frame = (uint8_t*)heap_caps_malloc(max_frame_size, MALLOC_CAP_SPIRAM);
        if (!previous_frame) {
            ESP_LOGE(TAG, "❌ Failed to allocate motion buffer (PSRAM)");
            return false;
        }
        ESP_LOGI(TAG, "✅ Motion buffer allocated: 128KB (Fixed PSRAM)");
        
        // Première frame: copier et sortir
        if (fb->len <= max_frame_size) {
            memcpy(previous_frame, fb->buf, fb->len);
            previous_frame_size = fb->len;
        }
        return false;
    }
    
    // Si la frame est plus grande que le buffer (très rare en Q18), on ignore
    if (fb->len > max_frame_size) {
        ESP_LOGW(TAG, "⚠️ Frame too big for motion buffer (%u > %u)", fb->len, max_frame_size);
        return false;
    }
    
    int changed_blocks_low = 0;   // Petits changements
    int changed_blocks_high = 0;  // Grands changements
    int total_blocks = 0;
    
    // Analyse par blocs (optimisé pour JPEG - analyse en-tête uniquement)
    // Pour JPEG on analyse les premiers pixels décodés (approximation rapide)
    // On limite l'analyse à la taille de la plus petite des deux images
    size_t analyze_len = (fb->len < previous_frame_size) ? fb->len : previous_frame_size;
    
    // Optimisation: Analyse 1 octet sur 32 (plus rapide, moins de CPU)
    // Permet de scanner plus de surface en moins de temps
    for (int i = 0; i < analyze_len; i += 32) {
        int diff = abs((int)fb->buf[i] - (int)previous_frame[i]);
        
        if (diff > MOTION_THRESHOLD) {
            changed_blocks_low++;
            if (diff > MOTION_THRESHOLD_HIGH) {
                changed_blocks_high++;
            }
        }
        total_blocks++;
    }
    
    // Mettre à jour frame précédente
    memcpy(previous_frame, fb->buf, fb->len);
    previous_frame_size = fb->len;
    
    // Motion si: seuil atteint (ajusté pour sampling 1/32)
    // Seuil relatif au nombre de blocs testés (plus robuste)
    bool motion = (changed_blocks_low >= MOTION_MIN_BLOCKS) || 
                  (changed_blocks_high >= MOTION_MIN_BLOCKS_HIGH);
    
    return motion;
}

// ==========================
// BUFFER CIRCULAIRE
// ==========================
void init_circular_buffer(void) {
    circular_buffer = (frame_buffer_t*)heap_caps_malloc(
        MOTION_BUFFER_FRAMES * sizeof(frame_buffer_t), 
        MALLOC_CAP_SPIRAM
    );
    
    if (!circular_buffer) {
        ESP_LOGE(TAG, "Failed to allocate circular buffer");
        return;
    }
    
    for (int i = 0; i < MOTION_BUFFER_FRAMES; i++) {
        circular_buffer[i].buffer = NULL;
        circular_buffer[i].len = 0;
        circular_buffer[i].timestamp = 0;
    }
    
    // Initialiser le GROUPE de tampons d'enregistrement
    // Pool de tampons pour gérer les séquences vidéo
    for (int pool = 0; pool < MAX_RECORDING_BUFFERS; pool++) {
        recording_buffers[pool] = (frame_buffer_t*)heap_caps_malloc(
            RECORDING_BUFFER_FRAMES * sizeof(frame_buffer_t),
            MALLOC_CAP_SPIRAM
        );
        if (!recording_buffers[pool]) {
            ESP_LOGW(TAG, "⚠️ Pool limité à %d/%d buffers (PSRAM pleine)", pool, MAX_RECORDING_BUFFERS);
            break;
        }
        for (int i = 0; i < RECORDING_BUFFER_FRAMES; i++) {
            recording_buffers[pool][i].buffer = NULL;
            recording_buffers[pool][i].len = 0;
            recording_buffers[pool][i].timestamp = 0;
        }
    }
    recording_buffer = recording_buffers[0];  // Sélectionner premier buffer
    recording_buffer_count = 0;
    
    ESP_LOGI(TAG, "✅ Circular buffer ready: %d frames (%.1fs @ 15fps)",
             MOTION_BUFFER_FRAMES, (float)MOTION_BUFFER_FRAMES / 15.0);
    ESP_LOGI(TAG, "✅ Recording pool: %d buffers × %d frames",
             RECORDING_BUFFER_FRAMES, (float)RECORDING_BUFFER_FRAMES / 15.0);
}

void add_frame_to_buffer(camera_fb_t *fb) {
    if (!circular_buffer) return;
    
    // Libérer ancien buffer si existant
    if (circular_buffer[circular_buffer_index].buffer) {
        free(circular_buffer[circular_buffer_index].buffer);
    }
    
    // Copier nouvelle frame (PSRAM pour économiser DRAM interne)
    circular_buffer[circular_buffer_index].buffer = (uint8_t*)heap_caps_malloc(fb->len, MALLOC_CAP_SPIRAM);
    if (circular_buffer[circular_buffer_index].buffer) {
        memcpy(circular_buffer[circular_buffer_index].buffer, fb->buf, fb->len);
        circular_buffer[circular_buffer_index].len = fb->len;
        circular_buffer[circular_buffer_index].timestamp = esp_timer_get_time();
    }
    
    circular_buffer_index = (circular_buffer_index + 1) % MOTION_BUFFER_FRAMES;
}

// ==========================
// ENREGISTREMENT VIDÉO SD (Format AVI Motion-JPEG)
// ==========================
void save_motion_video(void) {
    if (!circular_buffer) {
        ESP_LOGW(TAG, "⚠️  No circular buffer, skipping save");
        return;
    }
    
    if (!sd_card_mounted) {
        ESP_LOGW(TAG, "⚠️  SD Card not mounted, motion NOT saved to storage");
        return;
    }
    
    char filename[80];
    
    // Générer nom de fichier avec timestamp lisible
    time_t now;
    struct tm timeinfo;
    time(&now);
    localtime_r(&now, &timeinfo);
    
    // Format: motion_YYYYMMDD_HHMMSS.avi (ex: motion_20251202_143055.avi)
    snprintf(filename, sizeof(filename), 
             "/sdcard/motion_%04d%02d%02d_%02d%02d%02d.avi",
             timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday,
             timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
    
    ESP_LOGI(TAG, "📁 Creating: %s", filename);
    
    FILE *file = fopen(filename, "wb");
    if (!file) {
        ESP_LOGE(TAG, "❌ Failed to create file: %s (errno: %d - %s)", 
                 filename, errno, strerror(errno));
        ESP_LOGE(TAG, "   Check: 1) SD card inserted? 2) FAT32 formatted? 3) Write protected?");
        return;
    }
    
    // ESP_LOGI(TAG, "📹 Recording to: %s", filename);
    
    // Compter frames totales
    int frames_written = 0;
    int start_index = circular_buffer_index;
    
    // Compter frames pré-motion
    for (int i = 0; i < MOTION_BUFFER_FRAMES; i++) {
        int idx = (start_index + i) % MOTION_BUFFER_FRAMES;
        if (circular_buffer[idx].buffer && circular_buffer[idx].len > 0) {
            frames_written++;
        }
    }
    // Ajouter frames post-motion
    for (int i = 0; i < recording_buffer_count; i++) {
        if (recording_buffer[i].buffer && recording_buffer[i].len > 0) {
            frames_written++;
        }
    }
    
    if (frames_written == 0) {
        ESP_LOGW(TAG, "⚠️  No frames to save");
        fclose(file);
        return;
    }
    
    // Configuration vidéo
    const uint32_t fps = 15;
    const uint32_t width = CAM_WIDTH;
    const uint32_t height = CAM_HEIGHT;
    const uint32_t us_per_frame = 1000000 / fps;  // Microsecondes par frame
    
    // ========================================
    // ÉCRITURE EN-TÊTE AVI (Format RIFF)
    // ========================================
    
    // Position pour écrire taille du fichier plus tard
    long movi_size_pos = 0;
    long total_size_pos = 4;
    
    // RIFF Header
    fwrite("RIFF", 1, 4, file);
    uint32_t placeholder = 0;
    fwrite(&placeholder, 4, 1, file);  // Taille totale (à remplir plus tard)
    fwrite("AVI ", 1, 4, file);
    
    // LIST hdrl (Header List)
    fwrite("LIST", 1, 4, file);
    uint32_t hdrl_size = 192;  // Taille liste en-tête
    fwrite(&hdrl_size, 4, 1, file);
    fwrite("hdrl", 1, 4, file);
    
    // avih (AVI Header)
    fwrite("avih", 1, 4, file);
    uint32_t avih_size = 56;
    fwrite(&avih_size, 4, 1, file);
    fwrite(&us_per_frame, 4, 1, file);       // Microsecondes par image
    uint32_t max_bytes_per_sec = width * height * 3 * fps;
    fwrite(&max_bytes_per_sec, 4, 1, file);  // Débit max
    fwrite(&placeholder, 4, 1, file);        // Réservé
    uint32_t flags = 0x10;  // AVIF_HASINDEX
    fwrite(&flags, 4, 1, file);
    fwrite(&frames_written, 4, 1, file);     // Nombre total d'images
    fwrite(&placeholder, 4, 1, file);        // Images initiales
    uint32_t streams = 1;
    fwrite(&streams, 4, 1, file);            // Nombre de flux
    uint32_t buffer_size = width * height * 3;
    fwrite(&buffer_size, 4, 1, file);        // Taille tampon suggérée
    fwrite(&width, 4, 1, file);              // Largeur
    fwrite(&height, 4, 1, file);             // Hauteur
    fwrite(&placeholder, 4, 1, file);        // Réservé (4 × uint32)
    fwrite(&placeholder, 4, 1, file);
    fwrite(&placeholder, 4, 1, file);
    fwrite(&placeholder, 4, 1, file);
    
    // LIST strl (Stream List)
    fwrite("LIST", 1, 4, file);
    uint32_t strl_size = 116;
    fwrite(&strl_size, 4, 1, file);
    fwrite("strl", 1, 4, file);
    
    // strh (Stream Header)
    fwrite("strh", 1, 4, file);
    uint32_t strh_size = 56;
    fwrite(&strh_size, 4, 1, file);
    fwrite("vids", 1, 4, file);              // Type: video
    fwrite("MJPG", 1, 4, file);              // Codec: Motion JPEG
    fwrite(&placeholder, 4, 1, file);        // Flags
    fwrite(&placeholder, 2, 1, file);        // Priorité
    fwrite(&placeholder, 2, 1, file);        // Langue
    fwrite(&placeholder, 4, 1, file);        // Images initiales
    uint32_t scale = 1;
    fwrite(&scale, 4, 1, file);              // Échelle
    fwrite(&fps, 4, 1, file);                // Taux (fps)
    fwrite(&placeholder, 4, 1, file);        // Start
    fwrite(&frames_written, 4, 1, file);     // Longueur (images)
    fwrite(&buffer_size, 4, 1, file);        // Tampon suggéré
    uint32_t quality = 10000;
    fwrite(&quality, 4, 1, file);            // Qualité (-1 = défaut)
    fwrite(&placeholder, 4, 1, file);        // Sample size
    uint16_t left = 0, top = 0;
    fwrite(&left, 2, 1, file);
    fwrite(&top, 2, 1, file);
    fwrite(&width, 2, 1, file);
    fwrite(&height, 2, 1, file);
    
    // strf (Stream Format)
    fwrite("strf", 1, 4, file);
    uint32_t strf_size = 40;
    fwrite(&strf_size, 4, 1, file);
    fwrite(&strf_size, 4, 1, file);          // Taille BITMAPINFOHEADER
    fwrite(&width, 4, 1, file);              // Largeur
    fwrite(&height, 4, 1, file);             // Hauteur
    uint16_t planes = 1;
    fwrite(&planes, 2, 1, file);             // Planes
    uint16_t bit_count = 24;
    fwrite(&bit_count, 2, 1, file);          // Bits par pixel
    fwrite("MJPG", 1, 4, file);              // Compression
    uint32_t image_size = width * height * 3;
    fwrite(&image_size, 4, 1, file);         // Taille image
    fwrite(&placeholder, 4, 1, file);        // X pixels par mètre
    fwrite(&placeholder, 4, 1, file);        // Y pixels par mètre
    fwrite(&placeholder, 4, 1, file);        // Couleurs utilisées
    fwrite(&placeholder, 4, 1, file);        // Couleurs importantes
    
    // LIST movi (Movie Data)
    fwrite("LIST", 1, 4, file);
    movi_size_pos = ftell(file);
    fwrite(&placeholder, 4, 1, file);        // Taille movi (à remplir plus tard)
    fwrite("movi", 1, 4, file);
    
    // ========================================
    // ÉCRITURE DES FRAMES JPEG
    // ========================================
    
    long movi_start = ftell(file);
    int actual_frames = 0;
    
    // Écrire frames pré-motion
    for (int i = 0; i < MOTION_BUFFER_FRAMES; i++) {
        int idx = (start_index + i) % MOTION_BUFFER_FRAMES;
        if (circular_buffer[idx].buffer && circular_buffer[idx].len > 0) {
            fwrite("00dc", 1, 4, file);      // Chunk ID (stream 00, uncompressed DIB)
            fwrite(&circular_buffer[idx].len, 4, 1, file);
            fwrite(circular_buffer[idx].buffer, 1, circular_buffer[idx].len, file);
            // Padding si longueur impaire
            if (circular_buffer[idx].len & 1) {
                fputc(0, file);
            }
            actual_frames++;
        }
    }
    
    // Écrire frames post-motion
    for (int i = 0; i < recording_buffer_count; i++) {
        if (recording_buffer[i].buffer && recording_buffer[i].len > 0) {
            fwrite("00dc", 1, 4, file);
            fwrite(&recording_buffer[i].len, 4, 1, file);
            fwrite(recording_buffer[i].buffer, 1, recording_buffer[i].len, file);
            if (recording_buffer[i].len & 1) {
                fputc(0, file);
            }
            actual_frames++;
        }
    }
    
    long movi_end = ftell(file);
    uint32_t movi_size = movi_end - movi_start + 4;  // +4 pour "movi"
    
    // ========================================
    // MISE À JOUR DES TAILLES
    // ========================================
    
    // Mettre à jour taille movi
    fseek(file, movi_size_pos, SEEK_SET);
    fwrite(&movi_size, 4, 1, file);
    
    // Mettre à jour taille totale fichier
    fseek(file, 0, SEEK_END);
    long file_end = ftell(file);
    uint32_t total_size = file_end - 8;  // Taille totale - 8 (RIFF + size)
    fseek(file, total_size_pos, SEEK_SET);
    fwrite(&total_size, 4, 1, file);
    
    fclose(file);
    
    ESP_LOGI(TAG, "✅ Vidéo sauvegardée : %d images (%.1fs) [%d pré + %d post]", 
             actual_frames, (float)actual_frames / fps,
             MOTION_BUFFER_FRAMES, recording_buffer_count);
    motion_event_count++;
    
    // Nettoyer buffer d'enregistrement
    for (int i = 0; i < recording_buffer_count; i++) {
        if (recording_buffer[i].buffer) {
            free(recording_buffer[i].buffer);
            recording_buffer[i].buffer = NULL;
            recording_buffer[i].len = 0;
        }
    }
    recording_buffer_count = 0;
}

// Wrapper pour exécuter la sauvegarde en tâche de fond
void task_save_motion_video(void *pvParameters) {
    ESP_LOGI(TAG, "💾 Sauvegarde vidéo en arrière-plan...");
    save_motion_video();
    ESP_LOGI(TAG, "💾 Sauvegarde terminée");
    is_saving = false;
    vTaskDelete(NULL);
}

void start_recording(void) {
    // ⚠️ VÉRIFICATION CRITIQUE: SD montée?
    if (!sd_card_mounted) {
        ESP_LOGW(TAG, "⚠️  Enregistrement ignoré : Carte SD NON montée");
        return;
    }
    
    // Protection mutex : évite race condition avec HTTP handlers
    if (xSemaphoreTake(recording_mutex, pdMS_TO_TICKS(100)) != pdTRUE) {
        ESP_LOGW(TAG, "⚠️  Timeout Mutex, enregistrement ignoré");
        return;
    }
    
    // Réinitialiser buffer d'enregistrement
    for (int i = 0; i < recording_buffer_count; i++) {
        if (recording_buffer[i].buffer) {
            free(recording_buffer[i].buffer);
            recording_buffer[i].buffer = NULL;
        }
    }
    
    // ROTATION DU GROUPE : changer de tampon pour gérer les séquences
    current_recording_buffer_index = (current_recording_buffer_index + 1) % MAX_RECORDING_BUFFERS;
    recording_buffer = recording_buffers[current_recording_buffer_index];
    recording_buffer_count = 0;
    
    is_recording = true;
    recording_start_time = esp_timer_get_time();
    
    xSemaphoreGive(recording_mutex);
    
    ESP_LOGI(TAG, "🔴 Enregistrement démarré (pool %d/%d)",
             current_recording_buffer_index + 1, MAX_RECORDING_BUFFERS);
}

void check_stop_recording(void) {
    // Protection mutex pour accès thread-safe à is_recording
    if (xSemaphoreTake(recording_mutex, pdMS_TO_TICKS(10)) != pdTRUE) {
        return;  // Mutex occupé, réessaiera au prochain cycle
    }
    
    if (!is_recording) {
        xSemaphoreGive(recording_mutex);
        return;
    }
    
    int64_t now = esp_timer_get_time();
    int64_t elapsed_ms = (now - recording_start_time) / 1000;
    
    if (elapsed_ms >= MOTION_POST_RECORD_SEC * 1000) {
        save_motion_video();
        is_recording = false;
        ESP_LOGI(TAG, "⏹️  Recording stopped");
    }
    
    xSemaphoreGive(recording_mutex);
}

// ==========================
// TÂCHE CAPTURE CAMÉRA (Core 0 - temps réel)
// ==========================
// Architecture optimale (OPTIMIZATIONS.md Option B):
// - Core 0 dédié caméra (capture + JPEG jamais interrompu par WiFi)
// - Envoie images via file vers Core 1 (HTTP streaming)
// - Latence -30%, FPS ultra-stable
static void camera_capture_task(void *pvParameters) {
    ESP_LOGI(TAG, "📹 Camera capture task running on Core %d", xPortGetCoreID());
    ESP_LOGI(TAG, "   Priority: %d | Stack: 8KB", uxTaskPriorityGet(NULL));
    ESP_LOGI(TAG, "   Architecture: Core 0=Camera (isolation) | Core 1=WiFi+HTTP (réseau)");
    
    camera_fb_t *fb = NULL;
    uint32_t frames_captured = 0;
    uint32_t frames_queued = 0;
    uint32_t queue_full_count = 0;
    int64_t last_stats_time = esp_timer_get_time();
    int debug_frame_count = 0;  // Compteur pour debug limité
    
    while (true) {
        // ⏱️ Capture image (Core 0 dédié, zéro interruption WiFi)
        int64_t capture_start = esp_timer_get_time();
        fb = esp_camera_fb_get();
        int64_t capture_time = (esp_timer_get_time() - capture_start) / 1000;
        
        if (!fb) {
            ESP_LOGW(TAG, "⚠️  Camera capture failed");
            vTaskDelay(pdMS_TO_TICKS(10));
            continue;
        }
        
        frames_captured++;
        debug_frame_count++;
        
        // ========== DEBUG: Analyser 10 premières images ==========
        // if (debug_frame_count <= 10) {
        //     ESP_LOGI(TAG, "🔍 DEBUG Frame #%d:", debug_frame_count);
        //     ESP_LOGI(TAG, "  - Size: %u bytes (%.1f KB)", fb->len, fb->len / 1024.0);
        //     ESP_LOGI(TAG, "  - Width: %u px", fb->width);
        //     ESP_LOGI(TAG, "  - Height: %u px", fb->height);
        //     ESP_LOGI(TAG, "  - Format: %u (4=JPEG)", fb->format);
        //     ESP_LOGI(TAG, "  - Timestamp: %lld us", fb->timestamp.tv_sec * 1000000LL + fb->timestamp.tv_usec);
        //     ESP_LOGI(TAG, "  - Capture time: %lld ms", capture_time);
            
        //     // Vérifier headers JPEG (premiers 20 bytes)
        //     if (fb->len >= 20) {
        //         ESP_LOGI(TAG, "  - JPEG Header: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X",
        //                  fb->buf[0], fb->buf[1], fb->buf[2], fb->buf[3], fb->buf[4],
        //                  fb->buf[5], fb->buf[6], fb->buf[7], fb->buf[8], fb->buf[9]);
                         
        //         // Vérifier SOI marker (0xFFD8)
        //         if (fb->buf[0] == 0xFF && fb->buf[1] == 0xD8) {
        //             ESP_LOGI(TAG, "  ✅ JPEG SOI marker OK");
        //         } else {
        //             ESP_LOGW(TAG, "  ⚠️  JPEG SOI marker INVALID!");
        //         }
        //     }
        // }
        // =========================================================
        
        // ⏱️ MOTION DETECTION & RECORDING (Core 0)
        // DÉSACTIVÉ SI STREAMING ACTIF (Demande utilisateur)
        int64_t motion_time = 0;
        
        if (!stream_client_active) {
            // 1. Ajouter au buffer circulaire (Pre-record) - SEULEMENT SI PAS DE SAUVEGARDE EN COURS
            // Si une sauvegarde est en cours, on ne touche pas aux buffers pour éviter corruption
            if (!is_saving) {
                add_frame_to_buffer(fb);
            }

            // 2. Détecter mouvement
            int64_t motion_start = esp_timer_get_time();
            bool motion = detect_motion(fb);
            motion_time = (esp_timer_get_time() - motion_start) / 1000;
            
            // 3. Gestion enregistrement
            static int frames_to_record = 0;
            
            if (motion) {
                // ESP_LOGD pour éviter le flood, LOGI uniquement au début
                ESP_LOGD(TAG, "🚨 MOTION detected in %lldms | Frame size: %u bytes", motion_time, fb->len);
                
                if (sd_card_mounted && !is_saving) {
                    if (!is_recording) {
                        ESP_LOGI(TAG, "🎥 MOTION DETECTED! Starting recording... (Frame size: %u)", fb->len);
                        is_recording = true;
                        recording_buffer_count = 0;
                    }
                    // Étendre l'enregistrement (Post-record reset)
                    frames_to_record = MOTION_POST_RECORD_SEC * 15; // 15 fps approx
                } else {
                    // Pas de carte SD ou Sauvegarde en cours
                    static int64_t last_sd_warn = 0;
                    int64_t now = esp_timer_get_time();
                    if (now - last_sd_warn > 5000000) { // Max 1 warning toutes les 5s
                        if (!sd_card_mounted) {
                            ESP_LOGW(TAG, "⚠️ Motion detected but SD Card missing - Recording skipped");
                        } else if (is_saving) {
                            ESP_LOGW(TAG, "⚠️ Motion detected but Save in progress - New recording skipped");
                        }
                        last_sd_warn = now;
                    }
                }
            }

            if (is_recording) {
                // Ajouter au buffer d'enregistrement (Post-record)
                if (recording_buffer_count < RECORDING_BUFFER_FRAMES) {
                    // Allocation dynamique pour économiser RAM si pas utilisé
                    if (recording_buffer[recording_buffer_count].buffer) {
                        free(recording_buffer[recording_buffer_count].buffer);
                    }
                    recording_buffer[recording_buffer_count].buffer = (uint8_t*)heap_caps_malloc(fb->len, MALLOC_CAP_SPIRAM);
                    if (recording_buffer[recording_buffer_count].buffer) {
                        memcpy(recording_buffer[recording_buffer_count].buffer, fb->buf, fb->len);
                        recording_buffer[recording_buffer_count].len = fb->len;
                        recording_buffer_count++;
                    }
                }
                
                frames_to_record--;
                if (frames_to_record <= 0 || recording_buffer_count >= RECORDING_BUFFER_FRAMES) {
                    ESP_LOGI(TAG, "⏹️ STOP Recording. Starting background save...");
                    is_recording = false;
                    if (!is_saving) {
                        is_saving = true;
                        // Lancer sauvegarde en tâche de fond (Core 0, priorité basse)
                        // Core 0 est moins chargé que Core 1 (WiFi/HTTP)
                        xTaskCreatePinnedToCore(task_save_motion_video, "save_video", 4096, NULL, 1, NULL, 0);
                    }
                }
            } else if (motion_time > 20) {
                ESP_LOGW(TAG, "⏱️ Détection mouvement lente: %lldms (attendu <15ms)", motion_time);
            }
        } else {
            // Si streaming actif, on s'assure que l'enregistrement est stoppé proprement si c'était en cours
            if (is_recording) {
                ESP_LOGI(TAG, "⏹️ Stream démarré: Arrêt enregistrement immédiat (Sauvegarde arrière-plan).");
                is_recording = false;
                if (!is_saving) {
                    is_saving = true;
                    // Core 0, priorité 1 (Low)
                    xTaskCreatePinnedToCore(task_save_motion_video, "save_video", 4096, NULL, 1, NULL, 0);
                }
            }
        }
        
        if (capture_time > 500) {
            ESP_LOGW(TAG, "🐌 Capture TRÈS lente: %lld ms (attendu <50ms)", capture_time);
        }
        
        // Debug timing complet si lent
        int64_t total_time = capture_time + motion_time;
        if (total_time > 80) {
            ESP_LOGD(TAG, "⏱️ Timing Frame: capture=%lldms + motion=%lldms = %lldms total",
                     capture_time, motion_time, total_time);
        }
        
        // ⚡ MODE ADAPTATIF selon présence client:
        // - Client actif: Capture max FPS + envoi file
        // - Pas de client: Capture 1 fps + retour immédiat (économie ressources)
        
        if (stream_client_active) {
            // Client connecté → Envoyer image vers Core 1 via file
            stream_frame_t frame_data;
            frame_data.fb = fb;
            frame_data.timestamp = esp_timer_get_time();
            
            // Timeout 100ms : On autorise le buffering (lissage)
            // Si la queue est pleine, le stream handler purgera les vieilles frames
            int64_t queue_start = esp_timer_get_time();
            if (xQueueSend(stream_frame_queue, &frame_data, pdMS_TO_TICKS(100)) == pdTRUE) {
                frames_queued++;
                int64_t queue_time = (esp_timer_get_time() - queue_start) / 1000;
                if (queue_time > 50) {
                    ESP_LOGW(TAG, "⏳ Envoi file lent: %lldms (file occupée)", queue_time);
                }
                // Frame transférée, Core 1 responsable du esp_camera_fb_return()
            } else {
                // Queue bloquée > 200ms, stream handler vraiment trop lent
                queue_full_count++;
                esp_camera_fb_return(fb);  // Libérer immédiatement
                if (queue_full_count % 10 == 1) {  // Log toutes les 10 fois
                    UBaseType_t queued = uxQueueMessagesWaiting(stream_frame_queue);
                    ESP_LOGW(TAG, "⚠️  Timeout file: %lu pertes (file=%u/%d)", 
                             queue_full_count, queued, STREAM_QUEUE_SIZE);
                }
            }
        } else {
            // Pas de client → Retourner frame immédiatement (pas d'envoi)
            esp_camera_fb_return(fb);
            // Ralentir capture à 1 fps NON-BLOQUANT
            // (délai ajouté APRÈS stats, pas ici)
        }
        
        // Stats périodiques (10s pour réduire overhead UART)
        int64_t now = esp_timer_get_time();
        if ((now - last_stats_time) > 10000000) {  // 10 secondes
            UBaseType_t stack_free = uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t);
            
            // ESP_LOGI(TAG, "📊 Core 0 Camera: FPS=%.1f | Queued=%lu | Drops=%lu | Queue=%u/%d | Mode=%s",
            //          capture_fps, frames_queued, queue_full_count, 
            //          queue_used, STREAM_QUEUE_SIZE,
            //          stream_client_active ? "STREAM" : "IDLE");
            
            if (stack_free < 2048) {
                ESP_LOGW(TAG, "⚠️ Pile faible: %u octets restants", stack_free);
            }
            
            frames_captured = 0;
            frames_queued = 0;
            queue_full_count = 0;
            last_stats_time = now;
        }
        
        // Délai adaptatif:
        // - Si STREAM actif: On laisse le stream handler réguler (via la queue)
        // - Si IDLE: On maintient un framerate décent (10-15fps) pour que le Motion Detection
        //   et le Pre-Recording fonctionnent correctement.
        //   AVANT: 1000ms (1fps) -> Trop lent, ratait les mouvements
        //   MAINTENANT: 50ms (~20fps max) -> Fluide et réactif
        if (!stream_client_active) {
            vTaskDelay(pdMS_TO_TICKS(50)); 
        }
        // Sinon: capture en continu max FPS (stream handler contrôle débit)
    }
    vTaskDelete(NULL);
}

// ==========================
// TÂCHE MOTION DETECTION (SUPPRIMÉE - Intégrée dans camera_capture_task)
// ==========================
// Ancienne tâche supprimée pour éviter Stack Overflow et économiser ressources
// La logique est maintenant 100% dans camera_capture_task (Core 0)

// ==========================
// GESTIONNAIRE D'ÉVÉNEMENTS WiFi
// ==========================
static void wifi_event_handler(void* arg, esp_event_base_t event_base,
                                int32_t event_id, void* event_data) {
    if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
        esp_wifi_connect();
    } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
        esp_wifi_connect();
        xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
    } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
        ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data;
        snprintf(device_ip, sizeof(device_ip), IPSTR, IP2STR(&event->ip_info.ip));
        ESP_LOGI(TAG, "WiFi connecté - IP: %s", device_ip);
        
        // Initialiser SNTP pour horloge temps réel
        ESP_LOGI(TAG, "⏰ Initialisation SNTP...");
        esp_sntp_setoperatingmode(SNTP_OPMODE_POLL);
        esp_sntp_setservername(0, "pool.ntp.org");
        esp_sntp_init();
        
        // Configurer timezone (Europe/Paris = CET/CEST)
        setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1);
        tzset();
        
        // Démarrer mDNS et serveur HTTP APRES connexion WiFi
        ESP_LOGI(TAG, "📡 Démarrage service mDNS...");
        start_mdns_service();
        
        ESP_LOGI(TAG, "🌍 Démarrage serveur HTTP...");
        ESP_LOGI(TAG, "   Free DRAM: %lu bytes", heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
        ESP_LOGI(TAG, "   Free PSRAM: %lu bytes", heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
        start_webserver();
        
        xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
    }
}

// ==========================
// INITIALISATION WiFi (AP+STA pour Setup)
// ==========================
void wifi_init_ap_sta(void) {
    ESP_LOGI(TAG, "⚠️ Echec connexion. Démarrage mode AP+STA pour configuration...");
    wifi_ap_mode = true;
    
    // Config AP
    wifi_config_t ap_config = {
        .ap = {
            .ssid = "ESP32-CAM-SETUP",
            .ssid_len = strlen("ESP32-CAM-SETUP"),
            .channel = 1,
            .password = "",
            .max_connection = 4,
            .authmode = WIFI_AUTH_OPEN
        },
    };
    
    // Config STA (vide pour l'instant)
    wifi_config_t sta_config = {
        .sta = {
            .scan_method = WIFI_ALL_CHANNEL_SCAN,
        },
    };

    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
    ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_config));
    ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &sta_config));
    ESP_ERROR_CHECK(esp_wifi_start());
    
    ESP_LOGI(TAG, "✅ AP Démarré. Connectez-vous à 'ESP32-CAM-SETUP'");
    ESP_LOGI(TAG, "   Puis allez sur: http://192.168.4.1/setup");
}

void wifi_init_sta(void) {
    s_wifi_event_group = xEventGroupCreate();

    ESP_ERROR_CHECK(esp_netif_init());
    ESP_ERROR_CHECK(esp_event_loop_create_default());
    esp_netif_create_default_wifi_sta();
    esp_netif_create_default_wifi_ap(); 

    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&cfg));

    ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL));
    ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL));

    // Charger identifiants
    load_wifi_creds();

    wifi_config_t wifi_config = {
        .sta = {
            .threshold.authmode = WIFI_AUTH_WPA2_PSK,
            .sae_pwe_h2e = WPA3_SAE_PWE_BOTH,
        },
    };
    strncpy((char*)wifi_config.sta.ssid, wifi_ssid, sizeof(wifi_config.sta.ssid));
    strncpy((char*)wifi_config.sta.password, wifi_pass, sizeof(wifi_config.sta.password));
    
    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
    ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
    ESP_ERROR_CHECK(esp_wifi_start());
    
    ESP_LOGI(TAG, "Connexion au WiFi: %s...", wifi_ssid);
    
    // Attendre connexion avec timeout (10 secondes)
    EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group,
            WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
            pdFALSE,
            pdFALSE,
            pdMS_TO_TICKS(10000));

    if (bits & WIFI_CONNECTED_BIT) {
        ESP_LOGI(TAG, "✅ Connecté au SSID: %s", wifi_ssid);
        esp_wifi_set_ps(WIFI_PS_NONE); // Désactiver power save
    } else {
        ESP_LOGW(TAG, "❌ Echec connexion au SSID: %s", wifi_ssid);
        wifi_init_ap_sta(); // Basculer en AP+STA
    }
}

// ==========================
// mDNS SERVICE
// ==========================
void start_mdns_service(void) {
    esp_err_t err = mdns_init();
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "mDNS Init failed: %d", err);
        return;
    }

    mdns_hostname_set(MDNS_HOSTNAME);
    mdns_instance_name_set(DEVICE_NAME);

    // Service HTTP
    mdns_service_add(NULL, "_http", "_tcp", 80, NULL, 0);
    
    // Service caméra avec attributs
    mdns_txt_item_t camera_txt[] = {
        {"stream", "/stream"},
        {"snapshot", "/snapshot"},
        {"resolution", "1280x720"},
        {"sensor", "OV3660"},
        {"fps", "15"}
    };
    mdns_service_add(NULL, "_camera", "_tcp", 80, camera_txt, 5);
}

// ==========================
// HANDLERS HTTP
// ==========================

// Handler générique pour servir fichiers SPIFFS
static esp_err_t spiffs_file_handler(httpd_req_t *req) {
    char filepath[512]; // Augmenté pour éviter truncation warning
    // Si URI est /setup, servir /spiffs/setup.html
    if (strcmp(req->uri, "/setup") == 0) {
        strcpy(filepath, "/spiffs/setup.html");
    } else {
        // Sinon essayer de servir le fichier demandé
        // Limite explicite pour satisfaire le compilateur (512 - 7 "/spiffs" - 1 null = 504)
        snprintf(filepath, sizeof(filepath), "/spiffs%.500s", req->uri);
    }

    FILE *f = fopen(filepath, "r");
    if (f == NULL) {
        ESP_LOGE(TAG, "Failed to open file for reading: %s", filepath);
        httpd_resp_send_404(req);
        return ESP_FAIL;
    }

    char line[256];
    while (fgets(line, sizeof(line), f)) {
        httpd_resp_send_chunk(req, line, strlen(line));
    }
    fclose(f);
    httpd_resp_send_chunk(req, NULL, 0);
    return ESP_OK;
}

// Handler pour scanner les réseaux
static esp_err_t scan_handler(httpd_req_t *req) {
    wifi_scan_config_t scan_config = {
        .ssid = NULL,
        .bssid = NULL,
        .channel = 0,
        .show_hidden = true
    };
    
    ESP_ERROR_CHECK(esp_wifi_scan_start(&scan_config, true)); // Scan bloquant

    uint16_t ap_count = 0;
    esp_wifi_scan_get_ap_num(&ap_count);
    wifi_ap_record_t *ap_list = (wifi_ap_record_t *)malloc(sizeof(wifi_ap_record_t) * ap_count);
    ESP_ERROR_CHECK(esp_wifi_scan_get_ap_records(&ap_count, ap_list));

    httpd_resp_set_type(req, "application/json");
    httpd_resp_send_chunk(req, "[", 1);
    
    for (int i = 0; i < ap_count; i++) {
        char json_entry[128];
        // Filtrer SSIDs vides
        if (strlen((char *)ap_list[i].ssid) > 0) {
            int len = snprintf(json_entry, sizeof(json_entry), 
                "{\"ssid\":\"%s\",\"rssi\":%d}%s", 
                ap_list[i].ssid, ap_list[i].rssi, 
                (i < ap_count - 1) ? "," : "");
            httpd_resp_send_chunk(req, json_entry, len);
        }
    }
    
    httpd_resp_send_chunk(req, "]", 1);
    httpd_resp_send_chunk(req, NULL, 0);
    
    free(ap_list);
    return ESP_OK;
}

// Handler pour tenter la connexion (sans reboot immédiat)
static esp_err_t connect_handler(httpd_req_t *req) {
    char content[128];
    size_t recv_size = MIN(req->content_len, sizeof(content));
    int ret = httpd_req_recv(req, content, recv_size);
    if (ret <= 0) return ESP_FAIL;
    content[recv_size] = 0;

    char new_ssid[32] = {0};
    char new_pass[64] = {0};
    
    // Parsing basique
    char *ssid_start = strstr(content, "ssid=");
    char *pass_start = strstr(content, "pass=");
    
    if (ssid_start) {
        ssid_start += 5;
        char *ssid_end = strchr(ssid_start, '&');
        if (ssid_end) {
            int len = ssid_end - ssid_start;
            if (len > 31) len = 31;
            strncpy(new_ssid, ssid_start, len);
            // URL Decode simple (remplace %20 par espace)
            // TODO: Vrai URL decode si nécessaire
        }
    }
    if (pass_start) {
        pass_start += 5;
        strncpy(new_pass, pass_start, 63);
    }

    if (strlen(new_ssid) > 0) {
        ESP_LOGI(TAG, "Testing connection to: %s", new_ssid);
        
        // Sauvegarder NVS
        save_wifi_creds(new_ssid, new_pass);
        
        // Tenter connexion
        wifi_config_t wifi_config = {0};
        strncpy((char*)wifi_config.sta.ssid, new_ssid, 32);
        strncpy((char*)wifi_config.sta.password, new_pass, 64);
        wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
        
        esp_wifi_set_config(WIFI_IF_STA, &wifi_config);
        esp_wifi_connect();
        
        wifi_connect_status = 1; // Connecting
        httpd_resp_send(req, "OK", 2);
    } else {
        httpd_resp_send_500(req);
    }
    return ESP_OK;
}

// Handler pour vérifier le statut de connexion
static esp_err_t setup_status_handler(httpd_req_t *req) {
    char json[128];
    const char *status_str = "idle";
    
    // Vérifier si connecté
    EventBits_t bits = xEventGroupGetBits(s_wifi_event_group);
    if (bits & WIFI_CONNECTED_BIT) {
        wifi_connect_status = 2;
        // Récupérer IP
        esp_netif_ip_info_t ip_info;
        esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
        esp_netif_get_ip_info(netif, &ip_info);
        snprintf(last_connected_ip, sizeof(last_connected_ip), IPSTR, IP2STR(&ip_info.ip));
    } else if (bits & WIFI_FAIL_BIT) {
        wifi_connect_status = 3;
    }

    if (wifi_connect_status == 1) status_str = "connecting";
    else if (wifi_connect_status == 2) status_str = "connected";
    else if (wifi_connect_status == 3) status_str = "failed";
    
    snprintf(json, sizeof(json), "{\"status\":\"%s\",\"ip\":\"%s\"}", status_str, last_connected_ip);
    
    httpd_resp_set_type(req, "application/json");
    httpd_resp_send(req, json, strlen(json));
    return ESP_OK;
}

// Handler pour la page d'accueil
static esp_err_t index_handler(httpd_req_t *req) {
    // Si en mode AP, rediriger vers setup
    if (wifi_ap_mode) {
        httpd_resp_set_status(req, "302 Found");
        httpd_resp_set_hdr(req, "Location", "/setup");
        httpd_resp_send(req, NULL, 0);
        return ESP_OK;
    }

    const char* html = 
        "<!DOCTYPE html>"
        "<html><head><meta charset='utf-8'>"
        "<meta name='viewport' content='width=device-width, initial-scale=1'>"
        "<title>ESP32-S3 CAM - HA Edition</title>"
        "<style>"
        "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;margin:0;padding:20px;background:#f0f2f5;color:#1c1e21;}"
        ".container{max-width:800px;margin:0 auto;background:white;padding:20px;border-radius:8px;box-shadow:0 2px 4px rgba(0,0,0,0.1);}"
        "h1{text-align:center;color:#1877f2;margin-bottom:20px;}"
        ".stream-box{text-align:center;margin:20px 0;background:#000;border-radius:4px;overflow:hidden;}"
        "img{max-width:100%;height:auto;display:block;margin:0 auto;}"
        ".info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:15px;margin-top:20px;}"
        ".card{background:#f7f8fa;padding:15px;border-radius:6px;border:1px solid #ddd;}"
        ".card h3{margin:0 0 10px 0;font-size:12px;color:#65676b;text-transform:uppercase;letter-spacing:0.5px;}"
        ".card p{margin:0;font-weight:bold;font-size:15px;color:#050505;}"
        ".sub{font-size:12px;color:#606770;margin-top:4px;}"
        ".status-ok{color:#31a24c;}"
        ".links{text-align:center;margin-top:25px;padding-top:20px;border-top:1px solid #eee;}"
        "a{color:#1877f2;text-decoration:none;margin:0 10px;font-weight:600;font-size:14px;}"
        "a:hover{text-decoration:underline;}"
        "</style>"
        "</head><body>"
        "<div class='container'>"
        "<h1>ESP32-S3 CAM <span style='font-size:14px;color:#65676b;font-weight:normal'>| Home Assistant Ready</span></h1>"
        "<div class='stream-box'><img src='/stream' /></div>"
        "<div class='info-grid'>"
        "<div class='card'><h3>Camera Config</h3><p>OV3660 HD 720p</p><p class='sub'>16MHz | Q18 | Adaptive FPS</p></div>"
        "<div class='card'><h3>Motion Detection</h3><p class='status-ok'>ACTIVE</p><p class='sub'>Thresh: 12 | Pre: 5s | Post: 5s</p></div>"
        "<div class='card'><h3>Storage</h3><p>SD Card (FAT32)</p><p class='sub'>AVI Recording (Motion)</p></div>"
        "<div class='card'><h3>System</h3><p>Dual Core Optimized</p><p class='sub'>Static Alloc | TCP 32KB</p></div>"
        "</div>"
        "<div class='links'>"
        "<a href='/stream' target='_blank'>Full Stream</a>"
        "<a href='/snapshot' target='_blank'>Snapshot</a>"
        "<a href='/api/status' target='_blank'>JSON Status</a>"
        "<a href='/api/info' target='_blank'>JSON Info</a>"
        "</div>"
        "</div>"
        "</body></html>";
    
    httpd_resp_set_type(req, "text/html");
    return httpd_resp_send(req, html, HTTPD_RESP_USE_STRLEN);
}

// Handler pour le stream MJPEG (optimisé pour Home Assistant 2025.11)
static esp_err_t stream_handler(httpd_req_t *req) {
    camera_fb_t *fb = NULL;
    esp_err_t res = ESP_OK;
    size_t _jpg_buf_len = 0;
    uint8_t *_jpg_buf = NULL;
    
    // Buffer DRAM interne pour boundary+headers uniquement (PSRAM trop lente)
    // JPEG reste en PSRAM caméra - pas de copie inutile
    // 512B: boundary(70) + headers(150) + marge(292) = fusion 1 paquet TCP
    char *header_buffer = (char*)heap_caps_malloc(512, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
    if (!header_buffer) {
        ESP_LOGE(TAG, "❌ Erreur allocation buffer header (512B DRAM)");
        httpd_resp_send_500(req);
        return ESP_FAIL;
    }

    static const char* _STREAM_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=frame";
    static const char* _STREAM_BOUNDARY = "\r\n--frame\r\n";

    // Headers CORS pour permettre l'accès depuis Home Assistant
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, OPTIONS");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "*");
    
    // Headers optimisés pour streaming
    httpd_resp_set_hdr(req, "Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
    httpd_resp_set_hdr(req, "Pragma", "no-cache");
    httpd_resp_set_hdr(req, "Expires", "0");
    httpd_resp_set_hdr(req, "X-Framerate", "20");  // 20fps adaptatif (12fps si lent)

    // CRITIQUE: Optimisations TCP pour streaming MJPEG fluide
    int sockfd = httpd_req_to_sockfd(req);
    if (sockfd >= 0) {
        // TCP_NODELAY: Désactive algorithme de Nagle (envoi immédiat, pas d'attente)
        int nodelay = 1;
        if (setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &nodelay, sizeof(nodelay)) < 0) {
            ESP_LOGW(TAG, "⚠️  Echec configuration TCP_NODELAY");
        }
        
        // SO_RCVBUF: Buffer réception (Optimisation: 32KB pour meilleure stabilité WiFi)
        // Utilise DRAM disponible (reste >100KB libre)
        int rcvbuf = 32768;
        if (setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf)) < 0) {
            ESP_LOGW(TAG, "⚠️  Echec configuration SO_RCVBUF");
        } else {
            ESP_LOGI(TAG, "✅ Buffer TCP RX: 32KB (Optimisé pour stabilité)");
        }
        
        // SO_SNDTIMEO: Timeout envoi socket (10s pour frames JPEG 55KB)
        struct timeval snd_timeout;
        snd_timeout.tv_sec = 10;
        snd_timeout.tv_usec = 0;
        if (setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &snd_timeout, sizeof(snd_timeout)) < 0) {
            ESP_LOGW(TAG, "⚠️  Echec configuration SO_SNDTIMEO");
        } else {
            ESP_LOGI(TAG, "✅ Timeout envoi socket: 10s");
        }
        
        /* 
         * NOTE ESP-IDF 5.x: L'accès direct aux structures internes LwIP (lwip_socket_dbg_get_socket)
         * est déconseillé et peut causer des instabilités.
         * On utilise uniquement setsockopt standard.
         */
        
        // Essayer aussi SO_SNDBUF via setsockopt (même si non supporté)
        int sndbuf = 65535;
        if (setsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf)) == 0) {
            ESP_LOGI(TAG, "✅ Buffer TCP TX (SO_SNDBUF): 64KB");
        }
        
        ESP_LOGI(TAG, "✅ TCP optimisé: NODELAY=ON | RCVBUF=16KB | SNDBUF=64KB | SNDTIMEO=10s");
    }
    
    res = httpd_resp_set_type(req, _STREAM_CONTENT_TYPE);
    if (res != ESP_OK) {
        return res;
    }

    ESP_LOGI(TAG, "🔴 Stream démarré sur Core %d (Serveur HTTP)", xPortGetCoreID());
    ESP_LOGI(TAG, "   Architecture: Réception frames depuis Core 0 via queue");
    ESP_LOGI(TAG, "   Cible: 15fps | JPEG Q18 (~20-25KB/frame) | Buffers TCP 65KB");
    
    // ⚡ ACTIVER MODE HAUTE PERFORMANCE: Camera capture passe en max FPS
    ESP_LOGI(TAG, "⚡ Mode Caméra: IDLE(20fps) → STREAM(max fps)");
    stream_client_active = true;
    
    uint32_t frames_sent = 0;
    uint32_t frames_dropped = 0;
    // uint32_t slow_sends = 0;
    int64_t last_stats_time = esp_timer_get_time();

    while (true) {
        // ⚡ CORE 1: LOGIQUE "SMART CATCH-UP" (Rattrapage de latence)
        // Si on a accumulé trop de retard (> 4 frames, soit ~250ms), on saute directement au présent.
        // Cela permet de garder la fluidité (buffering) pour les petits hoquets, mais de tuer le lag.
        UBaseType_t waiting = uxQueueMessagesWaiting(stream_frame_queue);
        if (waiting > 4) {
            stream_frame_t drop_frame;
            uint32_t dropped_in_burst = 0;
            // On vide tout sauf la dernière frame (la plus récente)
            while (uxQueueMessagesWaiting(stream_frame_queue) > 1) {
                if (xQueueReceive(stream_frame_queue, &drop_frame, 0) == pdTRUE) {
                    esp_camera_fb_return(drop_frame.fb);
                    dropped_in_burst++;
                    frames_dropped++;
                }
            }
            if (dropped_in_burst > 0) {
                ESP_LOGW(TAG, "⏩ Rattrapage: %lu frames sautées pour synchro", dropped_in_burst);
            }
        }

        // Recevoir image de Core 0 (tâche caméra) via file d'attente
        stream_frame_t frame_data;
        
        // Timeout 50ms: réactivité pour 8fps (125ms/image)
        if (xQueueReceive(stream_frame_queue, &frame_data, pdMS_TO_TICKS(50)) != pdTRUE) {
            // Pas d'image disponible, vérifier si client toujours connecté
            continue;
        }
        
        fb = frame_data.fb;
        int64_t frame_start = frame_data.timestamp;

        // Vérifier format
        if (fb->format != PIXFORMAT_JPEG) {
            ESP_LOGE(TAG, "Données non-JPEG non supportées");
            esp_camera_fb_return(fb);
            frames_dropped++;
            continue;
        }

        _jpg_buf_len = fb->len;
        _jpg_buf = fb->buf;

        // ⚡ ENVOI OPTIMISÉ: En-têtes et JPEG séparés, TCP gère la fragmentation
        // NOTE: On garde fb en mémoire pendant tout l'envoi (pas de copie)
        // fb sera libéré APRÈS httpd_resp_send_chunk() complet
        char part_header[128];
        size_t hlen = snprintf(part_header, sizeof(part_header), 
                              "%s%s%u\r\n\r\n", 
                              _STREAM_BOUNDARY, "Content-Type: image/jpeg\r\nContent-Length: ", _jpg_buf_len);
        
        // ESP_LOGI(TAG, "📤 Frame #%lu: headers=%zu + JPEG=%u = %zu bytes | JPEG buf @ %p", 
        //      frames_sent + 1, hlen, _jpg_buf_len, hlen + _jpg_buf_len, (void*)_jpg_buf);
        
        int64_t send_start = esp_timer_get_time();
        
        // Envoi 1: En-têtes (tampon séparé)
        res = httpd_resp_send_chunk(req, part_header, hlen);
        if (res != ESP_OK) {
            ESP_LOGW(TAG, "⚠️ Echec envoi En-tête: %d", res);
            goto send_failed;
        }
        
        // Envoi 2: JPEG complet (TCP fragmente automatiquement si nécessaire)
        // Pas de chunking manuel : simplifie code et laisse TCP optimiser
        int64_t jpeg_start = esp_timer_get_time();
        res = httpd_resp_send_chunk(req, (const char *)_jpg_buf, _jpg_buf_len);
        int64_t jpeg_time = (esp_timer_get_time() - jpeg_start) / 1000;
        
        if (res != ESP_OK) {
            ESP_LOGW(TAG, "⚠️ Echec envoi JPEG: res=%d temps=%lldms", res, jpeg_time);
            goto send_failed;
        }
        
        // if (jpeg_time > 500) {
        //     ESP_LOGW(TAG, "🐌 JPEG send slow: %lldms for %u bytes (%.1f KB/s)",
        //              jpeg_time, _jpg_buf_len, (_jpg_buf_len / 1024.0) / (jpeg_time / 1000.0));
        // }
        
        // Success: log performance
        int64_t send_time = (esp_timer_get_time() - send_start) / 1000;
        
        frames_sent++;  // Incrémenter APRÈS envoi réussi
        // ESP_LOGI(TAG, "✅ Frame #%lu sent: %zu bytes in %lldms (%.1f KB/s)", 
        //          frames_sent, total_sent, send_time, throughput);
        
send_failed:
        // ⚡ Log avant libération du framebuffer
        if (fb != NULL) {
            // ESP_LOGI(TAG, "🔄 Free framebuffer: fb @ %p, buf @ %p, len=%u", (void*)fb, (void*)fb->buf, fb->len);
            esp_camera_fb_return(fb);
            fb = NULL;
        }
        
        // Détection déconnexion
        if (res != ESP_OK) {
            // On ne loggue plus en Warning (jaune/rouge) mais en Info (vert/blanc)
            // car une déconnexion est normale quand l'utilisateur ferme l'onglet
            ESP_LOGI(TAG, "👋 Client déconnecté (Fin de session)");
            
            // ⚡ DÉSACTIVER MODE HAUTE PERFORMANCE: Camera repasse en 1 fps
            stream_client_active = false;
            
            // CRITIQUE: Vider la queue des frames non-envoyées
            // Sinon elles restent bloquées et ralentissent captures suivantes
            stream_frame_t discarded_frame;
            uint32_t purged = 0;
            while (xQueueReceive(stream_frame_queue, &discarded_frame, 0) == pdTRUE) {
                esp_camera_fb_return(discarded_frame.fb);  // Libérer framebuffer
                purged++;
            }
            
            ESP_LOGI(TAG, "📊 Rapport Session: Envoyées=%lu | Perdues=%lu | Purgées=%lu", 
                     frames_sent, frames_dropped, purged);
            ESP_LOGI(TAG, "⚡ Mode Caméra: STREAM(15fps) → IDLE(1fps)");
            break;
        }
        
        // Détection envoi lent (pas de log size car 2 envois séparés)
        if (send_time > 500) {
             // On ne loggue que les VRAIS ralentissements (>500ms) pour ne pas spammer
             // slow_sends++; 
             // ESP_LOGD(TAG, "🐌 Slow send: %lld ms", send_time);
        }

        
        // ⏱️ FPS control: Limité à 15 FPS pour éviter saturation WiFi
        // C'est le "Sweet Spot" : Fluide mais laisse respirer le réseau
        int64_t frame_time = (esp_timer_get_time() - frame_start) / 1000;
        int64_t target_frame_time = 66;  // 15 FPS (66ms)
        int64_t delay_time = target_frame_time - frame_time;
        
        if (delay_time > 0) {
            vTaskDelay(pdMS_TO_TICKS(delay_time));
        }
        
        // Statistiques périodiques (Toutes les 5 secondes)
        int64_t now = esp_timer_get_time();
        if ((now - last_stats_time) > 5000000) {  // 5s
            float elapsed_sec = (now - last_stats_time) / 1000000.0;
            float actual_fps = frames_sent / elapsed_sec;
            UBaseType_t queue_waiting = uxQueueMessagesWaiting(stream_frame_queue);
            
            ESP_LOGI(TAG, "📈 Stats Stream: %.1f FPS | File: %u/%d | Perdues: %lu",
                     actual_fps, queue_waiting, STREAM_QUEUE_SIZE, frames_dropped);
            
            frames_sent = 0;
            frames_dropped = 0;
            // slow_sends = 0;
            last_stats_time = now;
        }
    }

    // ⚡ CLEANUP: Désactiver mode haute performance si pas déjà fait
    stream_client_active = false;
    free(header_buffer);
    return res;
}

// Handler pour capturer une seule image (snapshot)
static esp_err_t capture_handler(httpd_req_t *req) {
    // Support des requêtes HEAD pour healthcheck (Home Assistant)
    if (req->method == HTTP_HEAD) {
        httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
        httpd_resp_set_type(req, "image/jpeg");
        httpd_resp_set_hdr(req, "Content-Length", "0");
        return httpd_resp_send(req, NULL, 0);
    }
    
    camera_fb_t *fb = esp_camera_fb_get();
    if (!fb) {
        ESP_LOGE(TAG, "Camera capture failed");
        httpd_resp_send_500(req);
        return ESP_FAIL;
    }

    // Headers CORS
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, HEAD, OPTIONS");
    
    // Headers optimisés pour image statique
    httpd_resp_set_type(req, "image/jpeg");
    httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=snapshot.jpg");
    httpd_resp_set_hdr(req, "Cache-Control", "no-store, no-cache, must-revalidate");
    
    esp_err_t res = httpd_resp_send(req, (const char *)fb->buf, fb->len);
    esp_camera_fb_return(fb);
    
    return res;
}

// ==========================
// API REST JSON
// ==========================

// Handler /api/info - Informations caméra
static esp_err_t api_info_handler(httpd_req_t *req) {
    char json[768];
    uint8_t mac[6];
    esp_wifi_get_mac(WIFI_IF_STA, mac);
    
    // Headers CORS
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    httpd_resp_set_type(req, "application/json");
    
    snprintf(json, sizeof(json),
        "{"
        "\"device\":\"%s\","
        "\"sensor\":\"OV3660\","
        "\"resolution\":\"1280x720\","
        "\"quality\":\"Photo\","
        "\"fps\":15,"
        "\"format\":\"MJPEG\","
        "\"jpeg_quality\":18,"
        "\"mac\":\"%02X:%02X:%02X:%02X:%02X:%02X\","
        "\"ip\":\"%s\","
        "\"stream_url\":\"http://%s/stream\","
        "\"snapshot_url\":\"http://%s/snapshot\","
        "\"capture_url\":\"http://%s/capture\","
        "\"mdns\":\"http://%s.local\","
        "\"ha_2025_optimized\":true,"
        "\"hls_compatible\":true"
        "}",
        DEVICE_NAME,
        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5],
        device_ip, device_ip, device_ip, device_ip, MDNS_HOSTNAME
    );
    
    httpd_resp_set_type(req, "application/json");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    return httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN);
}

// Handler /api/status - État actuel
static esp_err_t api_status_handler(httpd_req_t *req) {
    char json[512];
    int64_t uptime = (esp_timer_get_time() - start_time) / 1000000;
    float actual_fps = 0;
    
    if (uptime > 0) {
        actual_fps = (float)frame_count / uptime;
    }
    
    snprintf(json, sizeof(json),
        "{"
        "\"uptime\":%lld,"
        "\"free_heap\":%lu,"
        "\"frame_count\":%lu,"
        "\"fps_actual\":%.2f,"
        "\"camera_ready\":%s,"
        "\"motion_detection\":%s,"
        "\"motion_events\":%lu,"
        "\"sd_card_ready\":%s,"
        "\"recording\":%s"
        "}",
        uptime,
        esp_get_free_heap_size(),
        frame_count,
        actual_fps,
        camera_ready ? "true" : "false",
        MOTION_DETECTION_ENABLED ? "true" : "false",
        motion_event_count,
        sd_card_mounted ? "true" : "false",
        is_recording ? "true" : "false"
    );
    
    httpd_resp_set_type(req, "application/json");
    httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
    return httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN);
}

// ==========================
// DÉMARRAGE SERVEUR HTTP
// ==========================
void start_webserver(void) {
    httpd_config_t config = HTTPD_DEFAULT_CONFIG();
    config.server_port = 80;
    config.ctrl_port = 32768;
    config.max_open_sockets = 13;           // CRITIQUE: 4 streams + 8 requêtes rapides + 1 réserve
    config.stack_size = 12288;              // 12KB stack (handlers complexes streaming)
    config.task_priority = 22;              // CRITIQUE: 22 (entre TCPIP 24 et WiFi 23) pour réactivité streaming
    config.core_id = 1;                     // ⚡ Core 1: WiFi/HTTP (OPTIMIZATIONS.md Option B)
                                            // Core 0: camera_capture_task dédiée
    config.max_uri_handlers = 12; // Augmenté pour supporter tous les endpoints (setup, scan, etc.)
    config.max_resp_headers = 8;
    config.backlog_conn = 5;
    config.lru_purge_enable = true;         // IMPORTANT: purge LRU pour libérer sockets inactifs
    config.recv_wait_timeout = 10;          // 10s timeout recv (streaming continu)
    config.send_wait_timeout = 10;          // 10s timeout envoi (aligné sur SO_SNDTIMEO)
    config.close_fn = NULL;                 // Cleanup automatique sockets fermés
    config.enable_so_linger = true;         // Linger court pour fermeture propre
    config.linger_timeout = 1;              // 1s max pour fermer socket
    config.keep_alive_enable = true;        // CRITIQUE: TCP keepalive pour détecter déconnexions
    config.keep_alive_idle = 5;             // 5s idle avant premier keepalive
    config.keep_alive_interval = 3;         // 3s entre probes keepalive
    config.keep_alive_count = 3;            // 3 probes ratées = déconnexion

    httpd_uri_t index_uri = {
        .uri       = "/",
        .method    = HTTP_GET,
        .handler   = index_handler,
        .user_ctx  = NULL
    };

    httpd_uri_t stream_uri = {
        .uri       = "/stream",
        .method    = HTTP_GET,
        .handler   = stream_handler,
        .user_ctx  = NULL
    };

    httpd_uri_t capture_uri = {
        .uri       = "/capture",
        .method    = HTTP_GET,
        .handler   = capture_handler,
        .user_ctx  = NULL
    };

    // Endpoint /snapshot (standard Home Assistant)
    httpd_uri_t snapshot_uri = {
        .uri       = "/snapshot",
        .method    = HTTP_GET,
        .handler   = capture_handler,
        .user_ctx  = NULL
    };

    // Support HEAD pour healthcheck (Home Assistant)
    httpd_uri_t snapshot_head_uri = {
        .uri       = "/snapshot",
        .method    = HTTP_HEAD,
        .handler   = capture_handler,
        .user_ctx  = NULL
    };

    httpd_uri_t capture_head_uri = {
        .uri       = "/capture",
        .method    = HTTP_HEAD,
        .handler   = capture_handler,
        .user_ctx  = NULL
    };

    httpd_uri_t api_info_uri = {
        .uri       = "/api/info",
        .method    = HTTP_GET,
        .handler   = api_info_handler,
        .user_ctx  = NULL
    };

    httpd_uri_t api_status_uri = {
        .uri       = "/api/status",
        .method    = HTTP_GET,
        .handler   = api_status_handler,
        .user_ctx  = NULL
    };

    httpd_uri_t setup_uri = {
        .uri       = "/setup",
        .method    = HTTP_GET,
        .handler   = spiffs_file_handler,
        .user_ctx  = NULL
    };

    httpd_uri_t scan_uri = {
        .uri       = "/api/scan",
        .method    = HTTP_GET,
        .handler   = scan_handler,
        .user_ctx  = NULL
    };

    httpd_uri_t connect_uri = {
        .uri       = "/api/connect",
        .method    = HTTP_POST,
        .handler   = connect_handler,
        .user_ctx  = NULL
    };

    httpd_uri_t setup_status_uri = {
        .uri       = "/api/setup_status",
        .method    = HTTP_GET,
        .handler   = setup_status_handler,
        .user_ctx  = NULL
    };

    esp_err_t ret = httpd_start(&camera_httpd, &config);
    if (ret == ESP_OK) {
        ESP_LOGI(TAG, "✅ HTTP: Core %d | WiFi: Core %s | Arch: %s",
                 xPortGetCoreID(),
                 "1",  // WiFi Core 1 (sdkconfig)
                 "Core0=Cam | Core1=WiFi+HTTP");
        httpd_register_uri_handler(camera_httpd, &index_uri);
        httpd_register_uri_handler(camera_httpd, &stream_uri);
        httpd_register_uri_handler(camera_httpd, &capture_uri);
        httpd_register_uri_handler(camera_httpd, &capture_head_uri);
        httpd_register_uri_handler(camera_httpd, &snapshot_uri);
        httpd_register_uri_handler(camera_httpd, &snapshot_head_uri);
        httpd_register_uri_handler(camera_httpd, &api_info_uri);
        httpd_register_uri_handler(camera_httpd, &api_status_uri);
        httpd_register_uri_handler(camera_httpd, &setup_uri);
        httpd_register_uri_handler(camera_httpd, &scan_uri);
        httpd_register_uri_handler(camera_httpd, &connect_uri);
        httpd_register_uri_handler(camera_httpd, &setup_status_uri);
        ESP_LOGI(TAG, "✅ Tous les handlers URI enregistrés");
    } else {
        ESP_LOGE(TAG, "❌ Echec serveur HTTP: 0x%x (%s)", ret, esp_err_to_name(ret));
        ESP_LOGW(TAG, "⚠️  Caméra et SD fonctionnent, mais pas de streaming HTTP");
        ESP_LOGW(TAG, "   Heap: %lu, PSRAM: %lu", 
                 esp_get_free_heap_size(), 
                 heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
    }
}

// ==========================
// FONCTION PRINCIPALE
// ==========================
void app_main(void) {
    // Initialisation NVS
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        ret = nvs_flash_init();
    }
    ESP_ERROR_CHECK(ret);

    start_time = esp_timer_get_time();

    // ========================================
    // TEST CARTE SD EN PRIORITÉ (auto-détection pins)
    // ========================================
    ESP_LOGI(TAG, "");
    ESP_LOGI(TAG, "========================================");
    ESP_LOGI(TAG, "  DÉMARRAGE ESP32-S3-CAM GÉNÉRIQUE");
    ESP_LOGI(TAG, "  DRAM Free: %lu bytes (Internal)", esp_get_free_heap_size());
    ESP_LOGI(TAG, "  PSRAM Free: %lu bytes (External)", heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
    ESP_LOGI(TAG, "========================================");
    vTaskDelay(pdMS_TO_TICKS(100));  // Pause pour stabiliser UART
    init_sd_card();
    init_spiffs(); // Initialiser SPIFFS pour la page de setup
    ESP_LOGI(TAG, "========================================");
    vTaskDelay(pdMS_TO_TICKS(500));  // Pause pour lire les résultats
    
    // Initialisation de la caméra
    if (init_camera() != ESP_OK) {
        ESP_LOGE(TAG, "Echec initialisation caméra!");
        return;
    }
    ESP_LOGI(TAG, "Caméra: OV3660 HD 1280x720, JPEG Q18 (Equilibré @ 15MHz)");
    ESP_LOGI(TAG, "Détection Mouvement: ACTIVÉE (seuil: %d, blocs: %d)", 
             MOTION_THRESHOLD, MOTION_MIN_BLOCKS);

    // Initialiser buffer circulaire pour motion detection
    init_circular_buffer();

    // ⚡ ARCHITECTURE OPTIMALE (OPTIMIZATIONS.md Option B)
    // Queue streaming Core 0 (camera) → Core 1 (HTTP)
    stream_frame_queue = xQueueCreate(STREAM_QUEUE_SIZE, sizeof(stream_frame_t));
    
    // Motion detection (optionnel)
    recording_mutex = xSemaphoreCreateMutex();
    motion_frame_queue = xQueueCreate(MOTION_QUEUE_SIZE, sizeof(motion_frame_t));
    
    if (!stream_frame_queue || !recording_mutex || !motion_frame_queue) {
        ESP_LOGE(TAG, "Echec création files/mutex!");
        return;
    }
    ESP_LOGI(TAG, "✅ Files créées: stream(%d) + motion(%d)", 
             STREAM_QUEUE_SIZE, MOTION_QUEUE_SIZE);

    // Tâche caméra Core 0 (capture dédiée, isolation WiFi)
    xTaskCreatePinnedToCore(
        camera_capture_task,
        "cam_capture",
        8192,                           // 8KB stack
        NULL,
        configMAX_PRIORITIES - 1,       // Priorité 24 (très haute)
        NULL,
        0                               // ⚡ Core 0
    );
    ESP_LOGI(TAG, "✅ Tâche Caméra: Core 0, Priorité %d", configMAX_PRIORITIES - 1);

    // Tâche motion SUPPRIMÉE (évite Stack Overflow et économise RAM)
    ESP_LOGI(TAG, "✅ Tâche Motion: SUPPRIMÉE (Logique intégrée au Core 0)");
    ESP_LOGI(TAG, "   Architecture: Core 0=Camera(24)+Motion | Core 1=TCPIP(24)+WiFi(23)+HTTP(22)");

    // Initialisation WiFi (mDNS + HTTP server démarreront automatiquement après connexion)
    ESP_LOGI(TAG, "🌐 Démarrage WiFi...");
    wifi_init_sta();

    // Attendre que WiFi soit connecté ET serveur HTTP démarré
    // Si AP mode, on démarre le serveur manuellement car pas d'event GOT_IP
    if (wifi_ap_mode) {
        start_webserver();
    }

    ESP_LOGI(TAG, "===========================================");
    ESP_LOGI(TAG, "%s PRÊT", DEVICE_NAME);
    ESP_LOGI(TAG, "===========================================");
    if (wifi_ap_mode) {
        ESP_LOGI(TAG, "⚠️  MODE AP ACTIF");
        ESP_LOGI(TAG, "   Connectez-vous à: ESP32-CAM-SETUP");
        ESP_LOGI(TAG, "   Allez sur: http://192.168.4.1/setup");
    } else {
        ESP_LOGI(TAG, "Stream:   http://%s/stream", device_ip);
        ESP_LOGI(TAG, "Snapshot: http://%s/snapshot", device_ip);
        ESP_LOGI(TAG, "Info:     http://%s/api/info", device_ip);
        ESP_LOGI(TAG, "Status:   http://%s/api/status", device_ip);
        ESP_LOGI(TAG, "mDNS:     http://%s.local", MDNS_HOSTNAME);
    }
    ESP_LOGI(TAG, "===========================================");
    ESP_LOGI(TAG, "🎬 Détection Mouvement: ON | SD: %s", 
             sd_card_mounted ? "✅ PRÊT" : "❌ NON MONTÉE");
    if (!sd_card_mounted) {
        ESP_LOGW(TAG, "");
        ESP_LOGW(TAG, "⚠️  ATTENTION: Carte SD NON MONTÉE!");
        ESP_LOGW(TAG, "   - Détection de mouvement: ✅ Fonctionne");
        ESP_LOGW(TAG, "   - Streaming vidéo: ✅ Fonctionne");
        ESP_LOGW(TAG, "   - Enregistrement vidéo: ❌ DÉSACTIVÉ");
        ESP_LOGW(TAG, "");
        ESP_LOGW(TAG, "   Solutions:");
        ESP_LOGW(TAG, "   1) Insérer une carte SD FAT32 <32GB");
        ESP_LOGW(TAG, "   2) Vérifier que la carte n'est pas protégée en écriture");
        ESP_LOGW(TAG, "   3) Reformater en FAT32 si nécessaire");
        ESP_LOGW(TAG, "");
    }
    ESP_LOGI(TAG, "==========================================");
}
1 « J'aime »

Bonjour @Guf
merci pour le partage, j’ai déjà 7 ESPAM-32, je viens de commander des ESP32-S3-CAM avec caméra OV3660.
Je ne connais pas platformio, j’utilise ESPHome Builder.
On verra ce que ça donne.

Bob