This is a quick writeup of my Home Assistant setup for controlling a Marstek Venus A plug-in battery using my P1 smart meter readings, without needing the Marstek app or any cloud control for day-to-day use.

Disclaimer: some AI was used in the process of making the yaml automation, and formatting this blog post.

EDIT: FIRST UPDATE THE FIRMWARE

Unfortunately, the Marstek Venus A batteries have a flaw in the older firmwares. If you query them using the local API constantly, an internal buffer overflows, which can actually brick the whole thing as it can no longer initialize and gets in a boot loop, or shuts down right after being turned on.

This means you HAVE to register the battery using the official app and create an account, unfortunately. On the plus side, this means you can use their support more easily, as they’ll see the serial being activated and tied to your account.

The goal was basically:

  • stay close to 0W grid import/export
  • avoid draining the battery below 5%
  • charge the battery to about 60% during cheap tariff
  • behave a little more conservatively on cloudy days, when solar production jumps around a lot
  • keep the entire thing local

The nice bit: I set up the Marstek Venus A using an online BLE test tool, so I never had to install or log in to the official app. Once the battery was on my Wi-Fi and visible to Home Assistant through the local integration, the whole control loop runs locally.

This is not a perfect industrial-grade energy management system, but it has worked well enough for my setup, and hopefully this helps someone else who wants a “plugin battery + Home Assistant + no cloud nonsense” setup.

Hardware / software used

My setup:

The important live P1 sensors in my case are:

sensor.slimmelezer_power_consumed
sensor.slimmelezer_power_produced

These are live power sensors in kW, so the automations below multiply them by 1000 to get watts.

The relevant Marstek SOC sensor in my setup is:

sensor.marstek_venus_a_state_of_charge

One important Marstek detail: the local API service uses signed watts:

negative power = charge battery
positive power = discharge battery

So:

power: -500

means “charge at about 500W”, while:

power: 500

means “discharge at about 500W”.

The problem with naive zero-export

The first simple idea is something like:

if exporting 400W, charge battery at 400W
if importing 400W, discharge battery at 400W

But that tends to wobble all over the place.

The trick that made it stable for me was remembering the last command sent to the battery.

So the control logic becomes:

new battery command = previous battery command + grid import - grid export

For example, if the battery is already charging at 400W and I am still exporting 100W, then the next command should be:

-400 - 100 = -500W

So instead of jumping around based only on the current grid reading, the automation nudges the previous command up or down.

Helpers

I created three Home Assistant helpers.

You can create these through:

Settings → Devices & services → Helpers

1. Last battery command

Type: Number

input_number.marstek_last_power_command

Settings:

Name: Marstek last power command
Minimum: -1200
Maximum: 1200
Step: 1
Unit: W
Mode: Input field

This stores the last wattage command sent to the Marstek battery.

2. Last grid error

Type: Number

input_number.marstek_last_grid_error

Settings:

Name: Marstek last grid error
Minimum: -5000
Maximum: 5000
Step: 1
Unit: W
Mode: Input field

This lets the automation detect whether the grid reading suddenly changed a lot between cycles. If it did, I treat that as “cloudy / volatile mode” and make the battery respond more gently.

3. Cheap tariff active

Type: Toggle

input_boolean.marstek_cheap_tariff_active

This stores whether tariff 1 or tariff 2 is active.

In my case, tariff 1 appears to be the cheap tariff.

Detecting the active tariff

My SlimmeLezer did not expose a nice clean “current tariff” entity.

What it did expose were tariff energy counters, like:

sensor.slimmelezer_energy_consumed_tariff_1
sensor.slimmelezer_energy_consumed_tariff_2

These are cumulative kWh counters. They do not directly say “tariff 1 is active right now”, but they do increase when that tariff is active.

So this automation watches which tariff counter increased most recently. If tariff 1 increases, it turns on my cheap-tariff helper. If tariff 2 increases, it turns it off.

If your cheap tariff is tariff 2 instead, swap the turn_on and turn_off actions.

alias: Detect Active P1 Tariff
description: Infer current tariff from SlimmeLezer tariff energy counters
mode: queued
max: 10

triggers:
  - trigger: state
    entity_id:
      - sensor.slimmelezer_energy_consumed_tariff_1
      - sensor.slimmelezer_energy_consumed_tariff_2
      - sensor.slimmelezer_energy_produced_tariff_1
      - sensor.slimmelezer_energy_produced_tariff_2

conditions:
  - condition: template
    value_template: >
      {{
        trigger.from_state is not none
        and trigger.to_state is not none
        and trigger.from_state.state not in ['unknown', 'unavailable', 'none']
        and trigger.to_state.state not in ['unknown', 'unavailable', 'none']
        and trigger.to_state.state | float(0) > trigger.from_state.state | float(0)
      }}

