Moniteur de niveau pellets dans réservoir de poêle

Bonjour,

Je suis en train de porter sous ESP32 un capteur de niveau de pellets pour mon poêle (non connecté.
J’avais fait ca sur Arduino avant.
Là c’est pas la même chose avec ESP builder et le yaml (:sweat_smile: )

Je suis passé parle micro mircro M5nanaoC6.
1port usb C pour l’alim. un port Grove 4pins (5v, gnd G1 et G2) 1 led de status en G7 et une led RGB en G20 (datat) et G19 (pwr).

Le capteur était un ultrasonic v2.0 de Grove qui s’est avéré impossible à reprendre - seulement 3 pins; la 4eme inutilisé. J’ai du reprendre le “même’ en version HC-SR04.
J’ai voulu utilisé la led RGB pour afficher des couleurs selon le niveau restant mais a priori c’est impossible en esphome

(neopixel n’existe pas ) C’est là toute la question que je pose ici si quelqu’un sais faire ?

voici le yaml flashé dans sa dernière version.
Des stats son ajoutée dans configuration.yaml
(désolé j’arrive pas à le mettre formatté ici)
EDIT
Je modifie mon sujet avec la version actuelle bien plus aboutie.
J’abandonne définitivement l’usage de la led RGB donc la gestion est non compatible avec cet ESP.
J’utilise la Led de statut avec diverses patterns de clignotement pour simplement indiquer si en veille (clignotement de type “respiration”) ou en mode actif (à chaque ping de sonar).

  • Les fonctionnalités actuelles sont:

  • Mesure de la distance adéquate dans la cuve de pellet.
    J’ai dû faire un tableau de mesures empiriques hauteur occupée/poids réel afin d’avoir une relativement bonne fiabilité notamment pour la partie inférieur du réservoir qui n’est plus un simple parallélépipède puisque de forme complexe façon entonnoir guidant les pellets jusqu’à la vis sans fin du poêle. J’ai reformé la forme de cône inversé que prnd le tas de pellets au fil de sa descente. Le sonar doit donc toujours être placé sus le couvercle au même endroit (son boitier tient par un aimant).

  • Le programme fait une interpolation entre les diverses étapes des mesures que j’ai fourni.

  • Les distances entre le sonar et le haut du tas sont remontées et interprétées comme des kg.

  • on conserve les stats pour les conso/jour /sem. /mois /année.

  • on gère le fait que le couvercle est ouvert : il faut stopper toute action d’enregistrement des valeurs sinon on va enregistrer une cuve vide ce qui va fausser toutes le stats. Donc si on mesure une distance incongrue (dans mon cas >60cm) alors le couvercle est ouvert et on passe en mode spécifique jusqu’à ce que les mesures reviennent dans des valeurs acceptables.

  • Un mode veille que je déclenche par un switch dans le dashboard stoppe toute mesure (et aussi toute écrire en flash) afin de limiter le grillage de la flash sur des années.

  • il y a probablement encore des bugs qui trainent et des entités fantômes avec tous les changements que j’ai fait…

esphome:
  name: pelletslevel
  friendly_name: "Pellets Level"

esp32:
  board: esp32-c6-devkitc-1
  framework:
    type: esp-idf

wifi:
  ssid: "*********"
  password: "*************"
  output_power: 20dB
  reboot_timeout: 15min
  power_save_mode: none
  ap: {}



api:
  encryption:
    key: "tWv6CMGEJoh1***********ODPkijkxk="
  services:
    - service: set_consumption_value
      variables:
        value: float
      then:
        - lambda: |-
            id(pellets_total_consumed) = value;
            id(last_saved_consumption) = value;
            global_preferences->sync();
            ESP_LOGI("pellets", "Nouvelle consommation: %.1f kg", value);  
            
    - service: set_new_pellets_consumption_value
      variables:
        value: float
      then:
        - globals.set:
            id: pellets_total_consumed
            value: !lambda 'return value;'
        - lambda: |-
            // Mettre à jour la référence pour éviter sur- ou sous-cumuls
            static float &last_ref = *((float*)&id(pellet_consumption)); // référence au static last
            last_ref = id(pellet_weight).state;
        - logger.log:
            format: "⚙️ Nouvelle valeur de consommation fixée à %.1f kg via Home Assistant"
            args: ['value']
        - text_sensor.template.publish:
            id: pellet_last_refill_text
            state: "Synchronisation manuelle"

# Synchronisation de l'heure avec Home Assistant
time:
  - platform: homeassistant
    id: homeassistant_time

ota:
  platform: esphome
  password: "52421d46ad0397*******47e272869"

logger:
  level: INFO
  baud_rate: 115200

# Sauvegarde en flash toutes les 3h
preferences:
  flash_write_interval: 3h

output:
  - platform: ledc
    pin: GPIO7
    id: status_led
    frequency: 1000Hz
  - platform: ledc #ne sert pas incompatible
    pin: GPIO19
    id: rgb_power
    channel: 0

# Scripts pour gérer les patterns LED et màj conso.to
script:
  # Clignotement lent pour monitoring désactivé (300ms ON / 3s OFF)
  - id: led_slow_blink
    mode: restart
    then:
      - while:
          condition:
            lambda: 'return !id(monitoring_enabled);'
          then:
            # Fade in (montée progressive) fade_status_led_index
            - output.set_level:
                id: status_led
                level: 0%
            - output.ledc.set_frequency:
                id: status_led
                frequency: 1000Hz
            # --- BLOC CORRIGÉ 1 : Fade In ---
            # --- FADE IN 
            - globals.set:
                id: fade_status_led_index
                value: '0' # Réinitialiser le compteur
            - while:
                condition:
                  lambda: 'return id(fade_status_led_index) < 30;'
                then:
                  - output.set_level:
                      id: status_led
                      # Utilise le compteur global pour le calcul
                      level: !lambda |-
                        return (id(fade_status_led_index) * 3.3) / 100.0;
                  - delay: 10ms
                  - globals.set:
                      id: fade_status_led_index
                      value: !lambda 'return id(fade_status_led_index) + 1;' # Incrémenter le compteur

            # Maintien 100ms
            - delay: 100ms 

            # Fade out (descente progressive)
            - globals.set:
                id: fade_status_led_index
                value: '0' # Réinitialiser le compteur
            - while:
                condition:
                  lambda: 'return id(fade_status_led_index) < 30;'
                then:
                  - output.set_level:
                      id: status_led
                      # Utilise le compteur global pour le calcul (inversé)
                      level: !lambda |-
                        return (100.0 - id(fade_status_led_index) * 3.3) / 100.0;
                  - delay: 10ms
                  - globals.set:
                      id: fade_status_led_index
                      value: !lambda 'return id(fade_status_led_index) + 1;' # Incrémenter le compteur
            # Pause
            - output.set_level:
                id: status_led
                level: 0%
            - delay: 7700ms
            
  # Clignotement rapide pour niveau critique < 10%
  - id: led_critical_blink
    mode: restart
    then:
      - while:
          condition:
            lambda: 'return id(low_pellets_alert).state;'
          then:
            - output.turn_on: status_led
            - delay: 200ms
            - output.turn_off: status_led
            - delay: 300ms
  
  # Flash visible au ping du sonar (300ms)
  - id: led_ping_flash
    mode: queued
    then:
      - output.turn_on: status_led
      - delay: 300ms
      - output.turn_off: status_led

  # Mise à jour manuelle conso totale    
  - id: update_pellets_consumption
    mode: restart
    then:
      - lambda: |-
          id(pellets_total_consumed) = id(new_pellets_consumption_value);
          id(last_saved_consumption) = id(new_pellets_consumption_value);
          global_preferences->sync();
          ESP_LOGI("pellets", "Consommation mise à jour à %.1f kg", id(new_pellets_consumption_value));
      - script.execute: led_ping_flash

globals:
  - id: fade_status_led_index
    type: int
    initial_value: '0'

  - id: last_valid_distance
    type: float
    initial_value: 'NAN'
  
  # Stockage persistant de la consommation totale
  - id: pellets_total_consumed
    type: float
    restore_value: no
    initial_value: '10.3'

  # Pour bouton de reset conso/modif valeur manuelle
  - id: new_pellets_consumption_value
    type: float
    initial_value: '0.0' 

  # Pour détecter les changements significatifs avant sauvegarde
  - id: last_saved_consumption
    type: float
    restore_value: no
    initial_value: '0.0'
  
  # Date et heure du dernier remplissage
  - id: last_refill_timestamp
    type: std::string
    restore_value: yes
    initial_value: '"Jamais"'
    #initial_value: '"07/10/2025 20:00"'
  
  # Flag pour détecter si couvercle ouvert (distance invalide)
  - id: lid_open
    type: bool
    restore_value: no
    initial_value: 'false'
  
  # Flag pour activer/désactiver le monitoring (contrôlé par switch)
  - id: monitoring_enabled
    type: bool
    restore_value: yes
    initial_value: 'true'

text_sensor:
  - platform: template
    name: "Pellets Last Refill"
    id: pellet_last_refill_text
    icon: mdi:calendar-clock
    lambda: |-
      return {id(last_refill_timestamp)};
    update_interval: 60s

  - platform: template
    name: "Pellets Lid Status"
    id: pellet_lid_status
    icon: mdi:window-open-variant
    lambda: |-
      if (!id(monitoring_enabled)) {
        return {"Monitoring OFF"};
      } else if (id(lid_open)) {
        return {"Ouvert"};
      } else {
        return {"Fermé"};
      }  
  - platform: template
    name: "Conso. Totale (kg) à appliquer"
    id: pellets_conso_new_value
    lambda: |-
      return {to_string(id(new_pellets_consumption_value)) + " kg"};



sensor:
  - platform: ultrasonic
    name: "Pellet Distance"
    id: tank_distance
    trigger_pin: GPIO1
    echo_pin: GPIO2
    update_interval: 60s
    timeout: 5m
    pulse_time: 10us
    unit_of_measurement: "cm"
    filters:
      - median:
          window_size: 5
      - multiply: 100.0
    on_value:
      then:
        - logger.log:
            format: "Distance: %.1f cm"
            args: ["x"]
        - if:
            condition:
              lambda: 'return id(monitoring_enabled);'
            then:
              - script.execute: led_ping_flash
        - lambda: |-
            // Garder la dernière valeur valide même si monitoring OFF
            if (!isnan(x) && x > 0) {
              id(last_valid_distance) = x;
            }
            
            // NE RIEN TRAITER si monitoring désactivé
            if (!id(monitoring_enabled)) {
              return;
            }
            
            // Détection couvercle ouvert : distance > 60cm uniquement
            if (!isnan(x) && x > 60.0) {
              if (!id(lid_open)) {
                id(lid_open) = true;
                ESP_LOGW("pellets", "⚠️ Couvercle ouvert détecté (distance: %.1f cm) - Mesures suspendues", x);
              }
            } else if (!isnan(x)) {
              // Distance valide : <= 60cm (couvercle fermé)
              if (id(lid_open)) {
                id(lid_open) = false;
                ESP_LOGI("pellets", "✅ Couvercle refermé - Mesures reprises");
              }
            }

  - platform: template
    name: "Pellet Level %"
    id: pellet_level
    unit_of_measurement: "%"
    accuracy_decimals: 1
    lambda: |-
      float d = id(last_valid_distance);
      if (isnan(d)) return {};
      
      float d_min = 3.0f;   // Plein (100%)
      float d_max = 55.0f;  // Vide (0%)
      
      float level = 100.0f * (d_max - d) / (d_max - d_min);
      return max(0.0f, min(100.0f, level));

  - platform: template
    name: "Pellet Weight"
    id: pellet_weight
    unit_of_measurement: "kg"
    accuracy_decimals: 1
    state_class: measurement
    device_class: weight
    lambda: |-
      float d = id(last_valid_distance);
      if (isnan(d)) return {};
      
      // Tableau de calibration basé sur mesures réelles
      // Format: {distance_cm, poids_kg}
      const float calibration[][2] = {
        {3.0f, 27.0f},   // Estimé max (sac 15kg + 9kg + marge)
        {10.0f, 24.0f},  // Mesuré: sac 15kg + 9kg
        {33.0f, 9.0f},   // Mesuré
        {37.0f, 7.0f},   // Mesuré
        {40.0f, 5.0f},   // Mesuré
        {43.0f, 3.0f},   // Mesuré
        {49.0f, 1.0f},   // Mesuré
        {55.0f, 0.3f}    // Mesuré: résiduel
      };
      const int n_points = 8;
      
      // Interpolation linéaire entre les points de mesure
      if (d <= calibration[0][0]) {
        return calibration[0][1]; // Au-dessus du max
      }
      if (d >= calibration[n_points-1][0]) {
        return calibration[n_points-1][1]; // Vide
      }
      
      // Trouver les deux points encadrants
      for (int i = 0; i < n_points - 1; i++) {
        if (d >= calibration[i][0] && d <= calibration[i+1][0]) {
          float d1 = calibration[i][0];
          float w1 = calibration[i][1];
          float d2 = calibration[i+1][0];
          float w2 = calibration[i+1][1];
          
          // Interpolation linéaire
          float ratio = (d - d1) / (d2 - d1);
          return w1 + ratio * (w2 - w1);
        }
      }
      
      return 0.0f; // Fallback


  - platform: template
    name: "Pellets Consumption"
    id: pellet_consumption
    unit_of_measurement: "kg"
    state_class: total_increasing
    device_class: weight
    accuracy_decimals: 1
    lambda: |-
      static bool initialized = false;
      float current = id(pellet_weight).state;
      static float last = current;

      // Initialisation
      if (!initialized) {
        initialized = true;
        last = current;
        id(last_saved_consumption) = id(pellets_total_consumed);
        ESP_LOGI("pellets", "Consommation restaurée: %.2f kg", id(pellets_total_consumed));
      }

      // Conditions d'inhibition
      if (!id(monitoring_enabled) || id(lid_open)) {
        return id(pellets_total_consumed);
      }

      // 1. Détection de consommation (baisse > 0.1 kg)
      if (last - current > 0.1) {
        float consumed = last - current;
        float new_total = id(pellets_total_consumed) + consumed;
        id(pellets_total_consumed) = new_total;
        last = current;

        // Sauvegarde si variation > 0.3 kg
        float diff = abs(new_total - id(last_saved_consumption));
        if (diff > 0.3) {
          id(last_saved_consumption) = new_total;
          global_preferences->sync();
          ESP_LOGI("pellets", "Consommation: +%.2f kg (total: %.2f kg)", consumed, new_total);
        }
      }

      // 2. Détection de remplissage (augmentation > 1 kg)
      else if (current - last > 1.0) {  // Seuil à 1kg comme demandé
        auto time = id(homeassistant_time).now();
        char timestamp[20];
        sprintf(timestamp, "%02d/%02d/%04d %02d:%02d",
                time.day_of_month, time.month, time.year,
                time.hour, time.minute);
        id(last_refill_timestamp) = timestamp;

        // On ne met PAS à jour last tout de suite : on attend la stabilisation
        ESP_LOGW("pellets", "Remplissage détecté (%.2f kg) - Attente stabilisation avant reset", current - last);
      }

      return id(pellets_total_consumed);
    update_interval: 60s


binary_sensor:
  - platform: status
    name: "Pellet Online"
    on_press:
      then:
        - script.stop: led_slow_blink
        - script.stop: led_critical_blink
        - if:
            condition:
              lambda: 'return id(monitoring_enabled);'
            then:
              - output.turn_on: status_led
            else:
              - script.execute: led_slow_blink
    on_release:
      then:
        - output.turn_off: status_led
        - script.stop: led_slow_blink
        - script.stop: led_critical_blink

  - platform: template
    name: "Low Pellets Alert"
    id: low_pellets_alert
    device_class: problem
    lambda: |-
      if (!id(monitoring_enabled) || id(lid_open)) {
        return false;
      }
      return id(pellet_level).state < 10.0;
    on_press:
      then:
        - logger.log: "⚠️ Niveau critique : moins de 10 pcents de pellets"
        - script.stop: led_slow_blink
        - script.execute: led_critical_blink
    on_release:
      then:
        - script.stop: led_critical_blink
        - if:
            condition:
              lambda: 'return id(monitoring_enabled);'
            then:
              - output.turn_on: status_led

  - platform: template
    name: "Low Pellets Warning"
    id: low_pellets_warning
    device_class: problem
    lambda: |-
      if (!id(monitoring_enabled) || id(lid_open)) {
        return false;
      }
      return id(pellet_level).state < 20.0;
    on_press:
      then:
        - logger.log: "⚠️ Niveau bas : moins de 20 pcents de pellets"

switch:
  - platform: restart
    name: "Restart Pellet Monitor"
    id: restart_switch
  
  - platform: template
    name: "Pellets Monitoring"
    id: pellets_monitoring_switch
    icon: mdi:home-thermometer
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON
    turn_on_action:
      - lambda: |-
          id(monitoring_enabled) = true;
          ESP_LOGI("pellets", "🔥 Monitoring activé - Saison de chauffe");
      - script.stop: led_slow_blink
      - output.turn_on: status_led
    turn_off_action:
      - lambda: |-
          id(monitoring_enabled) = false;
          ESP_LOGI("pellets", "☀️ Monitoring désactivé - Hors saison");
      - script.execute: led_slow_blink
    lambda: |-
      return id(monitoring_enabled);

  - platform: template
    name: "Mettre à jour conso. totale"
    id: update_pellets_consumption_btn
    icon: mdi:content-save
    optimistic: true
    turn_on_action:
      - script.execute: update_pellets_consumption

les automations (dans automations.yaml):

- id: pellets_critical_alert
  alias: Alerte pellets critique <10%
  description: Notification urgente quand niveau < 10%
  trigger:
  - platform: state
    entity_id: binary_sensor.pelletslevel_low_pellets_alert
    to: 'on'
  condition:
  - condition: state
    entity_id: switch.pelletslevel_pellets_monitoring
    state: 'on'
  action:
  - service: persistent_notification.create
    data:
      title: "\U0001F6A8 Pellets CRITIQUE"
      message: 'Niveau très bas : {{ states(''sensor.pelletslevel_pellet_level'')
        }}% Stock restant : {{ states(''sensor.pelletslevel_pellet_weight'') }}kg

        Remplissage URGENT nécessaire !

        '
- id: pellets_warning_alert
  alias: Alerte pellets niveau bas <20%
  description: Notification quand niveau < 20%
  trigger:
  - platform: state
    entity_id: binary_sensor.pelletslevel_low_pellets_warning
    to: 'on'
  condition:
  - condition: state
    entity_id: switch.pelletslevel_pellets_monitoring
    state: 'on'
  action:
  - service: persistent_notification.create
    data:
      title: ⚠️ Pellets niveau bas
      message: 'Niveau : {{ states(''sensor.pelletslevel_pellet_level'') }}% Stock
        restant : {{ states(''sensor.pelletslevel_pellet_weight'') }}kg

        Prévoir un remplissage prochainement.

        '
- id: pellets_refill_detected
  alias: Notification remplissage pellets
  description: Confirme la détection d'un remplissage
  trigger:
  - platform: state
    entity_id: text_sensor.pelletslevel_pellets_last_refill
  condition:
  - condition: template
    value_template: '{{ trigger.to_state.state != ''Jamais'' and trigger.from_state.state
      != trigger.to_state.state }}'
  action:
  - service: persistent_notification.create
    data:
      title: ✅ Remplissage détecté
      message: 'Remplissage effectué le {{ states(''text_sensor.pelletslevel_pellets_last_refill'')
        }} Nouveau stock : {{ states(''sensor.pelletslevel_pellet_weight'') }}kg

        '
