Dual Battery Automation in Home Assistant (Part 2)
Published: 2026-08-15 , Revised: 2026-08-20

TL;DR This is part two of my Mastek Battery Automation project with Home Assistant. I added a second battery, improved parameter handling in the Home Assistant GUI and overhauled the automation & balancing script for (almost) zero grid export. It works better than ever. I wish I could continue working on this project, but there really is not much more to do, because it is perfect! I used a header picture of an open pit coal mine I took in 2008 near my home town. Here is still hope that this centralized and polluting period of humanity will come to an end soon.
Motivation
This was the most fun endeavor I had in my homelab since its start in 2019: The Battery Automation with Home Assistant. How it finally turned out was quite different to my expectations. First, I thought creating the automation logic would be much more simple (but less interesting). I realized there are a lot more edges to it. Individual context, PV solar size, home energy consumption patterns (etc.), all of these parameters affect automation design. But then, addressing these was straightforward and not complicated at all! No external Python scripts, just plain YAML-Jinja script that runs directly in HASS. The tricky part was organizing it systematically, adding all the sensors with a reproducible and documented naming scheme, integrating Grafana-Reporting, or InfluxDB long-term data storage, and reducing verbose activity logs.
I purchased the first battery at the start of 2026 (about 1100€), a Marstek Venus E v2. In April, the v3 was available at 850€. From an economic perspective, a second battery was not strictly necessary. My first battery already brought me 80% through the night and had an astronomical estimation of 278 cycles per year (extrapolated from 6 months of use). But it feels so fulfilling to not buy any electricity from the grid provider! When batteries are so cheap, this was a no-brainer. The payback period for a single Venus E at this price is about 4 years. A second battery would just increase this to 5 years.
The only thing left to do was update my 1-battery-automation script to two batteries. You may ask: Why custom automate batteries at all? Shouldn't we trust corporate algorithms that are much better than any custom, homemade Home Assistant automation? Trust me, this is not the case. And (likely) never will be. Every household is different. If you have a massive 30kWp solar array but only 5kWh of storage, your charging strategy looks different than someone with a 5kWp array and a 20kWh battery. Manufacturers provide a single, generic (often "black box") algorithm that is optimized for average use cases. There will never be a perfectly matching default algorithm for your specific heating, server rack base-load, or roof orientation.
This was the outset for my battery automation in HASS. Part two introduces a dual-battery automation script. I will keep this blog post a little bit more high-level, by describing the underlying arguments for my parameter decisions or algorithm details, rather than the raw-technical implementation. I consider this now as a tailored HEMS that can be easily adapted to individual needs. Whether I add a third Marstek Battery Venus E Max (10 kWh) or replace one of my existing two, after 10 years, with another vendor's AC battery, I expect this script to survive the next decade.
Language Disclaimer
This post contains a mixture of German and English because I am too lazy to translate languages back and forth. I wanted my other family members to read my Home Assistant dashboards in their native language (German). Home Assistant has a peculiarity that if you give entities a name, it will ignore the entity id and generate a slug from the name, which is used from then on as a reference in scripts (etc.). It is not straightforward to clean these naming IDs without introducing subtle bugs, so I left this mixture as is. Feel free to adapt to your needs.
This is what I mean
utility_meter:
marstek_v3_charge_daily: # <-- This key is _ignored_ for addressing!
name: "Marstek V3 Ladung Heute" # <-- HA generates: sensor.marstek_v3_ladung_heute
If you later write an automation, you must use sensor.marstek_v3_ladung_heute.
Marstek Venus E v2.0 vs v3.0#
While physically similar, the internal architecture changed significantly. The primary difference is that no more RS485-to-Ethernet bridge (like the Waveshare I used for v2.0) is needed. The v3.0 has a native LAN port. The v3.0 also uses completely new Modbus registers. For example, SOC is no longer 32104, but 37005. Find the mappings in my git repository links at the end.
32104 and 37005 return the same value
During testing, I discovered that the old 32104 register on the V3 battery actually returns the same SOC value. Marstek mirrored the old registers to the new firmware internally. This means if you have old scripts, they will (partially) continue to function on the new hardware.
Firmware Update
Out of the box, the LAN port and Modbus implementation are reported as buggy. You should update the v3.0 firmware to at least V146+. I never tested this but updated directly to V147. Do not use the LAN port for the update. Disconnect the LAN cable, use the Marstek App via Bluetooth while standing next to the battery, and push the update. Once finished, plug in the LAN cable and block the battery's IP from accessing the internet in your router.
Figure: New Marstek Venus E V3.0 with refitted CEE plug on the left and the three CEE plugs, one for each phase.
I also had an electrician install three CEE plugs, one for each phase, so I could safely and legally raise the max output power from 800W to 2500W. One plug for each phase means that there's optional room for a third battery, with a total of 7500W combined, balanced output power. Forgive me: that meant cutting the original Marstek cable (be aware that this voids warranty!). Don't repeat if you don't know what you are doing - the automation here will work just fine with the 800W output limit. My main motivation was the ability to freely replace & upgrade batteries in the future, without worrying about overloading the Schuko plugs (the common German Type F plugs that are rated to max. 10 Amps or 2,300 Watts for permanent base load). For the common installation, this is really overkill.
UI-managed Parameters#
Instead of hardcoding limits in YAML, like I did in my Part I automation, I moved all tuning parameters to Home Assistant as "Input Helpers" (input_number). This not only allows tweaking the system directly from a dashboard but also makes the active state of the automation immediately transparent.

Figure: The central dashboard with global parameter settings to fine-tune the automation based on context (solar size, battery capacity, household consumption patterns).
How you set these sliders depends on your context. Here is how I set these parameters based on my experience, which I will also discuss with live Grafana charts later to show the actual impact of these parameters:
Parameter Tuning
- Max Charge / Discharge Power: This sets the absolute ceiling per battery. Because I have a massive 30kWp solar array, my summer context is "abundance." I throttle the charge limit down to
800W. The sun shines for 14 hours, so I have plenty of time to fill the batteries slowly, which significantly preserves cell health. In winter, however, my context is "scarcity." I raise this limit to2000Wso the batteries can rapidly harvest brief peaks of sunlight passing between dense clouds. - Primary Base Discharge Limit: This acts as a stepped switch for the dual-battery setup. Up to this value (e.g.,
800W), only the primary battery works. If the house load exceeds 800W, the secondary battery handles the "spillover" peak. This prevents both inverters from running parallel at highly inefficient low loads during the night. Furthermore, if the base load isn't quite enough to catch the evening peaks, the secondary battery briefly jumps in. This guarantees the secondary battery quickly drops from 100% SOC, which is an important wear-leveling mechanism to prolong battery life. - Charge Start Threshold (Hysteresis): The batteries only wake up to charge when the PV surplus at the grid meter exceeds this value. For example, if set to
400W, the first 399W of morning solar power flows directly into the house (handling the fridge, servers, etc.). Once 400W is breached, charging begins. If a cloud passes and the surplus temporarily drops to 100W, the automation does not immediately abort the charge cycle. This hysteresis prevents the system from nervously toggling on and off during scattered cloud cover. - Physical inverters require about 2 to 3 seconds to react to load changes in the house. These target parameters define a permanent offset from the absolute zero-line to absorb that latency.
- Target Grid Export (Charging Buffer): A negative value (e.g.,
-50W) used only when the battery is actively charging. The automation intentionally leaves 50W trickling into the grid. If the fridge compressor suddenly kicks in, this 50W buffer absorbs the 2-second inverter delay. The result? The electricity meter stays in the positive (export) range. This prevents accidental grid import while the battery is charging. - Target Grid Import (Discharging Buffer): Used when the battery is powering the house. In summer, I set this to a negative value (e.g.,
-100W). With plenty of stored energy, I force the system to permanently push 100W out to the grid at night. This makes sure that brief nightly load spikes never draw from the grid. In winter, I invert this strategy and set it to a positive50W. The house intentionally draws 50W from the grid permanently at night, to stretch the precious battery capacity across the long 14-hour winter nights. - Min Efficiency Discharge: Battery inverters are quite inefficient at low loads (sometimes dropping to 55% efficiency below 150W). This parameter sets a hard floor. If my house only needs 80W at 3:00 AM, the battery is forced to output
200W. The house takes its 80W, and 120W is exported to the grid. While it sounds counterintuitive to give energy away, keeping the inverter operating in its >80% efficiency curve actually saves more battery capacity overall. (Note: This is protected by the Sunrise Projection logic, which I will explain later).
- Target Grid Export (Charging Buffer): A negative value (e.g.,
Of these values, most will likely stay the same for extended periods. There is very little need to tweak, once these have been adjusted to individual needs. Of particular importance is the Target Grid Import (Discharging Buffer). To memorize: Target Grid Import (Discharging Buffer) is a permanent offset. It shifts the entire curve. A 500W home load becomes a 600W battery discharge (with a -100W buffer). It always applies. Lastly, Target Grid Export (Charging Buffer) acts similarly for charging, but is mainly relevant in Winter or for small PV arrays. In contrast, the Min. Efficiency Discharge is only a safety net - an efficiency safeguard rule that only activates when consumption is very low at night.
Wear-Leveling Role Switch#
When adding a second battery, the easiest solution would have been to simply divide the required target power by two and send the exact same command to both batteries. If the house needs 500W, both batteries discharge at 250W. If the solar surplus is 1000W, both charge at 500W.
However, this introduces a major efficiency problem. As mentioned above, inverters drop significantly in efficiency at lower output levels. Even though my house has a relatively high base load of 400-500W at night due to my server rack: Splitting that load means running two inverters at 200-250W each. Instead, running a single inverter at 400-500W means running it at a much higher efficiency.
Therefore, I decided to assign roles primary and secondary. The primary battery is responsible for the base load (e.g. up to 800W). Only if the house load spikes above that limit does the secondary battery wake up to supply the "spillover".
While this solves the efficiency problem, it creates a new issue: the Primary battery would cycle constantly, while the Secondary battery would just sit at 100% SOC waiting for brief evening spikes (which degrades LifePo4 cells over time). To ensure both batteries are utilized equally and degrade at the exact same rate, I introduced a daily wear-leveling switch (Verschleiß-Wechsel). Every day at 12:00 PM (noon), Home Assistant toggles an input_boolean helper to swap the primary and secondary roles. I will explain the exact math behind this "spillover" mechanism in the Waterfall chapter below.
Season Profiles#
At the moment, the system works so well that I am not overly interested in utilizing weather forecasts or machine learning algorithms to adjust these parameters dynamically. The basic context of the seasons provides enough data to optimize the system.
Figure: Season dropdown manual toggle. HASS will automatically toggle profiles on season change (Fall, Winter, Spring, Summer), but for testing this can be useful.
I introduced "Season Profiles" via a simple input_select helper in Home Assistant (Spring, Summer, Autumn, Winter). An automation triggers at 03:00 AM on the 1st of March, June, September, and December to switch the profile.
A second automation listens to this profile change and automatically injects my predefined parameters into the input sliders. This guarantees that my high-efficiency summer settings automatically switch to more aggressive winter settings without me having to remember to open the dashboard.
Code: Season Profiles Automation
- alias: "System: Marstek Parameter nach Jahreszeit laden"
id: system_marstek_season_parameters
mode: restart
trigger:
- platform: state
entity_id: input_select.marstek_jahreszeit_preset
action:
- choose:
# SUMMER (Abundance, short nights) -> Max efficiency, gentle charging
- conditions: "{{ trigger.to_state.state == 'Sommer' }}"
sequence:
- action: input_number.set_value
target: { entity_id: input_number.marstek_max_charge_power }
data: { value: 800 }
- action: input_number.set_value
target: { entity_id: input_number.marstek_target_grid_export }
data: { value: -200 }
- action: input_number.set_value
target: { entity_id: input_number.marstek_target_grid_import }
data: { value: -100 }
# WINTER (Scarcity, long nights) -> Harvest everything, stretch discharge
- conditions: "{{ trigger.to_state.state == 'Winter' }}"
sequence:
- action: input_number.set_value
target: { entity_id: input_number.marstek_max_charge_power }
data: { value: 2000 }
- action: input_number.set_value
target: { entity_id: input_number.marstek_charge_start_threshold }
data: { value: 200 }
- action: input_number.set_value
target: { entity_id: input_number.marstek_target_grid_import }
data: { value: 50 }
# TRANSITION (Spring / Autumn) -> The middle ground
- conditions: "{{ trigger.to_state.state in ['Frühling', 'Herbst'] }}"
sequence:
- action: input_number.set_value
target: { entity_id: input_number.marstek_max_charge_power }
data: { value: 1200 }
- action: input_number.set_value
target: { entity_id: input_number.marstek_target_grid_import }
data: { value: 0 }
Here is a comparison of how the system adapts to the seasons:
| Parameter | Summer | Spring / Autumn | Winter | Goal |
|---|---|---|---|---|
| Max Charge Power | 800 W | 1200 W | 2000 W | Gentle charging in summer; aggressive charging of brief sun peaks in winter. |
| Target Grid Import | -100 W | 0 W | +50 W | Explicitly pushing excess at night in summer to prevent grid draw; drawing from grid in winter to stretch capacity. |
| Target Grid Export | -200 W | -150 W | -100 W | High charge buffer in summer prevents accidental grid import during sudden load spikes. |
| Charge Start Threshold | 400 W | 300 W | 200 W | Waking the batteries up earlier in winter to capture every available watt. |
These values will need to be tuned based on individual context (solar size, battery capacity, consumption patterns). This is what I found the sweet spot for my 30kWp solar and 10kWh battery setup.
Multi-Battery Automation#
Managing two batteries required breaking the automation down into logical blocks. The entire script runs every 10 seconds. When starting with battery automation, my initial thought was: "Read the meter every second and react instantly". However, physical hardware does not work that way.
1. Reducing Oscillation#
Setting a smart meter to read every 1 second causes a lot of system oscillation. The Marstek inverter requires 2 to 3 seconds to physically increase or decrease its power output. A 1-second read triggers new correction commands in Home Assistant before the previous command has even settled in the inverter, which created a bad feedback loop. Ideally, I a) wanted my batteries to keep output or charge levels for extended periods as long as possible and b) still be able to cover sudden spikes or lows with target buffers.
To solve this, I set the VZLogger aggregation to 10 seconds (agg = 10). This functions as a hardware-level low-pass filter. It absorbs short spikes and gives the inverter time to reach the target state.
Inside the automation, I kept the proportional controller (P-Controller) with a damping factor of 0.5. It calculates the difference between the actual grid reading and my dynamic target, but only applies 50% of the required correction per cycle. This creates a steady, stepwise staircase pattern rather than erratic jumps.
Shouldn't we chase every single load spike to absolutely prevent any grid import?
I agree, the damping factor and 10 second delay feels counter-intuitive!
Let's calculate a classic latency spike. Suppose a device jumps up and draws 1000W. The inverter takes about 3 seconds to physically ramp up and compensate. Because of our pre-set export buffer of -80W, the house effectively draws 920W from the grid for those 3 seconds.
How much energy is that actually?
920W * 3 seconds = 2760 Watt-seconds (Ws)
Divided by 3600, that is just 0.76 Wh (or 0.00076 kWh).
You would need over 1300 of these uncompensated 3-second spikes to accumulate a single billable kilowatt-hour. At 0.35€ per kWh, the cost is literally negligible. Chasing these micro-spikes with aggressive 1-second interval scripts only causes oscillation and stresses the inverter hardware. I decided to let these brief spikes slip into the grid because it is much cheaper.
During development, I noticed a persistent 50W grid import during discharge, even though my target was set to 0W. The root cause was a Jinja2 Rounding Trap. I round the commands to 50W steps to futher reduce oscillation (and thus, inverter wear): (neu_soll_final / 50) | int * 50. However, the int filter in Jinja truncates values towards zero. A required house load of 440W gets truncated to a 400W battery target. This meant the grid automatically supplies the missing 40W. The solution to this truncation gap is the Target Grid Import Buffer mentioned in the previous chapter. By intentionally setting the target to a negative value (e.g., -50W), we force a small permanent export that safely overrides the rounding gap.
2. Sunrise Projection#
As mentioned, running an inverter at 80W is highly inefficient. My Min. Efficiency Discharge parameter forces the battery to output at least 200W, to maintain >80% efficiency. During these times, the excess is exported to the grid.
But what if the battery is at 30% capacity and the night is still long? Exporting excess power will drain the battery before morning, which may result in expensive grid power draw. To fix this, I implemented a Sunrise Projection.
Every 10 seconds, Home Assistant calculates the remaining usable watt-hours of the batteries and divides it by the hours left until sunrise. This yields a Safe Budget. If the budget drops below the 200W efficiency floor, the automation automatically overrides the floor. It throttles the battery down to match the actual low house load, ensuring the battery "survives" until the sun comes up.
Note
Talking of edge cases! One morning at 6:08 AM, the house load was 141W, but the automation throttled the battery down to a meager 22W. Why? The sun had just risen above the horizon. Home Assistant immediately updated the next_rising timestamp to the following day. The script calculated 23.5 hours until the next sunrise, divided the low remaining 10% battery capacity by 23.5, and yielded a highly restrictive 22W budget. The fix required an explicit daytime check that is now properly implemented and working.
Code: Sunrise Projection
next_sunrise: "{{ as_timestamp(state_attr('sun.sun', 'next_rising')) }}"
now_ts: "{{ as_timestamp(now()) }}"
hours_to_sunrise: "{{ ((next_sunrise - now_ts) / 3600) | float }}"
hours_clean: "{{ 12 if (hours_to_sunrise < 0 or hours_to_sunrise > 24) else hours_to_sunrise }}"
# Calculate usable Wh (ignoring the bottom 20% SOC)
usable_wh1: "{{ [(soc1 - 20) / 80 * 5120, 0] | max }}"
usable_wh2: "{{ [(soc2 - 20) / 80 * 5120, 0] | max }}"
# Bypass budget if the sun is currently shining
safe_budget_w: >
{% if is_state('sun.sun', 'above_horizon') %}
9999
{% else %}
{{ ((usable_wh1 + usable_wh2) / hours_clean) | round(0) }}
{% endif %}
# Target power calculation
# If load < min_efficiency AND budget allows, force min_efficiency.
# If budget is tight, throttle to the budget limit.
target_power_gesamt: >
# ... (abbreviated, see full automations_manual.yaml for details)
3. Waterfall 2.0#
Once the total required power (target_power_gesamt) is calculated, it must be distributed between the two batteries. As discussed, a simple 50/50 split is inefficient. I developed a 3-stage "Waterfall" algorithm to route the power based on the daily Primary/Secondary roles.
- Stage 1 (Base Load): The Primary battery attempts to serve the entire load up to the
base_dischargelimit (e.g., 800W). - Stage 2 (Spillover): If the required load exceeds 800W, the Secondary battery wakes up and handles the remaining peak up to its
max_dischargelimit (e.g., 1500W). This will make sure that the secondary battery leaves its 100% SOC as soon as possible (preventing wear), during large evening spikes after the sun has set. - Stage 3 (Extreme Loads): If the house draws a massive 2800W spike (e.g., water kettle + oven), Stage 1 and Stage 2 max out at 2300W. In this scenario, the script allows the Primary battery to break its 800W base limit and ramp up to its own maximum hardware limit to assist the secondary battery.
Code: Waterfall Distribution
is_bat1_primary: "{{ is_state('input_boolean.marstek_primary_is_bat1', 'on') }}"
# Check who is available (SOC > 20%)
{% set primary_can = b1_can if is_bat1_primary else b2_can %}
{% set secondary_can = b2_can if is_bat1_primary else b1_can %}
# Stage 1: Primary handles base load
{% if primary_can %}
{% set take = [remaining, base_d] | min %}
{% if is_bat1_primary %}{% set t1 = -take %}{% else %}{% set t2 = -take %}{% endif %}
{% set remaining = remaining - take %}
{% endif %}
# Stage 2: Secondary catches the peak (Spillover)
{% if secondary_can and remaining > 0 %}
{% set take = [remaining, max_d] | min %}
{% if is_bat1_primary %}{% set t2 = -take %}{% else %}{% set t1 = -take %}{% endif %}
{% set remaining = remaining - take %}
{% endif %}
# Stage 3: Primary breaks base limit for extreme loads
{% if primary_can and remaining > 0 %}
{% set primary_already_taken = (t1 | abs) if is_bat1_primary else (t2 | abs) %}
{% set primary_allowance_left = max_d - primary_already_taken %}
{% if primary_allowance_left > 0 %}
{% set take = [remaining, primary_allowance_left] | min %}
{% if is_bat1_primary %}{% set t1 = t1 - take %}{% else %}{% set t2 = t2 - take %}{% endif %}
{% endif %}
{% endif %}
4. "Wake-Up Kick" Requirement#
Once the targets are calculated, the automation triggers executor scripts to send the Modbus commands to the batteries.
During early testing, I found that the Marstek Battery Management System (BMS) automatically disables remote control after periods of inactivity or when reaching 12% (min) or 100% SOC. If Home Assistant just sends a new power parameter, the battery ignores it.
To fix this quirk, automation scripts must force the BMS back into "listen mode" prior to adjusting power parameters. The specific integer 21930 must be written to register 42000. You will see this "Wake-Up Kick" sequence explicitly programmed into all my charging and discharging scripts.
Code: Modbus Executor Script (Wake-Up Kick)
marstek_set_forcible_charge_bat1:
# ...
# FALL 2: ENTLADEN (Negative value)
- conditions:
- condition: numeric_state
entity_id: input_number.marstek_target_power_bat1
below: -9
sequence:
# 1. WAKE-UP KICK
- action: modbus.write_register
data: {hub: Marstek, address: 42000, slave: 1, value: 21930}
# 2. Set actual power value
- action: modbus.write_register
data:
hub: Marstek
address: 42021
slave: 1
value: "{{ states('input_number.marstek_target_power_bat1') | int | abs }}"
# 3. Set mode to Discharge (2)
- action: modbus.write_register
data: {hub: Marstek, address: 42010, slave: 1, value: 2}
Dashboard Upgrade#
Home Assistant allows for a lot of customization. And a good dashboard is important for monitoring long-term health and catching anomalies.
I split my Custom Battery Dashboard into three tabs: 🏠 Central (for the parameter sliders we discussed earlier), 1️⃣ Marstek V2, and 2️⃣ Marstek V3.
The device-specific tabs hold all the raw diagnostic data. I use simple gauges and entity lists to quickly check the State of Charge (SOC), live AC/DC power, and internal temperatures. I also included the manual override buttons here, just in case I need to force a charge or discharge cycle.

Figure: Live gauges for the Marstek V2, showing SOC, AC Power, and daily charged capacity.

Figure: Raw Modbus values directly pulled from the battery management system.
Cell Drift#
One interesting metric on the V3 tab is the Cell Voltage Drift. Over time, lithium cells can drift apart in voltage, which degrades overall capacity. The V3 Modbus exposes the max and min cell voltages. This allowed me to calculate and visualize the delta (max - min). Keeping an eye on this metric is the best way to estimate long-term wear and battery health.

Figure: Tracking cell voltage drift on the V3 battery. Spikes naturally occur during rapid charge/discharge phases, but the baseline remains healthy and balanced.
To understand why this metric is important, we need to look at what happens inside the battery when it reaches 100% SOC.
Figure: A screenshot of the last afternoon charging and the relaxation period at 100% for my V3 battery.
- Charging Complete (the spike): LiFePO4 cells have a very flat voltage curve up to about 95% SOC. Then the voltage shoots up vertically. No two cells are exactly identical. One cell always hits the hardware ceiling (e.g., 3.60V) a few minutes early. The BMS cuts the charging current to protect this cell. This creates the sudden delta spike.
- Voltage Settling (the slope): This slow downward curve over several hours is not the BMS actively balancing.1 The charging current stops and the battery sits at 100%. In this period, the "surface charge" of the fullest cell slowly dissipates. The voltage settles naturally back toward the resting plateau (~3.35V).
- Evening Discharge (the sharp drop): The line suddenly drops to near zero at the end. This marks the moment the battery starts discharging to power the house. Drawing power instantly strips the remaining surface charge from all cells. They drop back into the flat voltage plateau. All cells look identical there, the voltage difference is (almost) zero.
So when does the actual balancing happen? Community developers (like the creator of Omnibattery1) discovered an interesting hardware limit. The Marstek BMS only performs passive balancing while a charging current is actively flowing and cell voltage is > 3.45V. Once the battery reaches 100% and goes into standby (0W), balancing stops.
Cell Drift issues?
After publishing this post, feedback from the community highlighted that cell voltage drift seems quite dependent on the Marstek battery firmware. Some versions (like V148) appear to have problems with cell drift. I have seen users reporting cell spreads stuck at 60-80mV and a loss of up to 1 kWh in usable capacity due to premature charging cutoffs. Older versions (V144) or the V147 that I am using for my v3.0 appear to handle this much better.
Monitoring this delta is your best defense against bad firmware updates and cell degradation. However, it is important to read the graph correctly by distinguishing between dynamic and static drift.
Cell Drift (Delta U) Evaluation
Ignore voltage spikes during active charging or discharging. When pushing high currents, or exactly when reaching the steep 12% or 100% marks of the LiFePO4 voltage curve, brief peaks of 200mV to 300mV (0.2V - 0.3V) are completely normal due to the internal resistance of the cells.
You must only evaluate the drift at rest (Standby) at 100% SOC. Once the internal BMS has had time to equalize the cells (top-balancing), the following values apply:
- < 30mV (0.03V): Excellent. The battery packs are perfectly balanced.
- 30mV to 60mV: Normal operation.
- 60mV to 100mV: Suboptimal. This seems to be the range caused by buggy firmware interrupting the top-balancing phase too early.
- > 100mV (0.1V) at rest: Action required. The cells are drifting significantly.
How to fix high drift: You cannot balance the cells by simply leaving the battery at 100% SOC. The Marstek BMS only balances while charging. You must stretch the final charging phase. When your battery reaches 95% SOC, use your Home Assistant dashboard to manually drop the Max Charge Power to 100W or 200W. This forces the battery to spend hours in the critical voltage window (> 3.45V) while still receiving charge current. This gives the tiny passive balancing resistors enough time to bleed off the highest cells. We could easily automate this: Just create a weekly routine that automatically throttles the charge power to 150W for the final 5% of the charge cycle.
There's also a lot of information in this (German) forum thread2:
- Internal balancing resistors do not engage at ultra-low power. The battery requires at least 90W to 95W of AC charging power (about 1A DC) to activate the bypass circuits.
- Passive balancing only operates in a narrow upper window between 3.50V and 3.62V per cell. Below 3.45V, the LiFePO4 curve is too flat for the BMS to distinguish cell voltages. Above 3.64V, the BMS halts charging entirely.
- Internal bleed resistors only dissipate around 50mA of current. Equalizing a big 200mV imbalance requires between 200 and 400 cumulative hours inside this active charging window.
- When cells are well-balanced, the total pack voltage hits its 57.3V safety limit before any single cell reaches the 3.64V cutoff. The firmware cannot handle this state cleanly and rapidly oscillates between charging and discharging.
- To maximize battery lifespan and avoid this oscillation bug, you can use Home Assistant to cap daily charging once the highest cell reaches 3.46V to 3.48V. This captures roughly 98% of usable capacity while keeping the cells out of the degradation zone.
More Dashboard Widgets (Stats & Overrides)

