# 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`).

```yaml
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:

```yaml
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:

```yaml
# 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"}`.

```javascript
// 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:
```yaml
# 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

```javascript
// 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):
```bash
# 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
```
