Gestion du chauffage avancée : Calendrier + Géofence + Forçage + Limitation charge

· il y a 3 jours · 0 réponses · 29 vues
<User />
Messages : 12
Âge : 41 ans
9 juin 2026 à 11:12
#1

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és climate.*) 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é
ecoeco
confort ou comfortcomfort
home ou presencehome
nuit ou sleepsleep
away ou absentaway

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 via input_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 home pendant 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_chauffageON/OFF saison chauffage
input_select.chauffage_preset_actifPreset courant calculé
input_boolean.chauffage_forceForçage global
timer.chauffage_force_timeoutTimer 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 }}"
0

Vous devez être connecté pour répondre.

Se connecter