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

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

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

I wanted to avoid the manufacturer's cloud and app entirely.
- Privacy/Security: I don't want any IoT devices from Chinese vendors active on my main Ethernet network (no offence, Marstek!).
- Control: I want granular control over charging/discharging logic based on my specific grid meter readings, not a black-box algorithm.
- 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.

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
Cat7patch 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)

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)

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.

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:
- I have an oversized PV system (
30kWp) paired with a relatively small battery (5.12kWh). My household consumption is medium-high (~6000kWh/a). - 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 (400Wsurplus). 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-100Wgrid 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 least50Wgrid 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 exceeds400W. 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 to800Wfor Schuko connection, I will ramp this up once my battery is hardwired).soc(Battery Protection): Monitors the State of Charge. Stops charging at100%6 and stops discharging at20%(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:
- Graceful Shutdown: Using the
homeassistant.shutdownevent trigger, HA sends a hard0(Stop) command to the battery immediately before the system stops. - Clean Startup: On boot (
homeassistant.start), a logic lock is applied. The automation sets the target power to0and disables the "Autopilot" boolean. It waits for20 secondsto 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#
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.

Then there is a Hardware Limits and Diagnosis part:

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!).

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

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"])

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 MQTTCyan: total PV inverter production, from my Huawei inverter, pulled via Modbus TCP

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

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.

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

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.


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.

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.