actions:
  - choose:
      - conditions:
          - condition: template
            value_template: >
              {{
                trigger.entity_id in [
                  'sensor.slimmelezer_energy_consumed_tariff_1',
                  'sensor.slimmelezer_energy_produced_tariff_1'
                ]
              }}
        sequence:
          - action: input_boolean.turn_on
            target:
              entity_id: input_boolean.marstek_cheap_tariff_active

      - conditions:
          - condition: template
            value_template: >
              {{
                trigger.entity_id in [
                  'sensor.slimmelezer_energy_consumed_tariff_2',
                  'sensor.slimmelezer_energy_produced_tariff_2'
                ]
              }}
        sequence:
          - action: input_boolean.turn_off
            target:
              entity_id: input_boolean.marstek_cheap_tariff_active

Small caveat: this only updates when one of the tariff counters increases. That means if absolutely nothing is being imported or exported at the exact tariff switch moment, the helper might not flip until the first bit of energy moves. For my use case, that is fine.

Main Marstek automation

This is the main control loop.

What it does:

  • runs every 15 seconds
  • reads live P1 import/export
  • calculates current grid error
  • nudges the previous battery command up/down
  • limits the size of each correction
  • avoids discharging below 5%
  • if cheap tariff is active and the battery is below 60%, it charges from the grid
  • if the grid readings are changing wildly, it switches to a more conservative cloudy-day mode

Replace the device_id with your own Marstek device ID.

alias: Marstek Dynamic Zero-Export
description: Zero-export Marstek control with cheap-tariff charging and cloudy-day damping
mode: single
max_exceeded: silent

triggers:
  - trigger: time_pattern
    seconds: "/15"

conditions:
  - condition: template
    value_template: |
      {{
        states('sensor.slimmelezer_power_consumed') not in ['unknown', 'unavailable', 'none']
        and states('sensor.slimmelezer_power_produced') not in ['unknown', 'unavailable', 'none']
        and states('sensor.marstek_venus_a_state_of_charge') not in ['unknown', 'unavailable', 'none']
        and states('input_number.marstek_last_power_command') not in ['unknown', 'unavailable', 'none']
        and states('input_number.marstek_last_grid_error') not in ['unknown', 'unavailable', 'none']
        and states('input_boolean.marstek_cheap_tariff_active') not in ['unknown', 'unavailable', 'none']
      }}

actions:
  - variables:
      grid_error_w: >-
        {{
          (
            states('sensor.slimmelezer_power_consumed') | float(0) * 1000
          )
          -
          (
            states('sensor.slimmelezer_power_produced') | float(0) * 1000
          )
        }}

  - variables:
      target_power: >-
        {# -----------------------------
           Main battery limits

           Marstek passive mode convention:
           positive = discharge
           negative = charge
        ------------------------------ #}
        {% set reserve_soc = 5 %}
        {% set max_discharge_w = 1200 %}
        {% set max_charge_w = -1200 %}

        {# -----------------------------
           Cheap tariff settings
        ------------------------------ #}
        {% set cheap_tariff_active = is_state('input_boolean.marstek_cheap_tariff_active', 'on') %}
        {% set cheap_target_soc = 60 %}
        {% set cheap_charge_w = 700 %}

        {# -----------------------------
           Normal zero-export tuning
        ------------------------------ #}
        {% set normal_deadband_w = 60 %}
        {% set normal_response_factor = 0.65 %}
        {% set normal_max_step_w = 250 %}

        {# -----------------------------
           Cloudy / volatile tuning

           If the grid error jumps by more than
           cloudy_swing_threshold_w between cycles,
           the controller becomes more conservative.
        ------------------------------ #}
        {% set cloudy_deadband_w = 130 %}
        {% set cloudy_response_factor = 0.35 %}
        {% set cloudy_max_step_w = 120 %}
        {% set cloudy_swing_threshold_w = 350 %}

        {% set soc = states('sensor.marstek_venus_a_state_of_charge') | float(0) %}
        {% set last_command_w = states('input_number.marstek_last_power_command') | float(0) %}
        {% set last_grid_error_w = states('input_number.marstek_last_grid_error') | float(0) %}

        {% set current_grid_error_w = grid_error_w | float(0) %}
        {% set swing_w = (current_grid_error_w - last_grid_error_w) | abs %}
        {% set cloudy_mode = swing_w > cloudy_swing_threshold_w %}

        {% if cloudy_mode %}
          {% set deadband_w = cloudy_deadband_w %}
          {% set response_factor = cloudy_response_factor %}
          {% set max_step_w = cloudy_max_step_w %}
        {% else %}
          {% set deadband_w = normal_deadband_w %}
          {% set response_factor = normal_response_factor %}
          {% set max_step_w = normal_max_step_w %}
        {% endif %}

        {# -----------------------------
           Normal zero-export correction

           grid_error_w > 0 = importing, discharge more
           grid_error_w < 0 = exporting, charge more
        ------------------------------ #}
        {% if current_grid_error_w | abs < deadband_w %}
          {% set correction_w = 0 %}
        {% else %}
          {% set correction_w = current_grid_error_w * response_factor %}
        {% endif %}

        {# Limit how much the target can change per cycle #}
        {% if correction_w > max_step_w %}
          {% set correction_w = max_step_w %}
        {% elif correction_w < -max_step_w %}
          {% set correction_w = -max_step_w %}
        {% endif %}

        {% set zero_export_target = last_command_w + correction_w %}

        {# -----------------------------
           Cheap tariff override

           If cheap tariff is active and SOC is below 60%,
           force at least cheap_charge_w of charging.

           Since charging is negative, this uses min().
        ------------------------------ #}
        {% if cheap_tariff_active and soc < cheap_target_soc %}
          {% set raw_target = [zero_export_target, 0 - cheap_charge_w] | min %}
        {% else %}
          {% set raw_target = zero_export_target %}
        {% endif %}

        {# Clamp to battery limits #}
        {% set limited_high = [raw_target, max_discharge_w] | min %}
        {% set clamped = [limited_high, max_charge_w] | max %}

        {# Do not discharge below reserve SOC #}
        {% if soc <= reserve_soc and clamped > 0 %}
          0

        {# During cheap tariff, if below target SOC,
           avoid discharging again before it reaches the base charge.
        #}
        {% elif cheap_tariff_active and soc < cheap_target_soc and clamped > 0 %}
          0

        {% else %}
          {{ clamped | round(0) | int }}
        {% endif %}

  - action: marstek_local_api.set_passive_mode
    data:
      device_id: YOUR_MARSTEK_DEVICE_ID_HERE
      power: "{{ target_power | int(0) }}"
      duration: 25

  - action: input_number.set_value
    target:
      entity_id: input_number.marstek_last_power_command
    data:
      value: "{{ target_power | int(0) }}"

  - action: input_number.set_value
    target:
      entity_id: input_number.marstek_last_grid_error
    data:
      value: "{{ grid_error_w | int(0) }}"

Tuning values

These are the values I would tweak first.

Cheap tariff charge level

{% set cheap_target_soc = 60 %}

This is the target base charge during cheap tariff.

I picked 60% because it gives the battery enough energy to work with later without always forcing it to full.

Cheap tariff charge power

{% set cheap_charge_w = 700 %}

This controls how strongly the battery charges during cheap tariff.

For gentler charging:

{% set cheap_charge_w = 500 %}

For faster charging:

{% set cheap_charge_w = 900 %}

Normal response

{% set normal_deadband_w = 60 %}
{% set normal_response_factor = 0.65 %}
{% set normal_max_step_w = 250 %}

These values worked decently for me at a 15-second loop.

If your battery overshoots a lot:

{% set normal_response_factor = 0.45 %}
{% set normal_max_step_w = 150 %}

If it is too slow:

{% set normal_response_factor = 0.8 %}
{% set normal_max_step_w = 350 %}

Cloudy mode

{% set cloudy_deadband_w = 130 %}
{% set cloudy_response_factor = 0.35 %}
{% set cloudy_max_step_w = 120 %}
{% set cloudy_swing_threshold_w = 350 %}

This is what keeps it from going berserk when clouds are moving over the panels and the P1 readings swing around quickly.

Lowering cloudy_swing_threshold_w makes it enter conservative mode more often.

Raising it makes the automation behave aggressively unless the swing is really large.

Debug template

This is useful in Developer Tools → Template:

{% set grid_import_w = states('sensor.slimmelezer_power_consumed') | float(0) * 1000 %}
{% set grid_export_w = states('sensor.slimmelezer_power_produced') | float(0) * 1000 %}
{% set grid_error_w = grid_import_w - grid_export_w %}
{% set soc = states('sensor.marstek_venus_a_state_of_charge') | float(0) %}
{% set last_command_w = states('input_number.marstek_last_power_command') | float(0) %}
{% set last_grid_error_w = states('input_number.marstek_last_grid_error') | float(0) %}
{% set swing_w = (grid_error_w - last_grid_error_w) | abs %}

Grid import W: {{ grid_import_w }}
Grid export W: {{ grid_export_w }}
Grid error W: {{ grid_error_w }}
SOC: {{ soc }}
Cheap tariff active: {{ is_state('input_boolean.marstek_cheap_tariff_active', 'on') }}
Last battery command W: {{ last_command_w }}
Last grid error W: {{ last_grid_error_w }}
Swing W: {{ swing_w }}
Cloudy mode would be active: {{ swing_w > 350 }}

Notes and caveats

This is a feedback loop, not magic. It will never be perfectly 0W all the time, especially if solar production or household load changes suddenly.

Also, I would be careful with very fast update intervals. I ended up using 15 seconds because 60 seconds felt too slow on cloudy days. I would not personally run this every 1-5 seconds unless you are happy to experiment and potentially deal with flaky device behaviour.

The Marstek integration and firmware ecosystem is still a bit rough around the edges. My battery was on firmware v143 when I set this up. Since it worked locally, I did not immediately update firmware just for the sake of updating.

Why I like this setup

The big win here is that this is local.

No energy provider API.

No cloud battery scheduler.

No app-based automation.

No “please trust this random vendor server to control the thing connected to your house power”.

Just:

P1 meter → Home Assistant → local Marstek command

That is exactly the kind of boring, understandable home automation I like.

Updated: