Files

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 discharge
  • marstek_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