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

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

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.

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:
- 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) 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 of50W. 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 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 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 to800Wfor Schuko connection, I will ramp this up to2500Wonce my battery is hardwired).soc(Battery Protection): Monitors the State of Charge. Stops charging at100%and stops discharging at20%(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:
- 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
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.

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