Skip to content

Instantly share code, notes, and snippets.

@SunEnergyXT
Created January 7, 2026 11:14
Show Gist options
  • Select an option

  • Save SunEnergyXT/e5487ff1669ed17c7a14e00ca2390f75 to your computer and use it in GitHub Desktop.

Select an option

Save SunEnergyXT/e5487ff1669ed17c7a14e00ca2390f75 to your computer and use it in GitHub Desktop.
blueprint:
name: Zero Feed-in Controller (Hybrid Deye/APS)
description: |
A automation designed for precise zero feed-in control and battery standby management. This system utilizes an incremental PI algorithm to regulate grid exchange power, supporting a hybrid configuration of up to 4 microinverters.
domain: automation
input:
shelly_power_sensor:
name: Shelly total active power sensor (Required)
selector: { entity: { domain: sensor, device_class: power } }
avg_battery_soc:
name: Battery remaining soc (Required)
selector: { entity: { domain: sensor, device_class: battery } }
timeout_no_io_entity:
name: Battery no-input/no-output auto-shutdown timeout (Required)
selector: { entity: { domain: number } }
timeout_dod_entity:
name: Battery DOD-limit auto-shutdown timeout (Required)
selector: { entity: { domain: number } }
discharge_limit_a:
name: Minimum SOC allowed for discharge (Required)
selector: { entity: { domain: number } }
discharge_limit_b:
name: Battery BMS hardware discharge-limit SOC (Required)
selector: { entity: { domain: sensor, device_class: battery } }
integral_store_entity:
name: PI cumulative points storage entity (Required)
selector: { entity: { domain: input_number } }
# Deye Inverters (Percentage Based)
deye_1_entity:
name: Deye inverter 1 - active power regulation entity (Optional)
default: []
selector: { entity: { domain: number } }
deye_1_rated:
name: Deye inverter 1 - Rated Power (W)
default: 0
selector: { number: { min: 0, max: 2500, mode: box, unit_of_measurement: W } }
deye_2_entity:
name: Deye inverter 2 - active power regulation entity (Optional)
default: []
selector: { entity: { domain: number } }
deye_2_rated:
name: Deye inverter 2 - Rated Power (W)
default: 0
selector: { number: { min: 0, max: 2500, mode: box, unit_of_measurement: W } }
# APS Inverters (Wattage Based)
aps_1_entity:
name: APS inverter 1 - Max Output Limit Entity (Optional)
default: []
selector: { entity: { domain: number } }
aps_1_power_sensor:
name: APS inverter 1 - Actual Output Power Sensor
default: []
selector: { entity: { domain: sensor, device_class: power } }
aps_1_rated:
name: APS inverter 1 - Rated Power (W)
default: 0
selector: { number: { min: 0, max: 800, mode: box, unit_of_measurement: W } }
aps_2_entity:
name: APS inverter 2 - Max Output Limit Entity (Optional)
default: []
selector: { entity: { domain: number } }
aps_2_power_sensor:
name: APS inverter 2 - Actual Output Power Sensor
default: []
selector: { entity: { domain: sensor, device_class: power } }
aps_2_rated:
name: APS inverter 2 - Rated Power (W)
default: 0
selector: { number: { min: 0, max: 800, mode: box, unit_of_measurement: W } }
# Single mode to prevent race conditions
mode: single
max_exceeded: silent
variables:
# --- Input Mapping ---
grid_sensor: !input shelly_power_sensor
avg_soc_sensor: !input avg_battery_soc
integral_entity: !input integral_store_entity
# Protection
timeout_no_io: !input timeout_no_io_entity
timeout_dod: !input timeout_dod_entity
limit_a_ent: !input discharge_limit_a
limit_b_ent: !input discharge_limit_b
# Devices Configuration
d1_ent: !input deye_1_entity
d1_rated: !input deye_1_rated
d2_ent: !input deye_2_entity
d2_rated: !input deye_2_rated
a1_ent: !input aps_1_entity
a1_p_ent: !input aps_1_power_sensor
a1_rated: !input aps_1_rated
a2_ent: !input aps_2_entity
a2_p_ent: !input aps_2_power_sensor
a2_rated: !input aps_2_rated
# Existence Checks
d1_on: "{{ d1_ent != [] and d1_ent != None and d1_rated > 0 }}"
d2_on: "{{ d2_ent != [] and d2_ent != None and d2_rated > 0 }}"
a1_on: "{{ a1_ent != [] and a1_ent != None and a1_rated > 0 }}"
a2_on: "{{ a2_ent != [] and a2_ent != None and a2_rated > 0 }}"
# Global Check: Is ANY inverter configured?
any_inv_on: "{{ d1_on or d2_on or a1_on or a2_on }}"
# Calculate Total System Power
p_sum: >
{{ (d1_rated if d1_on else 0) +
(d2_rated if d2_on else 0) +
(a1_rated if a1_on else 0) +
(a2_rated if a2_on else 0) }}
# Count Active APS Units
aps_count: >
{{ (1 if a1_on else 0) + (1 if a2_on else 0) }}
# PI Parameters
kp: 0.7
ki: 0.05
# Protection Constants
c_soc_threshold: 10
c_timeout_long: 1440
c_timeout_def_no_io: 15
c_timeout_def_dod: 5
c_buffer_soc: 3
# APS Hardware Constraints
c_aps_min_w: 30
# Deadband Configuration
# 0 APS: -30W | 1 APS: -50W | 2 APS: -100W
c_deadband_max: 30
c_deadband_min: >
{% if aps_count == 0 %} -30
{% elif aps_count == 1 %} -50
{% else %} -100
{% endif %}
# Output Hysteresis (W)
c_output_hysteresis_w: 5
c_integral_limit_pct: 0.3
trigger:
- platform: state
entity_id: !input shelly_power_sensor
action:
# 1. State Retrieval
- variables:
avg_soc: "{{ states(avg_soc_sensor) | float(0) }}"
p_grid: "{{ states(grid_sensor) | float(0) }}"
limit_a: "{{ states(limit_a_ent) | float(0) }}"
limit_b: "{{ states(limit_b_ent) | float(0) }}"
real_min_soc: "{{ [limit_a, limit_b] | max }}"
curr_timeout_no_io: "{{ states(timeout_no_io) | float(0) }}"
curr_timeout_dod: "{{ states(timeout_dod) | float(0) }}"
# 2. Timeout Management (Executed regardless of inverter presence)
- choose:
- conditions:
- condition: template
value_template: "{{ avg_soc > c_soc_threshold }}"
sequence:
- if: "{{ curr_timeout_no_io == c_timeout_def_no_io }}"
then:
- service: number.set_value
target: { entity_id: "{{ timeout_no_io }}" }
data: { value: "{{ c_timeout_long }}" }
- if: "{{ curr_timeout_dod == c_timeout_def_dod }}"
then:
- service: number.set_value
target: { entity_id: "{{ timeout_dod }}" }
data: { value: "{{ c_timeout_long }}" }
- conditions:
- condition: template
value_template: "{{ avg_soc <= c_soc_threshold }}"
sequence:
- if: "{{ curr_timeout_no_io != c_timeout_def_no_io }}"
then:
- service: number.set_value
target: { entity_id: "{{ timeout_no_io }}" }
data: { value: "{{ c_timeout_def_no_io }}" }
- if: "{{ curr_timeout_dod != c_timeout_def_dod }}"
then:
- service: number.set_value
target: { entity_id: "{{ timeout_dod }}" }
data: { value: "{{ c_timeout_def_dod }}" }
# 3. NO-INVERTER CHECK
- if:
- condition: template
value_template: "{{ not any_inv_on }}"
then:
- stop: "No inverters configured. Timeout check complete. Stopping execution."
# 4. Discharge Protection (Only runs if inverters exist)
- choose:
- conditions:
- condition: template
value_template: "{{ avg_soc <= (real_min_soc + c_buffer_soc) }}"
sequence:
# Shut down Deye (0%)
- if: "{{ d1_on }}"
then:
- service: number.set_value
target: { entity_id: "{{ d1_ent }}" }
data: { value: 0 }
- if: "{{ d2_on }}"
then:
- service: number.set_value
target: { entity_id: "{{ d2_ent }}" }
data: { value: 0 }
# Min limit APS (30W)
- if: "{{ a1_on }}"
then:
- service: number.set_value
target: { entity_id: "{{ a1_ent }}" }
data: { value: "{{ c_aps_min_w }}" }
- if: "{{ a2_on }}"
then:
- service: number.set_value
target: { entity_id: "{{ a2_ent }}" }
data: { value: "{{ c_aps_min_w }}" }
# Reset Integral
- service: input_number.set_value
target: { entity_id: "{{ integral_entity }}" }
data: { value: 0 }
- stop: "Protection Active. Output forced to Min."
# 5. Deadband Check (Dynamic)
- if:
- condition: template
value_template: "{{ p_grid > c_deadband_min and p_grid < c_deadband_max }}"
then:
- stop: "Grid within dynamic deadband ({{ c_deadband_min }}W to {{ c_deadband_max }}W). Skipping."
# 6. Core PI Calculation (Watts Domain)
- variables:
# a. Calculate Time Delta (dt)
last_ts_list: >
{% set times = [] %}
{% if d1_on and states[d1_ent].last_changed %}{% set times = times + [as_timestamp(states[d1_ent].last_changed)] %}{% endif %}
{% if d2_on and states[d2_ent].last_changed %}{% set times = times + [as_timestamp(states[d2_ent].last_changed)] %}{% endif %}
{% if a1_on and states[a1_ent].last_changed %}{% set times = times + [as_timestamp(states[a1_ent].last_changed)] %}{% endif %}
{% if a2_on and states[a2_ent].last_changed %}{% set times = times + [as_timestamp(states[a2_ent].last_changed)] %}{% endif %}
{{ times | max if times else 0 }}
current_ts: "{{ now().timestamp() }}"
raw_diff: "{{ 999 if last_ts_list == 0 else (current_ts - last_ts_list) }}"
dt: "{{ 15 if raw_diff > 60 else raw_diff | round(3) }}"
# b. Calculate Total Current Output (Watts)
# Deye: Calculate from percentage setting (Assuming Deye follows setting well)
w_d1: "{{ (states(d1_ent)|float(0) / 100 * d1_rated) if d1_on else 0 }}"
w_d2: "{{ (states(d2_ent)|float(0) / 100 * d2_rated) if d2_on else 0 }}"
# APS: Use Actual Power Sensor if available, otherwise fallback to Setting
w_a1: >
{% if a1_on %}
{% if a1_p_ent != [] and states(a1_p_ent) not in ['unknown', 'unavailable'] %}
{{ states(a1_p_ent)|float(0) }}
{% else %}
{{ states(a1_ent)|float(0) }}
{% endif %}
{% else %} 0 {% endif %}
w_a2: >
{% if a2_on %}
{% if a2_p_ent != [] and states(a2_p_ent) not in ['unknown', 'unavailable'] %}
{{ states(a2_p_ent)|float(0) }}
{% else %}
{{ states(a2_ent)|float(0) }}
{% endif %}
{% else %} 0 {% endif %}
total_curr_w: "{{ w_d1 + w_d2 + w_a1 + w_a2 }}"
# c. PI Algo
error: "{{ -p_grid }}"
last_integral: "{{ states(integral_entity) | float(0) }}"
integ_limit_w: "{{ p_sum * c_integral_limit_pct }}"
raw_new_integ: "{{ last_integral + (ki * error * dt) }}"
# Anti-windup
new_integral: >
{% if raw_new_integ > integ_limit_w %} {{ integ_limit_w }}
{% elif raw_new_integ < -integ_limit_w %} {{ -integ_limit_w }}
{% else %} {{ raw_new_integ }}
{% endif %}
power_offset: "{{ (kp * error) + new_integral }}"
# Corrected logic: Target = Current - Offset
raw_target_total: "{{ total_curr_w - power_offset }}"
# Clamp Total Target
target_total_w: >
{% if raw_target_total > p_sum %} {{ p_sum }}
{% elif raw_target_total < 0 %} 0
{% else %} {{ raw_target_total }}
{% endif %}
# 7. Distribute & Execute
- variables:
# Distribution Ratios (Safe because p_sum > 0 if we reached here)
r_d1: "{{ d1_rated / p_sum if d1_on else 0 }}"
r_d2: "{{ d2_rated / p_sum if d2_on else 0 }}"
r_a1: "{{ a1_rated / p_sum if a1_on else 0 }}"
r_a2: "{{ a2_rated / p_sum if a2_on else 0 }}"
# Calculate Targets
t_d1: "{{ target_total_w * r_d1 }}"
t_d2: "{{ target_total_w * r_d2 }}"
t_a1: "{{ target_total_w * r_a1 }}"
t_a2: "{{ target_total_w * r_a2 }}"
# Format Values
val_d1: "{{ (t_d1 / d1_rated * 100) | round(1) if d1_on else 0 }}"
val_d2: "{{ (t_d2 / d2_rated * 100) | round(1) if d2_on else 0 }}"
# APS: Clamp to Min 30W
val_a1: "{{ [t_a1 | int, c_aps_min_w] | max if a1_on else 0 }}"
val_a2: "{{ [t_a2 | int, c_aps_min_w] | max if a2_on else 0 }}"
# 8. Apply Changes
- if:
- condition: template
value_template: "{{ (target_total_w - total_curr_w) | abs > c_output_hysteresis_w }}"
then:
- service: input_number.set_value
target: { entity_id: "{{ integral_entity }}" }
data: { value: "{{ new_integral | round(2) }}" }
- if: "{{ d1_on }}"
then:
- service: number.set_value
target: { entity_id: "{{ d1_ent }}" }
data: { value: "{{ val_d1 }}" }
- if: "{{ d2_on }}"
then:
- service: number.set_value
target: { entity_id: "{{ d2_ent }}" }
data: { value: "{{ val_d2 }}" }
- if: "{{ a1_on }}"
then:
- service: number.set_value
target: { entity_id: "{{ a1_ent }}" }
data: { value: "{{ val_a1 }}" }
- if: "{{ a2_on }}"
then:
- service: number.set_value
target: { entity_id: "{{ a2_ent }}" }
data: { value: "{{ val_a2 }}" }
- service: system_log.write
data:
level: info
message: >
PI-Univ: Grid={{ p_grid }}W, Target={{ target_total_w | int }}W.
Cur: D1/2={{ w_d1 }}/{{ w_d2 }}W, A1/2={{ w_a1 }}/{{ w_a2 }}W.
Set: D1={{ val_d1 }}%, D2={{ val_d2 }}%, A1={{ val_a1 }}W, A2={{ val_a2 }}W.
@beffge

beffge commented May 21, 2026

Copy link
Copy Markdown

@SunEnergyXT if you would add states(d1_ent/d2_ent/a1_ent/a2_ent) not in ["unavailable", "unknown"] }}' to lines https://gist.github.com/SunEnergyXT/e5487ff1669ed17c7a14e00ca2390f75#file-zero-feed-in-controller-yaml-L113-L116 the blueprint would avoid spamming the logs with

WARNING (MainThread) [homeassistant.helpers.service] Referenced entities number.solar_max_output are missing or not currently available

@Nordwind020

Copy link
Copy Markdown

Hi,
I'm using the Blueprint with two Deye 2000W micro-inverters, but unfortunately the control isn't working. It works well initially, but becomes inaccurate over time until it can no longer keep up.
The inverters then always output full power, without taking into account the required house power.

Do you have a solution for this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment