Part 6 of 7 · Home Assistant Energy Build

The automation architecture — and the thinking behind it

The actual product: six live automations that charge the battery and heat the water on cheap-rate energy overnight, safely and unattended — and defend themselves against the vendor clouds trying to undo them. Each is shown as the YAML currently running, with the reasoning that shaped it.

Everything so far has been plumbing. This part is the actual product: a set of automations that charge the battery and heat the water on cheap-rate energy, overnight, safely, without human intervention — and that defend themselves against the vendor clouds trying to undo them. Each automation below is shown as the live YAML currently running on the system, with the reasoning that shaped it.

The principles first

Every automation here is built on the same handful of hard-won rules. They explain why the code looks the way it does — especially why so much of it is spent verifying that a command actually took effect.

The six automations

The control layer is six automations. Three run the nightly routine; three defend it.

AutomationJob
Enforce 5.7kW Charge RateCaps AC charge to 48% around the grid-charge slot to protect the 32A breaker.
Act on Battery Charge ChoiceArms the 01:00–02:00 charge slot each night (opt-out), resets in the morning.
Boost Hot Water TonightThe big one: verifies, fires and confirms the Eddi grid boost, pausing and resuming the battery around it.
Cheap Rate Battery Pause WatchdogDefensive backstop — pauses the battery if the Eddi draws while the battery isn’t paused.
GivEnergy Settings GuardianReverts cloud-pushed mode changes while leaving manual and automation changes alone.
Evening Energy ReminderA 21:01 summary of what’s scheduled for the night, with an opt-out tap.

Enforce 5.7kW charge rate (grid charging only)

The original breaker-tripping problem came from the battery charging at full AC rate off the grid. On the dual-AIO system, battery_charge_rate_ac is a percentage, and 48% is about 5.7kW — safely below the 32A breaker’s limit. But it can’t be pinned at 48% permanently, because AC and DC rates are firmware-linked and that would throttle daytime solar too. So the cap is applied only in a tight window around the grid-charge slot:

Safety — battery_charge_rate_ac must never sit at 100% while grid-charging — full-rate AC charge can trip the 32A incoming breaker. The 48% cap during the charge slot is a hard safety constraint. If the slot times change, both the time triggers and the time condition inside the rate_changed branch must be updated together.

alias: Enforce 5.7kW Charge Rate (Grid Charging Only)
description: >-
  Caps AC charge rate to 48% (5.7kW) only around the configured grid charge slot
  (currently 01:00–02:00), protecting the 32A breaker during the only time grid
  charging actually happens. Restores to 100% outside that window so solar
  charging during the day is unrestricted. State trigger reactively corrects
  external rate changes (e.g. GivEnergy cloud reset) only when the cap is
  supposed to be in force.

  Cap window is hardcoded to 00:55–02:05 (a 5 min buffer either side of the
  current charge slot). Update both the time triggers AND the time condition
  inside the rate_changed branch if you change your charge slot.
triggers:
  - at: "00:55:00"
    id: cap_start
    trigger: time
  - at: "02:05:00"
    id: cap_end
    trigger: time
  - entity_id: number.givtcp_gwXXXXXXXX_battery_charge_rate_ac
    id: rate_changed
    trigger: state
actions:
  - choose:
      - conditions:
          - condition: trigger
            id: cap_start
        sequence:
          - target:
              entity_id: number.givtcp_gwXXXXXXXX_battery_charge_rate_ac
            data:
              value: 48
            action: number.set_value
          - data:
              name: Charge Rate Capped
              message: >-
                Grid charge slot approaching. AC rate capped at 48% (5.7kW) to
                protect 32A breaker.
            action: logbook.log
      - conditions:
          - condition: trigger
            id: cap_end
        sequence:
          - target:
              entity_id: number.givtcp_gwXXXXXXXX_battery_charge_rate_ac
            data:
              value: 100
            action: number.set_value
          - data:
              name: Charge Rate Restored
              message: >-
                Grid charge slot ended. AC rate restored to 100% — solar
                charging unrestricted.
            action: logbook.log
      - conditions:
          - condition: trigger
            id: rate_changed
          - condition: time
            after: "00:55:00"
            before: "02:05:00"
          - condition: numeric_state
            entity_id: number.givtcp_gwXXXXXXXX_battery_charge_rate_ac
            above: 48
        sequence:
          - target:
              entity_id: number.givtcp_gwXXXXXXXX_battery_charge_rate_ac
            data:
              value: 48
            action: number.set_value
          - data:
              name: Charge Rate Override
              message: >-
                AC rate externally set to {{ trigger.to_state.state }}% during
                grid charge slot. Forced back to 48% to protect 32A breaker.
            action: logbook.log
mode: single

Act on battery charge choice

This automation arms the charge itself. It follows the opt-out model: at 21:00 every night it enables the charge schedule unless it has already been turned off, sets charge slot 1 to 01:00–02:00 at 100% target SOC, and notifies. A dashboard toggle (input_boolean.charge_battery_tonight) lets a human opt out or back in, and the user_id is not none check ensures those branches only run for genuine manual toggles — not for the automation’s own state changes. At 06:30 it resets, turning the schedule and the toggle back off. Note it deliberately no longer touches the discharge schedule — that lived here once, and moving battery-pause responsibility wholesale into the boost automation was a deliberate consolidation.

alias: Act on Battery Charge Choice
description: >-
  Auto-enables battery charge at 21:00 every night. Toggle off on dashboard to
  opt out. Sets charge slot 01:00-02:00 at 100% target. Resets at 06:30. Battery
  pause during Eddi boost is handled by 'Boost Hot Water Tonight' — this
  automation no longer touches the discharge schedule.
triggers:
  - entity_id: input_boolean.charge_battery_tonight
    id: toggled
    trigger: state
  - at: "21:00:00"
    id: auto_enable
    trigger: time
  - at: "06:30:00"
    id: reset
    trigger: time
actions:
  - choose:
      - conditions:
          - condition: trigger
            id: reset
        sequence:
          - target:
              entity_id: switch.givtcp_gwXXXXXXXX_enable_charge_schedule
            action: switch.turn_off
          - target:
              entity_id: input_boolean.charge_battery_tonight
            action: input_boolean.turn_off
      - conditions:
          - condition: trigger
            id: auto_enable
          - condition: state
            entity_id: input_boolean.charge_battery_tonight
            state: "off"
        sequence:
          - target:
              entity_id: input_boolean.charge_battery_tonight
            action: input_boolean.turn_on
          - target:
              entity_id: switch.givtcp_gwXXXXXXXX_enable_charge_schedule
            action: switch.turn_on
          - target:
              entity_id: select.givtcp_gwXXXXXXXX_charge_start_time_slot_1
            data:
              option: "01:00:00"
            action: select.select_option
          - target:
              entity_id: select.givtcp_gwXXXXXXXX_charge_end_time_slot_1
            data:
              option: "02:00:00"
            action: select.select_option
          - target:
              entity_id: number.givtcp_gwXXXXXXXX_charge_target_soc_1
            data:
              value: 100
            action: number.set_value
          - data:
              title: ✅ Battery Charge Enabled
              message: >-
                Battery will charge 01:00-02:00 to 100% SOC. Toggle off on
                dashboard to skip tonight. · Sent {{ now().strftime('%a %d %b
                %H:%M') }}
              data:
                when: "{{ as_timestamp(now()) | int }}"
            action: notify.all_devices
      - conditions:
          - condition: trigger
            id: toggled
          - condition: state
            entity_id: input_boolean.charge_battery_tonight
            state: "on"
          - condition: template
            value_template: "{{ trigger.to_state.context.user_id is not none }}"
        sequence:
          - target:
              entity_id: switch.givtcp_gwXXXXXXXX_enable_charge_schedule
            action: switch.turn_on
          - target:
              entity_id: select.givtcp_gwXXXXXXXX_charge_start_time_slot_1
            data:
              option: "01:00:00"
            action: select.select_option
          - target:
              entity_id: select.givtcp_gwXXXXXXXX_charge_end_time_slot_1
            data:
              option: "02:00:00"
            action: select.select_option
          - target:
              entity_id: number.givtcp_gwXXXXXXXX_charge_target_soc_1
            data:
              value: 100
            action: number.set_value
          - data:
              title: ✅ Battery Charge Enabled
              message: >-
                Battery will charge 01:00-02:00 to 100% SOC. Toggle off on
                dashboard to skip tonight. · Sent {{ now().strftime('%a %d %b
                %H:%M') }}
              data:
                when: "{{ as_timestamp(now()) | int }}"
            action: notify.all_devices
      - conditions:
          - condition: trigger
            id: toggled
          - condition: state
            entity_id: input_boolean.charge_battery_tonight
            state: "off"
          - condition: template
            value_template: "{{ trigger.to_state.context.user_id is not none }}"
        sequence:
          - target:
              entity_id: switch.givtcp_gwXXXXXXXX_enable_charge_schedule
            action: switch.turn_off
          - data:
              title: ❌ Battery Charge Disabled
              message: >-
                Charge schedule turned off for tonight. · Sent {{
                now().strftime('%a %d %b %H:%M') }}
              data:
                when: "{{ as_timestamp(now()) | int }}"
            action: notify.all_devices
mode: queued

Boost hot water tonight

This is the most defensively engineered automation in the build, and for good reason: it caused real debugging pain across several nights in June 2026. Two distinct cloud failure modes drove its design:

  1. The mode wouldn’t hold. A write to the Eddi’s operating_mode could be accepted by Home Assistant and then reverted by the myenergi cloud on its next poll. So the automation re-asserts Normal in a loop until it has held continuously for 75 seconds (long enough to survive a cloud poll), with a hard 10-minute wall-clock deadline and a repeat-index ceiling as infinite-loop insurance.
  2. The boost silently didn’t fire. Even with the mode held at Normal, the cloud would sometimes accept the boost call with no error but never action it on the device — the Eddi stayed idle, CT1 at 0W. So after firing via the myenergi.myenergi_eddi_boost service, it verifies actual current draw on CT1 and re-fires up to five times, with a 120-second settle after each, before giving up.

Around that core, the automation: pre-sets Normal at 20:59 (roughly four hours early, using the same hold-loop, so the cloud has all evening to converge); pauses the battery to Eco (Paused) at MAX(02:00, boost start); fires and confirms the boost; and if it can’t confirm, fails loud — notifying with seven key state fields, setting the Eddi to Stopped, resuming the battery to Eco and aborting. It also folds in what used to be a separate “resume battery” automation: an eddi_finished branch triggers when CT1 drops below 100W for two minutes and — only if this automation owns the in-flight boost — sets the Eddi to Stopped first (so tank-temperature cycling can’t re-engage it) then resumes the battery. Finally, a hard 06:30 reset stops the Eddi and releases the battery whatever else has happened.

Design note — The input_boolean.eddi_boost_fired_by_automation flag is what lets the eddi_finished cleanup distinguish “the boost I started has finished” from a manual or smart-boost draw it should leave alone. Ownership tracking is what keeps the branches from fighting each other.

alias: Boost Hot Water Tonight
description: >-
  Auto-enables boost at 20:59 (opt-out model). When toggle is on tonight: pause
  battery at MAX(02:00, eddi_boost_start) via Eco (Paused), fire Eddi boost at
  eddi_boost_start. Before firing, re-assert Normal in a loop until it has held
  continuously for 75s (survived a myenergi cloud poll), with a hard 10-minute
  wall-clock deadline and a repeat-index ceiling as infinite-loop insurance. If
  Normal never sticks within the deadline, abort loud and clean up. After firing,
  a 120s CT1 draw-verification confirms the immersion is actually drawing; if not,
  abort and roll back. If Eddi CT1 drops below 100W for 2 min during the boost
  window AND this automation's own boost is in flight: set Eddi to Stopped FIRST
  (prevents tank-temp cycling from re-engaging the boost), then resume battery
  early. At 06:30: set Eddi to Stopped FIRST (kills any active boost so it cannot
  draw from battery), then resume battery if still paused, then clear toggle.
  Sets input_boolean.battery_pause_active ON whenever the battery is paused for
  the boost flow and OFF when the pause is cleared, so Guardian can stay out of
  the way during the protected window.
triggers:
  - entity_id: input_boolean.boost_hot_water_tonight
    id: toggled
    trigger: state
  - entity_id: sensor.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_internal_load_ct1
    below: 100
    for:
      minutes: 2
    id: eddi_finished
    trigger: numeric_state
  - at: "02:00:00"
    id: pause_battery_floor
    trigger: time
  - at: input_datetime.eddi_boost_start
    id: pause_battery_dynamic
    trigger: time
  - at: input_datetime.eddi_boost_start
    id: fire_boost
    trigger: time
  - at: "06:30:00"
    id: morning_reset
    trigger: time
  - at: "20:59:00"
    id: auto_enable
    trigger: time
actions:
  - choose:
      # ---------- toggled ON: notify schedule ----------
      - conditions:
          - condition: trigger
            id: toggled
          - condition: state
            entity_id: input_boolean.boost_hot_water_tonight
            state: "on"
        sequence:
          - data:
              title: ✅ Hot Water Boost Scheduled
              message: >-
                Boost will fire at {{ states('input_datetime.eddi_boost_start')
                }} for {{ (states('input_number.eddi_boost_duration') | float *
                60) | int }} minutes. Battery pauses at 02:00 or boost start,
                whichever is later. · Sent {{ now().strftime('%a %d %b %H:%M')
                }}
              data:
                when: "{{ as_timestamp(now()) | int }}"
            action: notify.all_devices

      # ---------- toggled OFF by user: notify cancel ----------
      - conditions:
          - condition: trigger
            id: toggled
          - condition: state
            entity_id: input_boolean.boost_hot_water_tonight
            state: "off"
          - condition: template
            value_template: "{{ trigger.to_state.context.user_id is not none }}"
        sequence:
          - data:
              title: ❌ Hot Water Boost Cleared
              message: >-
                Tonight's boost cancelled. Battery resumes at 06:30 backstop if
                already paused. · Sent {{ now().strftime('%a %d %b %H:%M') }}
              data:
                when: "{{ as_timestamp(now()) | int }}"
            action: notify.all_devices

      # ---------- pause battery (floor at 02:00 or dynamic at boost_start) ----------
      - conditions:
          - condition: trigger
            id:
              - pause_battery_floor
              - pause_battery_dynamic
          - condition: state
            entity_id: input_boolean.boost_hot_water_tonight
            state: "on"
          - condition: time
            after: "01:59:59"
          - condition: not
            conditions:
              - condition: state
                entity_id: select.givtcp_gwXXXXXXXX_mode
                state: Eco (Paused)
        sequence:
          - target:
              entity_id: input_boolean.battery_pause_active
            action: input_boolean.turn_on
          - target:
              entity_id: select.givtcp_gwXXXXXXXX_mode
            data:
              option: Eco (Paused)
            action: select.select_option
          - data:
              title: 🔒 Battery Paused
              message: >-
                Battery in Eco (Paused) ahead of Eddi boost. · Sent {{
                now().strftime('%a %d %b %H:%M') }}
              data:
                when: "{{ as_timestamp(now()) | int }}"
            action: notify.all_devices
          - data:
              name: Battery Paused
              message: Eco (Paused) set ahead of boost.
            action: logbook.log

      # ---------- fire boost: persistent Normal-hold loop, fire, CT1 verify ----------
      - conditions:
          - condition: trigger
            id: fire_boost
          - condition: state
            entity_id: input_boolean.boost_hot_water_tonight
            state: "on"
        sequence:
          # Capture a hard 10-minute wall-clock deadline for the Normal-hold loop
          - variables:
              normal_deadline: "{{ as_timestamp(now()) + 600 }}"

          # Re-assert Normal until it has HELD continuously for 75s (survived a
          # myenergi cloud poll), OR the 10-minute deadline passes, OR the index
          # ceiling (40) is hit as infinite-loop insurance.
          - repeat:
              until:
                - condition: template
                  value_template: >-
                    {{ (is_state('select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode','Normal')
                        and (now() - states.select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode.last_changed).total_seconds() > 75)
                       or as_timestamp(now()) > normal_deadline
                       or repeat.index >= 40 }}
              sequence:
                - if:
                    - condition: not
                      conditions:
                        - condition: state
                          entity_id: select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode
                          state: Normal
                  then:
                    - target:
                        entity_id: select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode
                      data:
                        option: Normal
                      action: select.select_option
                - delay:
                    seconds: 20

          # Final mode check — fail loud, resume battery, abort if Normal didn't stick
          - if:
              - condition: not
                conditions:
                  - condition: state
                    entity_id: select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode
                    state: Normal
            then:
              - data:
                  title: ⚠️ Eddi Boost ABORTED (mode)
                  message: >-
                    Normal mode did not hold within the 10-minute deadline. Boost
                    NOT fired. Eddi mode: {{
                    states('select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode')
                    }}. CT1: {{
                    states('sensor.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_internal_load_ct1')
                    }}W. Battery mode: {{
                    states('select.givtcp_gwXXXXXXXX_mode') }}. Toggle: {{
                    states('input_boolean.boost_hot_water_tonight') }}. · Sent
                    {{ now().strftime('%a %d %b %H:%M') }}
                  data:
                    when: "{{ as_timestamp(now()) | int }}"
                action: notify.all_devices
              - data:
                  name: Eddi Boost ABORTED (mode)
                  message: >-
                    Normal did not hold within 10-minute deadline. Eddi mode={{
                    states('select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode')
                    }}, CT1={{
                    states('sensor.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_internal_load_ct1')
                    }}W, toggle={{
                    states('input_boolean.boost_hot_water_tonight') }},
                    battery_mode={{ states('select.givtcp_gwXXXXXXXX_mode')
                    }}, battery_pause_active={{
                    states('input_boolean.battery_pause_active') }},
                    boost_start={{ states('input_datetime.eddi_boost_start')
                    }}, boost_duration={{
                    states('input_number.eddi_boost_duration') }}h.
                action: logbook.log
              - target:
                  entity_id: input_boolean.battery_pause_active
                action: input_boolean.turn_off
              - target:
                  entity_id: select.givtcp_gwXXXXXXXX_mode
                data:
                  option: Eco
                action: select.select_option
              - stop: Eddi could not be held at Normal — boost aborted

          # Eddi confirmed Normal and stable — fire the boost
          - variables:
              boost_mins: >-
                {{ (states('input_number.eddi_boost_duration') | float * 60) |
                int }}

          # Mark that THIS automation owns the in-flight boost
          # (used by eddi_finished branch to ignore manual / unscheduled boosts)
          - target:
              entity_id: input_boolean.eddi_boost_fired_by_automation
            action: input_boolean.turn_on

          # Fire the boost with a RETRY LOOP. New failure mode (seen 19/21/22 Jun
          # 2026): the myenergi cloud accepts the boost call (no error) but never
          # actions it on the Eddi — device stays "Paused", CT1 = 0W — even though
          # operating_mode held Normal all night. It's intermittent (some nights
          # work first try), so re-fire the boost and re-check CT1 up to 5 times,
          # 120s settle after each, before giving up.
          - repeat:
              until:
                - condition: template
                  value_template: >-
                    {{ states('sensor.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_internal_load_ct1')|float(0) > 100
                       or repeat.index >= 5 }}
              sequence:
                # Keep Normal asserted between attempts (cheap safety re-assert)
                - if:
                    - condition: not
                      conditions:
                        - condition: state
                          entity_id: select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode
                          state: Normal
                  then:
                    - target:
                        entity_id: select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode
                      data:
                        option: Normal
                      action: select.select_option
                - target:
                    device_id: your_eddi_device_id
                  data:
                    target: Heater 1
                    time: "{{ boost_mins }}"
                  action: myenergi.myenergi_eddi_boost
                - data:
                    name: Eddi Boost attempt
                    message: >-
                      Boost attempt {{ repeat.index }} fired for {{ boost_mins }}
                      min on Heater 1 (myenergi cloud sometimes silently drops the
                      command).
                  action: logbook.log
                # ~60-65s cloud latency to energise; 120s settle gives margin
                - delay:
                    seconds: 120

          # Success if CT1 now drawing; otherwise fail loud and roll back
          - if:
              - condition: numeric_state
                entity_id: sensor.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_internal_load_ct1
                above: 100
            then:
              - data:
                  title: 🔥 Eddi Boost Confirmed
                  message: >-
                    Immersion drawing {{
                    states('sensor.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_internal_load_ct1')
                    }}W on Heater 1 — boost running {{ boost_mins }} min. · Sent
                    {{ now().strftime('%a %d %b %H:%M') }}
                  data:
                    when: "{{ as_timestamp(now()) | int }}"
                action: notify.all_devices
              - data:
                  name: Eddi Boost confirmed
                  message: >-
                    CT1={{
                    states('sensor.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_internal_load_ct1')
                    }}W after boost retry loop — boost running {{ boost_mins }}
                    min.
                action: logbook.log
            else:
              - data:
                  title: ⚠️ Eddi Boost FAILED to draw
                  message: >-
                    Boost retried 5x (CT1 re-checked 120s after each) but Eddi
                    never drew >100W. Likely silent rejection by device or cloud.
                    Eddi mode: {{
                    states('select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode')
                    }}. CT1: {{
                    states('sensor.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_internal_load_ct1')
                    }}W. Battery mode: {{
                    states('select.givtcp_gwXXXXXXXX_mode') }}. · Sent {{
                    now().strftime('%a %d %b %H:%M') }}
                  data:
                    when: "{{ as_timestamp(now()) | int }}"
                action: notify.all_devices
              - data:
                  name: Eddi Boost FAILED to draw
                  message: >-
                    CT1 still below 100W after 5 boost retries. Eddi mode={{
                    states('select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode')
                    }}, CT1={{
                    states('sensor.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_internal_load_ct1')
                    }}W, battery_mode={{
                    states('select.givtcp_gwXXXXXXXX_mode') }},
                    battery_pause_active={{
                    states('input_boolean.battery_pause_active') }}.
                action: logbook.log
              # Set Eddi to Stopped in case the device wakes up late and tries to draw
              - target:
                  entity_id: select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode
                data:
                  option: Stopped
                action: select.select_option
              # Release the in-flight flag and resume battery
              - target:
                  entity_id: input_boolean.eddi_boost_fired_by_automation
                action: input_boolean.turn_off
              - target:
                  entity_id: input_boolean.battery_pause_active
                action: input_boolean.turn_off
              - target:
                  entity_id: select.givtcp_gwXXXXXXXX_mode
                data:
                  option: Eco
                action: select.select_option
              - stop: Eddi boost retried 5x but device never drew power

      # ---------- eddi finished early: clean up (only if OUR boost is in flight) ----------
      - conditions:
          - condition: trigger
            id: eddi_finished
          - condition: state
            entity_id: input_boolean.boost_hot_water_tonight
            state: "on"
          - condition: state
            entity_id: select.givtcp_gwXXXXXXXX_mode
            state: Eco (Paused)
          # only clean up if THIS automation fired the boost
          - condition: state
            entity_id: input_boolean.eddi_boost_fired_by_automation
            state: "on"
          - condition: time
            after: "02:30:00"
            before: "06:30:00"
        sequence:
          - target:
              entity_id: select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode
            data:
              option: Stopped
            action: select.select_option
          - target:
              entity_id: input_boolean.eddi_boost_fired_by_automation
            action: input_boolean.turn_off
          - target:
              entity_id: input_boolean.battery_pause_active
            action: input_boolean.turn_off
          - target:
              entity_id: select.givtcp_gwXXXXXXXX_mode
            data:
              option: Eco
            action: select.select_option
          - data:
              title: 🔓 Battery Resumed
              message: >-
                Eddi finished — Eddi set to Stopped and battery back to Eco. ·
                Sent {{ now().strftime('%a %d %b %H:%M') }}
              data:
                when: "{{ as_timestamp(now()) | int }}"
            action: notify.all_devices
          - data:
              name: Battery Resumed Early
              message: >-
                Eddi CT1 below 100W for 2 min. Eddi Stopped, battery resumed to
                Eco.
            action: logbook.log

      # ---------- morning reset at 06:30 ----------
      - conditions:
          - condition: trigger
            id: morning_reset
        sequence:
          - target:
              entity_id: select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode
            data:
              option: Stopped
            action: select.select_option
          - data:
              title: ☀️ Eddi Stopped
              message: >-
                Eddi set to Stopped at 06:30 for max solar export. · Sent {{
                now().strftime('%a %d %b %H:%M') }}
              data:
                when: "{{ as_timestamp(now()) | int }}"
            action: notify.all_devices
          - target:
              entity_id: input_boolean.eddi_boost_fired_by_automation
            action: input_boolean.turn_off
          - target:
              entity_id: input_boolean.battery_pause_active
            action: input_boolean.turn_off
          - if:
              - condition: state
                entity_id: select.givtcp_gwXXXXXXXX_mode
                state: Eco (Paused)
            then:
              - target:
                  entity_id: select.givtcp_gwXXXXXXXX_mode
                data:
                  option: Eco
                action: select.select_option
              - data:
                  title: ☀️ Backstop Reset
                  message: >-
                    Battery still paused at 06:30 — resumed by backstop. · Sent
                    {{ now().strftime('%a %d %b %H:%M') }}
                  data:
                    when: "{{ as_timestamp(now()) | int }}"
                action: notify.all_devices
          - target:
              entity_id: input_boolean.boost_hot_water_tonight
            action: input_boolean.turn_off

      # ---------- auto-enable + early Normal pre-set at 20:59 ----------
      - conditions:
          - condition: trigger
            id: auto_enable
        sequence:
          # Pre-set Eddi to Normal ~4h before the boost using the SAME persistent
          # hold-loop as fire_boost: re-assert Normal every 20s until it has held
          # continuously for 75s (survived a myenergi cloud poll). Gives the cloud
          # all evening to converge on Normal so the 01:05 boost is not fighting
          # reverts in real time. 20-minute wall-clock deadline.
          - variables:
              normal_deadline: "{{ as_timestamp(now()) + 1200 }}"
          - repeat:
              until:
                - condition: template
                  value_template: >-
                    {{ (is_state('select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode','Normal')
                        and (now() - states.select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode.last_changed).total_seconds() > 75)
                       or as_timestamp(now()) > normal_deadline
                       or repeat.index >= 65 }}
              sequence:
                - if:
                    - condition: not
                      conditions:
                        - condition: state
                          entity_id: select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode
                          state: Normal
                  then:
                    - target:
                        entity_id: select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode
                      data:
                        option: Normal
                      action: select.select_option
                - delay:
                    seconds: 20
          # Heads-up only (NOT an abort) if Normal did not settle this evening.
          # The 01:05 fire_boost loop remains the real gate/backstop.
          - if:
              - condition: not
                conditions:
                  - condition: state
                    entity_id: select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode
                    state: Normal
            then:
              - data:
                  title: ⚠️ Eddi early Normal didn't hold (20:59)
                  message: >-
                    Pre-set Normal did not hold within 20 min — cloud may be
                    reverting tonight. Boost still scheduled; the 01:05 loop will
                    retry. Eddi mode: {{
                    states('select.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_operating_mode')
                    }}. · Sent {{ now().strftime('%a %d %b %H:%M') }}
                  data:
                    when: "{{ as_timestamp(now()) | int }}"
                action: notify.all_devices
          # Auto-enable tonight's boost (opt-out model) only if not already on.
          - if:
              - condition: state
                entity_id: input_boolean.boost_hot_water_tonight
                state: "off"
            then:
              - target:
                  entity_id: input_boolean.boost_hot_water_tonight
                action: input_boolean.turn_on
mode: queued
trace:
  stored_traces: 50

Cheap rate battery pause watchdog

This is a defensive backstop for the whole cheap-rate window (00:30–06:30). If the Eddi is drawing power while the battery is not in Eco (Paused) — a manual boost, a smart-boost override, or a scheduled pause that didn’t take — the immersion would drain the battery instead of pulling from the grid. The watchdog catches that: it fires when the Eddi rises above 100W for 30 seconds (and again at 00:30, in case the Eddi was already drawing when the window opened), confirms the battery isn’t already paused, and directly forces Eco (Paused) itself.

Resolved gap — An earlier design had the watchdog only flag the pause and rely on a separate executor to apply it — a real hole, given that Eco (Paused) is the only reliable lock. The live version closes it: the watchdog both sets input_boolean.battery_pause_active and switches GivTCP to Eco (Paused) in the same action. Resume is left to the boost automation’s eddi_finished branch or its 06:30 backstop.

alias: Cheap Rate Battery Pause Watchdog
description: >-
  Defensive backstop for the cheap rate window (00:30–06:30). If the Eddi is
  drawing power while the battery is NOT in Eco (Paused), pause the battery to
  prevent the immersion from draining it. Fires on Eddi rising above 100W for
  30s, and also at 00:30 in case the Eddi was already drawing when the cheap
  window opened. Catches manual Eddi boosts, smart-boost overrides, or any
  case where the scheduled pause didn't take effect. Resume is handled by
  'Boost Hot Water Tonight' — either the eddi_finished branch (scheduled boost
  case) or the 06:30 morning backstop (manual / unscheduled case).
triggers:
  - entity_id: sensor.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_internal_load_ct1
    above: 100
    for:
      seconds: 30
    id: eddi_drawing
    trigger: numeric_state
  - at: "00:30:00"
    id: cheap_rate_start
    trigger: time
conditions:
  - condition: time
    after: "00:30:00"
    before: "06:30:00"
  - condition: numeric_state
    entity_id: sensor.eddi_XXXXXXXX_myenergi_eddi_XXXXXXXX_internal_load_ct1
    above: 100
  - condition: not
    conditions:
      - condition: state
        entity_id: select.givtcp_gwXXXXXXXX_mode
        state: Eco (Paused)
actions:
  - target:
      entity_id: input_boolean.battery_pause_active
    action: input_boolean.turn_on
  - target:
      entity_id: select.givtcp_gwXXXXXXXX_mode
    data:
      option: Eco (Paused)
    action: select.select_option
  - data:
      title: 🛡️ Battery Protected
      message: >-
        Eddi drawing during cheap rate while battery wasn't paused. Battery
        forced to Eco (Paused) to prevent drain. · Sent {{ now().strftime('%a
        %d %b %H:%M') }}
      data:
        when: "{{ as_timestamp(now()) | int }}"
    action: notify.all_devices
  - data:
      name: Watchdog Pause
      message: >-
        Eddi CT1 > 100W during cheap rate with battery not paused. Forced Eco
        (Paused) to protect battery.
    action: logbook.log
mode: single

GivEnergy settings guardian

This automation exists solely because the GivEnergy cloud interferes. It watches the GivTCP mode for changes and reverts the ones it didn’t authorise, using the change’s context to tell friend from foe. A change made through the Home Assistant UI carries a user_id; a change made by one of these automations carries a parent_id; a change pushed by the cloud carries neither. Only that last kind is reverted.

It runs two regimes. While a managed pause is active (battery_pause_active on), any non-paused state is forced back to Eco (Paused) so the pause holds even against live cloud interference. Outside a pause window, anything other than Eco, Eco (Paused) or Export (Paused) is reverted to Eco. Paused variants are always left alone.

Why the context check — Without the user_id/parent_id test, the Guardian would either fight the user’s own dashboard changes or fail to catch cloud ones. Reading the change’s origin is what lets it tell “a human tapped a button” and “our own automation did this” apart from “the cloud reasserted itself”.

alias: GivEnergy Settings Guardian
description: >-
  Reverts external (cloud) mode changes. Allows manual dashboard changes via
  user_id check and allows our own automation changes via parent_id check. Two
  regimes: (1) during a managed pause window (input_boolean.battery_pause_active
  ON), any non-paused state is forced back to Eco (Paused) so the pause holds
  even against active cloud interference; paused variants (Eco (Paused), Export
  (Paused)) are left alone. (2) Outside the pause window, any state other than
  Eco, Eco (Paused), or Export (Paused) is reverted to Eco.
triggers:
  - entity_id: select.givtcp_gwXXXXXXXX_mode
    trigger: state
conditions:
  - condition: template
    value_template: |-
      {{ trigger.to_state.context.user_id is none
         and trigger.to_state.context.parent_id is none
         and trigger.from_state.state != trigger.to_state.state
         and trigger.from_state.state not in ['unknown', 'unavailable']
         and trigger.to_state.state not in ['unknown', 'unavailable'] }}
actions:
  - choose:
      - conditions:
          - condition: state
            entity_id: input_boolean.battery_pause_active
            state: "on"
          - condition: template
            value_template: "{{ trigger.to_state.state not in ['Eco (Paused)', 'Export (Paused)'] }}"
        sequence:
          - target:
              entity_id: select.givtcp_gwXXXXXXXX_mode
            data:
              option: Eco (Paused)
            action: select.select_option
          - data:
              title: 🛡️ Guardian Held Pause
              message: >-
                External change to "{{ trigger.to_state.state }}" during managed
                pause — forced back to Eco (Paused) to protect the battery. ·
                Sent {{ now().strftime('%a %d %b %H:%M') }}
              data:
                when: "{{ as_timestamp(now()) | int }}"
            action: notify.all_devices
          - data:
              name: Guardian
              message: >-
                Pause window active. Reverted external change from {{
                trigger.from_state.state }} to {{ trigger.to_state.state }}
                back to Eco (Paused).
            action: logbook.log
      - conditions:
          - condition: state
            entity_id: input_boolean.battery_pause_active
            state: "off"
          - condition: template
            value_template: "{{ trigger.to_state.state not in ['Eco', 'Eco (Paused)', 'Export (Paused)'] }}"
        sequence:
          - target:
              entity_id: select.givtcp_gwXXXXXXXX_mode
            data:
              option: Eco
            action: select.select_option
          - data:
              title: 🛡️ Guardian Reverted
              message: >-
                External change to "{{ trigger.to_state.state }}" — reverted to
                Eco. · Sent {{ now().strftime('%a %d %b %H:%M') }}
              data:
                when: "{{ as_timestamp(now()) | int }}"
            action: notify.all_devices
          - data:
              name: Guardian
              message: >-
                Reverted external mode change from {{ trigger.from_state.state }} to {{
                trigger.to_state.state }}. Restored to Eco.
            action: logbook.log
mode: single

Evening energy reminder

The one purely informational automation. At 21:01 — just after the charge and boost have auto-armed — it pulls tomorrow’s weather forecast and the current battery SOC and sends a single summary notification: what’s scheduled tonight, with a tap-through to the dashboard to adjust or opt out. It is the human-facing bookend to the two 20:59/21:00 auto-enable steps.

alias: Evening Energy Reminder
description: >-
  At 21:01 sends a status summary showing what's scheduled for tonight. Both
  battery and Eddi are auto-enabled. Tap to opt out of either.
triggers:
  - at: "21:01:00"
    trigger: time
actions:
  - target:
      entity_id: weather.forecast_home
    data:
      type: daily
    response_variable: forecast
    action: weather.get_forecasts
  - variables:
      tomorrow_condition: "{{ forecast['weather.forecast_home'].forecast[0].condition }}"
      current_soc: "{{ states('sensor.givtcp_gwXXXXXXXX_soc') | float }}"
  - data:
      title: ⚡ Tonight's Energy Plan
      message: >-
        SOC: {{ current_soc | int }}% · Tomorrow: {{ tomorrow_condition }} ✅
        Battery charge: 01:00-02:00 ✅ Eddi boost: auto-scheduled Tap to adjust
        or opt out. · Sent {{ now().strftime('%a %d %b %H:%M') }}
      data:
        clickAction: /my-energy/3
        importance: high
        channel: energy_alerts
        when: "{{ as_timestamp(now()) | int }}"
    action: notify.mobile_app_phone
mode: single

Naming and helpers

The control surface is a small set of helpers that the automations and the dashboard both read and write:

input_boolean.charge_battery_tonight          # arm tonight's battery charge (opt-out)
input_boolean.boost_hot_water_tonight         # arm tonight's hot-water boost (opt-out)
input_boolean.battery_pause_active            # managed-pause flag (Guardian reads this)
input_boolean.eddi_boost_fired_by_automation  # ownership flag for the in-flight boost
input_datetime.eddi_boost_start               # when the boost should begin
input_number.eddi_boost_duration              # boost length, in hours
number.givtcp_gwXXXXXXXX_charge_target_soc_1  # slot-1 target SOC (not target_soc)
select.givtcp_gwXXXXXXXX_mode                 # battery mode: Eco / Eco (Paused) / Export (Paused)
select.eddi_XXXXXXXX_..._operating_mode       # Eddi mode: Normal / Stopped / Paused

Notifications fan out through a group, notify.all_devices, reaching both the phone (notify.mobile_app_phone) and a Samsung tablet, so a fail-loud abort is seen wherever someone happens to be looking. The Evening Reminder targets the phone directly so it can carry a dashboard clickAction. Every automation also writes a logbook.log entry on each meaningful action — the Logbook is the primary tool for telling Home-Assistant-triggered changes apart from cloud-triggered ones.

That is the brain of the system. Part 7 covers the face of it: the dashboard that makes all of this observable at a glance, and the read-only version shared with a second user.

A personal home-energy project, shared as-is and not affiliated with any vendor mentioned. The YAML is shared as a reference for how this specific system is wired — entity names, device IDs, slot times and safety limits are specific to this installation. Treat any battery charge-rate configuration as a safety matter for your own setup, and check current vendor docs before reusing any of it.