Manual overrides for forcing the BMS into specific states (here on the V2 Dasboard, with simple temperature monitoring and fault codes).

Riemann integral calculations providing high-accuracy statistics over the battery's lifetime (again, V2 dashboard).
Energy Dashboard#
While the custom dashboards handle the raw hardware, the native Home Assistant Energy Dashboard provides a good holistic view of the house. Massive kudos to the HA developers! This feature is really well-designed. I am actually looking forward to integrating gas and water consumption next.
Here is what a typical, somewhat boring summer week looks like:

Figure: A complete summer week. Zero grid consumption, massive solar export.
The most critical thing to look for in the animation below is the connection between battery and grid. in summer and for my context (solar size, battery capacity), using the automation and parameters I share here, you will never see dots moving along this line in neither direction. This is the ultimate goal of the automation. Any discharge from the battery into the grid means throwing away a 20% conversion loss for pennies (remuneration via German Renewable Energy Sources Act - EEG). Any import from the grid into the battery is even worse. You pay the full ~0.35€ electricity price plus the 20% conversion loss.

Figure: The live energy flow animation. Notice the leaf symbol indicating grid carbon intensity. This data is provided by the ElectricityMaps API and is mainly relevant for winter, when there is actual grid consumption.

Figure: Financial summary of the week based on my specific grid pricing and EEG feed-in tariffs.
The main reason I need 10kWh of storage in the first place is the base load of my house. As a homelabber, my server rack draws a constant, substantial amount of power.

Figure: The server rack alone consumes nearly 5 kWh each day (~200W power draw).

Figure: Weekly breakdown of the server rack consumption.
100% Autarky?#
If you look at my self-sufficiency score for the week, it sits at 99%.

Figure: 99% self-sufficiency in a summer week.
There is a huge temptation in the solar community to chase that final 1% to reach absolute 100% autarky. But bridging that final 1% (which is usually just a brief spike on a cloudy day) would require a massive overprovisioning of both battery capacity and solar panels.
This overcapacity would sit idle (e.g.) 95% of the year and waste critical resources (lithium, copper, silicon). I am absolutely against the idea of total "Abkopplung" (disconnecting) or building an "Inselnetz" (island grid). The energy transition to renewables (Energiewende) is fundamentally about sharing resources. It requires the grid! It relies on neighbors absorbing your excess, and the grid buffering your deficits. The fact that current regulations in Germany lag behind this reality, and create an imbalance between citizens and network providers, is very frustrating. But, disconnecting entirely is not the sustainable answer. The 99% score is just a hit to the ego, but a big win for efficiency.
Additional Energy Dashboard Views

Solar production over the last week.

Energy flows.
Grafana Charts#
The only way to prove the control logic works is to look at the actual high-resolution data in (e.g.) Grafana.
How to read the charts in this section:
- Red Area (
Marstek V2): Battery (Discharging/Charging) - Purple Area (
Marstek V3): Battery (Discharging/Charging) - Blue Line (
PV Leistung): Solar Inverter Production - Yellow Line (
MQTT Einspeisezähler): The Grid Meter. This is the critical line. As long as it is above the zero line, we are exporting excess to the grid (good). If it dips below the zero line, we are importing from the grid (bad).
1. Charging Phase#
Here is a typical charging cycle. The primary battery (Red) is charged first because it handled the base load the night before and is emptiest. Because the solar surplus (Blue) is high enough, the script allows the secondary battery (Purple) to charge simultaneously (Spillover).
Figure: A high-solar charging phase. The batteries dynamically ramp their charge rates to absorb the solar curve.
Look closely at the Yellow line. Despite fluctuations in solar generation and household load, the yellow line stays easily above the zero. There is only one brief spike visible where the yellow line crosses into the negative (grid import). This near-perfect isolation is entirely thanks to our -200W Target Grid Export buffer catching the inverter latency (read: max possible export minus 200W).
2. Night Discharge, Waterfall#
This chart shows a full night of discharging. You can clearly see the Waterfall 2.0 algorithm at work.
Figure: Nighttime discharge showing the primary (Red) and secondary (Purple) batteries sharing the load.
The primary battery (Red) handles the steady base load. Whenever the house draws a larger spike (e.g., an appliance turning on), the secondary battery (Purple) wakes up, supplies the peak, and goes back to sleep. There were two of these minor spillover periods at around 19:30 and at 23:30, when the secondary battery had to jump in. This ensures the secondary battery doesn't sit unused at 100% SOC all night. At 2:00 AM the primary battery reached 20% charge and turned off, while the secondary battery took over. am By morning, the secondary battery had dropped to a remaining charge of 37%. The rising sun relieves it!
3. Morning Transition#
This is perhaps my favorite chart. It shows the system perfectly handling the transition period at 2:00 AM, followed by a super smooth handoff to solar power at sunrise.
Figure: Role handoff at 2:00 AM and the sunrise transition.
At exactly 02:00, the primary battery (Purple) reached its 20% cut-off limit. Instantly, the secondary battery (Red) took over the base load for the rest of the night.
Then, around 05:30, the sun begins to rise (Blue line creeping up). As solar generation increases, the automation throttles the battery discharge gradually down. The yellow line (Grid) stays perfectly flat at zero. Only a few tiny negative dips appear. These are those 3-second latency gaps we discussed earlier. From a cost perspective, these micro-spikes are meaningless. The P-Controller and 10-second VZLogger filter successfully kept the system stable, with very little oscillation, until the solar panels take over the house load at 06:15.
Configuration Files#
To keep this post readable, I’ve added my configuration files to this blog's git repository. Adapt them to your needs.
- configuration.yaml (Helpers & Templates)
- marstek_modbus_sensors.yaml (v2 sensors)
- marstek_v3_modbus_sensors.yaml (v3 sensors)
- automations_manual.yaml (Brain, Watchdog, Startup/Shutdown safety)
- scripts.yaml (The Modbus Executors)
- lovelace.battery_marstek (raw dashboard code)
Conclusion#
Why AC-Coupled Batteries Win#
For the last decade, DC-coupled storage systems (where the battery connects directly to a hybrid solar inverter on the DC side) were the norm. Installers insisted on it because avoiding double-conversion losses (PV DC ➔ Inverter AC ➔ Battery Charger DC ➔ Inverter AC) was paramount. Especially when solar panels cost 2.00 € per Watt, losing 10% to conversion inefficiencies was a big deal.
The economics have flipped. Solar panels and LiFePO4 cells are super cheap. Conversely, certified electrician labor is very expensive, scarce, and booked out for months. Squeezing out the last 5% of round-trip efficiency by paying thousands of Euros for a certified hybrid inverter installation simply does not make sense anymore. Furthermore, DC batteries must be installed right next to the hybrid inverter (this often requires complex DC high-voltage cabling). AC batteries only require an AC phase line. You can place one in the garage, one in the basement utility room, and one in a workshop. It is completely independent of where your solar array enters the house. While formal grid registration in Germany remains bureaucratic, the sheer ease of plugging AC storage into any existing phase circuit means decentralized storage will spread regardless. Regulation will inevitably have to adapt at some point, even in Germany. Lastly, the biggest flaw of proprietary DC systems is vendor lock-in. If your hybrid inverter fails after 8 years, you are often forced to replace your battery too, because the next generation of inverters speaks an incompatible protocol or household consumption doesn't match battery size anymore.
+-------------------------------------------------------------------+
| HOME ASSISTANT (HEMS) |
| (P-Controller, Waterfall Routing, Local Modbus/MQTT Control) |
+------------------+-----------------------+------------------------+
| | |
[ AC Phase 1 / RS485 ] [ AC Phase 2 / LAN ] [ AC Phase 3 / Modbus/OCPP ]
| | |
+------+------+ +------+------+ .- - - - - - - - -.
| Marstek V2 | | Marstek V3 | | Future EV / V2H |
| (5.12 kWh) | | (5.12 kWh) | | (100+ kWh) |
+-------------+ +-------------+ '- - - - - - - - -'
AC-coupled batteries solve this upgrade dilemma. They turn household energy infrastructure into three independent, modular layers: Generation (Solar/Wind), Storage (AC Batteries), and Logic (Home Assistant). You are not tied to a single manufacturer. Run a Marstek V2 alongside a V3 today, and add a 10 kWh battery from a different vendor next year: No problem! Your solar array size is decoupled from your battery capacity. You can expand a 10 kWp array to 30 kWp without changing your batteries. Or, triple your battery capacity without replacing your solar inverter. Most importantly, you don't rely on a manufacturer’s generic cloud algorithm that might be discontinued when the vendor changes or goes bankrupt.
2040s Outlook#
As solar systems installed in the 2010s and 2020s age, I expect to see a shift toward component-level replacement rather than full-system tear-downs. AC-coupled storage, automated by an open, local Home Energy Management System like this one here, is the only architecture that provides the long-term modularity needed for this future.
Writing thousands of lines of YAML to regulate a pair of relatively small 5kWh batteries might seem like a nerdy hobby. To me, it means building the knowledge to understand and automate energy systems locally for the future grid.
This automation script was explicitly designed with a hardware-agnostic architecture. The "Brain" calculates the required power based on abstract math (P-Controller, Sunrise Projection, Waterfall Distribution). It only hands the final target number to the Modbus scripts at the very end. This modularity means the system is future-proof for gradual component replacement. If a Marstek battery dies in 2035, I can replace it with a completely different AC-coupled battery from another vendor. I would just rewrite the Modbus executor script. The core Waterfall logic and balancing math remain untouched. Adding a third, fourth, or fifth battery simply means adding a "Stage 4" or "Stage 5" to the Waterfall script.
Let us extrapolate this to the 2040s. Imagine my 1925 house with a massive 90kWp solar roof (after old feed-in tariffs expire), a 100kWh+ bidirectional EV battery (V2H), and a high-draw heat pump. By 2044, daytime solar energy will be almost worthless. When the sun shines, prices at the energy exchange will regularly hit zero or turn negative.
In that future, "dumb" grid exporting will not work. The ultimate value of the grid shifts from Energy to Flexibility.
Because this Home Assistant logic already uses dynamic parameters for Target Grid Import/Export, the next obvious step is dynamic energy trading. Instead of loading "Winter" or "Summer" presets, those sliders will eventually be driven by real-time spot prices (e.g., EPEX Spot or Tibber). The system will automatically decide to charge the batteries at 1:00 PM when prices are at -5 cents, and intentionally export these 100kWh back into the grid at 8:00 PM when prices spike to +40 cents.
This is the Energy Transition (Energiewende). It is not about disconnecting from the grid to build isolated island networks, but about transforming the grid into a shared, highly flexible, peer-to-peer network. Building a custom Home Energy Management System (HEMS) helps us learn how to shift loads, throttle discharge curves, and buffer hardware latency. It means building the operating system for your future personal utility company. Having the privilege to do this in 2026 was absolutely fulfilling. I hope this post series helps distribute the knowledge to the wider community.
Final note: Since I shared my custom sensors to measure accidental grid imports and exports caused by hardware latency and there's no evaluation: I only have two weeks of data right now. I will come back and publish an update in one year with these long-term statistics.
Tip
Feel free to download the raw markdown format of this blog post here to drop it into your favourite LLM.
Changelog
2026-08-20
- fix: watchdog should not trigger if charge>95%soc not possible or discharge<20% because BMS may actively prevent charging/discharging
- fix: reference id for latency sensor
- Add latency loss monitoring to central dashboard
2026-08-16
- added cell drift evaluation and monitoring tips
2026-08-15
- Initial post.
-
Omnibattery Cell balancing docs. ↩↩
-
Mythen rund ums Top-Balancing der VenusE photovoltaikforum.com ↩