- id: pellets_daily_report
  alias: Rapport quotidien pellets
  description: Résumé de la consommation à 21h
  trigger:
  - platform: time
    at: '21:00:00'
  condition:
  - condition: state
    entity_id: switch.pelletslevel_pellets_monitoring
    state: 'on'
  - condition: numeric_state
    entity_id: sensor.pellets_daily
    above: 0
  action:
  - service: persistent_notification.create
    data:
      title: "\U0001F4CA Rapport pellets du jour"
      message: 'Consommation aujourd''hui : {{ states(''sensor.pellets_daily'') }}kg
        Stock restant : {{ states(''sensor.pelletslevel_pellet_weight'') }}kg ({{
        states(''sensor.pelletslevel_pellet_level'') }}%) Consommation totale : {{
        states(''sensor.pelletslevel_pellets_consumption'') }}kg'
- id: '1759910087843'
  alias: set_new_pellets_consumption_value2
  description: ''
  triggers:
  - trigger: state
    entity_id:
    - input_number.pellets_consumption_reset
  conditions: []
  actions:
  - action: esphome.pelletslevel_set_new_pellets_consumption_value
    metadata: {}
    data: {}
  mode: single
et dans configuration.yaml:- platform: derivative
  source: sensor.pelletslevel_pellet_weight
  name: "Pellets Consumption Rate"
  round: 2
  unit_time: h
  unit: "kg/h"
  time_window: "01:00:00"
  
