Domotiser un poêle à granulés avec Home Assistant

Voici comment connecter à Home Assistant un poêle à granulés qui initialement ne le permettait pas. Tutoriel clair, étape par étape, fiable.
Domotiser un poêle à granulés avec Home Assistant

Sommaire

AVERTISSEMENT
La mise en œuvre de ce projet nécessite un certain nombre de compétences techniques. Elle est réalisée sous la seule responsabilité de la personne qui l’entreprend. HACF et l'auteur de l'article déclinent toute responsabilité en cas de dommage, incident ou mauvaise utilisation.

L'objet de ce DIY est de détailler une méthode de prise de contrôle sur un poêle à granulés, à l'ancienne, qui ne dispose pas de connexion ni de supervision déportée.

La solution est transposable à tout équipement de chauffe disposant d'une sonde d'ambiance filaire de type résistance.

Introduction

État des lieux

Le chauffage de la maison est assuré par un poêle à granulés de 8 kW, de marque DeDietrich, modèle Quadralis (clone de Oertli OPPA8 qui est un haas+Sohn rebrandé), datant de 2012, et ne disposant d'aucun élément de connectivité (un module propriétaire a existé, mais en GSM, très peu répandu et introuvable).

Le poêle dispose de sa propre régulation interne de puissance, type PID, asservie à un thermostat interne alimenté par une sonde de température filaire de type détecteur de température à résistance/Pt100.

Le poêle est piloté sur une consigne de température, et une hystérésis, et fonctionne en mode marche / arrêt entre les deux bornes d’hystérésis.

Au niveau utilisateur, on dispose d'un paramètre sur le PID pour ajuster le ralentissement de la charge thermique lorsque la température approche de la consigne pour éviter de surchauffer.

💡
Remarque : Le poêle dispose d'un menu "installateur" qui donne accès à d'autres réglages dont l'hystérésis, la charge thermique mini… Pour les intéressés, l'accès se fait dans le menu des réglages en maintenant la flèche vers le haut enfoncée et en appuyant 5 fois sur la flèche vers le bas.

Ce type d'équipement pose divers problèmes de confort, de flexibilité et de facilité d'utilisation :

  • Un calendrier de programmation sommaire et peu flexible / adaptable
  • Une alternance de marches / arrêts qui génèrent un inconfort thermique et limitent la durée de vie de la résistance d'allumage
  • L'incapacité de prendre la main a distance pour relancer le chauffage en cas d'absence prolongée

Fonctionnement initial

Pour décrire le fonctionnement initial, puis la logique de domotisation, on introduit déjà un certain nombre de paramètres :

  • WTI : Température mesurée par la sonde filaire (Wired Temperature Input)
  • STT : Température de consigne du poêle (Stove Temperature Target)
  • SHY : Hystérésis du poêle (Stove HYsteresis)
  • RTT : Température de consigne réelle (Real Temperature Target)

La logique de fonctionnement du poêle est donc la suivante :

  • Si WTI < (STT + SHY) alors allumage (dans mons cas SHY = -1°C)
  • Tant que (STT + SHY) < WTI < STT alors on maintient la chauffe, moyennant un ralentissement quand (WTI - STI) < x°C, x etant paramétrable dans le poêle
  • quand WTI >= STT alors extinction du poêle

Traduit en français :

  • Lorsque la mesure de température descend en dessous de la consigne + son hystérésis alors le poêle lance sa séquence d'allumage
  • Tant que la mesure de température n'atteint pas la consigne, la chauffe se poursuit tout en ralentissant en approchant de l'objectif
  • Lorsque la mesure de température dépasse la consigne, le poêle s'éteint.

Exemple concret :

  • Consigne STT = 21°C
  • Hystérésis SHY = -1°C
  • Marge de régulation à la baisse x = -0,2°C
  • Quand WTI < (STT+SHY) = (21°C + (-1°C)) = 20°C le poêle demarre sa chauffe
  • arrivé a WTI > (STT+x) = (21°C + (-0,2°C)) = 20,8°C le poêle regule sa puissance au minimum
  • Quand WTI > STT = 21°C le poêle s'arrête

C'est le mode de fonctionnement classique de n'importe quel thermostat de base, y compris les climate qu'on définit dans Home Assistant.

Sur cette base le poêle ne dispose que de 3 modes :

  • Arrêt : Pas de chauffe du tout
  • Marche forcée : Gestion par le thermostat en continu, alternance infinie de marches / arrêts
  • Auto : Marche forcée pendant des plages définies et arrêt en dehors
💡
En résumé : Le poêle fonctionne sur un pilotage de type thermostat avec hystérésis, pour gérer le démarrage et l'arrêt des phases de chauffe + une régulation interne de la puissance de chauffe de type PID sur laquelle on peut ajuster la vitesse d'approche de la consigne pour limiter la surchauffe.

Stratégie de domotisation

Ne disposant pas de schémas électroniques clairement documentés et sans compétences sur le sujet, j'ai adopté une approche détournée. La stratégie repose sur un leurre.

👍
L'idée directrice est de prendre le contrôle de la sonde filaire de température pour injecter des valeurs corrigée dans la régulation du poêle pour l'amener à réagir comme on le souhaite.

La sonde filaire est de type "résistance". Quand la température ambiante évolue, la résistance du capteur varié et le poêle mesure la température ambiante en lisant la valeur de cette résistance et en l'interprétant au moyen d'une table d'étalonnage.

On comprend alors qu'en injectant des valeurs de résistance dans la régulation du poêle, on arrivera à lui faire faire ce qu'on veut :

  • Démarrage forcé de la chauffe : Injection d'une valeur faible de résistance correspondant à une température d'ambiance basse (typiquement 5 °C)
  • Arrêt forcé de la chauffe : Injection d'une valeur haute de résistance correspondant à une température d'ambiance basse (typiquement 45 °C)
  • Maintien de la chauffe : Injection en continu de valeurs de résistance ajustées en fonction d'une mesure de température déportée
  • Modification des consignes de chauffe : Injection d'un offset de correction dans les valeurs envoyées en continu pour créer un écart entre la consigne voulue, et la consigne définie en dur dans le poêle.

On va donc devoir introduire deux nouveaux paramètres :

  • ETS : Température mesurée par le capteur déporté (External Temperature Sensor)
  • ETC : Correction de mesure de température, (External Temperature Correction)
  • CRI : Résistance corrigée (Corrected Resistance Input)
  • CTI : Température corrigée (Corrected Temperature input)

La correction de mesure servira à faire croire au poêle qu'il s'approche de sa consigne de chauffe interne (STT) alors qu'en fait, il s'approchera de la consigne réelle (RTT)

💡
Remarque : La méthode repose sur une utilisation de la régulation interne du poêle et de son thermostat interne, mais qu'on va leurrer en lui envoyant des informations corrigées. Le poêle sera positionné en fonctionnement "auto" avec une consigne interne RTT = 21 °C, et il continuera à fonctionner sur ce principe, en aveugle sur les conditions réelles, en voyant uniquement les données qu'on lui enverra.

Un exemple concret :

  1. Le poêle est calé en mode auto, avec une consigne STT = 21 °C et une Hystérésis SHY = -1 °C et je veux qu'il chauffe à RTT = 20 °C
  2. Pour déclencher la chauffe le poêle attend une température mesurée WTI < (STT + SHY) = (21 °C + (-1 °C)) = 20 °C
  3. Ce qu'on veut, c'est qu'il régule à RTT = 20 °C et non pas à STT = 21 °C, on injecte donc dans la sonde (WTI) des valeurs de résistance corrigées, correspondant à la température corrigée (CTI) calculées à partir des mesures déportées et de la correction de consigne CRI = f(CTI) = f(ETS + ETC) (on ajoute un offset positif pour diminuer artificiellement la consigne, un offset négatif pour l'augmenter)

Avec ETC = (STT - RTT) = (21 °C - 20 °C) = 1 °C

et CTI = ETS + ETC

Le poêle va alors recevoir des informations erronées via son entrée filaire et va continuer fonctionner comme d'habitude, mais en se calant finalement sur les consignes qu'on lui demande.

Admettons qu'on mesure ETS = 19,5 °C et qu'on veuille réguler à STT 20 °C on envoie au poêle la CRI correspondant à ETC = 19,5 + 1 °C = 20,5 °C. Le poêle continue à chauffer tout en voyant qu'il s'approche de sa consigne. On a bien décalé toute la régulation du poêle de 1 °C par rapport à sa consigne interne

Mise en œuvre

Matériel

Pour injecter des valeurs corrigées au poêle, j'utilise un module dédié, un potentiomètre numérique pilotable, qui crée des valeurs de résistance aux bornes en fonction des consignes qu'on lui envoie.

Un module tout prêt existe, OHMIGO / Ohm On wifi, qui fonctionne sous Tasmota, pilotable en wifi :

Ohm On WiFi | OHMIGO.io
OHMIGO®ohmonwifi is a precision digitally controllable resistor that allows to digitally control and cloud connect any existing analog equipment, using a two-wire thermistor or any other RTD for temperature sensing. The connection is made via Wifi and the integrated web server. The device can generate a resistance ranging from 68.5 ohms up to 9,000,000 ohms with superior accuracy, better than 1%. During the first few seconds of operation, an LED indicates that you have connected Rout and GND with the correct polarity - If LED=RED, then switch your connection for best accuracy. The motherboard is the same as in Ohmigo TTL and Ohmigo USB. The difference with this product is that you can connect it to WiFi and control with other devices connected to the network.
👍
On peut utiliser un module DIY a base d'ESP32 + potentiomètre numérique, il faut simplement trouver le bon type de potentiomètre qui permet de couvrir la plage de résistances correspondant à la courbe d'étalonnage de la sonde filaire. Dans mon cas, un AD5293 semble faire l'affaire, mais il vous faudra ajuster à votre cas personnel

Câblage

La sonde filaire est reliée directement à la carte mère du poêle, sur un bornier dédié (bornes 39 et 40 dans mon cas).

  • fil Gris : Masse / GND
  • fil rouge : Résistance (Ohms)
💡
Remarque : Pour se prémunir d’un éventuel problème, j'ai conservé la sonde filaire en parallèle du Ohmigo, avec un simple contact sec qui fait la bascule entre les deux en cas de problème.

Le câblage est donc le suivant :

  • Fil rouge de la sonde sur le NC du contact sec
  • Fil rouge du bornier poêle (39) sur le COM du contact sec
  • Liaison entre le OHM du ohmigo et le NO du contact SEC
  • Liaison du fil rouge de la sonde au NC du contact sec
  • Mise à la masse (40) des fils blancs et du Ohmigo

On finalise le câblage en reliant :

  • le 230V à l'alim du contact sec
  • le 5V USB à l'alim du ohmigo

En l'état, on dispose donc d'un système qui permet de bypasser la sonde filaire pour lui injecter des valeurs fictives de résistance via le module Ohmigo, et la possibilité de revenir en pilotage filaire via le basculement du contact sec.

Intégration à Home Assistant

Le module Ohmigo se connecte en wifi et dialogue via MQTT

Il dispose de deux modes de fonctionnement :

  • Mode Température : Injection par MQTT de valeurs de température et utilisation des bibliothèques d'étalonnage internes du module pour simuler la sonde
  • Mode Résistance : Injection par MQTT de valeurs de résistance directement

Pour rester le plus universel possible, et aussi parce que le modèle de ma sonde n'était pas présent dans les bibliothèques d'étalonnage, je travaille en mode Résistance.

Une fois connecté au wifi et à MQTT, on dispose d'une interface dans Home Assistant :

⚠️
Attention : seule une des deux entrées (Résistance ou température) fonctionne suivant le mode de fonctionnement choisi.

On peut vérifier avec MQTT Explorer que le Ohmigo et ses paramètres sont bien accessibles :

⚠️
Attention : Lorsque j'ai fait l'intégration, l'entrée numérique était modifiable via l'interface, mais je n'ai pas réussi à la modifier via des "Actions". Toutes les modifications se font alors directement par des commandes MQTT.

Si on regarde la figure au-dessus, on voit que l'envoi d'une valeur de résistance se fait en modifiant le topic MQTT :

aha/18fe34ed492b/oowifi_resistance/cmd

Il vous faudra bien évidemment ajuster les commandes MQTT à votre cas.

⚠️
Attention : Il est important de noter à ce stade que le Ohmigo n'accepte, en entrée résistance, que des valeurs entières en milliOhms et que la conversion en Ohms avec 3 décimales se fait en interne du module.

Étalonnage

Maintenant que le module Ohmigo est relié au poêle et intégré à Home Assistant, il faut définir la façon dont il va agir et envoyer des informations corrigées au poêle via les instructions reçues depuis home Assistant.

👍
On va donc commencer par l'étalonner, afin de définir une relation mathématique entre les valeurs de température qu'on veut simuler et les valeurs de résistance à injecter via le Ohmigo

La procédure est simple, mais un peu fastidieuse :

  1. On positionne le Ohmigo en mode Résistance
  2. On injecte manuellement des valeurs de résistance via l'interface Home Assistant
  3. On regarde la valeur de température vue par le poêle et on note la correspondance Résistance / Température
  4. On balaie ainsi toute la plage des températures qu'on souhaite simuler (typiquement 0 - 50 °C mais on peut se focaliser sur une plage plus petite)
💡
Remarque : Dans mon cas le poêle n'affiche que des valeurs entières de température. Pour aboutir à un étalonnage cohérent, j'augmente petit à petit la valeur de résistance jusqu'à obtenir la bascule de la valeur de température sur l'affichage. Si votre affichage supporte une décimale sur la température, ce sera plus simple, il suffira de noter la valeur telle qu'elle.

On reporte nos relevés (Ohms / °C) dans un tableau et on calcule une courbe mathématique par régression linéaire (EXCEL fait ça tout seul = Courbe de tendance) :

💡
Remarque : Pour les non-habitués, ou si vous n'avez pas d'équivalent Excel, il existe plein de petits services en ligne qui permettent de caler une courbe sur des tableaux de données.

On voit bien sûr cette illustration qu'on a une courbe parfaitement ajustée sur les points d'étalonnage, via un modèle mathématique simple de type quadratique (polynomiale de degré 2), mais on peut l'approcher de façon tout a fait correcte avec une interpolation linéaire (i.e. polynomiale de degré 1, de type R=a*T+b):

Rohm = f (T°C) = a * T2 + b * T + c

avec a, b et c des constantes. Dans mon cas ces constantes valent :

  • a = 0.0173
  • b = 6.9597
  • c = 813.1

Finalement, pour injecter la résistance en milliohms qui correspond à la température que je veux, il faut que je la convertisse avec ces paramètres :

 R = ((0.0173 * T2) + (6.9597 * T) + 813.1) * 1000

Traduit avec les paramètres qu'on a déjà détaillés :

CRI = f(ETS+RTC) = ((0.0173 * (ETS+RTC)2) + (6.9597 * (ETS+RTC)) + 813.1) * 1000

⚠️
Attention : Les valeurs d'étalonnage ainsi que le modèle mathématique sont propres à chaque sonde / type de sonde, n'utilisez pas les miens sur votre installation. Il faut adapter à votre cas en utilisant vos relevés.

Grâce au modèle on va ajuster la résistance qui est envoyée au poêle en fonction de la température qu'on souhaite lui faire croire, qui est une combinaison de la mesure (ETS) et de la correction par rapport à sa consigne interne (ETC).

Reste maintenant à automatiser toute la procédure et à prendre le contrôle du fonctionnement.

💡
Remarque : si vous faites fonctionner le module en mode température, en utilisant ses étalonnages internes, vous pouvez vous passer de cette procédure. Il vous faudra corriger les scripts en conséquence ilpour utiliser le bon topic MQTT.

Domotisation et pilotage

Entrées / Helpers

Il faut définir les entités qui vont nous permettre de piloter automatiquement toute l'installation.

Remarque : Pour éviter toute erreur de retranscription, j'utilise mes propres helpers, il vous faudra ajuster en conséquence.

  • sensor.temperature_rdc : Capteur déporté de température ambiante (°C)
  • input_number.correction_sonde_poele : entrée numérique, offset de correction entre la consigne du poele STT et la consigne réelle RTT (°C)
  • input_number.temperature_base_poele : la température de consigne STT figée en dur dans le poêle (°C)
  • climate.poele : le thermostat virtuel qui va piloter l'installation
  • switch.poele_a_granules_chauffe : le commutateur qui sera piloté par le climate.poele (voir section dédiée)
  • input_boolean.poele_a_granules_virtuel : un booleen qui permettra de suivre les phases de marche/arret du climate (voir section dédiée)
  • input_number.marge_mode_continu : l'écart à la température de consigne pour maintenir le poêle en mode chauffe minimale. Cette valeur est à ajuster manuellement par essais dans mon cas.
  • timer.minuteur_poele_mode_continu : un timer qui permet de limiter dans le temps le fonctionnement au mini de puissance.
  • switch.poele_mode_chauffe_continue : un interrupteur pour activer ou désactiver le mode de chauffe continue à faible puissance

Thermostat / Climate

Pour permettre à Home Assistant de gérer le poêle, on crée un thermostat virtuel.

Un thermostat a pour fonction, via un binary_switch, de mettre en route la chauffe et de l'arrêter en fonction :

  • De la température de consigne
  • De la mesure de température ambiante et d'une hystérésis

C'est un choix arbitraire :

  • On peut décider de gérer intégralement les démarrages et arrêts du poêle depuis Home Assistant via le thermostat, en définissant un binary_switch qui injecte des valeurs extrêmes de température dans le Ohmigo et ainsi forcer le poêle à démarrer ou s'arrêter
  • On peut choisir, de laisser le poêle démarrer et s'arrêter avec sa propre régulation et se contenter de lui envoyer des valeurs corrigées pour lui "forcer la main" de façon plus douce.
👍
C'est le second choix qui est fait dans ce guide, j'y reviendrai dans la définition du thermostat.

Pour définir le thermostat (climate.poele) on utilise les entrées génériques de l'interface Home Assistant :

On définit ensuite les paramètres qu'il va utiliser (je reviendrai sur l'actionneur qu'il faut créer avant) :

Les hystérésis correspondent à celles qui sont en dur dans le poêle. C'est à adapter suivant les poêles, et les réglages, mais le mien n'a qu'une hystérésis SHY froide, que j'ai mise en dur dans le poêle à 0,5 °C.

💡
Remarque : L'hystérésis chaude présentée ici est un ajustement que j'ai fait manuellement pour que l'affichage de l'arrêt de climate corresponde assez finement à l'arrêt réel du poêle et qui découle probablement de l'étalonnage contraint sur des valeurs entières de température. Si votre affichage permet une lecture plus fine cette valeur devrait tomber à zéro.

Ensuite, on définit les préréglages de température qu'on veut :

Enfin, dans le fichier configuration.yaml, on peut apporter quelques réglages plus fins à ce thermostat. Ici une précision d'affichage de 0,1 °C et un pas de température de 0,5 °C :

homeassistant:
  customize:
    climate.stove:
      precision: 0.1
      target_temp_step: 0.5
💡
Remarque : on pourrait pousser plus loin l'intégration en utilisant par exemple Versatile Thermostat, mais j'ai choisi de continuer à utiliser la régulation interne du poêle plutôt que d'en introduire une nouvelle.
Baissez vos factures de chauffage avec Versatile Thermostat
Mes conseils pour baisser vos factures d’énergie en optimisant l’usage de Versatile Thermostat

Commutateur thermostat / Switch virtuel

Pour pouvoir définir le thermostat / climate, il faut un commutateur (switch), qu'il faut définir (ici en YAML).

Pour laisser le poêle lancer sa chauffe et l'arrêter selon sa régulation interne, il faut que le switch qu'on définit ne soit que de l'affichage qui nous permette de savoir que le thermostat en marche ou pas, mais sans influence réelle sur le poêle. D'où sa qualification de virtuel.

Pour ce faire, le switch qu'on définit n'a qu'une action d'affichage, et se contente de passer à ON ou OFF un input.boolean :

  #################
  # Poele Chauffe #
  #################
  template:
    - switch:
      - name: "Poêle à granulés CHAUFFE"
        unique_id: poele_a_granules_chauffe
        state: "{{ is_state('input_boolean.poele_a_granules_virtuel', 'on') }}"
        turn_on:
          - action: input_boolean.turn_on
            target:
              entity_id: input_boolean.poele_a_granules_virtuel
        turn_off:
          - action: input_boolean.turn_off
            target:
              entity_id: input_boolean.poele_a_granules_virtuel
👍
Ce switch n'a d'autre utilité que de permettre de définir le thermostat / climate, et de permettre de suivre les démarrages / arrêts dans l'interface de Home Assistant.

Mode Continu

L'objectif du projet est aussi de disposer d'un mode de chauffe continu, hors de la gestion en démarrage / arrêts avec hystérésis.

À l'approche de la température de consigne STT, la régulation interne du poêle réduit la puissance thermique.

Typiquement, entre 0,5 °C et 0,2 °C avant l'atteinte de la consigne, le régime d'alimentation de granulés se cale sur sa puissance mini le temps d'atteindre STT.

L'ajustement de ce seuil se fait à l'aide d'un paramètre dans l'interface du poêle qui "tient compte de l'inertie thermique de la zone à chauffer".

👍
On voit alors que pour maintenir le poêle en marche continue minimale, il suffit de lui faire croire qu'il reste en permanence dans cette zone entre STT et (STT - 0,2 °C).

Pour ça on définit un paramètreinput_number.marge_mode_continu.

Sa valeur s'ajuste par essais expérimentaux. Elle dépend de la régulation interne du poele et du réglage de l'amortissement. Elle dépend aussi de la précision de l'affichage du poêle qui induit des biais lors de l'étalonnage.

Par principe la valeur de cette marge est négative (-0,2 à -0,5 °C pour mon poêle), mais suivant la précision de l'étalonnage, on peut aboutir à une valeur positive (+0,1 °C dans mon cas réel).

Si la valeur est trop basse le poêle ne réduira pas sa puissance, si elle est trop élevée il s'arrêtera. Il n'y a pas d'autre solution que de faire quelques essais pour stabiliser la valeur.

Enfin Pour éviter tout problème (surchauffe de la maison, fonctionnement prolongé en absence, mâchefer, …) j'ai introduit un timer sur le fonctionnement du mode continu qui permet un retour au fonctionnement normal au bout de 2 h (à ajuster chez vous).

💡
Remarque : Il est également judicieux d'abaisser la consigne de chauffe du climate (RTT) quand on rentre en mode continu si la maison est bien isolée, sinon la température finira par monter trop haut.

Le mode continu enclenché par un switch manuel, qui met en route le timer associé :

  • si le timer est actif alors mode continu
  • si le timer est inactif alors mode normal
  ######################
  # Poele Mode Continu #
  ######################
  template: 
    - switch:
        - name: "Poêle Mode Chauffe Continue"
          unique_id: poele_mode_chauffe_continue
          state: "{{ is_state('timer.minuteur_poele_mode_continu', 'active') }}"
          turn_on:
            - action: timer.start
              target:
                entity_id: timer.minuteur_poele_mode_continu
          turn_off:
            - action: timer.finish
              target:
                entity_id: timer.minuteur_poele_mode_continu

Les automatisations de pilotage tiennent compte de l'état de ce timer.

Scripts

On a défini l'affichage de l'installation via le climate, reste à construire le cœur de réacteur sous formes de script qu'on va rappeler au besoin dans les automatisations.

La première chose, c'est de créer un script qui lance la marche forcée du poêle.

💡
Remarque : Ce script est inutile à ce stade, mais peut servir pour des opérations de sécurité ou si on souhaite prendre la main totalement en créant un véritable commutateur pour mettre dans le climate.
alias: Demarrage chauffe poele FORCEE
sequence:
  - action: mqtt.publish
    metadata: {}
    data:
      topic: aha/18fe34ed492b/oowifi_resistance/cmd_t
      payload: "849000"
description: ""

Le script :

  • Envoie au topic MQTT qui correspond à la résistance
  • une valeur de résistance en milliohms qui correspond à 5 °C
  • Le poêle voit une température ambiante de 5 °C, il s'allume

De la même façon, on crée un script d'extinction forcée ou cette fois, on lui envoie une résistance correspondant à une température ambiante de 45 °C :

alias: Arret chauffe poele FORCE
sequence:
  - action: mqtt.publish
    metadata: {}
    data:
      topic: aha/18fe34ed492b/oowifi_resistance/cmd_t
      payload: "1161000"
description: ""

Pour piloter la température de consigne réelle (RTT), on définit un script qui définit la valeur de correction de la consigne du poêle (STT) :

alias: correction temperature cible poele
sequence:
  - action: input_number.set_value
    metadata: {}
    data:
      value: >-
        {{ (states('input_number.temperature_base_poele') | float -
        state_attr('climate.poele', 'temperature') | float) | round(1)  }}
    target:
      entity_id: input_number.correction_sonde_poele
description: ""

On définit ensuite le script qui va faire la mise à jour des valeurs de résistance du Ohmigo en fonction de la mesure de température déportée et de la correction.

Comme vous pouvez le voir le script intègre la gestion du mode continu, en fonction de l'état du timer associé. La logique est la suivante :

  • Si on est en mode continu : on vérifie la température de la sonde
    • Si elle dépasse (consigne + marge mode continu) alors on maintient de manière forcée une fausse valeur à égale à consigne + marge mode continu. et le poêle se maintient alors à la plus faible allure
    • Si elle est inférieure alors on envoie la bonne valeur corrigée
  • Si on n'est pas en mode continu alors on envoie simplement la bonne valeur corrigée
alias: update resistance ohmigo (Mode continu)
sequence:
  - if:
      - condition: state
        entity_id: timer.minuteur_poele_mode_continu
        state: active
    then:
      - variables:
          temp_sonde: "{{states('sensor.temperature_rdc')|float}}"
          temp_mode_continu: >-
            {{state_attr('climate.poele', 'temperature') | float +
            states('input_number.marge_mode_continu')|float}}
        alias: Etablissement des variables
      - alias: Si la mesure dépasse la consigne + marge
        if:
          - condition: template
            value_template: "{{temp_sonde > temp_mode_continu}}"
        then:
          - variables:
              temp_sonde: "{{temp_mode_continu}}"
            alias: Mesure forcée a consigne + marge
          - alias: update resistance avec mesure forcée
            action: mqtt.publish
            metadata: {}
            data:
              evaluate_payload: false
              retain: true
              topic: aha/18fe34ed492b/oowifi_resistance/cmd_t
              payload: >-
                {{ ((0.0173 * ((temp_sonde +
                states('input_number.correction_sonde_poele')|float )**2) +
                6.9597 * (temp_sonde +
                states('input_number.correction_sonde_poele')|float) + 813.1)
                *1000)|round(0) }}
              qos: "1"
            enabled: true
          - action: input_number.set_value
            target:
              entity_id: input_number.resistance_ohmigo
            data:
              value: >-
                {{ ( 0.0173 * ((temp_sonde + (
                states('input_number.correction_sonde_poele') | float )) **2 ) +
                6.9597 * (temp_sonde +
                (states('input_number.correction_sonde_poele') | float )) + 
                813.1 ) | round(3) }}
        enabled: true
        else:
          - alias: update resistance avec mesure
            action: mqtt.publish
            metadata: {}
            data:
              evaluate_payload: false
              retain: true
              topic: aha/18fe34ed492b/oowifi_resistance/cmd_t
              payload: >-
                {{ ((0.0173 * ((temp_sonde +
                states('input_number.correction_sonde_poele')|float )**2) +
                6.9597 * (temp_sonde +
                states('input_number.correction_sonde_poele')|float) + 813.1)
                *1000)|round(0)}}
              qos: "1"
            enabled: true
          - action: input_number.set_value
            target:
              entity_id: input_number.resistance_ohmigo
            data:
              value: >-
                {{ ( 0.0173 * ((temp_sonde + (
                states('input_number.correction_sonde_poele') | float )) **2 ) +
                6.9597 * (temp_sonde +
                (states('input_number.correction_sonde_poele') | float )) + 
                813.1 ) | round(3) }}
    else:
      - alias: Update résistance avec mesure
        action: mqtt.publish
        metadata: {}
        data:
          evaluate_payload: false
          retain: true
          topic: aha/18fe34ed492b/oowifi_resistance/cmd_t
          payload: >-
            {{ ((0.0173 * (((states('sensor.temperature_rdc')|float) +
            (states('input_number.correction_sonde_poele')|float) )**2) + 6.9597
            * ((states('sensor.temperature_rdc')|float) +
            (states('input_number.correction_sonde_poele')|float) )+ 813.1)
            *1000)|round(0) }}
          qos: "1"
        enabled: true
      - action: input_number.set_value
        target:
          entity_id: input_number.resistance_ohmigo
        data:
          value: >-
            {{ ( 0.0173 * (((states('sensor.temperature_rdc') | float ) + (
            states('input_number.correction_sonde_poele') | float )) **2 ) +
            6.9597 * (( states('sensor.temperature_rdc') | float ) +
            (states('input_number.correction_sonde_poele') | float )) +  813.1 )
            | round(3) }}
description: ""
mode: restart

On dispose maintenant de l'ensemble des scripts nécessaires pour piloter le Ohmigo et injecter les valeurs qu'on souhaite dans la régulation du poêle. Reste à construire les automatisations.

Automatisations

Le système tel que je l'ai conçu est composé de 5 automatisations simples qui s'articulent entre elles pour gérer automatiquement toute l'installation et éviter des boucles imbriquées et autres rétroactions.


Automatisation 1 : Gestion du thermostat / climate

  • Quand on démarre le thermostat :
    • On active l'automatisation 2 qui sert à mettre à jour en continu les valeurs de température
    • On force la mise à jour de la température une première fois
  • Quand on Arrête le thermostat :
    • On désactive l'automatisation 2, pour arrêter la mise à jour des températures
    • On ordonne au poêle de s'arrêter
    • On arrête le calendrier de chauffe (c'est un choix arbitraire, à vous de voir si c'est utile)
alias: Gestion Thermostat Poele
description: ""
triggers:
  - alias: Démarrage thermostat
    trigger: state
    entity_id:
      - climate.poele
    from: "off"
    to: heat
    id: demarrage thermostat
  - alias: arret thermostat
    trigger: state
    entity_id:
      - climate.poele
    from: heat
    to: "off"
    id: arret thermostat
conditions: []
actions:
  - choose:
      - conditions:
          - condition: trigger
            id:
              - arret thermostat
        sequence:
          - action: automation.turn_off
            data:
              stop_actions: true
            target:
              entity_id: automation.update_ohmigo_resistance
          - action: script.turn_on
            metadata: {}
            data: {}
            target:
              entity_id: script.arret_chauffe_poele_force
          - action: switch.turn_off
            metadata: {}
            data: {}
            target:
              entity_id:
                - switch.schedule_planning_temperatures
      - conditions:
          - condition: trigger
            id:
              - demarrage thermostat
        sequence:
          - action: automation.turn_on
            data: {}
            target:
              entity_id: automation.update_ohmigo_resistance
          - action: automation.trigger
            metadata: {}
            data:
              skip_condition: true
            target:
              entity_id: automation.update_ohmigo_resistance
mode: single

Automatisation 2 : Mise à jour de la résistance du Ohmigo

À chaque changement de valeur du capteur de température, on met à jour la résistance envoyée au Ohmigo

alias: update ohmigo resistance
description: ""
triggers:
  - trigger: state
    entity_id:
      - sensor.temperature_rdc
conditions: []
actions:
  - action: script.turn_on
    metadata: {}
    data: {}
    target:
      entity_id:
        - script.update_resistance_ohmigo_mode_continu
    enabled: true
mode: single

Automatisation 3 : Mise à jour de l'écart STT / RTT

À chaque fois qu'on modifie la consigne de température sur le thermostat, on recalcule l'écart entre la consigne réelle RTT et la consigne du poêle STT qui nous servira à ajuster les résistances du Ohmigo

alias: Correction consigne temperature Poele
description: ""
triggers:
  - trigger: state
    entity_id:
      - climate.poele
    attribute: temperature
conditions: []
actions:
  - action: script.turn_on
    metadata: {}
    data: {}
    target:
      entity_id: script.correction_temperature_cible_poele
  - if:
      - condition: state
        entity_id: climate.poele
        state: heat
    then:
      - action: script.turn_on
        metadata: {}
        data: {}
        target:
          entity_id:
            - script.update_resistance_ohmigo_mode_continu
        enabled: true
mode: single


Automatisations 4 et 5 : Gestion des erreurs en mode continu.

Comme on travaille dans une zone très étroite de température et/ou le poêle fonctionne à sa limite basse, on introduit une gestion des problèmes en mode continu.

La première automatisation enclenche la surveillance :

alias: Marche / Arrêt mode continu
description: ""
triggers:
  - trigger: state
    entity_id:
      - timer.minuteur_poele_mode_continu
    to: active
    from: idle
    id: "démarrage mode continu "
  - trigger: state
    entity_id:
      - timer.minuteur_poele_mode_continu
    to: idle
    from: active
    id: arrêt mode continu
conditions: []
actions:
  - action: script.turn_on
    metadata: {}
    data: {}
    target:
      entity_id: script.update_resistance_ohmigo_mode_continu
  - if:
      - condition: trigger
        id:
          - "démarrage mode continu "
    then:
      - action: automation.turn_on
        metadata: {}
        data: {}
        target:
          entity_id: automation.alerte_erreur_mode_continu
  - if:
      - condition: trigger
        id:
          - arrêt mode continu
    then:
      - action: automation.turn_off
        metadata: {}
        data:
          stop_actions: true
        target:
          entity_id: automation.alerte_erreur_mode_continu
mode: single

La seconde surveille la consommation électrique du poêle et avertit si elle devient nulle alors que le poêle est censé fonctionner en continu :

alias: Alerte erreur mode continu
description: ""
triggers:
  - trigger: numeric_state
    entity_id:
      - sensor.prise_poele_power
    for:
      hours: 0
      minutes: 5
      seconds: 0
    below: 1
conditions: []
actions:
  - action: notify.maison_soucieu
    metadata: {}
    data:
      message: >-
        le poêle s'est arrêté alors qu'il est en mode continu. allez vérifier
        sur l'écran du poêle.
      title: Attention
mode: single

Voilà, le poêle est désormais intégralement piloté via Home Assistant avec une programmation horaire bien plus fine issue d'un scheduler, adaptable facilement.

Interfaçage

L'interfaçage du système, passe par un Scheduler / Scheduler Card qui permet une programmation par horodatage et une modification des consignes de chauffe bien plus flexible et automatisable :

type: custom:scheduler-card
title: true
tags:
  - chauffage
discover_existing: false
show_header_toggle: false
sort_by:
  - relative-time
  - state
include:
  - climate.poele
exclude: []

Pour de la gestion ponctuelle, on peut prendre la main par l'application par exemple pour l'actionner à distance via une carte dédiée (ici un bubble card) :

type: custom:bubble-card
card_type: climate
entity: climate.poele
sub_button:
  main:
    - name: HVAC modes menu
      select_attribute: hvac_modes
      state_background: true
      show_arrow: false
      sub_button_type: select
    - entity: climate.poele
      select_attribute: preset_modes
      state_background: false
      show_background: true
      sub_button_type: select
  bottom: []
show_last_changed: false
show_state: false
state_color: false
show_name: true
card_layout: normal
show_icon: true
scrolling_effect: false
show_attribute: true
attribute: current_temperature

j'utilise aussi une carte "Simple Thermostat" sur laquelle j'ai rajouté un switch qui active ou désactive le calendrier de chauffe

type: custom:simple-thermostat
entity: climate.poele
layout:
  mode:
    headings: false
    icons: true
header:
  icon: mdi:fireplace
  toggle:
    entity: switch.schedule_planning_temperatures
    name: Mode AUTO
hide:
  state: true
control:
  hvac: true
  preset:
    none: false
    away:
      name: Absent
      icon: mdi:door-closed
    comfort:
      name: Confort
      icon: mdi:sofa
    eco:
      name: Eco
      icon: mdi:leaf
    home:
      name: Normal
      icon: mdi:home-thermometer
    sleep:
      name: Nuit
      icon: mdi:sleep

Exemple du fonctionnement du poêle en mode normal sur une journée froide, avec le calendrier de chauffe qui évolue sur la journée :

  1. À 5 h du matin le poêle se déclenche avec une consigne à 20 °C
  2. Au lever, il remonte sa consigne à 20,5 °C
  3. Le soir devant la télé le poêle régule à 21 °C
  4. La nuit la consigne redescend à 18 °C

Le poêle s'allume quand on atteint environ -0,6 °C par rapport à la consigne réelle (RTT) et se coupe quand on dépasse d'environ +0,2 °C la consigne réelle.

La courbe orange est le suivi du switch virtuel qu'on a créé explicitement pour définir le climate. On peut voir qu'il est tout à fait en cohérence avec le fonctionnement réel du poêle.

Compléments

Température d'ambiance

Plutôt que de me reposer sur un seul capteur, qui finalement ne fera que décaler le problème de la sonde filaire un peu plus loin dans l'espace et dans le temps, et pour éviter tout problème en cas de perte de ce capteur, j'ai construit une température pondérée à partir de 3 capteurs disséminés au rez-de-chaussée de la maison (sensor.temperature_rdc) et j'ai introduit un système de fallback qui empèche l'arrêt du chauffage en cas de perte d'une des sondes.

{% set sensors = [
  ('sensor.capteur_temperature_salon_temperature', 3),
  ('sensor.capteur_salle_a_manger_temperature', 1),
  ('sensor.capteur_cuisine_temperature', 1)
] %}

{% set total = namespace(value=0, weight=0) %}

{% for entity, weight in sensors %}
  {% set val = states(entity) %}
  {% if is_number(val) %}
    {% set total.value = total.value + (val | float) * weight %}
    {% set total.weight = total.weight + weight %}
  {% endif %}
{% endfor %}

{% if total.weight > 0 %}
  {{ (total.value / total.weight) | round(1) }}
{% else %}
  {# This returns the sensor's own current state (last known value) #}
  {# if all sub-sensors are unavailable. #}
  {{ this.state if is_number(this.state) else 'unavailable' }}
{% endif %}

La température est une pondération de 3 capteurs dont le poids varie en fonction de leur proximité avec le poêle ce qui permet :

  • De tenir compte de l'inertie de la pièce
  • De limiter la surchauffe immédiatement a proximité du poêle
  • De poursuivre la chauffe suffisamment pour que les zones les plus éloignées soient aussi correctmenet chauffées
  • D'avoir une valeur de repli cohérente et non fixe en cas de perte de 1 ou 2 sondes.

Visualisation sous AWTRIX

J'ai également rajouté une visualisation via le AWTRIX qui permet d'avoir un retour d'état visuel via le composant HACS adéquat :

alias: Notifications AWTRIX
description: ""
triggers:
  - alias: Poele en programmation auto
    trigger: state
    entity_id:
      - switch.schedule_planning_temperatures
    from: "off"
    to: "on"
    id: poele en programmation auto
  - alias: Poele en controle manuel
    trigger: state
    entity_id:
      - switch.schedule_planning_temperatures
    from: "on"
    to: "off"
    id: poele en controle manuel
  - trigger: state
    entity_id:
      - climate.poele
    from: "off"
    to: heat
    alias: Demarrage thermostat poele
    id: demarrage thermostat poele
  - alias: Arret thermostat poele
    trigger: state
    entity_id:
      - climate.poele
    from: heat
    to: "off"
    id: arret thermostat poele
conditions: []
actions:
  - choose:
      - conditions:
          - condition: trigger
            id:
              - poele en programmation auto
        sequence:
          - action: awtrix.awtrix_018f14_push_app_data
            data:
              data:
                text: Poele en mode automatique
                rainbow: false
                icon: "6208"
                duration: 30
                pushIcon: 0
                lifetime: 120
                repeat: -1
              name: controle_poele
          - action: awtrix.awtrix_018f14_switch_app
            metadata: {}
            data:
              name: controle_poele
      - conditions:
          - condition: trigger
            id:
              - poele en controle manuel
        sequence:
          - action: awtrix.awtrix_018f14_push_app_data
            data:
              name: controle_poele
              data:
                text: Poele en mode manuel
                rainbow: false
                icon: "59259"
                duration: 30
                pushIcon: 0
                lifetime: 120
                repeat: -1
          - action: awtrix.awtrix_018f14_switch_app
            metadata: {}
            data:
              name: controle_poele
      - conditions:
          - condition: trigger
            id:
              - demarrage thermostat poele
        sequence:
          - action: awtrix.awtrix_018f14_push_app_data
            data:
              name: chauffage
              data:
                text: Chauffage en service
                rainbow: false
                icon: "27076"
                duration: 30
                pushIcon: 0
                lifetime: 120
                repeat: -1
          - action: awtrix.awtrix_018f14_switch_app
            metadata: {}
            data:
              name: chauffage
      - conditions:
          - condition: trigger
            id:
              - arret thermostat poele
        sequence:
          - action: awtrix.awtrix_018f14_push_app_data
            data:
              name: chauffage
              data:
                text: chauffage a l'arret
                rainbow: false
                icon: "16755"
                duration: 30
                pushIcon: 0
                lifetime: 120
                repeat: -1
          - action: awtrix.awtrix_018f14_switch_app
            metadata: {}
            data:
              name: chauffage

Fallback perte Ohmigo

Grâce à un tracker Nmap, on suit la connectivité du Ohmigo et en cas de problème, on revient sur la sonde physique et le poêle reprend sa régulation normale sur sa sonde et sa consigne STT (d'où l'intérêt de figer STT à une température "raisonnable") :

alias: Fallback Ohmigo
description: ""
triggers:
  - trigger: state
    entity_id:
      - device_tracker.ohmigo
    to: not_home
    from: null
    for:
      hours: 0
      minutes: 5
      seconds: 0
    id: perte ohmigo nmap
  - trigger: state
    entity_id:
      - device_tracker.ohmigo
    to: home
    from: null
    for:
      hours: 0
      minutes: 5
      seconds: 0
    id: recuperation ohmigo nmap
conditions: []
actions:
  - choose:
      - conditions:
          - condition: trigger
            id:
              - perte ohmigo
              - perte ohmigo nmap
        sequence:
          - action: switch.turn_off
            metadata: {}
            data: {}
            target:
              entity_id: switch.fallback_poele
      - conditions:
          - condition: trigger
            id:
              - recuperation ohmigo
              - recuperation ohmigo nmap
        sequence:
          - action: switch.turn_on
            metadata: {}
            data: {}
            target:
              entity_id: switch.fallback_poele
mode: single

Mesure du niveau de pellets dans le réservoir

Pour mesurer la quantité de pellets restant dans le poêle, je suis parti d'un simple capteur à ultrasons SR04 relié à un ESP32 et installé sous le capot du réservoir :

La difficulté réside dans le fait que :

  • La surface du stock de pellets n'est pas uniforme
  • la surface évolue en créant des trous par extraction des granulés, en fond de réservoir
  • la surface évolue quand ces trous se remplissent, lorsque les pellets roulent.
  • le réservoir est une cavité métallique pas vraiment idéale pour des ultrasons
  • le remplissage du réservoir se fait en ouvrant le capot
💡
Remarque : Le choix du capteur n'est peut-être pas le plus heureux. Il semblerait que d'autres ont de bien meilleurs résultats en utilisant un capteur à impulsions infrarouge vl53l0x. Cependant, quelle que soit la nature du capteur les effets de creusement et de roulement se produiront quand même.

Si on se contente de suivre les valeurs du capteur, on a alors un signal hautement instable et variable et il est impossible de connaître avec une bonne précision le niveau de granulés restants.

👍
Pour obtenir quelque chose d'exploitable, et de physiquement cohérent, L'idée directrice est de suréchantillonner pour éliminer le bruit parasite puis de traiter les valeurs mesurées sur deux échéances, une à court terme (5 minutes) et une à long terme (15 minutes) et de filtrer / corriger les mesures pour respecter un principe physique de base : Le niveau de pellets ne peut pas remonter tout seul dans le réservoir sauf lors d'un remplissage.

Ainsi si le niveau baisse trop vite par rapport à la tendance à long terme, c'est qu'on est en train de creuser un trou et on l'ignore en se calant sur la tendance a long terme puis on reprend le suivi lorsque la tendance revient.

La tendance à court terme permet de visualiser les baisses abruptes de niveau qui sont le signe d'un remplissage.

Merci ChatGPT / Claude / Gemini pour la mise en œuvre de l'idée, ça dépasse mes compétences !!

Voici l'algorithme de traitement du signal au format mermaid :

flowchart TD
    A[📡 Ultrasonic Sensor Raw Data - Trigger GPIO5, Echo GPIO18, Update 200ms, Noise High] --> B[⚙️ Filtering and Bounding - NaN Rejection, Lid Open Threshold 0.75 m, Last Valid Value Retention]
    B --> C[📊 Temporal Stabilization and Trend Extraction - Median Filter Fast window_fast pts 5 to 15 min, Slow window_slow pts 15 to 60 min, Circular Buffer 120, Update 1s]
    C --> D[🛠️ Physical State Reconstruction - Max Consumption Rate 10 cm/h, Refill Delta 0.15 m, Refill Slope Trigger -200 cm/h, Refill Slope Reset -80 cm/h, System Ready Initialization]
    D --> E[⚡ Short-term Trend Analysis - Fast Slope pente_pellets_5min, Detect Rapid Drop for Refill Event]
    E --> F[🎯 Refill Event Detection - Increment Counters daily_refill_count, weekly_kg_total, monthly_kg_total]
    D --> G[🏠 Exposed State for Home Assistant - distance_pellets_reconstruit, binary_sensor Reservoir Empty]
    C --> H[📈 Historical Trends - daily_refill_count, weekly_kg_total, monthly_kg_total]

    %% Classes de style
    classDef raw fill:#f9f,stroke:#333,stroke-width:1px,color:#000
    classDef filter fill:#ffeb99,stroke:#333,stroke-width:1px,color:#000
    classDef median fill:#99ddff,stroke:#333,stroke-width:1px,color:#000
    classDef reconstruct fill:#9f9,stroke:#333,stroke-width:1px,color:#000
    classDef event fill:#ff9999,stroke:#333,stroke-width:1px,color:#000
    classDef ha fill:#cccccc,stroke:#333,stroke-width:1px,color:#000
    classDef monitor fill:#ffd699,stroke:#333,stroke-width:1px,color:#000

le YAML de l'ESP32 :

# ==============================================================================
# CONFIGURATION GÉNÉRALE & SUBSTITUTIONS
# Ces variables définissent les constantes métier pour le calcul des stocks.
# On utilise une logique de "pente" pour différencier la consommation (lente) 
# du remplissage (rapide/brutal).
# ==============================================================================
substitutions:
  device_name: "niveau-pellets"
  friendly_name: "Niveau Pellets"
  
  pente_buffer_size: "120"         # Capacité du buffer (120 points x 30s = 60 min)
  slope_multiplier: "12000.0"      # Facteur de conversion mathématique : m/30s vers cm/h
  
  lid_open_threshold: "0.75"       # Seuil d'exclusion : au-delà de 75cm, le capot est jugé ouvert
  refill_delta_m: "0.15"           # Delta minimum (15cm) pour valider physiquement un sac
  refill_slope_trigger: "-200.0"   # Seuil de pente négative pour détection de remplissage
  refill_slope_reset: "-80.0"      # Hystérésis pour réarmer la détection de remplissage
  consumption_max_cm_h: "10.0"     # Bride de sécurité : consommation max théorique du poêle

# ==============================================================================
# PARAMÈTRES SYSTÈME & RÉSEAU
# Utilisation du framework ESP-IDF pour une meilleure stabilité des tâches BLE/WiFi.
# Le Bluetooth Proxy permet d'étendre la portée du Bluetooth via Home Assistant.
# ==============================================================================
esphome:
  name: "${device_name}"
  friendly_name: "${friendly_name}"
  min_version: 2025.1.0
  build_path: build/niveau-pellets
  includes:
    - theil_sen.h

esp32:
  board: esp32dev
  framework:
    type: esp-idf

esp32_ble:
  max_connections: 5

esp32_ble_tracker:
  scan_parameters:
    interval: 320ms
    window: 300ms
    active: true

bluetooth_proxy:
  active: true
  connection_slots: 5

logger:
  level: DEBUG
  baud_rate: 0

wifi:
  networks:
    - ssid: !secret wifi_ssid
      password: !secret wifi_password
    - ssid: !secret iot_ssid
      password: !secret iot_password
  manual_ip: 
    static_ip: 192.168.0.26
    gateway: 192.168.0.1
    subnet: 255.255.255.0
  power_save_mode: NONE
  enable_btm: true
  enable_rrm: true

api:
  encryption:
    key: !secret niveau_pellets_api_key

ota:
  - platform: esphome
    password: !secret niveau_pellets_admin

# ==============================================================================
# GESTION DU TEMPS & RÉINITIALISATION DES COMPTEURS
# Synchronisation NTP via Home Assistant pour gérer les cycles de consommation.
# Remise à zéro automatique des totaux (Journalier, Hebdomadaire, Mensuel).
# ==============================================================================
time:
  - platform: homeassistant
    timezone: "Europe/Paris"
    id: homeassistant_time
    on_time:
      - seconds: 0
        minutes: 0
        hours: 0
        then:
          - lambda: id(daily_refill_count) = 0;
      - seconds: 0
        minutes: 0
        hours: 0
        days_of_week: MON
        then:
          - lambda: id(weekly_kg_total) = 0;
      - seconds: 0
        minutes: 0
        hours: 0
        days_of_month: 1
        then:
          - lambda: id(monthly_kg_total) = 0;

# ==============================================================================
# RÉGLAGES DYNAMIQUES (Number)
# Permet l'ajustement en temps réel de la sensibilité de l'algorithme sans reflasher.
# - Fenêtres de pente : définit la robustesse face au bruit.
# - Seuil vide : définit l'alerte de niveau bas.
# ==============================================================================
number:
  - platform: template
    name: "Points pente Rapide"
    id: window_fast
    min_value: 3
    max_value: 30
    step: 1
    initial_value: 10
    unit_of_measurement: "pts"
    restore_value: yes
    optimistic: yes

  - platform: template
    name: "Points pente Lente"
    id: window_slow
    min_value: 15
    max_value: 120
    step: 1
    initial_value: 30
    unit_of_measurement: "pts"
    restore_value: yes
    optimistic: yes

  - platform: template
    name: "Seuil réservoir vide"
    id: empty_threshold
    min_value: 0.0
    max_value: 0.75
    step: 0.01
    initial_value: 0.30
    unit_of_measurement: "m"
    restore_value: yes
    optimistic: yes

# ==============================================================================
# VARIABLES GLOBALES
# Stockage en RAM (et Flash pour 'restore_value') des états persistants.
# Sert de mémoire tampon pour l'algorithme de reconstruction du niveau.
# ==============================================================================
globals:
  - id: last_valid_distance
    type: float
    restore_value: yes
    initial_value: '0.01'
  - id: last_reconstructed_level
    type: float
    restore_value: yes
    initial_value: '0.01'
  - id: system_ready
    type: bool
    initial_value: 'false'
  - id: pente_index
    type: int
    initial_value: '0'
  - id: pente_count
    type: int
    initial_value: '0'
  - id: daily_refill_count
    type: int
    restore_value: yes
    initial_value: '0'
  - id: weekly_kg_total
    type: int
    restore_value: yes
    initial_value: '0'
  - id: monthly_kg_total
    type: int
    restore_value: yes
    initial_value: '0'
  - id: refill_latch
    type: bool
    initial_value: 'false'

# ==============================================================================
# SECTION CAPTEURS
# ==============================================================================
sensor:
  - platform: wifi_signal
    name: "Qualité Signal WiFi"
    state_class: measurement
  - platform: internal_temperature
    name: "Température CPU ESP32"
    state_class: measurement

  # ----------------------------------------------------------------------------
  # STATISTIQUES DE CONSOMMATION
  # Conversion des variables globales en entités capteurs pour Home Assistant.
  # ----------------------------------------------------------------------------
  - platform: template
    name: "Rechargement Journalier"
    unit_of_measurement: "sacs"
    icon: "mdi:sack"
    state_class: measurement
    accuracy_decimals: 0
    lambda: return (float)id(daily_refill_count);

  - platform: template
    name: "Consommation Hebdomadaire"
    unit_of_measurement: "kg"
    icon: "mdi:weight-kilogram"
    state_class: measurement
    accuracy_decimals: 0
    lambda: return (float)id(weekly_kg_total);

  - platform: template
    name: "Consommation Mensuelle"
    unit_of_measurement: "kg"
    icon: "mdi:weight-kilogram"
    state_class: measurement
    accuracy_decimals: 0
    lambda: return (float)id(monthly_kg_total);

  # ----------------------------------------------------------------------------
  # 1. ACQUISITION BRUTE
  # Capteur HC-SR04. On utilise un filtre médian agressif pour éliminer les
  # échos parasites dus à la poussière ou aux parois du silo.
  # ----------------------------------------------------------------------------
  - platform: ultrasonic
    trigger_pin: GPIO5
    echo_pin: GPIO18
    name: "Distance pellets brute"
    id: distance_pellets_brute
    icon: "mdi:signal-distance-variant"
    update_interval: 200ms
    filters:
      - median:
          window_size: 41
          send_every: 5

  # ----------------------------------------------------------------------------
  # 2. BORNAGE ET GESTION DU CAPOT
  # Étape de "Sanity Check". Si la mesure dépasse le seuil physique (lid_open), 
  # on fige la valeur précédente pour éviter de fausser les calculs de pente 
  # pendant que l'utilisateur remplit le silo.
  # ----------------------------------------------------------------------------
  - platform: template
    name: "Distance pellets bornee"
    id: distance_pellets_bornee
    icon: "mdi:filter-check"
    unit_of_measurement: "m"
    accuracy_decimals: 3
    state_class: measurement
    update_interval: 1s
    lambda: |-
      float val = id(distance_pellets_brute)->state;
      float last = id(last_valid_distance);
      if (isnan(val)) return last;
      if (val > ${lid_open_threshold}) {
        return last;
      }
      if (val >= 0.01) {
        if (val < last || fabs(val - last) < 0.05) {
          id(last_valid_distance) = val;
          return val;
        }
      }
      return last;

  # ----------------------------------------------------------------------------
  # 3. BUFFERISATION CIRCULAIRE
  # Stockage des mesures dans un tableau (global_pente_buffer) pour permettre
  # l'analyse statistique sur le temps long. Un filtre médian temporel est 
  # appliqué pour lisser la courbe avant l'analyse de pente.
  # ----------------------------------------------------------------------------
  - platform: template
    name: "Distance pellets filtre median"
    id: distance_pellets_filtre_median
    icon: "mdi:filter-plus"
    unit_of_measurement: "m"
    accuracy_decimals: 3
    state_class: measurement
    update_interval: 1s
    lambda: return id(distance_pellets_bornee)->state;
    on_value:
      then:
        - lambda: |-
            if (!isnan(x)) {
              global_pente_buffer[id(pente_index)] = x;
              id(pente_index) = (id(pente_index) + 1) % ${pente_buffer_size};
              if (id(pente_count) < ${pente_buffer_size}) id(pente_count)++;
            }
    filters:
      - median:
          window_size: 300
          send_every: 30
      - lambda: |-
          return isnan(x) ? id(last_valid_distance) : x;

  # ----------------------------------------------------------------------------
  # 4 & 5. ESTIMATEUR DE PENTE (THEIL-SEN)
  # Utilisation de l'algorithme robuste de Theil-Sen pour calculer la tendance.
  # Contrairement à une régression linéaire classique, cet estimateur ignore
  # les valeurs aberrantes (outliers), idéal pour un milieu poussiéreux.
  # ----------------------------------------------------------------------------
  - platform: template
    name: "Pente pellets rapide"
    id: pente_pellets_5min
    icon: "mdi:trending-up"
    unit_of_measurement: "cm/h"
    accuracy_decimals: 2
    state_class: measurement
    update_interval: 30s
    lambda: |-
      int n = std::min(id(pente_count), (int)id(window_fast)->state);
      float s = compute_theil_sen_circular(n, id(pente_index), ${pente_buffer_size});
      float res = s * (float)${slope_multiplier};
      return (float)std::clamp(res, -5000.0f, 5000.0f);

  - platform: template
    name: "Pente pellets lente"
    id: pente_pellets_15min
    icon: "mdi:trending-up"
    unit_of_measurement: "cm/h"
    accuracy_decimals: 2
    state_class: measurement
    update_interval: 30s
    lambda: |-
      int n = std::min(id(pente_count), (int)id(window_slow)->state);
      float s = compute_theil_sen_circular(n, id(pente_index), ${pente_buffer_size});
      float res = s * (float)${slope_multiplier};
      return (float)std::clamp(res, -5000.0f, 5000.0f);

  # ----------------------------------------------------------------------------
  # 6. MOTEUR DE RECONSTRUCTION LOGIQUE
  # C'est ici que l'intelligence métier réside :
  # - Détecte un remplissage si la pente chute brutalement et le niveau monte.
  # - Verrouille (Latch) pour ne compter qu'un seul sac à la fois.
  # - Filtre la descente (consommation) en limitant la vitesse de variation
  #   physiquement possible du poêle pour ignorer les bruits de mesure.
  # ----------------------------------------------------------------------------
  - platform: template
    name: "Distance pellets reconstruit"
    id: distance_pellets_reconstruit
    icon: "mdi:ruler"
    unit_of_measurement: "m"
    accuracy_decimals: 3
    state_class: measurement
    update_interval: 30s
    lambda: |-
      float target = id(distance_pellets_filtre_median)->state;
      float current = id(last_reconstructed_level);
      float p_slow = isnan(id(pente_pellets_15min)->state) ? 0.0f : id(pente_pellets_15min)->state;
      float p_fast = isnan(id(pente_pellets_5min)->state) ? 0.0f : id(pente_pellets_5min)->state;

      if (!id(system_ready)) {
        if (!isnan(target)) {
          id(last_reconstructed_level) = target;
          id(system_ready) = true;
          ESP_LOGI("pellets", "System Ready. Level: %.3f m", target);
        }
        return id(last_reconstructed_level);
      }

      if (p_fast < (float)${refill_slope_trigger} && target < current - (float)${refill_delta_m}) {
        if (!id(refill_latch)) {
          id(daily_refill_count)++;
          id(weekly_kg_total) += 15;
          id(monthly_kg_total) += 15;
          id(refill_latch) = true;
          ESP_LOGI("pellets", "Sack added!");
        }
        id(last_reconstructed_level) = target;
      } 
      else {
        if (p_fast > (float)${refill_slope_reset}) id(refill_latch) = false;
        if (p_slow > 0) {
          float diff_m = target - current;
          if (diff_m > 0) {
            float limited_p = std::min(p_slow, (float)${consumption_max_cm_h}); 
            float max_step_m = limited_p / (float)${slope_multiplier};
            id(last_reconstructed_level) += std::min(diff_m, max_step_m);
          }
        }
      }
      return id(last_reconstructed_level);

# ==============================================================================
# CAPTEURS BINAIRES
# Alerte de niveau critique basée sur la distance reconstruite.
# Utilisation d'un 'delayed_on' pour éviter les fausses alertes passagères.
# ==============================================================================
binary_sensor:
  - platform: template
    name: "Réservoir Vide"
    device_class: problem
    lambda: return id(distance_pellets_reconstruit)->state > id(empty_threshold)->state;
    filters:
      - delayed_on: 30min

# ==============================================================================
# ACTIONS MANUELLES (Buttons)
# Permet de corriger manuellement les compteurs ou de redémarrer l'unité.
# Le forçage de remplissage recalibre immédiatement le niveau reconstruit.
# ==============================================================================
button:
  - platform: template
    name: "Forcer Remplissage (+15kg)"
    id: btn_force_refill
    icon: "mdi:sack-percent"
    on_press:
      - lambda: |-
          float current_level = id(distance_pellets_filtre_median).state;
          if (isnan(current_level)) {
            ESP_LOGW("manual_refill", "Capteur indisponible (NaN), action annulée.");
            return;
          }
          id(last_reconstructed_level) = current_level;
          id(daily_refill_count) += 1;
          id(weekly_kg_total) += 15;
          id(monthly_kg_total) += 15;
          id(refill_latch) = false;
          ESP_LOGI("manual_refill", "Remplissage forcé exécuté. Nouveau niveau : %.3f m", current_level);
          id(distance_pellets_reconstruit).update();

  - platform: restart
    name: "Redémarrer ESP32 Pellets"
    icon: "mdi:restart"

⚠️
Attention : le code ci-dessus utilise le framework ESP-IDF et pas le framework ARDUINO, car je me sers aussi de l'ESP32 pour faire proxy bluetooth / localisation bluetooth et que j'ai besoin des composants Wifi MESH (802.11v/802.11k) qui sont indisponibles sous Arduino. Ça a peu d'incidences, mais si vous voulez rester sous Arduino, il vous faudra adapter le code.

Le capteur est aussi équipé :

  • d'un bouton qui permet de réarmer les remplissages en cas de pb de détection automatique
  • de curseurs pour régler le seuil de réservoir vide et l'inertie des tendances
  • d'un système de mémorisation des dernieres valeurs valides qui survit à un reboot

Il faut lui adjoindre sa routine de calcul des tendances basées sur un algo Theil-Sen qui permet de faire des tendances propres même avec des données assez éparpillées theil_sen.h :

#include "esphome.h"
#include <algorithm>

// Buffer global physique pour 120 points (60 minutes de données)
static float global_pente_buffer[120] = {0}; 

float compute_theil_sen_circular(int n, int head, int size) {
    if (n < 3) return 0.0f;

    // Capacité maximale pour n=120 points (7140 pentes)
    const int MAX_SLOPES = 7140;
    static float slopes[MAX_SLOPES]; 
    int k = 0;
    
    // Calcul de l'index de départ dans le buffer circulaire
    int start_idx = (head - n + size) % size;

    for (int i = 0; i < n - 1; i++) {
        for (int j = i + 1; j < n; j++) {
            float y_i = global_pente_buffer[(start_idx + i) % size];
            float y_j = global_pente_buffer[(start_idx + j) % size];
            
            if (k < MAX_SLOPES) {
                // Pente = Delta Distance / Delta Temps (en unités d'index)
                slopes[k++] = (y_j - y_i) / (float)(j - i);
            }
        }
    }

    if (k == 0) return 0.0f;

    // Recherche de la médiane par tri partiel (O(n) moyen)
    std::nth_element(slopes, slopes + k / 2, slopes + k);
    return slopes[k / 2];
}

et le résultat illustré sur le traitement des données. On passe d'un signal bruité et fortement variable à une courbe stabilisée qui donne une approximation réaliste du contenu du réservoir.

Les chutes brutales de distance sont bien capturées par le capteur qui traduit ça comme un remplissage et incrémente la consommation de granulés (1 sac = 15 kilos). On dispose d'un bouton qui permet de déclencher manuellement la prise en compte d'un remplissage si jamais ça ne s'est pas fait automatiquement.

L'ESP fait remonter dans HA toutes les données ainsi que les paramètres ajustables pour introduire plus ou moins d'inertie au système. Plus on met d'inertie plus la courbe sera lissée, mais plus le temps de réponse sera lent.

Conclusion

Moyennant un module préfabriqué, mais potentiellement remplaçable par une version DIY low cost, et quelques automatisations basiques, on aboutit à une prise de contrôle complète d'un poêle à granulés, ou de tout autre équipement (chaudière, pilotage radiateurs…) reposant sur la mesure de température par résistance.

La partie la plus compliquée a finalement été la construction du capteur de niveau de granulés qui demande un gros traitement du signal et un algorithme un peu compliqué. Potentiellement, on peut s'épargner une partie de ce traitement en changeant le type de capteur.

L'investissement est très vite amorti en termes de confort, de facilité d'utilisation et de flexibilité. La consommation de granulés s'en ressent aussi, tout en restant plus proche des besoins réels.

J'ai fait le choix de ne pas me séparer de la régulation de puissance du poêle, mais on pourrait sans doute exploiter la possibilité "Over Climate" de Versatile Thermostat pour ajuster la régulation et anticiper les besoins en fonction de la température extérieure. Cela demande de creuser le sujet et de faire un certain nombre d'essais, pour caler les TPI et vérifier que ça ne pose pas de problème, car ça revient à empiler des régulateurs, ce qui n'est jamais aisé.

Enfin, les modifications effectuées à l'équipement sont très facilement réversibles (il suffit de rebrancher la sonde directement sur la carte mère du poêle) ce qui permet de revenir à l'état d'origine simplement en cas de revente.