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


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.1 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

Tip

Once I had connected my Waveshare via RS485 to the battery, the Marstek's status light immediately switched on (at this time, the battery was not yet connected to the grid!). I took this as a good sign that I had got the RS485 pin assignment right.

Home Assistant Configuration#

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

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 }}"

    # Below values are optional and not needed for battery automation
    # I used these to populate the Home Assistant Energy Dashboard

    # 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 }}"

    # 1. Current PV Power (Aktuelle Leistung) in Watt
    # Topic aus deinem Log: solaranzeige/33ktl-a/pv_leistung
    - name: "PV Wechselrichter Leistung"
      unique_id: pv_wechselrichter_leistung
      state_topic: "solaranzeige/33ktl-a/pv_leistung"
      unit_of_measurement: "W"
      device_class: "power"
      state_class: "measurement"

    # 2. PV Production (Gesamt Ertrag) in kWh - Für das Energy Dashboard
    # from Solaranzeige, via MQTT
    # Wert: 145710100 (Wh) -> Divide by 1000
    - name: "PV Wechselrichter Gesamt Ertrag"
      unique_id: pv_wechselrichter_gesamt_kwh
      state_topic: "solaranzeige/33ktl-a/wattstundengesamt"
      unit_of_measurement: "kWh"
      device_class: "energy"
      state_class: "total_increasing"
      value_template: "{{ value | float(0) / 1000 }}"

For MQTT, the critical parameter is Stromnetz Leistung (MQTT) (Power grid output). This becomes the slug sensor.stromnetz_leistung_mqtt that we reference in our automation (see my note below). It provides the current output at the central house meter (grid consumption or PV surplus). I have also added total grid statistics (consumption 1.8.0/feed-in 2.8.0) and PV inverter values (power and yield) to populate the energy dashboard in Home Assistant under /energy/overview.

Info

If you use my YAML configurations, please note this peculiarity3 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.

Tip

You can use the list under Developer Tools / States as an aid. Search for the name and ensure that the referencing matches the .yaml configuration.

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.5

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

Below is my complete configuration.yaml. For instance, this makes sure that logging is tuned down for automation and script actions, which otherwise will flood your /activity overview in Home Assistant.

See my complete configuration.yaml
logbook:
  exclude:
    entities:
      # disable logging of selected automation and script actions
      # in Home Assistant /activity overview
      - automation.marstek_intelligente_regelung_nulleinspeisung
      - automation.marstek_befehl_an_speicher_senden
      - input_number.marstek_discharging_charging_power
      - script.marstek_set_forcible_charge

# 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)6 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): Here we use a switch. When discharging, we aim for at least 50W grid import (buffer). When charging, however, we switch to at least -100W (always some grid export). This negative buffer ensures that when consumers (e.g. lights, TV) are switched on, the load curve does not immediately slip into grid consumption, but first uses up the buffer.
  • 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 dynamic target (50W, -100W). 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 once my battery is hardwired).
  • soc (Battery Protection): Monitors the State of Charge. Stops charging at 100%6 and stops discharging at 20% (to prolong battery life). This overrides the power calculation.

Info

These are the parameters that you will need to adjust according to your specific context. They enable you to make adjustments depending on the size of PV systems or specific consumption patterns. Such fine-tuning is often impossible with manufacturer apps.

automations.yaml: marstek_smart_regulation
- alias: "Marstek: Intelligente Regelung (Nulleinspeisung)"
  id: marstek_smart_regulation
  mode: restart
  trigger:
    - platform: time_pattern
      seconds: "/10"
  condition:
    # Automation must be on
    - condition: state
      entity_id: input_boolean.marstek_automatik
      state: "on"
    # Sensor must be available
    - 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 %}
          {% set lade_start_grenze = 400 %}

          {# --- DYNAMIC TARGET VALUE --- #}
          {# If we are already charging (>0), we want to keep a buffer for EXPORT (-100W) #}
          {# If we are discharging or in standby, we want to keep a buffer for IMPORT (+50W) #}

          {% if aktuell_soll > 0 %}
            {# Mode CHARGING: Target is -100W (export), so we don't accidentally charge from the grid #}
            {% set ziel_netz = -100 %}
          {% else %}
            {# Mode DISCHARGING/STANDBY: Target is 50W (import), so we don't feed into the grid #}
            {% set ziel_netz = 50 %}
          {% endif %}

          {# 3. Calculate correction #}
          {% set korrektur = (netz - ziel_netz) * 0.5 %}
          {% set neu_soll_raw = aktuell_soll - korrektur %}

          {# 4. Logic switch (Start hysteresis) #}
          {% if aktuell_soll <= 0 %}
            {# Start condition: Only start charging if we are significantly below -400W #}
            {% if neu_soll_raw > 0 %}
                {% if netz < (lade_start_grenze * -1) %}
                  {% set neu_soll_final = neu_soll_raw %}
                {% else %}
                  {% set neu_soll_final = 0 %}
                {% endif %}
            {% else %}
                {% set neu_soll_final = neu_soll_raw %}
            {% endif %}
          {% else %}
            {# We are already charging: Let regulation continue normally #}
            {% set neu_soll_final = neu_soll_raw %}
          {% endif %}

          {# 5. Rounding #}
          {# 'int' truncates positive numbers (rounds down) -> Safe against grid usage while charging #}
          {# 'int' truncates negative numbers (rounds towards 0) -> Safe against export while discharging #}
          {# Calculation example: Target would be 440W -> Becomes 400W. #}
          {# The remaining 40W go into the grid in addition to the 100W buffer. #}
          {% set neu_soll_gerundet = (neu_soll_final / 50) | int * 50 %}

          {# 6. Safety limits #}
          {% if neu_soll_gerundet > 0 and soc >= 100 %}
            0
          {% elif neu_soll_gerundet < 0 and soc <= 20 %}
            0
          {% elif neu_soll_gerundet > limit_max %}
            {{ limit_max }}
          {% elif neu_soll_gerundet < (limit_max * -1) %}
            {{ limit_max * -1 }}
          {% 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

Tip

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}

How do I keep track of all these yaml files?

As you can see in the article, I configure my Home Assistant mainly via files and less via the user interface. I don't believe in buttons and mouse clicks, as they are too prone to errors and difficult to track. At the same time, a Home Assistant instance can quickly become complex. When important things like battery control are added, it is suddenly ‘critical infrastructure’ that should be treated accordingly. There isn't enough space here to tell the whole story. But the most important tip is: track all Home Assistant configurations in Git. I repeat: track all Home Assistant configurations in Git.

How well this works also depends on whether you have separated your persistent Home Assistant data well from the ‘ephemeral base images’. Basically, I always follow the same system when setting up different services. Home Assistant is no exception. For Home Assistant, my Git folder looks like this, for example:

Home Assistant file tree
hass@zfs-iotdocker:~$ tree
.
├── .git
├── data
│   └── config
│       ├── automations.yaml
│       ├── blueprints
│       │   ├── automation
│       │   │   └── homeassistant
│       │   │       ├── motion_light.yaml
│       │   │       └── notify_leaving_zone.yaml
│       │   ├── script
│       │   │   └── homeassistant
│       │   │       └── confirmable_notification.yaml
│       │   └── template
│       │       └── homeassistant
│       │           └── inverted_binary_sensor.yaml
│       ├── configuration.yaml
│       ├── core
│       ├── deps
│       ├── home-assistant.log
│       ├── home-assistant.log.1
│       ├── home-assistant.log.fault
│       ├── home-assistant_v2.db
│       ├── home-assistant_v2.db-shm
│       ├── home-assistant_v2.db-wal
│       ├── marstek_modbus_sensors.yaml
│       ├── marstek_modbus_switches.yaml
│       ├── scripts.yaml
│       └── tts
└── docker
    └── docker-compose.yml

12 directories, 16 files

docker-compose.yml
services:
  homeassistant:
    container_name: homeassistant
    image: "ghcr.io/home-assistant/home-assistant:stable"
    volumes:
      - /srv/hass/data/config:/config
      - /etc/localtime:/etc/localtime:ro
    ports:
      - 8123:8123
      - 0.0.0.0:5683:5683/udp
    restart: unless-stopped
    privileged: false
    # network_mode: host
    environment:
      - TZ=Europe/Berlin
    stop_grace_period: 30s

  watchtower:
    image: containrrr/watchtower
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command: --interval 86400

This structure enables all changes to be stored and documented in a traceable manner. Otherwise, you won't remember what you did a month ago. At the same time, it helps you to keep track of things and monitor the automatic changes made by Home Assistant. I have been using this system for many years without any problems, including with automatic updates. I often review configuration changes in GitLab to reflect on them. When editing, I can use the Ctrl+Shift+F key combination (Find All across files) in VS Code to search for a variable in multiple YAML files and ensure that nothing is overlooked.

dashboard_overview

Dashboard#

Ok, back to the battery. 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:

configuration.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:

import "experimental"

from(bucket: "homeassistant")
  |> range(
      start: experimental.subDuration(
        d: v.windowPeriod,
        from: 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: last,
      createEmpty: true
  )
  |> fill(usePrevious: true)
  |> set(key: "_field", value: "Marstek Batterie")
  |> keep(columns: ["_time", "_value", "_field"])

.. and for the battery gauge:

from(bucket: "homeassistant")
|> range(start: -30d, stop: v.timeRangeStop)
|> filter(fn: (r) => r["_measurement"] == "%")
|> filter(fn: (r) => r["_field"] == "value")
|> filter(fn: (r) => r["entity_id"] == "marstek_battery_soc")
|> last()
|> set(key: "_field", value: "Marstek Battery SoC")
|> keep(columns: ["_time", "_value", "_field"])

Grafana Battery Gauge

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.
On another day: Evening/night discharge

Here you can see the progression on a typical day in January, after fully charging the battery at around 3:30 p.m. It is clearly visible that the discharge buffer effectively prevented that battery power was fed into the grid. The battery reached the lower threshold of 20% approximately 3 hours later, at 1:41 a.m., and switched off.

Grafana Evaluation

Info

Since my battery is currently still connected to the Schuko socket (the registered electrician who is supposed to connect it has been putting me off for 6 months!), it is currently charging at a maximum of 800W. Once this limit is lifted, I assume that the storage unit will usually be full during the day.

Populating the native Home Assistant energy dashboard also provides a useful overview of daily statistics.

Home Assistant Energy Dashboard

Home Assistant Energy Statistics

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-22

  • added digression for managing configuration in git

2026-01-21

  • added configuration to exclude automation from logs, to prevent flooding the Home Assistant activity overview
  • slight update to the automation code, to make tweaking of parameters easier and separetly define charge and discharge buffer
  • added screenshot of home assistant energy dashboard
  • improved flux queries in Grafana

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 7

2026-01-16

  • Significantly updated automation, mainly targeted to reduce oscillation

2026-01-15

  • Initial post.