The EMS calculates power targets from local load and device telemetry.
For a visual user-facing map of where each config.json parameter affects the
control chain, see control-flow.md.
- Reload
runtime-state.jsonif changed. - Optionally sync Home Assistant helper values with runtime state.
- Read Shelly house load.
- Read Zendure telemetry.
- Detect runtime capabilities.
- Run state reconciliation when due.
- Detect strict night/minSoc idle.
- Stabilize total target.
- Allocate target across devices.
- Apply device ramp and limits.
- Apply
min_output_limitwhile enabled. - Apply deadband and write gates.
- Write
outputLimitonly behind safety gates.
The controller keeps an internal commanded_total_w.
It calculates:
desired_total_w = commanded_total_w + filtered_load_w
Positive load is integrated only when at least one active online device has
export capacity. Export capacity means current PV on solarInputPower or
solarPower1 through solarPower4, confirmed discharge capability, or current
home output. Without export capacity, the controller holds commanded_total_w
at the standby total derived from min_output_limit and the number of active
online devices.
Then it applies:
- load deadband
- no-export-capacity hold
- target deadband
- median/EMA filtering
- optional sign-change fast response for large import/export direction flips
- total ramp limit
- per-device ramp limit
- stale telemetry ramp reduction
- large import/export ramp bypass multiplier
This makes short loop intervals usable without large alternating target swings.
The sign-change fast response only adjusts the filtered load value inside the median/EMA stage. It does not bypass total ramping, per-device ramping, write deadbands, write gates, or state reconciliation safeguards.
home is a calculated runtime/dashboard value derived from currently available
telemetry. It is useful for visibility, but it is not the same thing as the
smoothed control target.
The control target can be filtered, smoothed, ramped, clamped, limited by
device state, and held back by write gates. The outputLimit written to a
Zendure device is the EMS command limit for that device; the actual Zendure
output can differ for a short time because of API delay, device behavior,
available PV/battery power, or firmware state.
Off-grid socket mode is an operator/device mode state. It is not output power and should not be added to the home-load, target, or output calculation.
When all active and online devices are blocked at their discharge floor, the EMS can enter a strict night/minSoc idle state. This state exists to avoid repeated night-time API writes while still keeping the inverter wakeup value configured.
The state is entered only when every controlled device reports all of these values exactly:
solarInputPower == 0solarPower1 == 0solarPower2 == 0solarPower3 == 0solarPower4 == 0packInputPower == 0outputPackPower == 0outputHomePower == 0electricLevel <= minSocorsocLimit == 2
In this state the existing runtime min_output_limit is used as the
standby/wakeup outputLimit. If a device is already at that value, no write is
sent. If it is not, the EMS writes the value once and then suppresses further
outputLimit writes until the state is left.
Night/minSoc idle is a control-idle state, not a system-idle state. The EMS loop continues to fetch device state, process runtime state, publish Home Assistant telemetry, and expose status and safety visibility. Only repeated output-control writes are suppressed after the optional parking write.
The idle state is left as soon as any controlled device reports positive PV on
solarInputPower or one of solarPower1 through solarPower4. The output
control memory is reset so the normal controller initializes from fresh
telemetry.
If house load is positive but no active online device currently has export
capacity, the EMS does not add that load to commanded_total_w. This prevents a
night or blocked-battery state from ramping the global target to
max_total_power when no device can actually serve the load.
The hold uses:
standby_total_w = min_output_limit * active_online_device_count
With two active devices and min_output_limit=35, the global target is held at
70W instead of integrating toward 800W. Once PV, discharge capability, or
current output is observed again, the normal fast output controller resumes.
When PV can cover the requested target, the EMS allocates output using PV-first weights and PV-only limits.
PV-first weights can include a charge-balancing bias. When SOC spread is above the configured deadband, fuller batteries receive more PV-first output weight so lower-SOC batteries can keep more local PV for charging. The allocation still uses each device's PV-only limit.
If PV-first allocation leaves unmet demand, the EMS may top up from battery only on devices that:
- can export
- can discharge
- have SOC above minSoc
- have target headroom
When battery top-up is used, the final constraint pass keeps the normal device and capability limits but does not clamp the intentional top-up back to the PV-only limits.
Battery discharge is weighted by usable battery energy:
usable_percent = max(0, soc - minSoc)
weight = battery_kwh * usable_percent / 100
This favors devices with more usable energy while avoiding devices at or below their discharge floor.
The EMS compares the calculated target with the runtime outputLimit when
available. If outputLimit is missing or zero, it falls back to current output.
Small changes below deadband are skipped.
If a device cannot be read, the EMS may use a cached state, or a zero fallback when no cached state exists, so the loop can continue safely. Offline devices are marked offline and skipped for output writes.
Cached telemetry is last-known data. Offline does not automatically mean the
device is currently producing 0W; it means the EMS does not have fresh device
telemetry for that cycle.