Gestion du chauffage avancée : Calendrier + Géofence + Forçage + Limitation charge
Bonjour à tous,
Je voulais partager l'architecture que j'ai construite pour piloter mes 3 radiateurs électriques (chambre 1, chambre 2, bureau) depuis HA. C'est parti d'un simple calendrier et ça a évolué vers quelque chose d'assez complet — donc autant documenter ici au cas où ça inspire quelqu'un.
Gestion du chauffage avancée : Calendrier + Géofence + Forçage + Limitation charge
Config : Home Assistant 2026.6 | Zigbee2MQTT | 3 radiateurs électriques
Architecture matérielle
- 3
generic_thermostat(entitésclimate.*) avec switch Zigbee physique derrière chacun - 1 capteur de fenêtre par pièce (
binary_sensor.fenetre_*_contact) - Calendrier local HA comme "télécommande planning"
- 2 personnes tracées par GPS
Le principe : 4 couches empilées
Calendrier local HA (titre de l'événement = preset)
↓
Orchestrateur (calendrier + géofence 150km)
↓
Appliquer presets + ON/OFF (avec fenêtres + limitation charge)
↑
Forçage manuel (global ou par radiateur)
Couche 1 : Le calendrier local HA comme planning
HA embarque une intégration calendrier local (sans dépendance externe). Le titre de l'événement est directement le preset :
| Titre de l'événement | Preset HA appliqué |
|---|---|
eco | eco |
confort ou comfort | comfort |
home ou presence | home |
nuit ou sleep | sleep |
away ou absent | away |
L'orchestrateur lit state_attr('calendar.mon_calendrier', 'message'), fait un mapping, et stocke le résultat dans input_select.chauffage_preset_actif. Le planning se gère directement depuis l'interface HA, sans outil externe.
Couche 2 : Géofence 150 km
Si les deux occupants sont à plus de 150 km de la maison, le preset bascule automatiquement en away, quoi que dise le calendrier.
dist_personne1: "{{ distance('person.personne1', 'zone.home') | float(0) }}"
dist_personne2: "{{ distance('person.personne2', 'zone.home') | float(0) }}"
both_far: "{{ dist_personne1 > 150 and dist_personne2 > 150 }}"
preset_base: "{{ 'away' if both_far else cal_preset }}"
Le seuil à 150 km (et pas 1 km) évite les faux positifs GPS en ville. Si l'un des deux est à la maison, le calendrier reprend le dessus.
Couche 3 : Application avec 3 règles prioritaires
C'est l'automatisation centrale, qui tourne sur chauffage_appliquer (event custom) + toutes les 2 minutes en fallback.
Règle 1 — Fenêtre ouverte = OFF prioritaire
Un capteur fenêtre en on coupe le radiateur, point. Pas de preset, pas de timer, rien.
Règle 2 — Limitation à 2 radiateurs simultanés en cycle de chauffe
La logique calcule quels radiateurs sont "en cycle actif" (température courante < température cible), et limite à 2 allumés simultanément. Le 3ème attend. Ça évite de tirer trop de puissance d'un coup sur le tableau électrique.
{% if fenetre %}
{% set on = false %}
{% elif force %}
{% set on = true %}
{% elif en_cycle %}
{% if ns.slots > 0 %}
{% set on = true %}
{% set ns.slots = ns.slots - 1 %}
{% else %}
{% set on = false %}
{% endif %}
{% else %}
{% set on = true %}
{% endif %}
Règle 3 — Contrôle via hvac_mode, pas via le switch
Évite un conflit avec generic_thermostat qui gère lui-même le switch. Passer par le switch directement créerait des états incohérents.
Couche 4 : Forçage manuel
Forçage global (ex. : rentrée impromptue, nuit froide)
input_boolean.chauffage_force→ active un timer de N heures (configurable viainput_number.chauffage_duree_force_global)- Pendant le forçage, tous les radiateurs passent en
eco - Timer expiré → retour automatique au calendrier
Forçage par radiateur (ex. : occupant malade, on monte une chambre)
input_boolean.chauffage_force_*par pièce- Durée configurable par radiateur (
input_number.chauffage_duree_force_*) - Preset
homependant la durée, puis retour auto
Bilan énergétique (logs)
Une automatisation dédiée log l'état complet toutes les 30 min et à chaque chauffage_appliquer :
preset_base=eco | cal=eco | dist p1=0.3km, p2=142.1km | both_far=False
Utile pour debugger les comportements inattendus.
Entités helpers utilisées
| Entité | Rôle |
|---|---|
input_boolean.mode_chauffage | ON/OFF saison chauffage |
input_select.chauffage_preset_actif | Preset courant calculé |
input_boolean.chauffage_force | Forçage global |
timer.chauffage_force_timeout | Timer forçage global |
input_boolean.chauffage_force_* | Forçage par radiateur |
timer.chauffage_force_timeout_* | Timer par radiateur |
input_number.chauffage_duree_force_* | Durée forçage en heures |
Les automatisations complètes
1. Orchestrateur — Calcul du preset (calendrier + géofence)
alias: Chauffage - Orchestrateur
description: Calcule le preset de base (calendrier + géofence).
triggers:
- trigger: calendar
entity_id: calendar.mon_calendrier
event: start
- trigger: calendar
entity_id: calendar.mon_calendrier
event: end
- trigger: state
entity_id:
- person.personne1
- person.personne2
- trigger: homeassistant
event: start
- trigger: state
entity_id: input_boolean.mode_chauffage
to: "on"
conditions:
- condition: state
entity_id: input_boolean.mode_chauffage
state: "on"
variables:
distance_threshold: 150
default_preset: eco
mapping:
eco: eco
confort: comfort
comfort: comfort
home: home
presence: home
away: away
absent: away
nuit: sleep
sleep: sleep
dist_personne1: "{{ distance('person.personne1', 'zone.home') | float(0) }}"
dist_personne2: "{{ distance('person.personne2', 'zone.home') | float(0) }}"
both_far: "{{ (dist_personne1 > distance_threshold) and (dist_personne2 > distance_threshold) }}"
cal_msg: "{{ state_attr('calendar.mon_calendrier', 'message') | default('', true) | lower | trim }}"
cal_preset: "{{ mapping.get(cal_msg, default_preset) }}"
preset_base: "{{ 'away' if both_far else cal_preset }}"
actions:
- action: input_select.select_option
target:
entity_id: input_select.chauffage_preset_actif
data:
option: "{{ preset_base }}"
- event: chauffage_appliquer
- action: logbook.log
data:
name: Chauffage - Orchestrateur
message: >
preset_base={{ preset_base }} | cal={{ cal_preset }} |
dist p1={{ dist_personne1|round(1) }}km, p2={{ dist_personne2|round(1) }}km |
both_far={{ both_far }}
entity_id: input_select.chauffage_preset_actif
mode: restart
2. Mode vacances — Preset away quand le chauffage est désactivé
alias: Chauffage - Mode Vacances
triggers:
- trigger: state
entity_id: input_boolean.mode_chauffage
to: "off"
for:
minutes: 1
conditions:
- condition: state
entity_id: input_boolean.mode_chauffage
state: "off"
actions:
- action: climate.set_preset_mode
target:
entity_id:
- climate.climat_chambre_1
- climate.climat_chambre_2
- climate.climat_chambre_bureau
data:
preset_mode: away
mode: single
3. Forçage global — Timer configurable + retour auto calendrier
alias: Chauffage - Forçage global
triggers:
- trigger: state
entity_id: input_boolean.chauffage_force
to: "on"
- trigger: state
entity_id: input_boolean.chauffage_force
to: "off"
- trigger: event
event_type: timer.finished
event_data:
entity_id: timer.chauffage_force_timeout
id: timer.finished
- trigger: state
entity_id: input_number.chauffage_duree_force_global
actions:
- choose:
- conditions:
- condition: state
entity_id: input_boolean.chauffage_force
state: "on"
sequence:
- variables:
duree_heures: "{{ states('input_number.chauffage_duree_force_global') | float }}"
duree_formatee: "{{ '%02d:%02d:%02d' | format(duree_heures|int, ((duree_heures % 1)*60)|int, 0) }}"
- action: timer.start
target:
entity_id: timer.chauffage_force_timeout
data:
duration: "{{ duree_formatee }}"
- event: chauffage_appliquer
- conditions:
- condition: state
entity_id: input_boolean.chauffage_force
state: "off"
sequence:
- action: timer.cancel
target:
entity_id: timer.chauffage_force_timeout
- event: chauffage_appliquer
- conditions:
- condition: trigger
id: timer.finished
sequence:
- action: input_boolean.turn_off
target:
entity_id: input_boolean.chauffage_force
mode: restart
4. Forçage par radiateur — Switch physique + timer individuel
alias: Chauffage - Forçage par radiateur
description: Active le switch physique du radiateur pendant la durée configurée.
triggers:
- trigger: state
entity_id:
- input_boolean.chauffage_force_chambre_1
- input_boolean.chauffage_force_chambre_2
- input_boolean.chauffage_force_bureau
to: "on"
id: force_on
- trigger: state
entity_id:
- input_boolean.chauffage_force_chambre_1
- input_boolean.chauffage_force_chambre_2
- input_boolean.chauffage_force_bureau
to: "off"
id: force_off
- trigger: event
event_type: timer.finished
id: timer_fini
actions:
- variables:
suffix: >
{% if trigger.id in ['force_on', 'force_off'] %}
{{ trigger.entity_id | replace('input_boolean.chauffage_force_', '') }}
{% else %}
{{ trigger.event.data.entity_id | replace('timer.chauffage_force_timeout_', '') }}
{% endif %}
- variables:
force_entity: "input_boolean.chauffage_force_{{ suffix }}"
timer_entity: "timer.chauffage_force_timeout_{{ suffix }}"
duree_entity: "input_number.chauffage_duree_force_{{ suffix }}"
switch_entity: >
{% if suffix == 'chambre_2' %}switch.heater_chambre_2
{% elif suffix == 'chambre_1' %}switch.heater_chambre_1
{% else %}switch.heater_bureau{% endif %}
- choose:
- conditions:
- condition: trigger
id: force_on
sequence:
- variables:
duree_heures: "{{ states(duree_entity) | float(2) }}"
duree_formatee: "{{ '%02d:%02d:%02d' | format(duree_heures|int, ((duree_heures % 1)*60)|int, 0) }}"
- action: timer.start
target:
entity_id: "{{ timer_entity }}"
data:
duration: "{{ duree_formatee }}"
- action: switch.turn_on
target:
entity_id: "{{ switch_entity }}"
- conditions:
- condition: trigger
id: force_off
sequence:
- action: timer.cancel
target:
entity_id: "{{ timer_entity }}"
- action: switch.turn_off
target:
entity_id: "{{ switch_entity }}"
- conditions:
- condition: trigger
id: timer_fini
- condition: template
value_template: "{{ 'chauffage_force_timeout_' in trigger.event.data.entity_id }}"
sequence:
- action: input_boolean.turn_off
target:
entity_id: "{{ force_entity }}"
mode: restart
5. Appliquer presets + ON/OFF — Le cœur du système
alias: Chauffage - Appliquer presets + ON/OFF
description: >
Contrôle via hvac_mode (pas le switch directement).
Fenêtre ouverte = OFF prioritaire. Max 2 radiateurs en cycle simultanément.
triggers:
- trigger: event
event_type: chauffage_appliquer
- trigger: time_pattern
minutes: "/2"
- trigger: homeassistant
event: start
actions:
- choose:
- conditions:
- condition: state
entity_id: input_boolean.mode_chauffage
state: "off"
sequence:
- action: climate.set_hvac_mode
target:
entity_id:
- climate.climat_chambre_1
- climate.climat_chambre_2
- climate.climat_chambre_bureau
data:
hvac_mode: "off"
- stop: mode_chauffage est off
- variables:
max_on: 2
global_force_on: "{{ is_state('input_boolean.chauffage_force', 'on') }}"
preset_base: "{{ states('input_select.chauffage_preset_actif') }}"
preset_commun: "{{ 'eco' if global_force_on else preset_base }}"
radiateurs:
- nom: chambre_1
climate: climate.climat_chambre_1
force: input_boolean.chauffage_force_chambre_1
fenetre: binary_sensor.fenetre_chambre_1_contact
- nom: chambre_2
climate: climate.climat_chambre_2
force: input_boolean.chauffage_force_chambre_2
fenetre: binary_sensor.fenetre_chambre_2_contact
- nom: bureau
climate: climate.climat_chambre_bureau
force: input_boolean.chauffage_force_bureau
fenetre: binary_sensor.fenetre_bureau_contact
radiateurs_etat: >
{% set ns = namespace(slots=max_on|int, list=[]) %}
{% for r in radiateurs %}
{% set force = is_state(r.force, 'on') %}
{% set current = state_attr(r.climate, 'current_temperature') | float(0) %}
{% set target = state_attr(r.climate, 'temperature') | float(0) %}
{% set en_cycle = current < target %}
{% set fenetre = is_state(r.fenetre, 'on') %}
{% if fenetre %}
{% set on = false %}
{% elif force %}
{% set on = true %}
{% elif en_cycle %}
{% if ns.slots > 0 %}
{% set on = true %}
{% set ns.slots = ns.slots - 1 %}
{% else %}
{% set on = false %}
{% endif %}
{% else %}
{% set on = true %}
{% endif %}
{% set ns.list = ns.list + [dict(r, on=on, force=force, en_cycle=en_cycle, fenetre=fenetre)] %}
{% endfor %}
{{ ns.list }}
- repeat:
for_each: "{{ radiateurs_etat }}"
sequence:
- variables:
preset_cible: "{{ 'home' if repeat.item.force else preset_commun }}"
- choose:
- conditions:
- condition: template
value_template: "{{ repeat.item.on and states(repeat.item.climate) == 'off' }}"
sequence:
- action: climate.set_hvac_mode
target:
entity_id: "{{ repeat.item.climate }}"
data:
hvac_mode: heat
- action: climate.set_preset_mode
target:
entity_id: "{{ repeat.item.climate }}"
data:
preset_mode: "{{ preset_cible }}"
- conditions:
- condition: template
value_template: "{{ repeat.item.on and state_attr(repeat.item.climate, 'preset_mode') != preset_cible }}"
sequence:
- action: climate.set_preset_mode
target:
entity_id: "{{ repeat.item.climate }}"
data:
preset_mode: "{{ preset_cible }}"
- conditions:
- condition: template
value_template: "{{ not repeat.item.on and states(repeat.item.climate) != 'off' }}"
sequence:
- action: climate.set_hvac_mode
target:
entity_id: "{{ repeat.item.climate }}"
data:
hvac_mode: "off"
mode: restart
6. Bilan énergétique — Log d'état complet toutes les 30 min
alias: Chauffage - Bilan énergétique
description: Log complet de l'état du chauffage à chaque chauffage_appliquer ou toutes les 30 min.
triggers:
- trigger: event
event_type: chauffage_appliquer
id: preset_change
- trigger: time_pattern
minutes: "/30"
id: periodic
conditions:
- condition: state
entity_id: input_boolean.mode_chauffage
state: "on"
actions:
- variables:
force_global: "{{ is_state('input_boolean.chauffage_force', 'on') }}"
preset_base: "{{ states('input_select.chauffage_preset_actif') }}"
preset_commun: "{{ 'eco' if force_global else preset_base }}"
radiateurs:
- nom: chambre_1
climate: climate.climat_chambre_1
force: input_boolean.chauffage_force_chambre_1
fenetre: binary_sensor.fenetre_chambre_1_contact
- nom: chambre_2
climate: climate.climat_chambre_2
force: input_boolean.chauffage_force_chambre_2
fenetre: binary_sensor.fenetre_chambre_2_contact
- nom: bureau
climate: climate.climat_chambre_bureau
force: input_boolean.chauffage_force_bureau
fenetre: binary_sensor.fenetre_bureau_contact
bilan_radiateurs_json: >
{% set ns = namespace(slots=2, list=[]) %}
{% for r in radiateurs %}
{% set hvac = states(r.climate) %}
{% set action = state_attr(r.climate, 'hvac_action') | default('unknown') %}
{% set current = state_attr(r.climate, 'current_temperature') | float(0) | round(1) %}
{% set target = state_attr(r.climate, 'temperature') | float(0) | round(1) %}
{% set force = is_state(r.force, 'on') %}
{% set en_cycle = current < target %}
{% set fenetre = is_state(r.fenetre, 'on') %}
{% if fenetre %}{% set on = false %}
{% elif force %}{% set on = true %}
{% elif en_cycle %}
{% if ns.slots > 0 %}{% set on = true %}{% set ns.slots = ns.slots - 1 %}
{% else %}{% set on = false %}{% endif %}
{% else %}{% set on = true %}{% endif %}
{% set ns.list = ns.list + [dict(r, hvac=hvac, action=action, current=current, target=target, force=force, en_cycle=en_cycle, fenetre=fenetre, on=on)] %}
{% endfor %}
{{ ns.list | tojson }}
- variables:
nb_heating: "{{ bilan_radiateurs_json | from_json | selectattr('action', 'eq', 'heating') | list | count }}"
Vous devez être connecté pour répondre.
Se connecter