Zendure SolarFlow 800 Pro - Completely local zereo feed-in without cloud and even faster! No MQTT, No HACS, Easy-Mode!

Here is my Home Assistant script that controls the Zendure SolarFlow 800 Pro completely locally. All parameters listed below can be freely adjusted. The current sampling rate is 5 seconds instead of Zendure’s default 10 seconds.

No HACS or MQTT required.

Strategy (Control Logic):

  • Economical zero feed-in:
    Instead of rigidly regulating to 0 W, a configurable grid bias (e.g. 10 W grid consumption) is maintained. This prevents expensive battery power from being fed into the grid during fluctuations. If the battery is full and PV production is high enough the script will automatically change to a forced minimum feed-in strategy.
  • Closed-loop without lag:
    The calculation is based on an internal memory (last_sent), not on sluggish sensor feedback. This allows the system to react extremely fast (every 5 seconds) without oscillation.
  • Self-healing memory:
    Automatically detects when the Zendure restarts or deviates (drift > 150 W) and resynchronizes the internal control loop.

Data Hygiene (Energy Dashboard Fixes):

  • Sanitization:
    Filters out negative “ghost values” (measurement noise) from the API that distort the dashboard (e.g. -0.06 kWh).
  • Mutual exclusivity:
    Calculates physically correct net flows. Prevents charging and discharging from being displayed simultaneously in the dashboard.
  • API swap fix:
    Corrects swapped API values (input/output) that occur with some firmware versions.

Features & Safety:

  • Seasonal logic:
    Automatic switching of the minimum SoC (winter 20% / summer 10%).
  • Emergency charging & calibration:
    Forces grid charging when a critical SoC is reached or when battery balancing (every X days) is required.
  • Bypass / conservation mode:
    Routes solar power directly to the house when the battery is sufficiently full, reducing conversion losses.
  • Defense in depth:
    Failsafe mode (fixed base load) if the Shelly device fails. Strict serial number verification before every HTTP command.
  • Forced BMS calibration:
    Triggered if the battery has not been charged to 100% within 7 days and the weather forecast predicts poor conditions.

Requirements:

  • SolarFlow 800 Pro
  • Shelly 3EM Pro in the local network (optionally with my Energy_Monitoring script listed below)
  • Home Assistant
  • Forecast.Solar integration (nice to have, but optional)

Setup Instructions:

  • Set the Zendure to base load mode with 0 watts
  • Set BMS minimum discharge to 5% (and 100% maximum charge)
  • Remove Zendure’s external internet access (e.g. block via router/firewall)
  • Disable Shelly 3EM Pro cloud access
  • Save the two .yaml files listed below in a folder called packages in Home Assistant
  • Adapt the local IP Adress of your SolarFlow in the Zendure.yaml (e.g. replace the placeholder IP “192.168.30.91” with your IP)
  • Add the following to configuration.yaml:
# Configure Packages (Looking in the "packages" folder)
homeassistant:
  packages: !include_dir_named packages
# Exclude certrain entities from spamming the database
recorder:
  exclude:
    entities:
      - automation.zendure_control_loop
      - automation.zendure_state_machine
      - input_number.zendure_last_sent_limit
      
# Exclude certrain entities from spamming the Activity logbook    
logbook:
  exclude:
    entities:
      - automation.zendure_control_loop
      - automation.zendure_state_machine

Zendure.yaml inside the packages folder:

################################################################################
#                   ZENDURE SOLARFLOW CONTROL PACKAGE 	                       #
################################################################################
#
# DESCRIPTION:
# Advanced local control for Zendure SolarFlow with Shelly 3EM.
# Features strict validation, safe math, and separate state/loop logic.
#
# REQUIREMENTS:
# - Zendure Hyper 2000 / SolarFlow 800 Pro (HTTP API enabled)
# - Shelly Pro 3EM (Grid measurement)
# - Integration "Forecast.Solar"
#
# STRATEGY (Managed by Logic Manager):
# - WINTER: Stop at 20% SoC | Bias 10W (Import) | Saturation 250W
# - SUMMER: Stop at 8% SoC  | Bias 0W (Zero)    | Saturation 250W
#
# NETWORK STABILITY:
# - Polling: 15s | Timeout: 30s | No forced updates (Anti-DDoS).
#
#
# COMPLETE CHANGELOG:
# V1.0  Initial release. Basic HTTP API connection.
# V2.0  Added basic zero-export logic based on Shelly 3EM grid sum.
# V3.0  Implemented "Failsafe Mode": Fallback output if Shelly data is missing.
# V4.0  Switch to "Package" format & added Dashboard Helpers (Input Numbers).
# V4.1  Added "Emergency Charge" (AC Charge) logic for critical battery levels.
# V4.2  Introduced Seasonal Logic (Winter 20% / Summer 10% MinSoC).
# V4.3  Added "Schonmodus" (Bypass): 90% Solar pass-through between MinSoC and 30%.
# V5.0  Performance Fix: Moved monitoring to separate automation to reduce spam.
# V6.0  Added "Option 1" (Weather Forecast): Smart Emergency Charge.
#       Added "Option 2" (Calibration): Weekly top-balancing logic.
#       Added "Option 3" (Winter Protection): Standby if Temp < 0°C.
# V6.1  "Smart Calibration": Only force charge if not full for 7 days.
# V6.2  "Saturation Mode": Force min. 150W output if Battery > 98% and Solar > 150W.
# V6.3  High-Speed Update: Loop interval reduced to 5s. Added Delta-Check logic.
# V6.4  Precision Update: Hysteresis configurable via Slider.
# V6.5  BUGFIX: Temp Calculation (Deci-Kelvin to Celsius). Fixed Winter Protection.
# V7.0  MAJOR ARCHITECTURE UPDATE: Split Logic (Loop vs State), Safe Templates (.get),
#       Command De-duplication (Last Sent Memory), Grid Bias & Fast Clamp.
# V8.0  CRITICAL FIXES & POLISH:
#       - Corrected Grid Bias Math (Subtraction instead of Addition).
#       - Fixed Input Number Range (Allow -2 for State Machine).
#       - Security: Failsafe now strictly checks for valid SN before acting.
#       - Stability: Control Loop now bases math on 'last_sent' to eliminate lag.
#       - Fast Clamp: Improved trigger condition for immediate export prevention.
#       - Resync: Auto-detects reboot/drift to reset internal memory.
# V9.0: CRITICAL BUGFIXES & FINAL POLISH:
#       - Jinja Comment Fix: Replaced '#' with '{# #}' to prevent calculation errors.
#       - Safety Gate: Execution block now explicitly demands valid SN (sn_ok).
#       - Robustness: All payloads strictly cast to |int.
#       - Logic: Resync mechanism automatically handles Reboot/API-Failures 
#         (replacing the need for complex HTTP status checks).
# V10.0: POLISHING & HARDENING:
#        - Self-Healing Memory: Automatically commits "Resync" values to memory 
#          if drift/reboot is detected.
#        - Smart Refresh: Forces an immediate sensor update after writing to the API
#          to minimize the "stale data" window.
#        - Logic Optimization: Cleaned up action sequences for maximum determinism.
# V11.0: PRODUCTION HARDENING:
#        - Negative Guard: base_value calculation now explicitly handles negative
#          memory codes (like -2) to prevent math errors after restarts.
#        - Traffic Optimization: Reduced Sensor Polling to 10s (relying on 
#          smart memory for speed and on-demand refresh for updates).
#        - Logic Cleanup: merged drift and negative checks for efficiency.
# V11.1: FINAL SAFETY UPGRADE:
#        - Explicit Action Gating: Execution block now requires 'zendure_ok' 
#          AND 'sn_ok' to fire. This prevents writes even if calculation logic
#          is accidentally modified in the future (Defense in Depth).
# V11.2: Dashboard Fix (Sanitization).
# V11.3: DATA PURITY FIX:
#        - Problem: API reports positive values for BOTH Charge and Discharge
#          sensors simultaneously (noise/measurement error). This confuses the
#          Energy Dashboard (counting charge as discharge).
#        - Fix: Implemented "Net Flow Logic". The script calculates the net
#          difference (Input - Output).
#          If Net > 0 -> Report Charging, force Discharging to 0.
#          If Net < 0 -> Report Discharging, force Charging to 0.
#          This makes the sensors mutually exclusive, as per physics.
# V11.4: PERFECT MERGE:
#        - Combines V11.2 and V11.3 logic.
#        - Raw sensor data is FIRST sanitized (clipping negatives to 0)
#        - THEN the net flow is calculated.
#        - Prevents "Ghost Charging" caused by negative noise on the output channel.
# V11.5: SENSOR SWAP FIX:
#        - Diagnosis: User reported that Charging Energy appears as Discharging
#          in Home Assistant, despite correct Dashboard config.
#        - Fix: SWAPPED the API mapping in the Template Sensors.
#          "Pack Input Power" (Charge) now reads 'outputPackPower' from API.
#          "Pack Output Power" (Discharge) now reads 'packInputPower' from API.
#          This aligns the script with the device's actual reporting behavior.
# V11.6: STRATEGY UPDATE:
#        - Summer Soft-Stop set to 8% (Logic Manager).
#        - Emergency Charge Start set to 6% (Buffer before Hardware Limit).
#        - Hardware Limit remains 5% (in Zendure App).
#        - Emergency Charge Stop remains 15% (Healthy Hysteresis).
# V11.7: STABILITY UPDATE:
#        - Removed version numbers from Automation Aliases and IDs.
#        - Ensures that recorder/logbook exclusions in configuration.yaml
#          remain valid even after future script updates.
# V11.8: NETWORK STABILITY FIX:
#        - Diagnosis: User reported periodic "drops to 0" on all sensors.
#          Cause: The aggressive "Write + Immediate Read" logic overwhelmed the
#          ESP32 chip of the Zendure, causing connection drops.
#        - Fix 1: Removed 'homeassistant.update_entity' (Force Refresh).
#          We now rely purely on internal memory for fast control, giving the
#          device time to breathe.
#        - Fix 2: Increased Polling Interval to 15s.
#        - Fix 3: Updated Template Sensors to return 'unavailable' instead of '0'
#          when API data is missing. This prevents "sawtooth" graphs.
################################################################################

# ==============================================================================
# 1. KONFIGURATION (HELFER)
# ==============================================================================
input_boolean:
  zendure_auto_mode:
    name: "Zendure Automatik Aktiv"
    icon: mdi:robot
    initial: true
  
  zendure_emergency_charge:
    name: "Status: Not-Ladung läuft"
    icon: mdi:battery-alert
    initial: false

  zendure_calibration_mode:
    name: "Status: Kalibrierung läuft"
    icon: mdi:battery-sync
    initial: false

input_datetime:
  zendure_last_full_charge:
    name: "Letzte 100% Ladung am"
    has_date: true
    has_time: true

input_number:
  # --- INTERNE SPEICHER ---
  zendure_last_sent_limit:
    name: "System: Letzter gesendeter Wert"
    min: -2
    max: 1200
    step: 1
    mode: box

  # --- EINSTELLUNGEN ---
  zendure_conf_max_output:
    name: "Zendure: Max. Einspeisung"
    min: 0
    max: 800
    step: 10
    initial: 800
    unit_of_measurement: "W"
    mode: box

  zendure_conf_failsafe_watt:
    name: "Zendure: Failsafe Grundlast"
    icon: mdi:network-off-outline
    min: 0
    max: 800
    step: 10
    initial: 130
    unit_of_measurement: "W"
    mode: box

  zendure_conf_saturation_watt:
    name: "Zendure: Min. Einspeisung bei 100%"
    icon: mdi:water-percent
    min: 0
    max: 800
    step: 10
    initial: 250
    unit_of_measurement: "W"
    mode: box

  # Grid Bias: Ziel ist ein leichter Netzbezug (Import).
  # Allow negative values for Summer Export Strategy
  zendure_conf_grid_bias:
    name: "Zendure: Netz-Puffer (Bias)"
    icon: mdi:arrow-collapse-right
    min: -100
    max: 100
    step: 5
    initial: 10
    unit_of_measurement: "W"
    mode: box

  zendure_conf_hysteresis:
    name: "Zendure: Regel-Hysterese"
    icon: mdi:target-variant
    min: 1
    max: 50
    step: 1
    initial: 5
    unit_of_measurement: "W"
    mode: box

  zendure_conf_emergency_watt:
    name: "Zendure: AC-Ladegeschwindigkeit"
    icon: mdi:flash
    min: 50
    max: 1200
    step: 50
    initial: 500
    unit_of_measurement: "W"
    mode: box

  # --- BATTERIE GRENZEN ---
  zendure_conf_soc_off:
    name: "SoC Limit: Not-Aus (Auto-Saison)"
    icon: mdi:battery-off
    min: 0
    max: 30
    initial: 20
    unit_of_measurement: "%"

  zendure_conf_soc_bypass:
    name: "SoC Limit: Schonmodus"
    icon: mdi:battery-heart-variant
    min: 10
    max: 90
    initial: 30
    unit_of_measurement: "%"

  # Start Not-Ladung
  zendure_conf_soc_emerg_start:
    name: "SoC Limit: Start Not-Ladung"
    min: 0
    max: 20
    initial: 6
    unit_of_measurement: "%"

  # Stopp Not-Ladung
  zendure_conf_soc_emerg_stop:
    name: "SoC Limit: Stopp Not-Ladung"
    min: 5
    max: 50
    initial: 15
    unit_of_measurement: "%"

  # --- LOGIK OPTIONEN ---
  zendure_conf_forecast_thresh:
    name: "Wetter: kWh morgen für Lade-Stopp"
    icon: mdi:weather-sunny
    min: 0
    max: 20
    step: 0.5
    initial: 2.5 
    unit_of_measurement: "kWh"
    mode: box

  zendure_conf_calib_days:
    name: "Kalibrierung: Max Tage ohne 100%"
    icon: mdi:calendar-refresh
    min: 1
    max: 30
    step: 1
    initial: 7
    unit_of_measurement: "Tage"
    mode: box


# ==============================================================================
# 2. SENSOREN (SAFE MODE & RELAXED POLLING)
# ==============================================================================
sensor:
  - platform: rest
    name: "SolarFlow Status Raw"
    resource: "http://192.168.30.91/properties/report"
    method: GET
    # V11.9: Interval 15s, Timeout 30s (Network Stability)
    scan_interval: 15
    timeout: 30
    value_template: "OK"
    json_attributes:
      - sn
      - properties

  - platform: integration
    source: sensor.solarflow_solar_input
    name: "SolarFlow Solar Ertrag Gesamt"
    unit_prefix: k
    round: 3
    method: left
  - platform: integration
    source: sensor.solarflow_pack_input_power
    name: "SolarFlow Batterie Energie Geladen"
    unit_prefix: k
    round: 3
    method: left
  - platform: integration
    source: sensor.solarflow_pack_output_power
    name: "SolarFlow Batterie Energie Entladen"
    unit_prefix: k
    round: 3
    method: left


# ==============================================================================
# 3. TEMPLATE SENSOREN (ZERO-DROP PROTECTION)
# ==============================================================================
template:
  - sensor:
      - name: "SolarFlow Battery Level"
        unique_id: solarflow_battery_level
        unit_of_measurement: "%"
        device_class: battery
        state: >
          {% set p = state_attr('sensor.solarflow_status_raw','properties') %}
          {% if p is mapping %}
            {{ p.get('electricLevel', 0) | int }}
          {% else %}
            unavailable
          {% endif %}
      
      - name: "SolarFlow Output Limit"
        unique_id: solarflow_output_limit
        unit_of_measurement: "W"
        device_class: power
        state: >
          {% set p = state_attr('sensor.solarflow_status_raw','properties') %}
          {% if p is mapping %}
            {{ p.get('outputLimit', 0) | int }}
          {% else %}
            unavailable
          {% endif %}
        
      - name: "SolarFlow Solar Input"
        unique_id: solarflow_solar_input
        unit_of_measurement: "W"
        device_class: power
        state: >
          {% set p = state_attr('sensor.solarflow_status_raw','properties') %}
          {% if p is mapping %}
            {% set val = p.get('solarInputPower', 0) | int %}
            {{ [val, 0] | max }}
          {% else %}
            unavailable
          {% endif %}

      - name: "SolarFlow Device Temp"
        unique_id: solarflow_device_temp
        unit_of_measurement: "°C"
        device_class: temperature
        state: >-
          {% set p = state_attr('sensor.solarflow_status_raw','properties') %}
          {% if p is mapping %}
            {% set raw = p.get('hyperTmp', 2931) | int %}
            {{ ((raw / 10) - 273.15) | round(1) }}
          {% else %}
            unavailable
          {% endif %}

      # NET FLOW LOGIC (Prevents Dashboard Errors) & SENSOR SWAP
      - name: "SolarFlow Pack Input Power"
        unique_id: solarflow_pack_input_power
        unit_of_measurement: "W"
        device_class: power
        state: >
          {% set p = state_attr('sensor.solarflow_status_raw','properties') %}
          {% if p is mapping %}
            {# SWAP: Charge Sensor reads 'outputPackPower' #}
            {% set in_raw = [p.get('outputPackPower', 0)|int, 0] | max %}
            {# SWAP: Discharge Sensor reads 'packInputPower' #}
            {% set out_raw = [p.get('packInputPower', 0)|int, 0] | max %}
            
            {% set net = in_raw - out_raw %}
            {% if net > 0 %} {{ net }} {% else %} 0 {% endif %}
          {% else %}
            unavailable
          {% endif %}

      - name: "SolarFlow Pack Output Power"
        unique_id: solarflow_pack_output_power
        unit_of_measurement: "W"
        device_class: power
        state: >
          {% set p = state_attr('sensor.solarflow_status_raw','properties') %}
          {% if p is mapping %}
            {# SWAP: Charge Sensor reads 'outputPackPower' #}
            {% set in_raw = [p.get('outputPackPower', 0)|int, 0] | max %}
            {# SWAP: Discharge Sensor reads 'packInputPower' #}
            {% set out_raw = [p.get('packInputPower', 0)|int, 0] | max %}
            
            {% set net = in_raw - out_raw %}
            {% if net < 0 %} {{ net | abs }} {% else %} 0 {% endif %}
          {% else %}
            unavailable
          {% endif %}


# ==============================================================================
# 4. REST COMMANDS (ROBUST)
# ==============================================================================
rest_command:
  zendure_set_output:
    url: "http://192.168.30.91/properties/write"
    method: POST
    headers:
      Content-Type: application/json
    payload: >
      {
        "properties": {
          "outputLimit": {{ limit | int }},
          "chargeMaxLimit": 0
        },
        "sn": "{{ state_attr('sensor.solarflow_status_raw', 'sn') }}"
      }

  zendure_force_charge:
    url: "http://192.168.30.91/properties/write"
    method: POST
    headers:
      Content-Type: application/json
    payload: >
      {
        "properties": {
          "outputLimit": 0,
          "chargeMaxLimit": {{ limit | int }}
        },
        "sn": "{{ state_attr('sensor.solarflow_status_raw', 'sn') }}"
      }


# ==============================================================================
# 5. AUTOMATISIERUNG A: DIE STATUS-MASCHINE
# ==============================================================================
automation:
  - alias: "Zendure: State Machine"
    id: "zendure_state_machine_stable"
    mode: restart
    trigger:
      - platform: state
        entity_id:
          - input_boolean.zendure_emergency_charge
          - input_boolean.zendure_calibration_mode
    condition:
      # Safety: SN muss existieren
      - condition: template
        value_template: "{{ state_attr('sensor.solarflow_status_raw','sn') is not none }}"
    action:
      - choose:
          # FALL 1: START LADUNG
          - conditions: >
              {{ is_state('input_boolean.zendure_emergency_charge','on')
                 or is_state('input_boolean.zendure_calibration_mode','on') }}
            sequence:
              - service: rest_command.zendure_force_charge
                data:
                  limit: "{{ states('input_number.zendure_conf_emergency_watt')|int }}"
              - service: input_number.set_value
                target:
                  entity_id: input_number.zendure_last_sent_limit
                data:
                  value: -2 

          # FALL 2: STOP LADUNG (Zurück zu 0W)
          - conditions: >
              {{ is_state('input_boolean.zendure_emergency_charge','off')
                 and is_state('input_boolean.zendure_calibration_mode','off') }}
            sequence:
              - service: rest_command.zendure_set_output
                data:
                  limit: 0
              - service: input_number.set_value
                target:
                  entity_id: input_number.zendure_last_sent_limit
                data:
                  value: 0


# ==============================================================================
# 6. AUTOMATISIERUNG B: DER REGEL-LOOP (MAIN LOOP)
# ==============================================================================
  - alias: "Zendure: Control Loop"
    id: "zendure_control_loop_stable"
    mode: restart
    trigger:
      - platform: time_pattern
        seconds: "/5"
    
    condition:
      - condition: state
        entity_id: input_boolean.zendure_auto_mode
        state: "on"
      # Pause während Lademodus
      - condition: state
        entity_id: input_boolean.zendure_emergency_charge
        state: "off"
      - condition: state
        entity_id: input_boolean.zendure_calibration_mode
        state: "off"

    action:
      - variables:
          # --- CONFIG ---
          max_output: "{{ states('input_number.zendure_conf_max_output') | int }}"
          failsafe_watt: "{{ states('input_number.zendure_conf_failsafe_watt') | int }}"
          saturation_watt: "{{ states('input_number.zendure_conf_saturation_watt') | int }}"
          hysteresis_watt: "{{ states('input_number.zendure_conf_hysteresis') | int }}"
          grid_bias: "{{ states('input_number.zendure_conf_grid_bias') | int }}"
          
          soc_off: "{{ states('input_number.zendure_conf_soc_off') | int }}"
          soc_bypass: "{{ states('input_number.zendure_conf_soc_bypass') | int }}"
          
          # --- SENSORS & MEMORY ---
          shelly_ok: "{{ states('sensor.power_import') | is_number and states('sensor.power_export') | is_number }}"
          zendure_ok: "{{ states('sensor.solarflow_status_raw') == 'OK' }}"
          sn_ok: "{{ state_attr('sensor.solarflow_status_raw','sn') is not none }}"
          
          # Memory Logic
          raw_last_sent: "{{ states('input_number.zendure_last_sent_limit') | int }}"
          current_limit: "{{ states('sensor.solarflow_output_limit') | float(0) }}"
          
          # Check Drift
          is_drifting: "{{ raw_last_sent >= 0 and (raw_last_sent - current_limit)|abs > 150 }}"
          
          # Safe Base
          base_value: >
             {% if raw_last_sent < 0 or is_drifting %}
               {{ current_limit }}
             {% else %}
               {{ raw_last_sent }}
             {% endif %}

          temp_device: "{{ states('sensor.solarflow_device_temp') | float(20) }}"
          battery: "{{ states('sensor.solarflow_battery_level') | float(0) }}"
          pv_power: "{{ states('sensor.solarflow_solar_input') | float(0) }}"

          grid_power: >
            {% if shelly_ok %}
              {{ (states('sensor.power_import') | float(0) - states('sensor.power_export') | float(0)) }}
            {% else %} 0 {% endif %}


      # --- SELF-HEALING MEMORY ---
      - choose:
          - conditions: "{{ is_drifting }}"
            sequence:
              - service: input_number.set_value
                target:
                  entity_id: input_number.zendure_last_sent_limit
                data:
                  value: "{{ current_limit | int }}"


      # --- CALCULATION BLOCK ---
      - variables:
          calc_target: >
            {# PRIO 1: FAILSAFE (Strict SN Check) #}
            {% if not shelly_ok and zendure_ok and sn_ok %}
              {{ failsafe_watt }}

            {# PRIO 2: API/SN ERROR #}
            {% elif not (zendure_ok and sn_ok) %}
              -1

            {# PRIO 3: WINTER PROTECTION #}
            {% elif temp_device < 0 and pv_power < 100 %}
              0

            {# PRIO 4: BATTERY EMPTY #}
            {% elif battery <= soc_off %}
              0

            {# PRIO 5: BYPASS (SCHONMODUS) #}
            {% elif battery > soc_off and battery <= soc_bypass %}
               {# Math: Target = House Load - Bias. #}
               {% set house_demand = base_value + grid_power - grid_bias %}
               
               {% set available_solar = (pv_power * 0.9) | int %}
               {% set tmp = [house_demand, available_solar] | min %}
               {% if tmp < 0 %} 0 {% else %} {{ tmp | int }} {% endif %}

            {# PRIO 6: NORMAL OPERATION #}
            {% else %}
               {# Base logic: Start from Last Sent (Base), add Grid discrepancy, subtract Bias. #}
               {% set target_base = base_value + grid_power - grid_bias %}
               
               {# Saturation Logic #}
               {% if battery > 98 and pv_power > saturation_watt %}
                  {% set target_final = [target_base, saturation_watt] | max %}
               {% else %}
                  {% set target_final = target_base %}
               {% endif %}

               {# Safety Bounds #}
               {% if target_final < 0 %} 0
               {% elif target_final > max_output %} {{ max_output }}
               {% else %} {{ target_final | int }} {% endif %}
            {% endif %}


      # --- EXECUTION BLOCK (GATED) ---
      - choose:
          # Case: Error -> Abort
          - conditions: "{{ calc_target | int == -1 }}"
            sequence: []
          
          # Case: Fast Export Clamp
          - conditions: 
              - condition: template
                value_template: "{{ zendure_ok and sn_ok and grid_power < -80 and calc_target < base_value }}"
            sequence:
              - service: rest_command.zendure_set_output
                data:
                  limit: "{{ calc_target | int }}"
              - service: input_number.set_value
                target:
                  entity_id: input_number.zendure_last_sent_limit
                data:
                  value: "{{ calc_target | int }}"

          # Case: Normal Adjustment
          - conditions:
              - condition: template
                value_template: "{{ zendure_ok and sn_ok }}"
              - condition: template
                value_template: "{{ (calc_target | int - base_value) | abs > hysteresis_watt }}"
            sequence:
              - service: rest_command.zendure_set_output
                data:
                  limit: "{{ calc_target | int }}"
              - service: input_number.set_value
                target:
                  entity_id: input_number.zendure_last_sent_limit
                data:
                  value: "{{ calc_target | int }}"


# ==============================================================================
# 7. AUTOMATISIERUNG C: MANAGER (DAILY & CHECK)
# ==============================================================================
  # Track last time battery was full
  - alias: "Zendure: Tracker 100% Ladung"
    id: "zendure_track_100_percent_stable"
    trigger:
      - platform: numeric_state
        entity_id: sensor.solarflow_battery_level
        above: 99
    action:
      - service: input_datetime.set_datetime
        target:
          entity_id: input_datetime.zendure_last_full_charge
        data:
          datetime: "{{ now().strftime('%Y-%m-%d %H:%M:%S') }}"

# Logic Manager
  - alias: "Zendure: Logic Manager"
    id: "zendure_logic_manager_stable"
    trigger:
      - platform: time
        at: "00:00:00"
      - platform: time_pattern
        minutes: "/10"
      - platform: state
        entity_id: sensor.solarflow_battery_level
    
    action:
      # 1. Season Switch (SoC Limit & Grid Bias & Saturation)
      - choose:
          # --- WINTER (Oktober bis März) ---
          - conditions: "{{ now().month >= 10 or now().month < 4 }}"
            sequence:
              # Limit: Früher aufhören (20%)
              - service: input_number.set_value
                target:
                  entity_id: input_number.zendure_conf_soc_off
                data:
                  value: 20
              # Bias: 10W Netzbezug zulassen (Sicherheit)
              - service: input_number.set_value
                target:
                  entity_id: input_number.zendure_conf_grid_bias
                data:
                  value: 10
              # Sättigung: 250W (ANGEPASST auf deinen Wunsch)
              - service: input_number.set_value
                target:
                  entity_id: input_number.zendure_conf_saturation_watt
                data:
                  value: 250  # <--- HIER GEÄNDERT VON 150 AUF 250

          # --- SOMMER (April bis September) ---
          - conditions: "{{ now().month >= 4 and now().month < 10 }}"
            sequence:
              # Limit: Tief entladen (8%)
              - service: input_number.set_value
                target:
                  entity_id: input_number.zendure_conf_soc_off
                data:
                  value: 8
              # Bias: 0W (Echte Nulleinspeisung)
              - service: input_number.set_value
                target:
                  entity_id: input_number.zendure_conf_grid_bias
                data:
                  value: 0
              # Sättigung: 250W (Deine Wunsch-Einstellung)
              - service: input_number.set_value
                target:
                  entity_id: input_number.zendure_conf_saturation_watt
                data:
                  value: 250

      # 2. Check Emergency Charge
      - variables:
          bat: "{{ states('sensor.solarflow_battery_level')|float(0) }}"
          start_lim: "{{ states('input_number.zendure_conf_soc_emerg_start')|int }}"
          stop_lim: "{{ states('input_number.zendure_conf_soc_emerg_stop')|int }}"
          forecast: "{{ states('sensor.energy_production_tomorrow')|float(0) }}"
          thresh: "{{ states('input_number.zendure_conf_forecast_thresh')|float }}"
      
      - choose:
          - conditions:
              - condition: template
                value_template: "{{ bat < start_lim }}"
              - condition: template
                value_template: "{{ forecast < thresh or bat < 3 }}"
              - condition: state
                entity_id: input_boolean.zendure_emergency_charge
                state: "off"
            sequence:
              - service: input_boolean.turn_on
                target:
                  entity_id: input_boolean.zendure_emergency_charge
          
          - conditions:
              - condition: template
                value_template: "{{ bat > stop_lim }}"
              - condition: state
                entity_id: input_boolean.zendure_emergency_charge
                state: "on"
            sequence:
              - service: input_boolean.turn_off
                target:
                  entity_id: input_boolean.zendure_emergency_charge

      # 3. Check Calibration
      - variables:
          days_threshold: "{{ states('input_number.zendure_conf_calib_days') | int }}"
          last_run: "{{ states('input_datetime.zendure_last_full_charge') }}"
          days_diff: >
            {% if last_run not in ['unknown', 'unavailable'] %}
              {{ (as_timestamp(now()) - as_timestamp(last_run)) / 86400 }}
            {% else %} 999 {% endif %}
      
      - choose:
          - conditions: 
              - condition: template
                value_template: "{{ days_diff | float >= days_threshold }}"
              - condition: state
                entity_id: input_boolean.zendure_calibration_mode
                state: "off"
            sequence:
              - service: input_boolean.turn_on
                target:
                  entity_id: input_boolean.zendure_calibration_mode
              - service: persistent_notification.create
                data:
                  title: "Zendure Zellpflege"
                  message: "Start Kalibrierung ({{ days_diff | int }} Tage nicht voll)."
          
          - conditions:
              - condition: template
                value_template: "{{ bat >= 100 }}"
              - condition: state
                entity_id: input_boolean.zendure_calibration_mode
                state: "on"
            sequence:
              - service: input_boolean.turn_off
                target:
                  entity_id: input_boolean.zendure_calibration_mode
              - service: persistent_notification.create
                data:
                  title: "Zendure Kalibrierung"
                  message: "Beendet (100% erreicht)."


# ==============================================================================
# 8. MONITORING
# ==============================================================================
  - alias: "Zendure: Warnung bei Shelly Ausfall"
    id: "zendure_notify_shelly_fail_stable"
    trigger:
      - platform: template
        value_template: >
          {{ not (states('sensor.power_import') | is_number and states('sensor.power_export') | is_number) }}
        for: "00:00:30"
    action:
      - service: persistent_notification.create
        data:
          title: "⚠️ Zendure Failsafe"
          message: "Shelly offline. Grundlast aktiv."
          notification_id: "zendure_failsafe_alert"

  - alias: "Zendure: Entwarnung bei Shelly Recovery"
    id: "zendure_notify_shelly_ok_stable"
    trigger:
      - platform: template
        value_template: >
          {{ states('sensor.power_import') | is_number and states('sensor.power_export') | is_number }}
        for: "00:00:10"
    action:
      - service: persistent_notification.dismiss
        data:
          notification_id: "zendure_failsafe_alert"

Energy_Monitoring.yaml inside packages folder:

####################################################
#           SHELLY ENERGY MONITORING PACKAGE       #
#  Integrates 3-phase power, calculates net logic, #
#  and tracks utility usage (Daily/Monthly).       #
####################################################

# Shelly 3EM templates for 3-phase Net Import, Export and Consumption (if you have solar generation details)
# This uses the Shelly instantaneous power sensors to achieve the best possible accuracy.
# Shelly Sensors are:
#   sensor.shellypro3em_d890ewfh_phase_a_active_power,
#   sensor.shellypro3em_d890ewfh_phase_b_active_power,
#   sensor.shellypro3em_d890ewfh_phase_c_active_power for the three phases.
# Solar generation in W is used to calculate consumption via sensor.power_solargen
# V1.0 Initial release by Uksa007
# V1.1 Add float(0) to Consumption template to stop log warnings.
# V1.2 Add Friendly names to Utility Meter sensors.
# V1.3 Remove negative spikes from power consumption due to different update timing of solar sensor.
# V1.4 Change round: 2 for small value users.
# M1.0 Set round to 4, integrate PV-Summation_Delivered to be used with sensor.power_solargen, add float for each phase (otherwise error).
# M1.1 Introduce performance and accuracy enhancements:
#       - Use of local 'set' variables to minimize repeated state lookups and improve performance.
#       - Accurate 'availability_template' using float(none) and is_number validation.
#       - Introduce a tolerance threshold (3W) in power_consumption logic to suppress jitter/noise when solar ≈ export.
#       - Improve clarity and robustness of export/consumption calculation by avoiding undefined states and false negatives.
#       - Remove early rounding in all sensors; final values are left raw to preserve precision for integration processing.
# M1.2 Remove integration of PV-Summation_Delivred and use shellypmminig3_5432046f3b24_power instead
# M1.3 Make tolerance dynamic but 1,5W at minimum
# M1.4 Make tolerance less dynamic by applying a fixed 2W tolerance but the tolerance only applies when solar generation is < 100W
#      - Please make sure if solar_val is being reported as positive or negative value and adapt sensor "power consumption" accordingly

# 1. Template Sensors (Calculations)
template:
  - sensor:
      # Calculates total power import (sum of 3 phases, only if active_power is > 0)
      - name: "Power Import"
        unit_of_measurement: "W"
        device_class: power
        state_class: measurement
        state: >-
          {% set a = states('sensor.shellypro3em_d890ewfh_phase_a_active_power') | float(0) %}
          {% set b = states('sensor.shellypro3em_d890ewfh_phase_b_active_power') | float(0) %}
          {% set c = states('sensor.shellypro3em_d890ewfh_phase_c_active_power') | float(0) %}
          {% set total = a + b + c %}
          {{ total if total > 0 else 0 }}
        availability: >-
          {% set sensors = [
            states('sensor.shellypro3em_d890ewfh_phase_a_active_power') | float(none),
            states('sensor.shellypro3em_d890ewfh_phase_b_active_power') | float(none),
            states('sensor.shellypro3em_d890ewfh_phase_c_active_power') | float(none)
          ] %}
          {{ sensors | select('is_number') | list | count == 3 }}

      # Calculates total power export (sum of 3 phases, only if active_power is < 0)
      - name: "Power Export"
        unit_of_measurement: "W"
        device_class: power
        state_class: measurement
        state: >-
          {% set a = states('sensor.shellypro3em_d890ewfh_phase_a_active_power') | float(0) %}
          {% set b = states('sensor.shellypro3em_d890ewfh_phase_b_active_power') | float(0) %}
          {% set c = states('sensor.shellypro3em_d890ewfh_phase_c_active_power') | float(0) %}
          {% set total = a + b + c %}
          {{ (total * -1) if total < 0 else 0 }}
        availability: >-
          {% set sensors = [
            states('sensor.shellypro3em_d890ewfh_phase_a_active_power') | float(none),
            states('sensor.shellypro3em_d890ewfh_phase_b_active_power') | float(none),
            states('sensor.shellypro3em_d890ewfh_phase_c_active_power') | float(none)
          ] %}
          {{ sensors | select('is_number') | list | count == 3 }}

      # Calculates real power consumption including solar generation and export
      # Sets a tolerance to try and mitgitate any sensor noise / lags / jitters
      # Make solar_val a positive number (the shelly will report the PV power as negative value)
      - name: "Power Consumption"
        unit_of_measurement: "W"
        device_class: power
        state_class: measurement
        state: >-
          {% set import_val = states('sensor.power_import') | float(0) %}
          {% set export_val = states('sensor.power_export') | float(0) %}
          {% set solar_val = (states('sensor.shellypmminig3_5432046f3b24_power') | float(0)) * -1 %}
          {% set delta = solar_val - export_val %}
          {% set tolerance = 2.0 if solar_val < 100 else 0 %}
          {% if delta < -tolerance %}
            {{ import_val + solar_val }}
          {% elif delta > tolerance %}
            {{ delta }}
          {% elif abs(delta) <= tolerance %}
            {{ 0 }}
          {% else %}
            {{ import_val + solar_val }}
          {% endif %}

# 2. Integration Sensors (W -> Wh)
sensor:
  - platform: integration
    source: sensor.power_import
    name: energy_import_sum
    unit_prefix: k
    round: 4
    method: left

  - platform: integration
    source: sensor.power_export
    name: energy_export_sum
    unit_prefix: k
    round: 4
    method: left

  - platform: integration
    source: sensor.power_consumption
    name: energy_consumption_sum
    unit_prefix: k
    round: 4
    method: left

# 3. Utility Meters (Tracking)
utility_meter:
  energy_import_daily:
    source: sensor.energy_import_sum
    name: Energy Import Daily
    cycle: daily

  energy_import_monthly:
    source: sensor.energy_import_sum
    name: Energy Import Monthly
    cycle: monthly

  energy_export_daily:
    source: sensor.energy_export_sum
    name: Energy Export Daily
    cycle: daily

  energy_export_monthly:
    source: sensor.energy_export_sum
    name: Energy Export Monthly
    cycle: monthly

  energy_consumption_daily:
    source: sensor.energy_consumption_sum
    name: Energy Consumption Daily
    cycle: daily

  energy_consumption_monthly:
    source: sensor.energy_consumption_sum
    name: Energy Consumption Monthly
    cycle: monthly

I would love to get some feedback and further suggestions :slight_smile:

It sounds good :slight_smile:

  • On-Grid settings?

That isnt possible, minimum for this entity is 5%. Or isnt it?

Yes in the on-grid settings. You can also change it to any other setting/value as it doesn’t really matter. The zendure cloud/app will set you SolarFlow 800 Pro this specific setting (and it might re-set it to it again after a device reboot) but your HomeAssistant will instantly overwrite it anyway.

Regarding the BMS: You are right! I believe I was able to set it lower on a previous firmware. In that case I did a quick re-work which I think works best:

Set zendure BMS min. to 5%
The new summer default min is 8%.
The existing winter default is 20%.
The new emergency charging value is 6% (charging to 15%).

Basically we will try to never even hit the minimum of the zendure BMS.
Values should be adjusted based on how much you expect to get charging/discharging in your location / battery capacity.

I already updated the script! Thanks for the input! :slight_smile:

1 Like

Made some minor changes in performance / polling / logging settings. We are not twice as fast as zendure app but use less ressources!

I get some warnings… Looks like it need some recruitments; do I need restful or arest integration?

Konfigurationswarnungen
Setup of package 'template' failed: Invalid package definition 'template': expected a dictionary. Package will not be initialized
Setup of package 'sensor' failed: Invalid package definition 'sensor': expected a dictionary. Package will not be initialized
Setup of package 'utility_meter' failed: Integration 'energy_import_daily' not found.
Setup of package 'utility_meter' failed: Integration 'energy_import_monthly' not found.
Setup of package 'utility_meter' failed: Integration 'energy_export_daily' not found.
Setup of package 'utility_meter' failed: Integration 'energy_export_monthly' not found.
Setup of package 'utility_meter' failed: Integration 'energy_consumption_daily' not found.
Setup of package 'utility_meter' failed: Integration 'energy_consumption_monthly' not found.
Setup of package 'input_boolean' failed: Integration 'zendure_auto_mode' not found.
Setup of package 'input_boolean' failed: Integration 'zendure_emergency_charge' not found.
Setup of package 'input_boolean' failed: Integration 'zendure_calibration_mode' not found.
Setup of package 'input_datetime' failed: Integration 'zendure_last_full_charge' not found.
Setup of package 'input_number' failed: Integration 'zendure_last_sent_limit' not found.
Setup of package 'input_number' failed: Integration 'zendure_conf_max_output' not found.
Setup of package 'input_number' failed: Integration 'zendure_conf_failsafe_watt' not found.
Setup of package 'input_number' failed: Integration 'zendure_conf_saturation_watt' not found.
Setup of package 'input_number' failed: Integration 'zendure_conf_grid_bias' not found.
Setup of package 'input_number' failed: Integration 'zendure_conf_hysteresis' not found.
Setup of package 'input_number' failed: Integration 'zendure_conf_emergency_watt' not found.
Setup of package 'input_number' failed: Integration 'zendure_conf_soc_off' not found.
Setup of package 'input_number' failed: Integration 'zendure_conf_soc_bypass' not found.
Setup of package 'input_number' failed: Integration 'zendure_conf_soc_emerg_start' not found.
Setup of package 'input_number' failed: Integration 'zendure_conf_soc_emerg_stop' not found.
Setup of package 'input_number' failed: Integration 'zendure_conf_forecast_thresh' not found.
Setup of package 'input_number' failed: Integration 'zendure_conf_calib_days' not found.
Setup of package 'rest_command' failed: Integration 'zendure_set_output' not found.
Setup of package 'rest_command' failed: Integration 'zendure_force_charge' not found.
Setup of package 'automation' failed: Invalid package definition 'automation': expected a dictionary. Package will not be initialized

Is it possible to use my Solarflow Hub2000 and my Shelly 3EM Setup with your code?
Greetings

Did you add the .yaml in a folder called “packages”?

I don’t have a Hub2000 but it should be working since the SolarFlow 800 Pro is basically a Hub2000 with integrated battery.

yep, everything there

And your configuration.yaml contains:

# Configure Packages (Looking in the "packages" folder)
homeassistant:
  packages: !include_dir_named packages
# Exclude certrain entities from spamming the database
recorder:
  exclude:
    entities:
      - automation.zendure_control_loop
      - automation.zendure_state_machine
      - input_number.zendure_last_sent_limit
      
# Exclude certrain entities from spamming the Activity logbook    
logbook:
  exclude:
    entities:
      - automation.zendure_control_loop
      - automation.zendure_state_machine

If so, then I am out of ideas atm. Are you on the laste HA version?

Entries in my configuration.yaml build - ha 2026.1.3

should be the problem: Setup of package ‘template’ failed: Invalid package definition ‘template’: expected a dictionary. Package will not be initialized
Setup of package ‘sensor’ failed: Invalid package definition ‘sensor’: expected a dictionary. Package will not be initialized

Hmm currently I am really not sure why this is happening at your end. Maybe someone else can help here.

here should be nearly the same issue (buts in french): Invalid config for ‹ template › at packages

Here is the github rep with the most up-to-date version: GitHub - Utini2000/Zendure-Solarflow-Local-HomeAssistant: Run Zendure SolarFlow 800 Pro devices locally without any cloud or internet access