# UTILITY METERS - utilisent directement le capteur ESPHome
# cummuls des conso (kg) de pellets
utility_meter:
  pellets_daily:
    source: sensor.pelletslevel_pellets_consumption
    cycle: daily
  pellets_weekly:     
    source: sensor.pelletslevel_pellets_consumption
    cycle: weekly    
  pellets_monthly:
    source: sensor.pelletslevel_pellets_consumption
    cycle: monthly
  pellets_yearly:
    source: sensor.pelletslevel_pellets_consumption
    cycle: yearly


# TEMPLATE pour sauvegarder la valeur (optionnel mais recommandé)
template:
  - sensor:
      - name: "Pellets Stock Actuel"
        unique_id: pellets_stock_actuel
        unit_of_measurement: "kg"
        device_class: weight
        state_class: measurement
        state: "{{ states('sensor.pelletslevel_pellet_weight') | float(0) }}"
        
      - name: "Pellets Niveau %"
        unique_id: pellets_niveau_pct
        unit_of_measurement: "%"
        state: "{{ states('sensor.pelletslevel_pellet_level') | float(0) }}"

# Helper pour stocker la consommation totale persistante
input_number:
  pellets_consumption_backup:
    name: "Backup consommation pellets"
    min: 0
    max: 100000
    step: 0.1
    unit_of_measurement: "kg"
    mode: box  
  pellets_consumption_reset:
    name: "Nouvelle conso.Tot. Pellets (kg)"
    min: 0
    max: 10000
    step: 0.1
    mode: box
    unit_of_measurement: kg
    icon: mdi:counter
1 « J'aime »

Bonjour,
Édit ton sujet et met ton automatisation dans une balise texte préformaté. Merci

(neopixel n’existe pas ) C’est là toute la question que je pose ici si quelqu’un sais faire ?

ou

https://esphome.io/components/light/neopixelbus/

et oui :
”NeoPixelBus does not work with ESP-IDF.”

c’est bien ca.

Désoler, j’avais lu Esphome :face_with_open_eyes_and_hand_over_mouth: