Contents
- 1 Intro
- 2 Upgrade:
- 3 Pourquoi un Routeur Solaire ?
- 4 Programme ESPHome : une régulation “intelligente” V2026
- 5 Affichage LCD
- 6 Intégration Home Assistant
- 7 Alimentation de l’ECS
- 8 Installation et mise en route (V2026)
- 9 Résultats et perspectives
- 10 Tableau de Bord:
- 11 Conclusion
- 12 Annexes
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 :
validrouteurest 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_pubattavant 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 = 6000Wpdispo = 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_puissanceet 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 RouteurSeuil SOCReserve Charge BatteriesAuto Reserve BatteriesT MaxPuissance Max TriacSeuil Bas ECS/Seuil Haut ECSMode_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)
- Assemblage et câblage identiques à la V2.
- Flasher le YAML (IP, secrets Wi-Fi, etc.).
- Vérifier que toutes les entités HA sources existent (Victron + volume ECS).
- Régler
pmax,tmax,seuil_soc,seuil_prod,res_pubatt. - Faire l’étalonnage et construire la table.
- 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
striacde 1% jusqu’à 100% - applique le dimmer
- attend 20 secondes (stabilisation)
- lit la puissance ECS
- envoie un message
{%,W}à HA vianotify.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 :
- Toujours commencer par :
- 0% → 0W
- Table monotone :
- W doit croître avec %
- 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();'
