HA-Routeur Solaire Photovoltaïque avec ESPHome — V2026

Intro

Dans un article précédent https://domo.rem81.com/index.php/2025/06/02/routeur-solaire-photovoltaique-avec-esphome-une-solution-diy-pour-optimiser-votre-autoconsommation/ , je décrivais une version V2 de mon routeur PV développé sous ESPHome et totalement intégré à Home Assistant.
Depuis, le routeur a continué d’évoluer “en production”, avec une contrainte principale : optimiser l’autoconsommation tout en protégeant les batteries, et en tirant parti d’une logique tarifaire HP/HC.

Cette V2026 apporte plusieurs nouveautés structurantes :

  • Bascule HP/HC automatique via SNTP (HP 06:00–21:59 / HC 22:00–05:59).
  • Deux stratégies distinctes :
    • HP : routeur piloté par production PV + SOC + état Victron (Bulk/Absorption/Float) + réserve batteries.
    • HC : chauffe ECS pilotée par hystérésis sur volume d’eau chaude + “cible” de puissance réseau.
  • Une régulation unique (HP + HC) via interpolation sur table de puissance.
  • Un mode Étalonnage amélioré pour générer les points {%, W} et construire la table.
  • Une réserve batteries automatique (optionnelle) qui ajuste dynamiquement la réserve selon SOC/PV/état VE.Bus.

Upgrade:

  • 09/01/2026 : ajout HP/HC + hystérésis ECS sur volume + régulation par interpolation + réserve batteries auto.

Pourquoi un Routeur Solaire ?

Depuis l’installation de mes panneaux photovoltaïques, je cherchais une solution pour maximiser l’utilisation de l’énergie produite, plutôt que de l’injecter dans le réseau à un tarif peu intéressant.
L’objectif reste le même : rediriger le surplus PV vers l’ECS, mais avec une logique plus fine, tenant compte :

  • du contexte Victron (Bulk/Absorption/Float),
  • de la charge/décharge batterie,
  • du SOC,
  • du tarif HP/HC,
  • et du besoin réel en ECS (volume disponible).

Configuration Solaire

La configuration générale ne change pas : production PV, onduleur Victron MultiPlus II + Cerbo GX (MQTT), batteries, et un chauffe-eau électrique piloté par gradateur AC.

L’évolution V2026 porte surtout sur la stratégie de régulation, plus “système énergétique” que simple routeur.


Matériel du Routeur Solaire

Le matériel reste identique dans l’esprit (ESP32 + dimmer + DS18B20 + LCD I2C + relais + LEDs).
Le câblage GPIO est clairement posé via substitutions :

  • DS18B20 : GPIO27
  • I2C LCD : GPIO21/22
  • Dimmer : GPIO33 (gate) + GPIO34 (ZC)
  • LED jaune : GPIO26 / LED rouge : GPIO25
  • Relais : GPIO5

Programme ESPHome : une régulation “intelligente” V2026

Bascule HP/HC via SNTP

Toutes les minutes, l’ESP calcule l’heure et met à jour is_hp_hours :

  • HP : 06:00 → 21:59
  • HC : 22:00 → 05:59

Ensuite, toutes les secondes :

  • si HP → calcul_injection_hp
  • sinon → calcul_injection_hc

Ce point est central : on garde une boucle très réactive (1s), mais la stratégie change totalement selon le tarif.


Les quatre modes de fonctionnement

Comme en V2, on conserve un select Home Assistant :

  • Auto : routeur autonome (HP/HC + sécurités)
  • Manu : triac forcé via ctriac_manu (0–100%)
  • Arrêt : triac OFF
  • Étalonnage : balayage 0→100% et log des points de puissance

À chaque changement de mode :

  • log envoyé à HA (notify.log_esp176)
  • triac remis à 0 + dimmer OFF (sécurité)
  • en Étalonnage : lancement du script dédié

Sécurités et contrôles (HP)

En HP, la chauffe est autorisée uniquement si :

  • validrouteur est ON
  • production PV > seuil_prod
  • SOC > seuil_soc (hystérésis 2%)
  • température dissipateur triac < (tmax - 2)

Si une condition tombe : triac à 0, dimmer OFF, publication d’un état “OFF”.


Stratégie HC : hystérésis ECS basée sur le volume

En HC, la logique change : on ne chauffe pas “juste parce que c’est HC”.
On chauffe parce que le ballon en a besoin, mesuré via un volume d’eau chaude disponible remonté dans HA (ex : sensor.esp139_ecs_ballon_volume_eau_chaude).

Deux seuils pilotent l’hystérésis :

  • SB = Seuil Bas ECS (L)
  • SH = Seuil Haut ECS (L)

Règles :

  • si volume < SB → ON forcé
  • si volume ≥ SH → OFF forcé
  • si SB ≤ volume < SH → maintien de l’état précédent

On évite ainsi :

  • les cycles courts,
  • les oscillations,
  • les micro-démarrages.

Sécurités HC supplémentaires :

  • température dissipateur OK
  • et un garde-fou “anti-injection” (p_reseau >= -200W) avant d’autoriser la chauffe.

Le cœur commun : regulation_interpolation (HP + HC)

Cette version est plus propre : un seul script calcule la puissance disponible pdispo, puis la convertit en % triac via une table de puissance calibrée.

Calcul de pdispo en HP selon l’état VE.Bus

L’état Victron (Bulk/Absorption/Float) est récupéré via HA (capteur vebus_inverter_state).

  • Bulk : on conserve une réserve batteries res_pubatt avant de chauffer l’ECS
  • Absorption / Float : on intègre la puissance batteries pour éviter de “subir” un Float trop tôt et mieux valoriser le surplus

Calcul de pdispo en HC avec une “cible réseau”

En HC, tu utilises une logique de plafonnement :

  • RESEAU_CIBLE_HC = 6000W
  • pdispo = cible - conso - clim - pac - batt

Cela revient à “remplir” jusqu’à la cible, sans dépasser, en tenant compte de la maison et de l’état batterie.

Conversion pdispo (W)% triac via table + interpolation

Une fois pdispo calculée :

  • limitation à pmax
  • conversion en % triac en parcourant table_puissance et en interpolant linéairement entre deux points

Résultat : régulation plus stable, plus linéaire, et surtout adaptée à la résistance réelle.


Affichage LCD

L’écran LCD 20×4 conserve son rôle de supervision locale (très utile en cas de souci réseau) :

  • Pr = puissance réseau / Pe = puissance ECS
  • Tr = % triac / Valid = OK/NOK
  • Tp = température dissipateur / Temp OK = OK/NOK
  • Mode : Auto/Manu/Arrêt/Étalonnage

Intégration Home Assistant

Réglages exposés

Cette V2026 expose proprement tous les paramètres clés :

  • Seuil Production Val Routeur
  • Seuil SOC
  • Reserve Charge Batteries
  • Auto Reserve Batteries
  • T Max
  • Puissance Max Triac
  • Seuil Bas ECS / Seuil Haut ECS
  • Mode_Fonctionnement_routeur + Valid Routeur

Capteurs importants

  • production PV, conso maison, batteries, SOC (via capteurs HA)
  • volume ECS HA (clé pour la stratégie HC)
  • puissance réseau et ECS (template côté ESP)

Alimentation de l’ECS

Le principe électrique ne change pas : le gradateur module l’alimentation de la résistance ECS.
La nouveauté V2026 se situe dans l’arbitrage :

  • HP : autoconsommation + protection batterie
  • HC : chauffe “utile” basée sur le volume et plafonnée par une cible réseau

Installation et mise en route (V2026)

  1. Assemblage et câblage identiques à la V2.
  2. Flasher le YAML (IP, secrets Wi-Fi, etc.).
  3. Vérifier que toutes les entités HA sources existent (Victron + volume ECS).
  4. Régler pmax, tmax, seuil_soc, seuil_prod, res_pubatt.
  5. Faire l’étalonnage et construire la table.
  6. Passer en Auto.

Résultats et perspectives

Dans la version V2, j’avais déjà une excellente réactivité sur la puissance ECS.
La V2026 apporte surtout :

  • un comportement plus “logique” en HC (chauffe pilotée par besoin réel),
  • une meilleure maîtrise batterie via la réserve auto,
  • une régulation plus propre grâce à l’interpolation sur table calibrée.

Pistes restantes (toujours pertinentes) :

  • intégration température d’eau,
  • prévisions météo,
  • second gradateur (si besoin).

Tableau de Bord:

type: markdown
title: CONDITIONS VALID ROUTEUR
content: >-
  --------------------------------🔥 État Routage ---------------------------

  Status Bus VE: {{
  states("sensor.victron_mqtt_c0619ab1db0d_vebus_276_vebus_inverter_state") }} 

  Switch Validation Routeur: {% if
  is_state('switch.esp176_routeur_valid_routeur', 'on') %} ✅ OK {% else %} ❌ NOK
  {% endif %}

  Pu Prod > Seuil Production: {% if
  is_state('binary_sensor.esp176_routeur_seuil_prod_ok', 'on') %} ✅ OK {% else
  %} ❌ NOK {% endif %}

  Seuil SOC: {% if is_state('binary_sensor.esp176_routeur_seuil_soc_ok', 'on')
  %} ✅ OK {% else %} ❌ NOK {% endif %}

  Temp Triac < Tmax: {% if is_state('binary_sensor.esp176_routeur_temp_ok',
  'on') %} ✅ OK {% else %} ❌ NOK {% endif %}

  Mode Routeur: {{ states("select.esp176_routeur_mode_fonctionnement_routeur")
  }}

  Mode de Régulation: {{ states("sensor.esp176_routeur_mode_regulation") }}

  ----------------------------------🔥 État ECS --------------------------------

  S Bas : {{ states("number.esp176_routeur_seuil_bas_ecs_l") }} L<Volume ECS: {{
  states("sensor.esp139_ecs_ballon_volume_eau_chaude") | float(0) | round(1) }}
  L<S Haut:{{ states("number.esp176_routeur_seuil_haut_ecs_l") }} L{% set vol =
  states("sensor.esp139_ecs_ballon_volume_eau_chaude") | float(0) %} {% set bas
  = states("number.esp176_routeur_seuil_bas_ecs_l") | float(0) %}{% set haut =
  states("number.esp176_routeur_seuil_haut_ecs_l") | float(0) %}

  {% if vol < bas %} 🟥 Vol Bas → chauffe demandée {% elif vol >= haut %} 🟩 Vol
  Haut → chauffe coupée {% else %} 🟨 ECS en plage neutre {% endif %}

  Puissance Réseau: {{
  states("sensor.victron_mqtt_c0619ab1db0d_grid_31_grid_power") | float(0) |
  round(0) }} W - S Triac: {{ states("sensor.esp176_routeur_sortie_triac") |
  float(0) | round(1) }} %

  Dernière mise à jour : {{ now().strftime('%H:%M:%S') }}
grid_options:
  columns: 12
  rows: 6
type: picture-elements
elements:
  - entity: sensor.esp176_routeur_mode_regulation
    prefix: "Regul "
    style:
      background: null
      color: white
      font-size: 120%
      left: 5%
      top: 0%
      transform: none
    type: state-label
  - entity: sensor.victron_mqtt_c0619ab1db0d_vebus_276_vebus_inverter_state
    prefix: "Bus Ve= "
    style:
      background: null
      color: white
      font-size: 120%
      left: 5%
      top: 10%
      transform: none
    type: state-label
  - entity: sensor.esp176_esp32_routeur_1r_pu_disponible
    style:
      background: none
      color: white
      font-size: 100%
      left: 47%
      top: 31%
      transform: none
    type: state-label
    prefix: "P= "
  - entity: sensor.ecocompteur_pac
    prefix: "PAC= "
    style:
      background: null
      color: white
      font-size: 100%
      left: 0%
      top: 56%
      transform: none
    type: state-label
  - entity: sensor.ecocompteur_clim
    prefix: "Clim= "
    style:
      background: null
      color: white
      font-size: 100%
      left: 0%
      top: 46%
      transform: none
    type: state-label
  - entity: sensor.esp176_routeur_sortie_triac
    prefix: ""
    style:
      background: null
      color: white
      font-size: 100%
      left: 82%
      top: 31%
      transform: none
    type: state-label
  - entity: sensor.esp176_esp32_routeur_1r_p_ecs_jsymk
    prefix: "ECS: "
    type: state-label
    style:
      background: null
      color: white
      font-size: 100%
      left: 47%
      top: 48%
      transform: none
  - entity: sensor.mp2_production_solaire_totale
    prefix: "Prod= "
    type: state-label
    style:
      background: null
      color: white
      font-size: 100%
      left: 0%
      top: 26%
      transform: none
  - entity: sensor.victron_mqtt_c0619ab1db0d_vebus_276_vebus_inverter_output_power_l1
    prefix: "Mais= "
    type: state-label
    style:
      background: null
      color: white
      font-size: 100%
      left: 0%
      top: 36%
      transform: none
  - entity: sensor.esp176_routeur_temp_triac
    prefix: "T° Triac= "
    type: state-label
    style:
      background: null
      color: white
      font-size: 100%
      left: 50%
      top: 60%
      transform: none
  - entity: sensor.esp139_ecs_ballon_volume_eau_chaude
    prefix: "Vol ECS= "
    type: state-label
    style:
      background: null
      color: white
      font-size: 100%
      left: 50%
      top: 70%
      transform: none
  - entity: sensor.esp176_esp32_routeur_1r_cons_batt_en_cours
    prefix: "Bat= "
    style:
      background: null
      color: white
      font-size: 100%
      left: 0%
      top: 66%
      transform: none
    type: state-label
image: /local/images/pid_routeur_v4.png
grid_options:
  columns: 12
  rows: 5

Le fichier « pid_routeur_v4.png » est téléchargeable ici: domo.rem81/pid_routeur_v4.png at main · remycrochon/domo.rem81


Conclusion

Cette V2026 n’est plus seulement un routeur PV “anti-injection”.
C’est un contrôleur énergétique :

  • conscient du tarif HP/HC,
  • conscient de l’état Victron (Bulk/Abs/Float),
  • conscient du besoin ECS via volume,
  • et qui module le triac avec une conversion W → % fidèle grâce à l’étalonnage.

Annexes

Calibration : pourquoi et comment

Le triac (commande en %) n’est pas linéaire en puissance. Sans calibration :

  • zones mortes,
  • saturation,
  • oscillations.

Le but est de construire une table réelle :
% triac → W ECS, puis d’utiliser l’inverse : W disponible → % triac.


Mode Étalonnage : génération des points {%, W}

Quand on passe en mode Étalonnage, le script :

  • incrémente striac de 1% jusqu’à 100%
  • applique le dimmer
  • attend 20 secondes (stabilisation)
  • lit la puissance ECS
  • envoie un message {%,W} à HA via notify.etalonnage_routeur

Extrait (principe) :

########################################################################
  # Mode Etalonnage Increment S Triac
  ########################################################################
  - id: etalonnage_striac
    mode: restart  
    then:
      - lambda: |-
          id(striac) = 0.0;
      - while:
          condition:
            lambda: 'return id(striac) < 100.0;'  # S'arrête après striac = 100
          then:
            - lambda: |-
                id(striac) += 1.0; // Incrémente striac
                ESP_LOGI("striac", "Valeur striac: %.2f", id(striac));
            - light.turn_on:
                id: gradateur
                brightness: !lambda 'return id(striac) / 100.0;'  # Normalise entre 0.0 et 1.0

            - delay: 20s  # Temporisation

            - lambda: |-
                ESP_LOGI("striac", "Valeur striac: %.2f Pu ECS %.0f", id(striac), id(puecs).state);            

            - lambda: |-
                std::string mess = "{";
                mess += std::to_string(id(striac)) + ",";
                mess += std::to_string(id(puecs).state)+"}";
                ESP_LOGI("fichier", "Message: %s", mess.c_str());
                id(_log_etalonnage).execute(mess);

      - lambda: |-
          ESP_LOGI("striac", "Fin de l'étalonnage, striac = %.2f", id(striac));

Construction de la table de puissance (table_pu.yaml)

Bonnes pratiques :

  1. Toujours commencer par :
  • 0% → 0W
  1. Table monotone :
  • W doit croître avec %
  1. Temporisation entre chaque point :
  • laisse le temps à la puissance de se stabiliser

Ensuite, ton script d’interpolation fait le reste : pdispo (W) devient un % triac stable.


Code ESPHome

Le code complet (YAML principal + includes) est disponible ici: home-assistant/esphome at master · remycrochon/home-assistant

Télécharger le code de l’ESP 176 et les includes ESP176.

# Affichage Status bus VE
# 0=Off;1=Low Power;2=Fault;3=Bulk;4=Absorption;5=Float;6=Storage;
# 7=Equalize;8=Passthru;9=Inverting;10=Power assist;11=Power supply;252=External control

substitutions:
  device_name: "esp176-routeur"
  friendly_name: esp176
  adress_ip: "192.168.0.176"
  time_timezone: "Europe/Paris"
  # Affectation des GPIO
  GPIO_onewire: "GPIO27"
  GPIO_sda: "GPIO21"
  GPIO_scl: "GPIO22"
  GPIO_tx: "GPIO17"
  GPIO_rx: "GPIO16"  
  GPIO_Led_jaune: "GPIO26"
  GPIO_Led_rouge: "GPIO25"
  GPIO_Led_status: "GPIO32"
  GPIO_Relais: "GPIO5"
  # Dimmer
  GPIO_Gate_pin: "GPIO33"
  GPIO_ZC_pin: "GPIO34"

packages:
  jsk: !include pack_esp176/jsk.yaml
  table_pu: !include pack_esp176/table_pu.yaml

esphome:
  name: ${device_name}
  on_boot:
    priority: -100
    # Force mode auto et tempok au demarrage
    then: 
      - binary_sensor.template.publish:
          id: temperatureok
          state: ON

esp32:
  board: esp32dev
  framework:
    type: arduino
    
wifi:
  networks:
    - ssid: !secret wifi_esp
      password: !secret mdpwifi_esp
  reboot_timeout: 5min
  min_auth_mode: WPA2
  manual_ip:
    static_ip: ${adress_ip}
    gateway: 192.168.0.254
    subnet: 255.255.255.0
    dns1: !secret dns1
    dns2: !secret dns2
    
    
# Utilisez la LED bleue de l'appareil comme LED d'état, qui clignotera s'il y a des avertissements (lent) ou des erreurs (rapide)
status_led:
  pin:
    number: ${GPIO_Led_status} # led jaune
    inverted: true

# Enable logging
logger:
  baud_rate: 0
  level: info

# Enable Home Assistant API
api:

ota:
  platform: esphome

web_server:
  port: 80
  version: 3

time:
  - platform: sntp
    id: sntp_time
    timezone: Europe/Paris
    servers:
      - 0.pool.ntp.org
      - 1.pool.ntp.org
      - 2.pool.ntp.org  
    on_time:
      # Mise à jour toutes les minutes du mode HP/HC
      - seconds: 0
        minutes: /1
        then:
          - lambda: |-
              auto now = id(sntp_time).now();
              if (!now.is_valid()) {
                return;
              }
              int h = now.hour;   // 0..23

              // HP : de 06:00 à 21:59
              // HC : de 22:00 à 05:59
              bool hp = (h >= 6 && h < 22);
              id(is_hp_hours) = hp; // En production
              //id(is_hp_hours) = !hp; // pour tester

              ESP_LOGD("horaire", "Heure = %02d:%02d → %s", h, now.minute, hp ? "HP" : "HC");

# Protocole I2C
i2c:
  sda: ${GPIO_sda}
  scl: ${GPIO_scl}
  scan: True
  id: bus_a
  frequency: 400kHz
  
globals:
  - id: p_dispo
    type: float
    restore_value: no
    initial_value: '0'

  - id: regul
    type: std::string
    restore_value: no
    initial_value: '"Pas de régulation"'

  - id: striac
    type: float
    restore_value: no
    initial_value: '0'

  - id: hc_chauffe          # mémorise l'état chauffe HC (hystérésis)
    type: bool
    restore_value: true
    initial_value: 'false'

  # 🔹 Indique si on est en heures pleines (HP) ou heures creuses (HC)
  # HP : 06:00 → 22:00  ;  HC : 22:00 → 06:00
  - id: is_hp_hours
    type: bool
    restore_value: no
    initial_value: 'true'

  # stocke temporairement le message à envoyer à telegram
  - id: telegram_msg_buffer
    type: std::string
    restore_value: no
    initial_value: '""'

# Sonde Temperature Dallas
one_wire:
  - platform: gpio  
    pin: ${GPIO_onewire}

# déclaration des modes de fonctionnement dans des "input select"
select:
  - platform: template
    name: "Mode_Fonctionnement_routeur"
    optimistic: true
    restore_value: true
    options:
      - Auto
      - Manu
      - Arret
      - Etalonnage
    id: _Mode_Fonctionnement_routeur
    on_value: 
      then:
        - lambda: |-
            char mess[128];
            snprintf(mess, sizeof(mess), "Mode Fonctionnement Routeur: %s", id(_Mode_Fonctionnement_routeur).current_option());
            ESP_LOGI("fichier", "Message: %s", mess);
            id(_log_fichier).execute(mess);  // Appelle le script _log_fichier avec le paramètre mess      
        # Passage en mode étalonnage
        - if:
            condition:
              - lambda: 'return id(_Mode_Fonctionnement_routeur).current_option() == "Etalonnage";'
            then:
              - script.execute: etalonnage_striac
        # Passage en mode Manu on remet à Zero le valid Routeur et la consigne Manu
        - if:
            condition:
              - lambda: 'return id(_Mode_Fonctionnement_routeur).current_option()  == "Manu";'
            then:
              - lambda: |-
                  id(ctriac_manu).publish_state(0);
              - switch.turn_off: validrouteur
        # Passage dans tous les modes on met à zéro le triac
        - lambda: |-
            id(striac) = 0;
        - light.turn_off:
            id: gradateur
        - script.execute: calcul_injection_hp     

binary_sensor:
  #Etat de la connection
  - platform: status
    name: "Status"
  
  - platform: template
    name: "Temp Ok"
    id: temperatureok
  
  - platform: template
    name: "Seuil Prod Ok"
    id: seuil_prod_ok

  - platform: template
    name: "Seuil SOC Ok"
    id: seuil_soc_ok
  
# Input Number
number:
  # seuil SOC validation routeur
  - platform: template
    name: "Consigne Triac en manu"
    id: ctriac_manu
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 100
    unit_of_measurement: "%"
    step: 1
    icon: mdi:arrow-collapse-vertical

  # Max sortie triac
  - platform: template
    name: "Puissance Max Triac"
    id: pmax
    optimistic: true
    restore_value: true
    mode: box
    min_value: 10
    max_value: 3000
    unit_of_measurement: "W"
    step: 1
    icon: mdi:arrow-collapse-vertical

  # Seuil MAX temperature
  - platform: template
    name: "T Max"
    id: tmax
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 75
    unit_of_measurement: "C°"
    step: 0.1
    icon: mdi:arrow-collapse-vertical

  # Consigne Régul sur Puissance Batteries en mode Bulk
  - platform: template
    name: "Reserve Charge Batteries"
    id: res_pubatt
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 2500
    unit_of_measurement: "W"
    step: 1
    icon: mdi:arrow-collapse-vertical

  # seuil SOC validation routeur
  - platform: template
    name: "Seuil SOC"
    id: seuil_soc
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 100
    unit_of_measurement: "%"
    step: 1
    icon: mdi:arrow-collapse-vertical

  # seuil Production Photovoltaique de validation routeur 
  - platform: template
    name: "Seuil Production Val Routeur"
    id: seuil_prod
    optimistic: true
    restore_value: true
    mode: box
    min_value: 100
    max_value: 3000
    unit_of_measurement: "W"
    step: 1
    icon: mdi:arrow-collapse-vertical

  # Simul Vol ECS
  - platform: template
    name: "Simul Vol ECS"
    id: volume_ecs_ha_simule
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 200
    unit_of_measurement: "L"
    step: 1
    icon: mdi:arrow-collapse-vertical
    
  - platform: template
    name: "Seuil Bas ECS (L)"
    id: seuil_bas_ha
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 200       # ballon 200L → à adapter si besoin
    unit_of_measurement: "L"
    step: 5
    icon: mdi:water

  - platform: template
    name: "Seuil Haut ECS (L)"
    id: seuil_haut_ha
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 200
    unit_of_measurement: "L"
    step: 5
    icon: mdi:water

sensor:
  - platform: wifi_signal # Reports the WiFi signal strength/RSSI in dB
    name: "WiFi Signal dB"
    id: wifi_signal_db
    update_interval: 60s
    entity_category: "diagnostic"

  - platform: copy # Reports the WiFi signal strength in %
    source_id: wifi_signal_db
    name: "WiFi Signal Percent"
    filters:
      - lambda: return min(max(2 * (x + 100.0), 0.0), 100.0);
    unit_of_measurement: "Signal %"
    entity_category: "diagnostic"
    device_class: ""
    
  ############### TEMPLATE  ######################"
  # Affichage dans HA et sur l'afficheur
  # Puissance lue par le JSk- Négative en injection/Positive en soutirage
  - platform: template
    name: "Pu Reseau"
    id: pureseau1
    unit_of_measurement: "W"
    state_class: "measurement"
    accuracy_decimals: 0

  # Sortie triac de 0à100%
  - platform: template
    name: "Sortie Triac"
    id: afstriac
    unit_of_measurement: "%"
    state_class: "measurement"  
    accuracy_decimals: 2

  # Pu disponible
  - platform: template
    name: "Pu Disponible"
    id: afpdispo
    unit_of_measurement: "W"
    state_class: "measurement"
    accuracy_decimals: 0

  # Sensor Intermediaire pour synoptique
  - platform: template
    name: "Cons batt en Cours"
    id: cons_batt_cours
    state_class: "measurement"
    unit_of_measurement: "W"
    accuracy_decimals: 0

  # Lecture dans HA

  - platform: homeassistant
    entity_id: sensor.victron_mqtt_c0619ab1db0d_vebus_276_vebus_inverter_output_power_l1
    id: conso_maison
    internal: true
    filters:
      - sliding_window_moving_average:
          window_size: 10
          send_every: 1

  - platform: homeassistant
    entity_id: sensor.victron_mqtt_c0619ab1db0d_battery_277_battery_power
    id: pu_batteries
    internal: true
    filters:
      - sliding_window_moving_average:
          window_size: 10
          send_every: 1
 
  - platform: homeassistant
    entity_id: sensor.mp2_production_solaire_totale
    id: pu_prod
    internal: true
    filters:
      - sliding_window_moving_average:
          window_size: 10
          send_every: 1

  - platform: homeassistant
    entity_id: sensor.victron_mqtt_c0619ab1db0d_battery_277_battery_soc
    id: soc
    internal: true
    filters:
      - sliding_window_moving_average:
          window_size: 10
          send_every: 1

  - platform: homeassistant
    entity_id: sensor.ecocompteur_clim
    id: pu_clim
    internal: true

  - platform: homeassistant
    entity_id: sensor.ecocompteur_pac
    id: pu_pac
    internal: true

  # Sonde Temperature radiateur
  - platform: dallas_temp
    address: 0xeb012112e461b128
    name: "Temp triac"
    id: temp_triac
    update_interval: 60s
    filters:
      - filter_out: NAN

  # Température ECS HA
  #- platform: homeassistant
  #  id: temp_ecs_ha9
  #  entity_id: sensor.esp139_ecs_temp_ecs
  #  name: "Température ECS HA"

  # Volume d'eau chaude dispo (en L) mesuré par ESP126
  - platform: homeassistant
    id: volume_ecs_ha
    entity_id: sensor.esp139_ecs_ballon_volume_eau_chaude
    name: "Volume ECS HA"

# déclaration des "text_sensors"
text_sensor:
  - platform: template
    name: "Mode Regulation"
    id: moderegul

  - platform: homeassistant
    entity_id: sensor.victron_mqtt_c0619ab1db0d_vebus_276_vebus_inverter_state
    id: etatbus_ve
    internal: true

switch:
  - platform: gpio
    name: "Relais"
    pin: ${GPIO_Relais}
    id: relais

  - platform: template
    name: "Valid Routeur"    
    id: validrouteur
    optimistic: true
    restore_mode: always_on

  - platform: template
    name: "Auto Reserve Batteries"
    id: auto_reserve_batt
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON

  - platform: restart
    name: "Restart"

output:
  #LEDS --------------------------------------
  - id: led_jaune
    platform: gpio
    pin: ${GPIO_Led_jaune} 

  - id: led_rouge
    platform: gpio
    pin: ${GPIO_Led_rouge}

  # Pilotage du Dimmer
  - platform: ac_dimmer 
    id: ecs
    gate_pin: ${GPIO_Gate_pin}
    method: leading
    zero_cross_pin:
      number: ${GPIO_ZC_pin} 
      mode:
        input: true
      inverted: yes
    min_power: 5%

light:
  - platform: monochromatic
    name: "STriac"
    output: ecs
    id: gradateur
    default_transition_length: 50ms

# Affichage
display:
  - platform: lcd_pcf8574
    dimensions: 20x4
    address: 0x27
    update_interval: 20s  # Plus espacé pour alléger la charge CPU
    lambda: |-
      char ligne0[21];
      char ligne1[21];
      char ligne2[21];
      char ligne3[21];

      snprintf(ligne0, sizeof(ligne0), "Pr=%0.0fW Pe=%0.0fW", id(pureseau1).state, id(puecs).state);
      snprintf(ligne1, sizeof(ligne1), "Tr=%0.1f%% V:%s", id(striac), id(validrouteur).state ? "OK" : "NOK");
      snprintf(ligne2, sizeof(ligne2), "Tp=%0.1fc E:%s", id(temp_triac).state, id(temperatureok).state ? "OK" : "NOK");
      snprintf(ligne3, sizeof(ligne3), "Mode:%s", id(_Mode_Fonctionnement_routeur).current_option());

      it.print(0, 0, ligne0);
      it.print(0, 1, ligne1);
      it.print(0, 2, ligne2);
      it.print(0, 3, ligne3);

interval:
  - interval: 1s
    then:
      - if:
          condition:
            lambda: |-
              // HP de 06:00 à 22:00, sinon HC
              return id(is_hp_hours);   // true = HP, false = HC
          then:
            - script.execute: calcul_injection_hp
          else:
            - script.execute: calcul_injection_hc

  - interval: 5s
    then:      
      - script.execute: etat_production
      - script.execute: calcul_relais_surprod      
  - interval: 60s
    then:
      - script.execute: maj_reserve_batt_auto
 ########################################################################
script:
  ########################################################################
  # 🔹 Script : calcul_injection_hp
  ########################################################################
  - id: calcul_injection_hp
    mode: single
    then:
      - lambda: |-
          // ============================================================
          // 🧭 ÉVALUATION DES CONDITIONS DE DÉMARRAGE
          // ============================================================

          // ✅ Vérifie si la production solaire est suffisante
          bool prod_ok = (id(pu_prod).state > id(seuil_prod).state);
          id(seuil_prod_ok).publish_state(prod_ok);

          // ✅ Vérifie si le SOC est supérieur au seuil (avec hysteresis de 2%)
          bool soc_ok = false;
          if (id(soc).state >= id(seuil_soc).state) {
            soc_ok = true;
          } else if (id(soc).state < (id(seuil_soc).state - 2)) {
            soc_ok = false;
          }
          id(seuil_soc_ok).publish_state(soc_ok);

          // ✅ Vérifie la température du radiateur triac
          bool temp_ok = (id(temp_triac).state < (id(tmax).state - 2));
          if (!temp_ok) {
            id(temperatureok).publish_state(false);
          } else {
            id(temperatureok).publish_state(true);
          }

          ESP_LOGI("HP", 
            "Check HP → Prod=%.0fW (Seuil=%.0f) | SOC=%.1f%% (Seuil=%.1f) | TempTriac=%.1f°C (Tmax=%.1f°C)",
            id(pu_prod).state, id(seuil_prod).state,
            id(soc).state, id(seuil_soc).state,
            id(temp_triac).state, id(tmax).state
          );

      # 1️⃣ Conditions NOK ou mode Arret → arrêt complet
      - if:
          condition:
            or:
              # Cas 1 : Mode Arret
              - lambda: 'return id(_Mode_Fonctionnement_routeur).current_option() == "Arret";'
              # Cas 2 : Mode Auto mais conditions NOK
              - and:
                  - lambda: 'return id(_Mode_Fonctionnement_routeur).current_option() == "Auto";'
                  - or:
                      - switch.is_off: validrouteur
                      - binary_sensor.is_off: temperatureok
                      - binary_sensor.is_off: seuil_prod_ok
                      - binary_sensor.is_off: seuil_soc_ok
          then:
            - lambda: |-
                id(striac) = 0;
                id(moderegul).publish_state("OFF");
                id(afpdispo).publish_state(0);
                id(cons_batt_cours).publish_state(0);
                ESP_LOGW("HP", "⚠️ Régulation OFF : conditions NOK ou arrêt manuel.");
            - light.turn_off: gradateur

      # 2️⃣ Mode Auto + toutes conditions OK → régulation active
      - if:
          condition:
            and:
              - lambda: 'return id(_Mode_Fonctionnement_routeur).current_option() == "Auto";'
              - switch.is_on: validrouteur
              - binary_sensor.is_on: seuil_prod_ok
              - binary_sensor.is_on: temperatureok
              - binary_sensor.is_on: seuil_soc_ok
          then:
            - logger.log:
                format: "✅ Conditions OK (HP) → Régulation interpolation active"
                level: INFO
            - script.execute: regulation_interpolation

            - light.turn_on:
                id: gradateur
                brightness: !lambda |-
                  return id(striac) / 100.0f;

      # 3️⃣ Mode Manuel → application directe de la consigne
      - if:
          condition:
            and:
              - lambda: 'return id(_Mode_Fonctionnement_routeur).current_option() == "Manu";'
              - switch.is_on: validrouteur
          then:
            - lambda: |-
                // Application directe de la consigne manuelle
                id(striac) = id(ctriac_manu).state;
                id(afpdispo).publish_state(0);
                id(moderegul).publish_state("Manu");
                id(cons_batt_cours).publish_state(0);
                ESP_LOGI("HP", "🧩 Mode MANU → Triac forcé à %.1f%%", id(striac));
            - light.turn_on:
                id: gradateur
                brightness: !lambda |-
                  return id(striac) / 100.0f;

      # Publication de la valeur du triac (affichage + MQTT)
      - lambda: |-
          id(afstriac).publish_state(id(striac));

  ########################################################################
  # 🔹 Script : calcul_injection_hc (avec hystérésis ECS)
  ########################################################################

  - id: calcul_injection_hc
    mode: single
    then:
      - script.execute: decision_chauffe_hc
      - script.execute: apply_chauffe_hc

  - id: decision_chauffe_hc
    mode: single
    then:
      - lambda: |-
          float vol = id(volume_ecs_ha).state;
          float sb  = id(seuil_bas_ha).state;
          float sh  = id(seuil_haut_ha).state;
          float p_reseau = id(pureseau1).state;

          bool temp_ok = (id(temp_triac).state < (id(tmax).state - 2));
          id(temperatureok).publish_state(temp_ok);

          bool injection_ok = (p_reseau >= -200);

          ESP_LOGI("HC",
            "HC Decision → Vol=%.0fL (SB=%.0f / SH=%.0f) | chauffe=%d | P_Réseau=%.0fW | TempOK=%d",
            vol, sb, sh, id(hc_chauffe), p_reseau, temp_ok
          );

          // Sécurités globales
          if (id(_Mode_Fonctionnement_routeur).current_option() != std::string("Auto") ||
              !id(validrouteur).state ||
              !temp_ok ||
              !injection_ok) {

            id(hc_chauffe) = false;
            id(moderegul).publish_state("HC OFF - Sécurité");
            ESP_LOGW("HC", "⛔ Chauffe OFF (sécurité)");
            return;
          }

          // ----- HYSTÉRÉSIS ECS -----
          if (vol < sb) {
            // Démarrage forcé
            id(hc_chauffe) = true;
            id(moderegul).publish_state("HC ON - Volume < SB");
            ESP_LOGI("HC", "🔥 Vol %.0f < %.0f → ON", vol, sb);
          }
          else if (vol >= sh) {
            // Arrêt forcé
            id(hc_chauffe) = false;
            id(moderegul).publish_state("HC OFF - Volume ≥ SH");
            ESP_LOGI("HC", "✅ Vol %.0f ≥ %.0f → OFF", vol, sh);
          }
          else {
            // Zone SB–SH → on conserve l'état précédent
            if (id(hc_chauffe)) {
              id(moderegul).publish_state("HC ON - Maintien");
              ESP_LOGI("HC", "♨️ Vol %.0f (SB–SH) → Maintien ON", vol);
            } else {
              id(moderegul).publish_state("HC OFF - Attente");
              ESP_LOGI("HC", "🕓 Vol %.0f (SB–SH) → OFF", vol);
            }
          }

  - id: apply_chauffe_hc
    mode: single
    then:
      - if:
          condition:
            lambda: 'return id(hc_chauffe);'
          then:
            - script.execute: regulation_interpolation
            - light.turn_on:
                id: gradateur
                brightness: !lambda 'return id(striac) / 100.0f;'
            - lambda: |-
                id(afstriac).publish_state(id(striac));
          else:
            - lambda: |-
                id(striac) = 0.0f;
                id(afstriac).publish_state(0);
                id(afpdispo).publish_state(0);
            - light.turn_off: gradateur
  ########################################################################
  # 🔹 Script : regulation_interpolation (commun HP + HC)
  ########################################################################
  - id: regulation_interpolation
    mode: single
    then:
      - lambda: |-
          // 🔹 Détermination du mode HP / HC à partir de l'heure
          bool is_hp = id(is_hp_hours);  // true = HP (06–22h), false = HC (22–06h)
          float pdispo = 0.0f;  // puissance disponible pour ECS

          // 🔸 Branche HP : régulation sur production PV / batteries
          if (is_hp) {
            if (id(etatbus_ve).state == "Bulk") {  // Bulk
              pdispo = id(pu_prod).state - id(conso_maison).state - id(pu_clim).state - id(pu_pac).state - id(res_pubatt).state;
              if (pdispo < 0.0f) pdispo = 0.0f;
              id(cons_batt_cours).publish_state(id(res_pubatt).state);
              id(regul) = "HP Reg. (Bulk)";
            } 
            else if (id(etatbus_ve).state == "Absorption" || id(etatbus_ve).state == "Float") { // Absorption / Float
              pdispo = id(pu_prod).state - id(conso_maison).state - id(pu_clim).state - id(pu_pac).state + id(pu_batteries).state;
              if (pdispo < 0.0f) pdispo = 0.0f;
              id(cons_batt_cours).publish_state(id(pu_batteries).state * -1);
              id(regul) = "HP Reg. (Abs/Float)";
            } 
            else {
              id(regul) = "HP Reg. (Bus VE NOK)";
              id(cons_batt_cours).publish_state(0);
              pdispo = 0.0f;
            }

            ESP_LOGI("regul",
                     "HP → Prod=%.0fW | Conso=%.0fW | Clim=%.0fW | PAC=%.0fW | Batt=%.0fW | p_dispo=%.0fW | Mode=%s",
                     id(pu_prod).state,
                     id(conso_maison).state,
                     id(pu_clim).state,
                     id(pu_pac).state,
                     id(pu_batteries).state,
                     pdispo,
                     id(regul).c_str());
          }
          // 🔸 Branche HC : calcul prédictif réseau (chauffe ECS)
          else {
            const float RESEAU_CIBLE_HC = 6000.0f;  // puissance max visée sur réseau

            float conso = id(conso_maison).state;
            float clim  = id(pu_clim).state;
            float pac  = id(pu_pac).state;
            float batt  = id(pu_batteries).state;   // >0 décharge, <0 charge

            if (isnan(conso)) conso = 0;
            if (isnan(clim))  clim  = 0;
            if (isnan(pac))  pac  = 0;
            if (isnan(batt))  batt  = 0;

            // p_dispo = cible - conso_maison - pu_clim - pu_batteries
            pdispo = RESEAU_CIBLE_HC - conso - clim - pac - batt;
            if (pdispo < 0.0f) pdispo = 0.0f;

            id(regul) = "HC Chauffe ECS";
            id(cons_batt_cours).publish_state(batt);

            ESP_LOGI("regul",
                     "HC → Cible=%.0fW | Conso=%.0fW | Clim=%.0fW | PAC=%.0fW| Batt=%.0fW | p_dispo=%.0fW",
                     RESEAU_CIBLE_HC, conso, clim, pac, batt, pdispo);
          }

          // 🔹 Limitation & interpolation table puissance
          pdispo = constrain(pdispo, 0.0f, id(pmax).state);
          id(p_dispo) = pdispo;

          float striac_f = 0.0f;
          for (size_t i = 1; i < id(table_puissance).size(); i++) {
            float x0 = id(table_puissance)[i-1].first;   // % Triac
            float y0 = id(table_puissance)[i-1].second;  // Puissance
            float x1 = id(table_puissance)[i].first;
            float y1 = id(table_puissance)[i].second;
            if (pdispo <= y1) {
              // interpolation linéaire
              striac_f = x0 + (pdispo - y0) * (x1 - x0) / (y1 - y0);
              break;
            }
            striac_f = x1;
          }

          if (isnan(striac_f)) striac_f = 0.0f;
          id(striac) = constrain(striac_f, 0.0f, 100.0f);

          id(afpdispo).publish_state(id(p_dispo));
          id(moderegul).publish_state(id(regul));

          ESP_LOGI("regul",
                   "%s | p_dispo=%.0fW | STriac=%.1f%% | Mode=%s",
                   is_hp ? "HP" : "HC",
                   pdispo, id(striac),
                   id(regul).c_str());
  ########################################################################
  # Script : Calcul dynamique de la production PV attribuée aux batteries
  ########################################################################
  - id: maj_reserve_batt_auto
    mode: single
    then:
      - lambda: |-
          if (!id(auto_reserve_batt).state) return;

          float soc_val = id(soc).state;
          float pv  = id(pu_prod).state;
          std::string st = id(etatbus_ve).state;

          if (isnan(soc_val)) soc_val = 0;
          if (isnan(pv))  pv  = 0;

          // Paramètres (à ajuster)
          const float SOC_LOW   = 70.0f;   // en dessous → on charge fort
          const float SOC_HIGH  = 90.0f;   // au-dessus → on privilégie ECS
          const float PV_MIN    = id(seuil_prod).state;  // en dessous → pas la peine d'assouplir
          const float R_MIN     = 50.0f;    // réserve min pour garder une micro-charge
          const float R_MAX     = 2500.0f; // réserve max "raisonnable"

          float r = id(res_pubatt).state; // valeur actuelle

          // Cas 1 : PV faible → réserve haute (on protège la charge batterie)
          if (pv < PV_MIN) {
            r = 1200.0f;
          }
          else {
            // Cas 2 : SOC bas → réserve haute
            if (soc_val <= SOC_LOW) {
              r = R_MAX;
            }
            // Cas 3 : SOC haut → réserve basse pour retarder la fin (éviter FLOAT)
            else if (soc_val >= SOC_HIGH) {
              r = R_MIN; 
            }
            // Cas 4 : interpolation entre les deux
            else {
              float t = (soc_val - SOC_LOW) / (SOC_HIGH - SOC_LOW); // 0..1
              r = R_MAX + t * (R_MIN - R_MAX);
            }
          }

          // Option : si déjà en Float, inutile de garder une réserve haute
          // (vous pouvez au contraire mettre R_MIN pour maximiser ECS)
          if (st == "Float") {
            r = R_MIN;
          }

          // Sécurités
          if (r < R_MIN) r = R_MIN;
          if (r > R_MAX) r = R_MAX;

          // Évite de spammer HA pour des petites variations
          float cur = id(res_pubatt).state;
          if (fabsf(cur - r) >= 50.0f) {
            id(res_pubatt).publish_state(r);
            ESP_LOGI("RES", "Auto reserve: SOC=%.1f PV=%.0fW State=%s -> res_pubatt=%.0fW",
                     soc_val, pv, st.c_str(), r);
          }


  ########################################################################
  # Mode Etalonnage Increment S Triac
  ########################################################################
  - id: etalonnage_striac
    mode: restart  
    then:
      - lambda: |-
          id(striac) = 0.0;
      - while:
          condition:
            lambda: 'return id(striac) < 100.0;'  # S'arrête après striac = 100
          then:
            - lambda: |-
                id(striac) += 1.0; // Incrémente striac
                ESP_LOGI("striac", "Valeur striac: %.2f", id(striac));
            - light.turn_on:
                id: gradateur
                brightness: !lambda 'return id(striac) / 100.0;'  # Normalise entre 0.0 et 1.0

            - delay: 20s  # Temporisation

            - lambda: |-
                ESP_LOGI("striac", "Valeur striac: %.2f Pu ECS %.0f", id(striac), id(puecs).state);            

            - lambda: |-
                std::string mess = "{";
                mess += std::to_string(id(striac)) + ",";
                mess += std::to_string(id(puecs).state)+"}";
                ESP_LOGI("fichier", "Message: %s", mess.c_str());
                id(_log_etalonnage).execute(mess);

      - lambda: |-
          ESP_LOGI("striac", "Fin de l'étalonnage, striac = %.2f", id(striac));

  ########################################################################
  # ------------  Pilotage led
  ########################################################################
  - id: etat_production
    mode: single
    then:
      - if: 
          condition:
            sensor.in_range:
              id: pureseau1
              below: -50
          then:
            - output.turn_on: led_rouge
          else:
            - output.turn_off: led_rouge
            
      - if: 
          condition:
            switch.is_on: validrouteur
          then:
            - output.turn_on: led_jaune
          else:
            - output.turn_off: led_jaune
 
  ########################################################################
  - id: calcul_relais_surprod
    mode: single
    then:
      - if: 
          condition:
            - lambda: 'return (id(striac)>=90 && id(puecs).state<10);'
          then:
            - delay: 300s
            - switch.turn_on: relais
            - logger.log: "Relais Activé"

      - if: 
          condition:
            - lambda: 'return id(puecs).state >= 10;'
          then:
            - switch.turn_off: relais
            - logger.log: "Relais Désactivé"
 
  ########################################################################
  - id: _log_fichier
    parameters:
      mess1: std::string
    then:
      - lambda: |-
          std::string mess = mess1;
          id(telegram_msg_buffer) = mess;
          ESP_LOGI("log_message", "Telegram buffer: %s", id(telegram_msg_buffer).c_str());
      - homeassistant.service:                
          action: notify.send_message
          data:
            entity_id: notify.log_esp176
            message: !lambda 'return id(telegram_msg_buffer).c_str();'

  ########################################################################
  - id: _log_etalonnage
    parameters:
      mess1: std::string
    then:
      - lambda: |-
          std::string mess = mess1;
          id(telegram_msg_buffer) = mess;
          ESP_LOGI("log_message", "Telegram buffer: %s", id(telegram_msg_buffer).c_str());
      - homeassistant.service:                
          action: notify.send_message
          data:
            entity_id: notify.etalonnage_routeur
            message: !lambda 'return id(telegram_msg_buffer).c_str();'

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *