11 KiB
Marstek Cascade Automation (Modbus)
HA automation automation.marstek_modbus_steuerung ("Marstek Kaskaden-Steuerung (Modbus)") controls the Marstek Venus battery as an overflow/reserve storage, capped at 2500W. It runs every 2 minutes and decides whether to charge, discharge, or stop the Marstek based on SMA battery state and home power balance.
Automation Structure
Retrieved via REST API: GET /api/config/automation/config/{numeric_id} (numeric ID from automation entity attributes.id field — in this case 1773314167598).
alias: Marstek Kaskaden-Steuerung (Modbus)
description: "Marstek arbeitet als reiner Überlauf/Reserve-Speicher, gedeckelt auf max. 2500 W."
mode: single
triggers:
- minutes: "/2"
trigger: time_pattern
actions:
- variables:
production: "{{ states('sensor.sn_3007105790_metering_power_supplied') | float(0) }}"
power_home: "{{ states('sensor.sn_3007105790_metering_power_absorbed') | float(0) }}"
soc: "{{ states('sensor.sn_3007105790_battery_soc_total') | float(0) }}"
battery_charge: "{{ states('sensor.sn_3007105790_battery_power_charge_total') | float(0) }}"
discharge: "{{ states('sensor.sn_3007105790_battery_power_discharge_total') | float(0) }}"
marstek_current_power: "{{ states('number.marstek_venus_modbus_ladeleistung_einstellen') | float(0) }}"
marstek_current_discharge: "{{ states('number.marstek_venus_modbus_entladeleistung_einstellen') | float(0) }}"
echter_ueberschuss: "{{ production - power_home + marstek_current_power - marstek_current_discharge }}"
echter_bedarf: "{{ power_home - production + marstek_current_discharge - marstek_current_power }}"
- choose:
# Option 1: Charge when SMA battery is full AND there's surplus
- conditions:
- "{{ (soc >= 80 or battery_charge >= 3900) and echter_ueberschuss > 50 }}"
sequence:
- action: number.set_value
target:
entity_id: number.marstek_venus_modbus_ladeleistung_einstellen
data:
value: "{{ [ echter_ueberschuss | int, 2500 ] | min }}"
- action: select.select_option
target:
entity_id: select.marstek_venus_modbus_force_mode
data:
option: charge
# Option 2: Discharge when SMA battery is low AND there's demand
- conditions:
- "{{ (soc <= 25 or discharge >= 3900) and echter_bedarf > 50 }}"
sequence:
- action: number.set_value
target:
entity_id: number.marstek_venus_modbus_entladeleistung_einstellen
data:
value: "{{ [ echter_bedarf | int, 2500 ] | min }}"
- action: select.select_option
target:
entity_id: select.marstek_venus_modbus_force_mode
data:
option: discharge
# Default: stop
default:
- action: select.select_option
target:
entity_id: select.marstek_venus_modbus_force_mode
data:
option: stop
- action: number.set_value
target:
entity_id: number.marstek_venus_modbus_ladeleistung_einstellen
data:
value: 0
- action: number.set_value
target:
entity_id: number.marstek_venus_modbus_entladeleistung_einstellen
data:
value: 0
Marstek Modbus Control Entities
| Entity | Type | Purpose |
|---|---|---|
number.marstek_venus_modbus_ladeleistung_einstellen |
number | Charge power setpoint (W) |
number.marstek_venus_modbus_entladeleistung_einstellen |
number | Discharge power setpoint (W) |
number.marstek_venus_modbus_maximale_ladeleistung |
number | Max charge power (hardware limit, 2500W) |
number.marstek_venus_modbus_maximale_entladeleistung |
number | Max discharge power (hardware limit, 2500W) |
select.marstek_venus_modbus_force_mode |
select | Force mode: charge / discharge / stop |
select.marstek_venus_modbus_modus |
select | User mode: anti_feed (prevents grid export) |
switch.marstek_venus_modbus_rs485_steuermodus |
switch | RS485 control mode (must be ON for Modbus control) |
Measured values (read-only):
| Entity | Purpose |
|---|---|
sensor.marstek_venus_modbus_ac_leistung |
Actual AC output power (W) |
sensor.marstek_venus_modbus_batterieleistung |
Battery power (W, negative = discharging) |
sensor.marstek_venus_modbus_batterie_ladezustand |
SoC (%) |
sensor.marstek_venus_modbus_entladeleistung |
Actual discharge power (W) |
sensor.marstek_venus_modbus_ladeleistung |
Actual charge power (W) |
The Feedback-Loop Bug (Root Cause of ~200W Grid Import)
The Problem
The echter_bedarf formula uses setpoint values (number.*_einstellen) instead of measured values:
echter_bedarf: "{{ power_home - production + marstek_current_discharge - marstek_current_power }}"
Where:
marstek_current_discharge=states('number.marstek_venus_modbus_entladeleistung_einstellen')— the setpoint, not actual dischargemarstek_current_power=states('number.marstek_venus_modbus_ladeleistung_einstellen')— the charge setpoint, not actual charge
Why This Spirals
With power_home=870W, production=0W (nighttime), and starting setpoint=0:
| Cycle | Setpoint | echter_bedarf = 870 + setpoint | New Setpoint = min(bedarf, 2500) |
|---|---|---|---|
| 1 | 0 | 870 | 870 |
| 2 | 870 | 1740 | 1740 |
| 3 | 1740 | 2610 | 2500 (capped) |
The controller ramps to 2500W within 3 cycles (6 minutes). The Marstek's anti_feed mode then throttles actual output to ~650W (preventing grid export). The mismatch between the 2500W command and the ~650W actual output leaves ~200W on the grid.
The Fix (Applied 2026-07-10)
The user requested consistency with the existing automation structure. Rather than introducing new variables or rewriting the formulas, the fix swaps the underlying sensor entities from setpoints to measured values, keeping all variable names and formula structures unchanged:
# BROKEN (uses setpoints → feedback loop):
marstek_current_power: "{{ states('number.marstek_venus_modbus_ladeleistung_einstellen') | float(0) }}"
marstek_current_discharge: "{{ states('number.marstek_venus_modbus_entladeleistung_einstellen') | float(0) }}"
# FIXED (uses measured values → stable, same variable names, same formulas):
marstek_current_power: "{{ states('sensor.marstek_ladeleistung') | float(0) }}"
marstek_current_discharge: "{{ states('sensor.marstek_entladeleistung') | float(0) }}"
The echter_ueberschuss and echter_bedarf formulas were NOT changed — only the two variable definitions above. This is the minimal, consistent patch.
With the fix: echter_bedarf = 870 - 0 + 650 - 0 = 1520 → but marstek_current_discharge is now the measured 650W, not the setpoint. Next cycle: the Marstek adjusts toward the new setpoint, marstek_current_discharge tracks the actual output, and echter_bedarf converges to the true residual (~220W). No positive feedback, no spiral to 2500W.
How the fix was applied: Retrieved the automation config via REST API (GET /api/config/automation/config/{id}), modified the two variable values in the JSON, and POSTed it back. The HA REST API accepts the full config object as the request body and returns {"result":"ok"}.
// In browser_console (after HA login):
(async () => {
const conn = await window.hassConnection;
const token = conn.auth.data.access_token;
const resp = await fetch('/api/config/automation/config/1773314167598', {
headers: {'Authorization': 'Bearer ' + token}
});
const config = await resp.json();
// Swap setpoint sensors for measured sensors
const vars = config.actions[0].variables;
vars.marstek_current_power = "{{ states('sensor.marstek_ladeleistung') | float(0) }}";
vars.marstek_current_discharge = "{{ states('sensor.marstek_entladeleistung') | float(0) }}";
// POST back
const updateResp = await fetch('/api/config/automation/config/1773314167598', {
method: 'POST',
headers: {'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'},
body: JSON.stringify(config)
});
return await updateResp.text(); // {"result":"ok"}
})()
General Lesson: Setpoint vs Measured in Control Automations
Never use a setpoint (command/output) variable in a feedback formula that computes the next setpoint. This creates positive feedback — the controller amplifies its own output every cycle. Always use the measured process variable (sensor reading) to compute the error/demand. This is classic control theory: the feedback path must measure the actual plant output, not the controller's commanded output.
Pattern to watch for in HA automations:
# DANGER: variable reads from number.*_einstellen (setpoint) → feedback loop
marstek_current_X: "{{ states('number.marstek_venus_modbus_X_einstellen') | float(0) }}"
# SAFE: variable reads from sensor.* (measured) → stable control
marstek_current_X: "{{ states('sensor.marstek_venus_modbus_X') | float(0) }}"
Interaction with Marstek anti_feed Mode
The Marstek's user mode is set to anti_feed (select.marstek_venus_modbus_modus), which prevents exporting power to the grid. When the automation commands 2500W discharge but home only needs 870W, the Marstek internally throttles to avoid feeding 1630W back to the grid. This is correct behavior by the Marstek — the bug is in the automation commanding too much, not in the Marstek refusing to deliver.
How to Retrieve HA Automation YAML
// In browser_console (after HA login):
(async () => {
const conn = await window.hassConnection;
const token = conn.auth.data.access_token;
// Get numeric ID from entity attributes
const stateResp = await fetch('/api/states/automation.ENTITY_ID', {
headers: {'Authorization': 'Bearer ' + token}
});
const state = await stateResp.json();
const numericId = state.attributes.id;
// Fetch full automation config
const configResp = await fetch(`/api/config/automation/config/${numericId}`, {
headers: {'Authorization': 'Bearer ' + token}
});
const config = await configResp.json();
return JSON.stringify(config);
})()
Or via REST API directly (no browser needed if token available):
# Step 1: Get numeric ID
NUMERIC_ID=$(curl -s "$HA_URL/api/states/automation.ENTITY_ID" \
-H "Authorization: Bearer $HA_TOKEN" | python3 -c "import sys,json; print(json.load(sys.stdin)['attributes']['id'])")
# Step 2: Get config
curl -s "$HA_URL/api/config/automation/config/$NUMERIC_ID" \
-H "Authorization: Bearer $HA_TOKEN" | python3 -m json.tool