Published: 2026-01-11, Revised: 2026-01-16


marstek_setup


TL;DR Integrating a cheap (~1050€) 5kWh AC-coupled battery into an existing 30kWp PV system. The goal: Zero-cloud dependency, full local control via Modbus TCP, and a "Zero Export" regulation loop using Home Assistant. This post covers the physical installation using a standard TV mount, RS485 wiring, and the complete software logic.

Motivation

In 2020, I took the initiative to install a 30kWp PV system on my own. Over the last 4 years, I achieved a self-consumption rate of roughly 48-50%.

See my PV production 2021-2025

calculation_history

With the price of the Marstek Venus E 2.0 dropping to around €1.049 in August 2025, I saw an opportunity to increase self-consumption to around 78%. The Marstek's size (5.12 kWh) was ideal for my consumption pattern, enabling me to maximise the number of use cycles. The return on investment (ROI) calculation was promising enough to justify the experiment. Note that my current electricity price is at €0.32/kWh and I sell my electricity at €0.082/kWh (fixed for the next 15 years). According to these figures, I should recoup my investment in about six years. After that, I estimate savings of around €250 per year.

See my ROI calculation for the battery

roi_projection

I wanted to avoid the manufacturer's cloud and app entirely.

  1. Privacy/Security: I don't want any IoT devices from Chinese vendors active on my main Ethernet network (no offence, Marstek!).
  2. Control: I want granular control over charging/discharging logic based on my specific grid meter readings, not a black-box algorithm.
  3. Robustness: The system should work regardless of internet connectivity. Local access also ensures that I can still use the battery if the vendor goes bankrupt or discontinues its service (local access ability was actually my main criteria for chosing the Marstek product).

Hardware Installation

Wall Mounting

The unit weighs about 50-60kg. Surprisingly, there is no official wall mount included or easily available. I found a hint on Facebook suggesting that standard TV brackets might work.

I used a One-For-All Solid WM4411 (TV wall mount, fixed, 32-65 inch), which is rated for up to 100kg.

  • Cost: ~13-20 EUR.
  • Fit: It fits perfectly.
  • Mounting: I used the screws originally intended for the battery's wheels to attach the bracket to the back of the Marstek unit. I used two angle brackets at the bottom as spacers (2.5cm) to keep it parallel to the wall.

wall_mount

RS485 & Network Connectivity

To connect the battery to my network without using its WiFi dongle, I used the RS485 interface.

  • Converter: Waveshare 20978 RS485 TO ETH (B)
  • Cable: A standard Cat7 patch cable (sacrificed).

The wiring pinout on the Marstek side is crucial. The included cable/adapter might have varying colors, so rely on pin positions. I connected the Cat7 wires via Wago clamps to the open ends of the Marstek adapter cable.

Wiring Mapping:

  • A: Yellow (Pin 5)
  • B: Red (Pin 4)
  • GND: Black (Pin 3)

wago_connection

Waveshare Configuration: The converter needs to be set to Modbus TCP to RTU mode. This allows Home Assistant to speak Modbus TCP, while the Waveshare handles the translation to Modbus RTU (serial) for the battery.

  • Baud Rate: 115200
  • Data Size: 8
  • Parity: None
  • Stop Bits: 1
  • Work Mode: TCP Server
  • Protocol: Modbus TCP to RTU
See my Waveshare settings (full screenshot)

waveshare_settings

Home Assistant Configuration

Note: A significant portion of this knowledge builds on the excellent yaml documentation from Michael Resch on Github.

Architecture

The control loop follows the separation of concerns principle.
1. Input: A Raspberry Pi Zero WH reads the electricity meter (via IR head/vzlogger) and pushes data to MQTT.
2. Logic: Home Assistant reads MQTT, calculates the required battery action, and sends commands via Modbus TCP.
3. Output: The Marstek battery adjusts its charge/discharge rate.

vzlogger_setup

Modbus Integration

First, we define the Modbus connection and the sensors/switches. I split these into separate files for maintainability.

The initial part of the configuration.yaml:

modbus:
  - name: Marstek
    type: tcp
    host: 192.168.50.77 # IP of the Waveshare
    port: 502
    timeout: 5
    delay: 1
    sensors: !include marstek_modbus_sensors.yaml
    switches: !include marstek_modbus_switches.yaml

input_number:
  # This helper acts as the "Gas Pedal" for the automation
  marstek_discharging_charging_power:
    name: "Marstek (Dis)Charging Power"
    min: -2500
    max: 2500
    step: 10
    unit_of_measurement: W
    mode: slider
    icon: mdi:battery-charging-medium

mqtt:
  sensor:
    - name: "Stromnetz Leistung (MQTT)"
      unique_id: stromnetz_leistung_mqtt
      state_topic: "vzlogger/data/chn0/agg"
      unit_of_measurement: "W"
      device_class: "power"
      state_class: "measurement"
    # Grid Consumption (Bezug 1.8.0) in kWh
    - name: "Stromnetz Bezug (1.8.0)"
      unique_id: stromnetz_bezug_kwh_mqtt
      state_topic: "vzlogger/data/chn1/agg"
      unit_of_measurement: "kWh"
      device_class: "energy"
      state_class: "total_increasing"
      # vzlogger usually sends Wh, but HA wants kWh. We divide by 1000.
      value_template: "{{ value | float / 1000 }}"

    # Return to Grid (Lieferung 2.8.0) in kWh
    - name: "Stromnetz Einspeisung (2.8.0)"
      unique_id: stromnetz_einspeisung_kwh_mqtt
      state_topic: "vzlogger/data/chn2/agg"
      unit_of_measurement: "kWh"
      device_class: "energy"
      state_class: "total_increasing"
      # vzlogger usually sends Wh, but HA wants kWh. We divide by 1000.
      value_template: "{{ value | float / 1000 }}"

For MQTT, the critical parameter is the current meter reading (e.g. grid loading or PV power export). I also added grid total statistics (ingress/egress) to populate the default Home Assistant Energy Dashboard at /energy/overview.

Info

If you use my YAML configurations, please note this peculiarity of Home Assistant. Usually, you can refer to entities via their unique_id. However, if the name parameter is set, Home Assistant will generate a slug from the name. In this case, the slug must be used. For example, Marstek Battery SOC will be available in dashboards via sensors.marstek_battery_soc. As a programmer, I found this somewhat unintuitive and error-prone, but it works.

marstek_modbus_sensors.yaml
# --- BATTERIE STATUS ---
- name: "Marstek Battery SOC"
  unique_id: marstek_battery_soc
  address: 32104
  slave: 1
  scan_interval: 30
  input_type: holding
  data_type: uint16
  unit_of_measurement: "%"
  device_class: battery
  state_class: measurement
  scale: 1
  precision: 0

- name: "Marstek Battery Voltage"
  unique_id: marstek_battery_voltage
  address: 32100
  slave: 1
  scan_interval: 30
  input_type: holding
  data_type: uint16
  unit_of_measurement: "V"
  device_class: voltage
  state_class: measurement
  scale: 0.01
  precision: 2

# --- LEISTUNG (Wichtig für Regelung) ---
- name: "Marstek AC Power"
  unique_id: marstek_ac_power
  # Positiv = Entladen, Negativ = Laden
  address: 32202
  slave: 1
  scan_interval: 5  # Schnell, für Regelung!
  input_type: holding
  data_type: int32
  unit_of_measurement: "W"
  device_class: power
  state_class: measurement
  scale: 1
  precision: 0

# --- ENERGIE ZÄHLER (Für Statistik) ---
- name: "Marstek Total Charging Energy"
  unique_id: marstek_total_charging_energy
  address: 33000
  slave: 1
  scan_interval: 60
  input_type: holding
  data_type: uint32
  unit_of_measurement: kWh
  device_class: energy
  state_class: total_increasing
  scale: 0.01
  precision: 1

- name: "Marstek Total Discharging Energy"
  unique_id: marstek_total_discharging_energy
  address: 33002
  slave: 1
  scan_interval: 60
  input_type: holding
  data_type: uint32
  unit_of_measurement: kWh
  device_class: energy
  state_class: total_increasing
  scale: 0.01
  precision: 1

# --- TEMPERATUREN (Sicherheit) ---
- name: "Marstek Internal Temperature"
  unique_id: marstek_internal_temperature
  address: 35000
  slave: 1
  scan_interval: 60
  input_type: holding
  data_type: int16
  unit_of_measurement: "°C"
  device_class: temperature
  state_class: measurement
  scale: 0.1
  precision: 1

- name: "Marstek Max Cell Temperature"
  unique_id: marstek_max_cell_temperature
  address: 35010
  slave: 1
  scan_interval: 60
  input_type: holding
  data_type: int16
  unit_of_measurement: "°C"
  device_class: temperature
  state_class: measurement
  scale: 0.1
  precision: 1

# --- STATUS ---
- name: "Marstek Inverter State"
  unique_id: marstek_inverter_state
  address: 35100
  slave: 1
  scan_interval: 10
  input_type: holding
  data_type: uint16
  # 0:Sleep, 1:Standby, 2:Charge, 3:Discharge, 4:Backup

- name: "Marstek RS485 Control Mode Status"
  unique_id: marstek_rs485_control_mode_status
  address: 42000
  slave: 1
  scan_interval: 10
  input_type: holding
  data_type: uint16

- name: "Marstek BMS Charge Current Limit"
  unique_id: marstek_bms_charge_current_limit
  address: 35111
  slave: 1
  scan_interval: 60
  input_type: holding
  data_type: uint16
  unit_of_measurement: "A"
  scale: 0.1
  precision: 1

- name: "Marstek BMS Discharge Current Limit"
  unique_id: marstek_bms_discharge_current_limit
  address: 35112
  slave: 1
  scan_interval: 60
  input_type: holding
  data_type: uint16
  unit_of_measurement: "A"
  scale: 0.1
  precision: 1

- name: "Marstek DC Power"
  unique_id: marstek_dc_power
  address: 32102
  slave: 1
  scan_interval: 10
  input_type: holding
  data_type: int32  # int32 laut PDF, da vorzeichenbehaftet
  unit_of_measurement: "W"
  scale: 1
  precision: 0

- name: "Marstek Alarm Code"
  unique_id: marstek_alarm_code
  address: 36000
  slave: 1
  scan_interval: 60
  input_type: holding
  data_type: uint16
  # Wert 0 = OK. Alles andere sind Warnungen.

- name: "Marstek Fault Code"
  unique_id: marstek_fault_code
  address: 36100
  slave: 1
  scan_interval: 60
  input_type: holding
  data_type: uint16
  # Wert 0 = OK. Alles andere sind Fehler (Abschaltung).

- name: "Marstek Config Max Charge Power"
  unique_id: marstek_config_max_charge_power
  address: 44002
  slave: 1
  scan_interval: 300 # Sehr selten, ändert sich ja nie
  input_type: holding
  data_type: uint16
  unit_of_measurement: "W"
  scale: 1

- name: "Marstek Config Max Discharge Power"
  unique_id: marstek_config_max_discharge_power
  address: 44003
  slave: 1
  scan_interval: 300
  input_type: holding
  data_type: uint16
  unit_of_measurement: "W"
  scale: 1

- name: "Marstek Config Discharge Cutoff SoC"
  unique_id: marstek_config_discharge_cutoff_soc
  address: 44001
  slave: 1
  scan_interval: 300
  input_type: holding
  data_type: uint16
  unit_of_measurement: "%"
  scale: 0.1
  precision: 1

- name: "Marstek Config Charging Cutoff SoC"
  unique_id: marstek_config_charge_cutoff_soc
  address: 44000
  slave: 1
  scan_interval: 300
  input_type: holding
  data_type: uint16
  unit_of_measurement: "%"
  scale: 0.1
  precision: 1

The main source for setting up sensors is the official Register documentation for the Marstek battery.[4] I found all of this information through the many reports from Photovoltaikforum.com.

marstek_modbus_switches.yaml
- name: "Marstek Enable RS485 Control Mode"
  unique_id: marstek_enable_rs485_control_mode
  address: 42000
  slave: 1
  command_on: 21930
  command_off: 21947
  write_type: holding
  verify:
    input_type: holding
    address: 42000
    state_on: 21930
    state_off: 21947
See my complete configuration.yaml
# Marstek Battery Integration via Waveshare
modbus:
  - name: Marstek
    type: tcp
    host: 192.168.50.77
    port: 502
    timeout: 5
    delay: 1
    sensors: !include marstek_modbus_sensors.yaml
    switches: !include marstek_modbus_switches.yaml

input_number:
  marstek_discharging_charging_power:
    name: "Marstek (Dis)Charging Power"
    min: -2500
    max: 2500
    step: 10
    unit_of_measurement: W
    mode: slider
    icon: mdi:battery-charging-medium

# Template Sensoren für Energie-Dashboard & Status
template:
  - sensor:
      # Berechnet Ladeleistung (nur positiv)
      - name: "Marstek Charging Power"
        unique_id: marstek_charging_power
        unit_of_measurement: "W"
        device_class: power
        state_class: measurement
        state: >
          {% set p = states('sensor.marstek_ac_power') | float(0) %}
          {{ (p * -1) if p < 0 else 0 }}

      # Berechnet Entladeleistung (nur positiv)
      - name: "Marstek Discharging Power"
        unique_id: marstek_discharging_power
        unit_of_measurement: "W"
        device_class: power
        state_class: measurement
        state: >
          {% set p = states('sensor.marstek_ac_power') | float(0) %}
          {{ p if p > 0 else 0 }}

      - name: "Marstek Ladezyklen (berechnet)"
        unique_id: marstek_cycles_calculated
        icon: mdi:battery-sync
        unit_of_measurement: "Zyklen"
        state_class: measurement
        state: >
          {% set total_discharged = states('sensor.marstek_total_discharging_energy_calculated') | float(0) %}
          {% set battery_capacity = 5.12 %}
          {{ (total_discharged / battery_capacity) | round(2) }}
      - name: "Marstek Efficiency"
        unique_id: marstek_efficiency
        unit_of_measurement: "%"
        icon: mdi:chart-donut
        state: >
          {% set chg = states('sensor.marstek_total_charging_energy_calculated') | float(0) %}
          {% set dis = states('sensor.marstek_total_discharging_energy_calculated') | float(0) %}
          {% if chg > 0 %}
            {{ ((dis / chg) * 100) | round(1) }}
          {% else %}
            0
          {% endif %}

sensor:
  - platform: influxdb
    api_version: 2
    host: influx.my.tld.com
    port: 443
    ssl: true
    token: [redacted]
    organization: "my org"
    bucket: "vzlogger"
    queries_flux:
      - name: "Stromnetz Leistung"
        unique_id: stromnetz_leistung_influxdb
        unit_of_measurement: "W"
        range_start: "-1m"  # <--- override the -15m default
        query: > # V--- query starts with the first filter
          filter(fn: (r) => r["_measurement"] == "mqtt_consumer")
          |> filter(fn: (r) => r["topic"] == "vzlogger/data/chn0/agg")
          |> map(fn: (r) => ({ r with _value: r._value * -1.0 }))
          |> last()
  # Riemann Summenintegrale (kWh aus Watt berechnen)
  # Genauer als die internen Zähler des Marstek.
  - platform: integration
    source: sensor.marstek_charging_power
    name: "Marstek Total Charging Energy (Calculated)"
    unique_id: marstek_total_charging_energy_calculated
    unit_prefix: k
    round: 3
    method: left
  - platform: integration
    source: sensor.marstek_discharging_power
    name: "Marstek Total Discharging Energy (Calculated)"
    unique_id: marstek_total_discharging_energy_calculated
    unit_prefix: k
    round: 3
    method: left
  - platform: filter
    name: "Stromnetz Leistung (Geglättet)"
    unique_id: stromnetz_leistung_smooth_2m
    entity_id: sensor.stromnetz_leistung_mqtt
    filters:
      - filter: time_simple_moving_average
        window_size: "00:02:00"  # Durchschnitt der letzten 120 Sekunden
        precision: 0

# Verbrauchszähler für Statistiken (Täglich, Monatlich, Jährlich)
utility_meter:
  # --- ENTLADEN (Verbrauch aus Batterie) ---
  marstek_discharge_daily:
    source: sensor.marstek_total_discharging_energy_calculated
    name: "Marstek Entladung Heute"
    cycle: daily
  marstek_discharge_monthly:
    source: sensor.marstek_total_discharging_energy_calculated
    name: "Marstek Entladung Monat"
    cycle: monthly
  marstek_discharge_yearly:
    source: sensor.marstek_total_discharging_energy_calculated
    name: "Marstek Entladung Jahr"
    cycle: yearly

  # --- LADEN (Speicherung in Batterie) ---
  marstek_charge_daily:
    source: sensor.marstek_total_charging_energy_calculated
    name: "Marstek Ladung Heute"
    cycle: daily
  marstek_charge_monthly:
    source: sensor.marstek_total_charging_energy_calculated
    name: "Marstek Ladung Monat"
    cycle: monthly
  marstek_charge_yearly:
    source: sensor.marstek_total_charging_energy_calculated
    name: "Marstek Ladung Jahr"
    cycle: yearly

As you can see, I also added my local InfluxDB, to pull additional values for the Dashboard (optional, not needed for the battery automation).

Logic: Zero Export Automation

When starting with the automation, I thought, this should be as simple as "If Solar > 0, then Charge". It is not.

To understand the automation choices, you must understand the specific premises of my setup:

  1. I have an oversized PV system (30kWp) paired with a relatively small battery (5.12kWh). My household consumption is medium-high (~6000kWh/a).
  2. Latency Reality: The electricity meter (vzlogger) reports via MQTT every 10 seconds, I could reduce this further, but decided against it (read below). The battery inverter also takes a few seconds to ramp up. Trying to chase second-by-second load peaks would result in a "cat-and-mouse" game, causing unnecessary oscillation.

Based on this, I defined my goals for the automation:

  • Avoid Grid Charging (Efficiency): Every kWh loaded from the grid and discharged later suffers a ~20% conversion loss. Therefore, I set a relatively high charging threshold (400W surplus). This ensures that in the mornings, scarce solar power goes directly to household consumption. I have ample surplus around noon to fill the small battery in no time, so there is no rush.
  • Avoid Battery Export (Economics): Discharging battery power into the grid is a financial loss. Since the battery is too small to cover the entire night anyway, I prioritize safety over coverage. Therefore, I decided for a safe discharge buffer (~50-100W grid import). If a device turns off, the battery has enough time to ramp down without accidentally feeding into the grid during the latency period.
  • Stability (Longevity): Instead of rapidly switching states, I prefer steady power levels. I implemented a damping factor and a step-logic (rounding to 100W). This creates a staircase curve (rather than a jagged line), likely improving inverter efficiency and reducing wear. I don't know if this is true or how important it is - let me know in the comments if you're an electricity or inverter expert!

To achieve this, the automation below uses an incremental control loop (similar to a P-controller) within a Jinja2 template in Home Assistant. Instead of calculating an absolute target, it adjusts the current power level relative to the grid meter reading.

  • ziel_netz (Target Grid Import): We aim for a permanent grid import of 50W. This acts as a safety buffer to ensure we never accidentally export battery power to the grid, even if consumption fluctuates slightly.
  • lade_start_grenze (Charge Start Threshold): A hysteresis setting. The battery only starts charging if the solar surplus exceeds 400W. However, once charging has started, it allows the power to drop below this threshold (e.g., during passing clouds) without immediately stopping. This ensures smoother operation.
  • korrektur (Correction Factor): The logic calculates the difference between the actual grid reading and the target (50W). It multiplies this by a damping factor (0.5) to calculate the adjustment. This prevents the system from overreacting and oscillating.
  • limit_max (Hardware Cap): A hard limit for the charging/discharging power (e.g., currently set to 800W for Schuko connection, I will ramp this up to 2500W once my battery is hardwired).
  • soc (Battery Protection): Monitors the State of Charge. Stops charging at 100% and stops discharging at 20% (to prolong battery life). This overrides the power calculation.
automations.yaml: marstek_smart_regulation
- alias: "Marstek: Intelligent Regulation (Zero Export)"
  id: marstek_smart_regulation
  mode: restart
  trigger:
    - platform: time_pattern
      seconds: "/10"
  condition:
    # Automation must be enabled via Dashboard switch
    - condition: state
      entity_id: input_boolean.marstek_automatik
      state: "on"
    # Sensor must provide valid data
    - condition: not
      conditions:
        - condition: state
          entity_id: sensor.stromnetz_leistung_mqtt
          state: ["unavailable", "unknown"]
  action:
    - action: input_number.set_value
      target:
        entity_id: input_number.marstek_discharging_charging_power
      data:
        value: >
          {# 1. Get current status #}
          {% set netz = states('sensor.stromnetz_leistung_mqtt') | float(0) %}
          {% set aktuell_soll = states('input_number.marstek_discharging_charging_power') | float(0) %}
          {% set soc = states('sensor.marstek_battery_soc') | float(0) %}

          {# 2. Settings #}
          {% set limit_max = 800 %}

          {# Lower Discharge Target: We always want at least 50W grid import (safety buffer) #}
          {% set ziel_netz = 50 %}

          {# Lower Charge Threshold: Only start charging when > 400W export (e.g. -450W) #}
          {% set lade_start_grenze = 400 %}

          {# 3. Calculate Correction (Incremental Regulation) #}
          {# Damping 0.5 for smooth adjustment #}
          {% set korrektur = (netz - ziel_netz) * 0.5 %}

          {# Preliminary new setpoint #}
          {% set neu_soll_raw = aktuell_soll - korrektur %}

          {# 4. Main Logic #}

          {# Scenario: We are not charging yet (or discharging) ... #}
          {% if aktuell_soll <= 0 %}
             {# ... but the calculation says "Please Charge" (positive value) ... #}
             {% if neu_soll_raw > 0 %}
                {# ... then we check: Is there enough surplus? #}
                {% if netz < (lade_start_grenze * -1) %}
                   {# Yes, ample surplus (-400W or more): Allow charging! #}
                   {% set neu_soll_final = neu_soll_raw %}
                {% else %}
                   {# No, just a little surplus: Stay at 0 (or keep discharging) #}
                   {% set neu_soll_final = 0 %}
                {% endif %}
             {% else %}
                {# We don't want to charge anyway (discharging or 0): All good #}
                {% set neu_soll_final = neu_soll_raw %}
             {% endif %}

          {# Scenario: We are already charging #}
          {% else %}
             {# Continue regulating normally (even under 400W) so it doesn't stop immediately on clouds #}
             {% set neu_soll_final = neu_soll_raw %}
          {% endif %}

          {# 5. Round to 100W steps (for stability) #}
          {% set neu_soll_gerundet = (neu_soll_final / 100) | int * 100 %}

          {# 6. Safety Limits (SoC & Max Power) #}

          {# --- Battery full --- #}
          {% if neu_soll_gerundet > 0 and soc >= 100 %}
             0

          {# --- Battery empty --- #}
          {% elif neu_soll_gerundet < 0 and soc <= 20 %}
             0

          {# --- Hard Limits --- #}
          {% elif neu_soll_gerundet > limit_max %}
             {{ limit_max }}
          {% elif neu_soll_gerundet < (limit_max * -1) %}
             {{ limit_max * -1 }}

          {# --- Normal --- #}
          {% else %}
             {{ neu_soll_gerundet | int }}
          {% endif %}

Since Modbus is a stateful protocol, the battery retains the last command it received. If Home Assistant crashes or reboots while the battery is discharging at full power, the battery would continue to discharge until empty because the control loop stops sending updates.

To prevent this state and ensure a clean startup, I implemented a safety logic in automations.yaml:

  1. Graceful Shutdown: Using the homeassistant.shutdown event trigger, HA sends a hard 0 (Stop) command to the battery immediately before the system stops.
  2. Clean Startup: On boot (homeassistant.start), a logic lock is applied. The automation sets the target power to 0 and disables the "Autopilot" boolean. It waits for 20 seconds to ensure the Modbus connection is fully established, sends another safety Stop command, and only then re-enables the automation loop. This prevents the regulation logic from acting on stale sensor data before the system is fully initialized.
automations.yaml: additonal parts
- alias: "Marstek: Befehl an Speicher senden"
  id: marstek_send_command
  mode: restart
  trigger:
    - platform: state
      entity_id: input_number.marstek_discharging_charging_power
  action:
    - action: script.turn_on
      target:
        entity_id: script.marstek_set_forcible_charge

# 3. Self-healing (Optional)
- alias: "Marstek: Watchdog (Selbstheilung)"
  id: marstek_watchdog
  trigger:
    - platform: time_pattern
      minutes: "/5"
  condition:
    # Wenn Sollwert und Istwert zu stark abweichen
    - condition: template
      value_template: >
        {{ (states('input_number.marstek_discharging_charging_power') | float(0) - states('sensor.marstek_ac_power') | float(0) * -1) | abs > 100 }}
    # Und wir nicht gerade bei 0 sind
    - condition: numeric_state
      entity_id: input_number.marstek_discharging_charging_power
      above: 50
  action:
    # RS485 Reset
    - action: switch.turn_off
      target: {entity_id: switch.marstek_enable_rs485_control_mode}
    - delay: 5
    - action: switch.turn_on
      target: {entity_id: switch.marstek_enable_rs485_control_mode}

# 4. On Shutdown: Stop the battery
# (Schützt vor ungewolltem Weiterlaufen während Updates)
- alias: "System: Marstek Stopp bei Shutdown"
  id: system_marstek_shutdown_safety
  trigger:
    - platform: homeassistant
      event: shutdown
  action:
    - action: script.turn_on
      target:
        entity_id: script.marstek_stop_system

# 5. On Startup: Restart the battery with a time delay
- alias: "System: Marstek Reset bei Start"
  id: system_marstek_startup_reset
  trigger:
    - platform: homeassistant
      event: start
  action:
    # 1. Sperre: Automatik sofort ausschalten
    - action: input_boolean.turn_off
      target:
        entity_id: input_boolean.marstek_automatik
    # 2. Werte auf Null
    - action: input_number.set_value
      target:
        entity_id: input_number.marstek_discharging_charging_power
      data:
        value: 0
    # 3. Warten
    - delay: "00:00:20"
    # 4. Reset: Sicherer Stopp-Befehl an Speicher senden
    - action: script.turn_on
      target:
        entity_id: script.marstek_stop_system
    # 5. Freigabe: Automatik einschalten -> System übernimmt ab jetzt
    - action: input_boolean.turn_on
      target:
        entity_id: input_boolean.marstek_automatik

I also added stop_grace_period: 30s to my docker-compose.yml, to give this automation a bit more time to put the battery in a save state.


The automation triggers a script that writes the actual registers. This abstracts the Modbus complexity away from the automation logic.

scripts.yaml
marstek_set_forcible_charge:
  alias: Marstek Set Forcible Charge
  icon: mdi:battery-charging-40
  sequence:
    - choose:
        # Fall 1: Stopp (Wert nahe 0)
        - conditions:
            - condition: numeric_state
              entity_id: input_number.marstek_discharging_charging_power
              above: -1
              below: 1
          sequence:
            - action: modbus.write_register
              data: {hub: Marstek, address: 42010, slave: 1, value: 0}
            - action: modbus.write_register
              data: {hub: Marstek, address: 42020, slave: 1, value: 0}
            - action: modbus.write_register
              data: {hub: Marstek, address: 42021, slave: 1, value: 0}
        # Fall 2: Entladen (Negativer Wert)
        - conditions:
            - condition: numeric_state
              entity_id: input_number.marstek_discharging_charging_power
              above: -2501
              below: -10
          sequence:
            # Leistung setzen (Absolutwert: aus -500 wird 500)
            - action: modbus.write_register
              data:
                hub: Marstek
                address: 42021
                slave: 1
                value: "{{ states('input_number.marstek_discharging_charging_power') | int | abs }}"
            # Modus Entladen (2)
            - action: modbus.write_register
              data: {hub: Marstek, address: 42010, slave: 1, value: 2}

        # Fall 3: Laden (Positiver Wert)
        - conditions:
            - condition: numeric_state
              entity_id: input_number.marstek_discharging_charging_power
              above: 10
              below: 2501
          sequence:
            # Leistung setzen
            - action: modbus.write_register
              data:
                hub: Marstek
                address: 42020
                slave: 1
                value: "{{ states('input_number.marstek_discharging_charging_power') | int | abs }}"
            # Modus Laden (1)
            - action: modbus.write_register
              data: {hub: Marstek, address: 42010, slave: 1, value: 1}

# Additional Scripts for Dashboard (manual, not needed for the automation)
marstek_start_charging:
  alias: "Marstek: Laden Starten (Manuell)"
  icon: mdi:battery-charging
  sequence:
    - action: modbus.write_register
      data: {hub: Marstek, slave: 1, address: 42000, value: 21930}
    - action: modbus.write_register
      data:
        hub: Marstek
        slave: 1
        address: 42020
        value: "{{ states('input_number.marstek_discharging_charging_power') | int | abs }}"
    - action: modbus.write_register
      data: {hub: Marstek, slave: 1, address: 42010, value: 1}

marstek_start_discharging:
  alias: "Marstek: Entladen Starten (Manuell)"
  icon: mdi:battery-charging-low
  sequence:
    - action: modbus.write_register
      data: {hub: Marstek, slave: 1, address: 42000, value: 21930}
    - action: modbus.write_register
      data:
        hub: Marstek
        slave: 1
        address: 42021
        value: "{{ states('input_number.marstek_discharging_charging_power') | int | abs }}"
    - action: modbus.write_register
      data: {hub: Marstek, slave: 1, address: 42010, value: 2}

marstek_stop_system:
  alias: "Marstek: Stopp (Manuell)"
  icon: mdi:stop-circle-outline
  sequence:
    - action: modbus.write_register
      data: {hub: Marstek, slave: 1, address: 42010, value: 0}

Dashboard

I created a dashboard to monitor the system state and allow for manual override. The system runs in "Autopilot" (Automation active) by default, but can be switched to manual control for testing or maintenance.

Main values (gauge). Note that the max cell temperature (1.5°C) value is likely a default, perhaps the battery register hasn't been triggered yet.

dashboard_overview

Then there is a Hardware Limits and Diagnosis part:

dashboard_limits

Next come the statistics and cycles. The most interesting part here is perhaps the 'Full Cycles' value. The more full cycles that are reached in a given year, the more effectively the battery is used. 200 full cycles per year is an achievable average for most. With my larger PV system, I expect to achieve slightly more, perhaps 250–300 cycles (we will see). The Marstek Venus E technical documentation guarantees 6,000 load cycles at 80% discharge, meaning my battery should last 20 years at 300 cycles/year. However, I expect it to last less than that (we will see, too!).

dashboard_statistics_

Finally overrides, to turn the automation off and force discharge or charge manually.

dashboard_gauges

Dashboard yaml
type: vertical-stack
cards:
  - type: heading
    heading: Marstek Speicher Status
    icon: mdi:battery-high
    heading_style: title
  - type: horizontal-stack
    cards:
      - type: gauge
        entity: sensor.marstek_battery_soc
        name: Ladestand (SoC)
        min: 0
        max: 100
        severity:
          green: 20
          yellow: 10
          red: 0
        needle: true
      - type: gauge
        entity: sensor.marstek_ac_power
        name: Leistung (AC)
        min: -2500
        max: 2500
        needle: true
        severity:
          green: -2500
          yellow: 0
          red: 1
  - type: horizontal-stack
    cards:
      - type: gauge
        entity: sensor.marstek_max_cell_temperature
        name: Max. Zell-Temp
        min: 0
        max: 60
        needle: true
        severity:
          green: 15
          yellow: 45
          red: 55
      - type: tile
        entity: sensor.marstek_internal_temperature
        name: Innen-Temp
        icon: mdi:thermometer
        color: orange
  - type: entities
    title: Live Werte
    entities:
      - entity: sensor.stromnetz_leistung_mqtt
        name: Aktueller Netzbezug (MQTT)
        icon: mdi:transmission-tower
      - entity: sensor.marstek_ac_power
        name: AC Leistung (Zum Haus)
        icon: mdi:current-ac
      - entity: sensor.marstek_dc_power
        name: DC Leistung (Von Batterie)
        icon: mdi:current-dc
      - entity: sensor.marstek_battery_voltage
        name: Batteriespannung
      - entity: sensor.marstek_inverter_state
        name: Inverter Status (1=Stby, 2=Chg, 3=Dis)
      - entity: switch.marstek_enable_rs485_control_mode
        name: RS485 Fernsteuerung aktiv
  - type: entities
    title: Hardware-Limits & Diagnose
    show_header_toggle: false
    entities:
      - entity: sensor.marstek_alarm_code
        name: Alarm Code (0 = OK)
        icon: mdi:alert-outline
      - entity: sensor.marstek_fault_code
        name: Fault Code (0 = OK)
        icon: mdi:alert-octagon-outline
      - type: section
        label: Temporäre BMS Limits (Temperaturbedingt)
      - entity: sensor.marstek_bms_charge_current_limit
        name: Max. Ladestrom (BMS)
      - entity: sensor.marstek_bms_discharge_current_limit
        name: Max. Entladestrom (BMS)
      - type: section
        label: Permanente Config Limits (EEPROM)
      - entity: sensor.marstek_config_max_charge_power
        name: Erlaubte Ladeleistung (System)
        icon: mdi:lock-outline
      - entity: sensor.marstek_config_max_discharge_power
        name: Erlaubte Entladeleistung (System)
        icon: mdi:lock-outline
      - entity: sensor.marstek_config_discharge_cutoff_soc
        name: Notabschaltung bei SoC (System)
        icon: mdi:battery-alert
      - entity: sensor.marstek_config_charging_cutoff_soc
        name: Maximal laden bis SoC (System)
        icon: mdi:battery-alert
  - type: entities
    title: Statistik & Zyklen
    show_header_toggle: false
    entities:
      - entity: sensor.marstek_ladezyklen_berechnet
        name: Vollzyklen (seit Installation)
      - entity: sensor.marstek_efficiency
        name: Wirkungsgrad (RTE)
      - type: section
        label: Gesamtenergie
      - entity: sensor.marstek_total_charging_energy_calculated
        name: Gesamt Geladen
        icon: mdi:battery-arrow-up
      - entity: sensor.marstek_total_discharging_energy_calculated
        name: Gesamt Entladen
        icon: mdi:battery-arrow-down
      - type: section
        label: Heute
      - entity: sensor.marstek_ladung_heute
        name: Geladen Heute
      - entity: sensor.marstek_entladung_heute
        name: Entladen Heute
      - type: section
        label: Dieser Monat
      - entity: sensor.marstek_ladung_monat
        name: Geladen Monat
      - entity: sensor.marstek_entladung_monat
        name: Entladen Monat
      - type: section
        label: Dieses Jahr
      - entity: sensor.marstek_ladung_jahr
        name: Geladen Jahr
      - entity: sensor.marstek_entladung_jahr
        name: Entladen Jahr
  - type: heading
    heading: Manuelle Steuerung
    icon: mdi:controller
  - type: tile
    entity: input_boolean.marstek_automatik
    name: Automatik-Modus (An = Autopilot)
    icon: mdi:robot
    color: accent
  - type: entities
    entities:
      - entity: input_number.marstek_discharging_charging_power
        name: Soll-Leistung (+ Laden / - Entladen)
  - type: horizontal-stack
    cards:
      - type: button
        name: LADEN
        icon: mdi:battery-charging
        tap_action:
          action: call-service
          service: script.marstek_start_charging
        show_name: true
        show_icon: true
        card_mod:
          style: |
            ha-card { background: #1b5e20; color: white; }
      - type: button
        name: STOPP
        icon: mdi:stop-circle-outline
        tap_action:
          action: call-service
          service: script.marstek_stop_system
        show_name: true
        show_icon: true
        card_mod:
          style: |
            ha-card { background: #424242; color: white; }
      - type: button
        name: ENTLADEN
        icon: mdi:battery-charging-low
        tap_action:
          action: call-service
          service: script.marstek_start_discharging
        show_name: true
        show_icon: true
        card_mod:
          style: |
            ha-card { background: #b71c1c; color: white; }

Evaluation

For evaluation and monitoring, I prefer InfluxDB and Grafana. Add this to your configuration.yaml to export the battery stats to InfluxDB, so it can be visualized in Grafana:

configurations.yaml: InfluxDB
influxdb:
  api_version: 2
  ssl: true # I am using https internally
  host: influx.my.tld.com
  port: 443 # Default port for https
  token: [redacted]
  organization: "my org"
  bucket: "homeassistant"
  exclude:
    entity_globs: "*" # This prevents HA from writing its own data to InfluxDB
  include: # except for these metrics
    entities:
      - sensor.marstek_ac_power         # Lade-/Entladeleistung
      - sensor.marstek_battery_soc      # Ladestand
      - sensor.marstek_battery_voltage  # Spannung

My flux query in Grafana then looks like this:

from(bucket: "homeassistant")
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r["_measurement"] == "W")
  |> filter(fn: (r) => r["_field"] == "value")
  |> filter(fn: (r) => r["entity_id"] == "marstek_ac_power")
  |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)
  |> set(key: "_field", value: "Marstek Batterie")
  |> keep(columns: ["_time", "_value", "_field"])
  |> yield(name: "mean")

Here is a common day in January where I produced 11.4 kWh.

  • Red: Battery load (charge/discharge) from Home Assistant via InfluxDB.
  • Orange: Grid load (egress/ingress), from VzLogger via MQTT
  • Cyan: total PV inverter production, from my Huawei inverter, pulled via Modbus TCP

Grafana Evaluation

As can be seen, the battery started charging at around 09:32. It was a fully overcast day. At around noon, there was a heavy downpour, which coincided with a high household energy demand (2500W, lunchtime!), during which the battery briefly switched to discharge mode. It then switched back to charging until 14:54, when the safety margin resulted in the battery switching to standby for around 30 minutes. At this time, the battery was 72% charged. At 15:08, it first switched to discharging, which continued to rise steadily until the peak evening usage period from 17:00 onwards. The battery reached a SoC of 20% around 9 pm and switched back to standby.

Here are some close-ups for the above periods

Grafana Evaluation

This is the lunchtime period. It is possible to observe the latency of the entire battery/VZLogger system. Nevertheless, the battery managed to dampen the brief periods of drawing power from the grid. On a few occasions, the battery did not react quickly enough and discharged briefly into the grid (yellow line above zero). However, since it is midday, this isn't too bad. There was still enough daylight left to recharge the battery.

In the second part (the right-hand side), we can see the inverse situation. The battery started charging again. There were only a few instances when the battery didn't react quickly enough and charged briefly from the grid (yellow line below zero). I also found this acceptable.

Later on in the evening (screenshot below), this was the transition from charging to discharging. There is really not much to complain about here.

Grafana Evaluation

  • The automation managed to keep the battery at relatively steady levels for long periods.
  • Very few peaks were charged from the grid, which I found entirely acceptable.
  • The discharging period was even better, with the battery only discharging into the grid on two occasions.

Conclusion

The system works reliably. The latency is low enough (~10s interval) to cover base load effectively. By using the "Zero Export" logic with a calculated gap, the system doesn't oscillate.

I successfully avoided the manufacturer's cloud, kept the device isolated in a separate VLAN (or just offline via direct cable), and integrated it seamlessly into my existing Home Assistant environment.

marstek venus e on wall

Apologies for the mixture of German (screens) and English!

Changelog

2026-01-17

  • Changed the upper load limit from 99% to 100%, as it doesn't make sense with LiFePO4 batteries to not charge the final percentage

2026-01-16

  • Significantly updated automation, mainly targeted to reduce oscillation

2026-01-15