Marstek Venus E + EMHASS on Home Assistant — a complete setup with self-healing #214 recovery and PV curtail on negative prices

Marstek Venus E + EMHASS on Home Assistant — a complete setup with self-healing #214 recovery and PV curtail on negative prices

Why I'm sharing this

This setup took several weeks of evenings and weekends to settle. A lot of the building blocks come from work others did before me — the ViperRNMC Modbus integration and the long thread around issue #214, the EMHASS project by David and the patient examples on the EMHASS forum, the Tibber and Nord Pool integrations, the Enphase Envoy Installer HACS plugin, and countless forum posts where people shared what did and didn't work for them. Without that groundwork I'd never have gotten here.

What I ran into is that the pieces are individually well documented, but stitching them together — under a Dutch dynamic tariff with the impending end of net metering, on first-generation Venus E firmware with a quirk that silently kills your control path, alongside Enphase PV — required a lot of trial and error that wasn't written down anywhere yet. The race conditions, the #214 recovery flow, the break-even maths for curtailing during the net-metering sunset, the soc_final = soc_init trick, the strategy mutex: each of those cost me real time to figure out.

So this post is my attempt to give some of that back. If even one section saves someone else a weekend of debugging, the writeup has paid for itself. Corrections and better ideas welcome in the replies — that's how this same body of knowledge got built in the first place.

TL;DR

A working setup where a Marstek Venus E 5.12 kWh (via ViperRNMC Modbus TCP) is driven by EMHASS under a dynamic tariff, with a fail-safe fallback to the inverter's native anti-feed mode. Highlights:

  • Self-healing recovery for the ViperRNMC #214 quirk (RS485 control drops out after a user_work_mode write) — no manual intervention
  • Shadow mode to validate EMHASS pre-launch at zero cost
  • Strategy mutex between rule-based dispatch and the EMHASS optimizer (prevents a race where both automations overwrite each other)
  • PV curtail on negative prices, with a break-even calculation for the Dutch net-metering rules (sunset 2027-01-01)

1. Setup and hardware

Component What
Battery Marstek Venus E 5.12 kWh (1st-gen Venus E v3, EMS firmware v148)
PV Enphase IQ Gateway + 8× IQ7 microinverters (3.08 kWp)
P1 meter SlimmeLezer-Shelly-emulator — an ESPHome firmware based on MarcelZuidwijk's SlimmeLezer that emulates a Shelly Pro 3EM so the Marstek's CT003 input can read the Dutch P1 port directly
HA Home Assistant Yellow
Tariff Tibber dynamic (goes live 2026-07-01); net metering until then

HACS integrations

Integration Role
ViperRNMC/MarstekVenusModbus Modbus TCP integration for the Venus — no cloud / HAME dependency
davidusb-geek/emhass (HA add-on) LP optimizer for battery dispatch under a dynamic tariff
Nord Pool Day-ahead price forecast (96 quarter-slots × 3 days)
Enphase Envoy Installer Exposes switch.envoy_production for PV curtail. (The official Enphase HA integration does NOT expose this switch — the Installer HACS plugin uses the Envoy's installer-level API.)
apexcharts-card Dual-axis plan-vs-price chart

I deliberately do not use HAME/hm2mqtt (cloud path, extra latency and failure modes) — pure Modbus TCP is direct and reliable.


2. Architecture: 4-layer control stack

It's tempting to cram everything into one "battery_mode" helper, but then the mental model collapses. My layout — four orthogonal layers, plus a parallel EMHASS pipeline:

LAYER 1: MASTER     input_boolean.battery_dispatch_enabled
                    (kill-switch; default on)
                              │
LAYER 2: BRAIN      input_select.battery_brain
                    (= who decides what the battery does)
                              │
              ┌──────────┬────┴─────┬──────────┐
              ▼          ▼          ▼          ▼
            native     rules     hybrid    optimizer
            (Marstek)  (threshold)(combo)   (EMHASS)
              │          │          │          │
              └──────────┴────┬─────┴──────────┘
                              │
LAYER 3: OPTIONS
  non-optimizer:    input_select.battery_override
                    (auto / force_charge / force_discharge / standby)
  optimizer:        input_boolean.battery_shadow_mode
                    (on = EMHASS plans but does not write)
                              │
LAYER 4: ACTION     Marstek Modbus (derived; not for you to touch directly):
                    select.force_mode + switch.rs485_control_mode
                    + number.set_charge_power / set_discharge_power

═══════════════════════════════════════════════════════════════════
LAYER 0 (parallel, monitoring): EMHASS PIPELINE
   - emhass_dayahead_optim       (24h LP solve at 14:05)
   - emhass_mpc_hourly           (short MPC re-optim every hour)
   - emhass_publish_5min         (publish current slot)
   - sensor.optim_status         ("Optimal" / "Infeasible" / …)
   - sensor.ems_plan_battery     (W; − = charge / + = discharge)

EMHASS computes continuously, even when battery_brain ≠ optimizer. The plan is published; whether it is followed is a Layer 2 choice.


3. The #214 quirk and the approach we settled on

What is #214?

ViperRNMC issue #214: when you write to the user_work_mode register, the Marstek firmware disables RS485 control mode within seconds. After that all force_mode commands are silently ignored until you toggle RS485 control back on.

The Venus has two control layers that are mutually exclusive:

Layer What Who writes it
user_work_mode High-level mode: Default / Forced / Off / Backup-only Marstek app (cloud) or HA
RS485 control + force_mode Low-level Modbus override External controller (us)

The firmware reads a user_work_mode write as "the app is taking over" → RS485 off to avoid conflict. Our force commands stay in the registers, but the inverter no longer looks at them. It falls back to native anti-feed (self-consumption without grid export) or Bypass.

The approach: keep RS485 off most of the time

Instead of fighting the firmware, accept that the Marstek's native anti-feed mode does its job well on its own. We only flip RS485 control mode on briefly during real forced charge/discharge — and we never touch user_work_mode. (In the ViperRNMC thread this is sometimes called "Option B"; the alternative was holding RS485 on permanently and trying to suppress #214, which proved unreliable.)

RS485 control mode default:    OFF
   → native anti-feed handles self-consumption autonomously

During forced dispatch:        ON
   1. select.force_mode = charge / discharge
   2. set_charge_power / set_discharge_power = X W
   3. switch.rs485_control_mode = on

When plan = stop / 0 W:        OFF
   1. select.force_mode = stop
   2. switch.rs485_control_mode = off
   → back to anti-feed

Never write user_work_mode. No automation, script or UI tile may touch it. This is a structural rule. (EMS v148 is the latest release as of May 2026; the often-cited "V151" was never released by Marstek.)

Dispatch automation (EMHASS path)

- id: battery_dispatch_optimizer
  alias: Battery dispatch - Optimizer
  description: >-
    sensor.ems_plan_battery -> Marstek force_mode + power.
    NEVER write user_work_mode (it disables RS485 - #214).
    Shadow mode (input_boolean.battery_shadow_mode on) = log only.
  triggers:
    - trigger: state
      entity_id: sensor.ems_plan_battery
      for: "00:00:30"
    - trigger: time_pattern
      minutes: /5
  conditions:
    - condition: state
      entity_id: input_select.battery_brain
      state: optimizer
    - condition: template
      value_template: >-
        {{ states('sensor.ems_plan_battery') not in
           ['unknown','unavailable','none','None'] }}
  variables:
    p_batt: "{{ states('sensor.ems_plan_battery') | float(0) }}"
    pw: "{{ (([ (p_batt|abs), 2500 ]|min) / 50)|round(0)*50 }}"
    mode: >-
      {{ 'discharge' if p_batt > 100
         else ('charge' if p_batt < -100 else 'stop') }}
  actions:
    - if:
        - condition: state
          entity_id: input_boolean.battery_shadow_mode
          state: "off"
      then:
        - choose:
            - conditions: ["{{ mode == 'charge' }}"]
              sequence:
                - action: switch.turn_on
                  target: { entity_id: switch.marstek_venus_rs485_control_mode }
                - action: number.set_value
                  target: { entity_id: number.marstek_venus_set_charge_power }
                  data: { value: "{{ pw }}" }
                - action: select.select_option
                  target: { entity_id: select.marstek_venus_force_mode }
                  data: { option: charge }
            - conditions: ["{{ mode == 'discharge' }}"]
              sequence:
                - action: switch.turn_on
                  target: { entity_id: switch.marstek_venus_rs485_control_mode }
                - action: number.set_value
                  target: { entity_id: number.marstek_venus_set_discharge_power }
                  data: { value: "{{ pw }}" }
                - action: select.select_option
                  target: { entity_id: select.marstek_venus_force_mode }
                  data: { option: discharge }
          default:
            - action: select.select_option
              target: { entity_id: select.marstek_venus_force_mode }
              data: { option: stop }
            - action: switch.turn_off
              target: { entity_id: switch.marstek_venus_rs485_control_mode }
        - action: logbook.log
          data:
            name: EMHASS dispatch
            message: "p_batt={{ p_batt|round(0) }}W → {{ mode }} {{ pw }}W"
      else:
        - action: logbook.log
          data:
            name: EMHASS dispatch (SHADOW)
            message: "p_batt={{ p_batt|round(0) }}W → would {{ mode }} {{ pw }}W [shadow]"
  mode: single
  max_exceeded: silent

Notes:

  • time_pattern: /5 keeps things in sync even during quiet periods.
  • ±100 W deadband (the if p_batt > 100 / < -100 template) prevents flutter around plan ≈ 0.
  • for: 00:00:30 on the state trigger debounces short fluctuations.
  • mode: single prevents overlap.

4. Bypass watchdog (self-healing #214)

Even when we carefully avoid user_work_mode writes, in churn tests the #214 quirk also fires sporadically on rapid back-to-back force_mode changes — likely a firmware race.

Symptom: HA writes rs485_control_mode = on, force_mode = charge, set_charge_power = 2000. Five seconds later inverter_state = Bypass (not Charge), ac_power = 0 W. The force-state registers still show charge / RS485 on, but the inverter isn't acting on them.

A watchdog handles the recovery automatically:

- id: marstek_bypass_watchdog
  alias: Marstek - Bypass watchdog (#214 recovery)
  triggers:
    - trigger: state
      entity_id: sensor.marstek_venus_inverter_state
      to: Bypass
      for: "00:00:20"
  conditions:
    - condition: state
      entity_id: switch.marstek_venus_rs485_control_mode
      state: "on"
    - condition: template
      value_template: "{{ states('select.marstek_venus_force_mode') != 'stop' }}"
  actions:
    - variables:
        saved_mode: "{{ states('select.marstek_venus_force_mode') }}"
        saved_chg: "{{ states('number.marstek_venus_set_charge_power') | float(0) }}"
        saved_dis: "{{ states('number.marstek_venus_set_discharge_power') | float(0) }}"
    # 1. Force stop + RS485 off
    - action: select.select_option
      target: { entity_id: select.marstek_venus_force_mode }
      data: { option: stop }
    - action: switch.turn_off
      target: { entity_id: switch.marstek_venus_rs485_control_mode }
    - delay: "00:00:03"
    # 2. RS485 back on + restore saved mode/power
    - action: switch.turn_on
      target: { entity_id: switch.marstek_venus_rs485_control_mode }
    - action: number.set_value
      target: { entity_id: number.marstek_venus_set_charge_power }
      data: { value: "{{ saved_chg }}" }
    - action: number.set_value
      target: { entity_id: number.marstek_venus_set_discharge_power }
      data: { value: "{{ saved_dis }}" }
    - action: select.select_option
      target: { entity_id: select.marstek_venus_force_mode }
      data: { option: "{{ saved_mode }}" }
    # 3. Counter + push notify
    - action: input_number.set_value
      target: { entity_id: input_number.marstek_bypass_recoveries }
      data:
        value: "{{ (states('input_number.marstek_bypass_recoveries') | float(0)) + 1 }}"
    - action: notify.mobile_app_<your_device>
      data:
        title: "🛡️ Marstek bypass watchdog"
        message: >-
          #214 quirk caught: mode={{ saved_mode }} restored after RS485 cycle
  mode: single
  max_exceeded: silent

Plus the counter helper:

input_number:
  marstek_bypass_recoveries:
    name: Marstek Bypass Recoveries
    icon: mdi:shield-refresh
    min: 0
    max: 100000
    step: 1
    initial: 0
    mode: box

Test results across cadence scenarios:

Cadence between mode switches stop→charge fail rate Watchdog needed
15 s (aggressive) 3/30 (10 %) on stop→charge transitions Yes, frequent
60 s 0/10 Not triggered
15 min (EMHASS quarter, production) No #214 events observed Idle

At production cadence the quirk barely shows up — the watchdog is insurance, not an active player. mode: single + the trigger's for: 20s prevent recovery loops.


5. EMHASS integration

Add-on config (relevant parts)

/addon_configs/5b918bf2_emhass/config.json:

{
  "battery_nominal_energy_capacity": 5.12,
  "battery_charge_power_max": 2500,
  "battery_discharge_power_max": 2500,
  "battery_minimum_state_of_charge": 0.13,
  "battery_maximum_state_of_charge": 0.95,
  "battery_target_state_of_charge": 0.5,
  "battery_charge_efficiency": 0.92,
  "battery_discharge_efficiency": 0.92,
  "set_use_battery": true,
  "set_use_pv": true,
  "set_total_pv_sell": false,
  "sensor_power_battery": "sensor.marstek_venus_ac_power",
  "sensor_power_load_no_var_loads": "sensor.power_consumed",
  "sensor_power_photovoltaics": "sensor.solar_power_used",
  "load_cost_forecast_method": "list",
  "production_price_forecast_method": "list",
  "load_forecast_method": "naive",
  "optimization_time_step": 15
}

Notes:

  • battery_minimum_state_of_charge: 0.13 — 1 % buffer above the BMS hard-stop (Marstek Venus drops out around ~12 %). 0.10 default is too close.
  • 0.92 charge × 0.92 discharge gives a theoretical round-trip efficiency around 85 %, which lines up with what people report on the ViperRNMC and Marstek forums for the Venus E. Defaults are realistic out of the box.
  • sensor_power_battery = AC power, sign convention: − charging / + discharging.

REST commands and pipeline

rest_command:
  emhass_dayahead_optim:
    url: http://5b918bf2-emhass:5000/action/dayahead-optim
    method: POST
    content_type: "application/json"
    timeout: 300
    payload: >-
      {
        "load_cost_forecast":  {{ state_attr('sensor.emhass_forecast_prices','load_cost') | tojson }},
        "prod_price_forecast": {{ state_attr('sensor.emhass_forecast_prices','prod_price') | tojson }},
        "pv_power_forecast":   {{ (state_attr('sensor.emhass_pv_forecast','pv_power_forecast') or []) | tojson }},
        "load_power_forecast": {{ (state_attr('sensor.emhass_load_forecast','load_power_forecast') or []) | tojson }},
        "load_forecast_method": "list",
        "prediction_horizon":  {{ state_attr('sensor.emhass_forecast_prices','load_cost') | length }},
        "soc_init":            {{ (states('sensor.marstek_venus_battery_soc') | float(50) / 100) }},
        "soc_final":           {{ (states('sensor.marstek_venus_battery_soc') | float(50) / 100) }}
      }

  emhass_naive_mpc_optim:
    url: http://5b918bf2-emhass:5000/action/naive-mpc-optim
    method: POST
    content_type: "application/json"
    timeout: 120
    payload: >-
      {
        "load_cost_forecast":  {{ state_attr('sensor.emhass_forecast_prices','load_cost')[:24] | tojson }},
        "prod_price_forecast": {{ state_attr('sensor.emhass_forecast_prices','prod_price')[:24] | tojson }},
        "pv_power_forecast":   {{ (state_attr('sensor.emhass_pv_forecast','pv_power_forecast') or [])[:24] | tojson }},
        "load_power_forecast": {{ (state_attr('sensor.emhass_load_forecast','load_power_forecast') or [])[:24] | tojson }},
        "load_forecast_method": "list",
        "prediction_horizon":  24,
        "soc_init":            {{ (states('sensor.marstek_venus_battery_soc') | float(50) / 100) }},
        "soc_final":           {{ (states('sensor.marstek_venus_battery_soc') | float(50) / 100) }}
      }

  emhass_publish_data:
    url: http://5b918bf2-emhass:5000/action/publish-data
    method: POST
    timeout: 60
    payload: "{}"

Load forecast from InfluxDB history

A detail worth highlighting: the REST payloads above pass load_power_forecast as a runtime parameter, which overrides EMHASS' configured load_forecast_method: "naive". The list itself comes from our own InfluxDB pipeline, not from EMHASS.

Why not just use naive? Naive replays the last 24 h verbatim, so a Monday gets forecast based on a Sunday — no day-of-week pattern, no recency. EMHASS' built-in mlforecaster (sklearn autoregression) does better but needs ≥ 30 days of HA recorder retention and a training pipeline; for a stable two-person household the marginal gain (5–10 % MAPE) wasn't worth the complexity.

The middle ground is a 28-day rolling median bucketed by (day-of-week × hour × quarter). Pipeline:

InfluxDB (28d of sensor.power_consumed)
  └─ Flux: aggregateWindow 15m, group by (DoW × hour × quarter), median()
  └─ Python script (cron / command_line sensor every 30 min)
  └─ sensor.emhass_load_forecast  (attribute: load_power_forecast, 96-entry list)
  └─ runtime param in emhass_dayahead_optim / naive_mpc_optim

The Flux query and Python wrapper that builds the list:

FLUX = """import "date"
from(bucket:"homeassistant")
  |> range(start: -28d)
  |> filter(fn: (r) => r.entity_id == "power_consumed" and r._field == "value")
  |> aggregateWindow(every: 15m, fn: mean, createEmpty: false)
  |> map(fn: (r) => ({
      _value: r._value,
      dow:    string(v: date.weekDay(t: r._time)),
      hr:     string(v: date.hour(t: r._time)),
      qtr:    string(v: date.minute(t: r._time) / 15)
  }))
  |> group(columns: ["dow", "hr", "qtr"])
  |> median()
"""
# For each of the next 96 quarters: compute (DoW, hr, qtr), look up the
# median. Fallback to the 4-week overall median if a bucket is empty.

Caveats:

  • Requires an InfluxDB instance already getting HA data. The recorder alone won't do — 30+ days of retention there is heavy on disk.
  • 7+ days before all DoW buckets fill; fallback covers the warmup.
  • The forecast sensor is an attribute holding the 96-entry list — EMHASS pulls it via state_attr('sensor.emhass_load_forecast', 'load_power_forecast').

Possible refinements (not implemented): weighted median with recency decay, IQR outlier trimming per bucket, separate day-types for holidays.

Pipeline automations

Day-ahead at 14:05, MPC hourly, publish every 5 min, plus a startup bootstrap:

- id: emhass_dayahead_14h
  alias: EMHASS - Day-ahead optimisation 14:00
  triggers:
    - trigger: time
      at: "14:05:00"
  conditions:
    - condition: template
      value_template: "{{ state_attr('sensor.emhass_forecast_prices','load_cost') | length >= 96 }}"
  actions:
    - action: rest_command.emhass_dayahead_optim
    - delay: "00:00:10"
    - action: rest_command.emhass_publish_data

6. Shadow mode (pre-launch validation)

Before letting EMHASS actually steer, you want to know its plan is sensible. But under net metering it mostly produces plan ≈ 0 (no arbitrage since net metering already compensates). So how do you validate?

Shadow mode = EMHASS keeps computing and publishing ems_plan_battery, but the dispatch automation writes no Modbus commands. Just a logbook entry: "would charge 800 W". You get weeks of pre-launch data at zero cost.

The pattern is in the dispatch automation above: if input_boolean.battery_shadow_mode == off then {writes + logbook} else {logbook (SHADOW)}.

Helper:

input_boolean:
  battery_shadow_mode:
    name: Battery Shadow Mode
    icon: mdi:eye-off-outline

Set it on weeks ahead of go-live. Compare in Grafana what EMHASS would do versus what native anti-feed actually does. Flip it off just before going live.

A useful overlay in Lovelace (price vs. plan, next 24h):

type: custom:apexcharts-card
header: { show: true, title: "EMHASS plan vs price — next 24h" }
graph_span: 24h
span: { start: hour }
yaxis:
  - id: price
    decimals: 3
    apex_config: { title: { text: "€/kWh" } }
  - id: power
    decimals: 0
    opposite: true
    apex_config: { title: { text: "Plan (W)" } }
series:
  - entity: sensor.emhass_forecast_prices
    name: "Purchase price"
    type: line
    yaxis_id: price
    data_generator: |
      const start = new Date(entity.attributes.start_time);
      const slot_ms = 15 * 60 * 1000;
      return (entity.attributes.load_cost || []).map((p, i) => [
        start.getTime() + i * slot_ms, p
      ]);
  - entity: sensor.p_batt_forecast
    name: "EMHASS plan (− = charge / + = discharge)"
    type: area
    opacity: 0.4
    yaxis_id: power
    data_generator: |
      return (entity.attributes.battery_scheduled_power || []).map(slot => [
        new Date(slot.date).getTime(),
        parseFloat(slot.p_batt_forecast)
      ]);

7. Strategy mutex

In an earlier setup I had two automations running side by side: rule-based dispatch (self-consumption / thresholds) and the EMHASS dispatch. Both triggered on time_pattern: /5. The problem:

00:05:00.0  EMHASS publishes plan = -1500 W (charge)
00:05:00.1  optimizer dispatch fires → writes charge 1500 W
00:05:00.2  rules dispatch also fires (same /5 tick) → template falls
            through to 'stop', writes stop + RS485 off
00:05:00.3  Battery is off — EMHASS' charge was overwritten in 100 ms

Invisible in normal logs, but very real under aggressive testing. Fix: one hard mutex condition on the rule-based automation:

- id: battery_dispatch_rules
  alias: Battery dispatch - Rules
  triggers:
    - trigger: state
      entity_id:
        - input_select.battery_brain
        - input_select.battery_override
        - input_boolean.battery_dispatch_enabled
    - trigger: state
      entity_id: sensor.nordpool_nl_current_price
    - trigger: time_pattern
      minutes: /5
  conditions:
    # Strategy mutex: skip when brain = optimizer
    - condition: not
      conditions:
        - condition: state
          entity_id: input_select.battery_brain
          state: optimizer
  # … (template + actions as described)

One condition. One source of truth (battery_brain). No more race.

General lesson: if two automations write to the same entity, add an explicit mutex via a condition. The HA scheduler doesn't serialise this for you.


8. The soc_final fix

EMHASS' LP solver needs a terminal SoC constraint — otherwise it drains the battery on the last minute of the window. The default in many EMHASS examples is soc_final: 0.5.

Problem: a fixed 0.5 doesn't fit my setup. From SoC 19 % the solver has to absorb a net +31 % capacity to reach the end-SoC, which eats all the absorption headroom that could otherwise have been spent on arbitrage.

Fix: soc_final = soc_init. "Finish where you started — no net change." Gives the solver full freedom to arbitrage within the window as long as it doesn't drain on net. MPC re-runs every hour, so the boundary slides forward — no risk of self-draining.

"soc_init":  {{ (states('sensor.marstek_venus_battery_soc') | float(50) / 100) }}
"soc_final": {{ (states('sensor.marstek_venus_battery_soc') | float(50) / 100) }}

Effect under a dynamic tariff: dramatically more arbitrage events in EMHASS' output. Under net metering still plan ≈ 0 (flat effective price curve), but that's not down to this fix.

When would you use a different soc_final? If you use the battery for backup / island operation, hold a minimum (e.g. 0.3). The Venus E has no island-operation function, so a backup reserve has no purpose for me.


9. PV curtail on negative prices

A feature that runs alongside EMHASS: shut off Enphase PV when prices go really negative and the battery has no room left.

EMHASS itself can only "switch off" PV indirectly by pushing surplus into the battery (force_charge during negative prices). Once the battery is full, the rest goes to grid → export during a negative price → costs you money.

Hardware

The HACS Enphase Envoy Installer plugin exposes switch.envoy_production. Toggle it and the IQ Gateway sends a PLC ramp-down signal to each IQ7 microinverter — production winds down to 0 within 5–10 min. Safe (no physical disconnect). The official Enphase HA integration does not expose this; you need the Installer plugin specifically.

Break-even under Dutch net metering

Until 2027-01-01 net metering is active, BUT only on the energy-tax + VAT component (≈ €0.16/kWh), not on the bare Tibber spot.

For 1 kWh exported at bare price P:

Tibber compensation:    P − €0.0248 (feed-in fee)
Energy-tax netting:     + €0.16 (€0.13 energy tax × 1.21 VAT)
Total revenue:          P + €0.135

Curtail: 0 revenue, 0 cost

Break-even:  P + 0.135 = 0   →   P = − €0.135

Curtail wins under net metering only at prices < −€0.135/kWh. Above that, not curtailing still pays. After 2027-01-01 (net metering gone) break-even shifts to −€0.025 (only the Tibber fee remains).

Period Threshold Reason
Until 2027-01-01 −€0.15 Safety margin above break-even
From 2027-01-01 −€0.025 Only the Tibber fee

Predictive lookahead (10 min lead, ≥ 2h minimum window)

PLC ramp-down takes ~10 min, so we need to look ahead:

  • Detect the next contiguous window of ≥ 2 hours below threshold
  • Turn the Envoy off 10 min before it starts

Two template binary sensors:

template:
  - binary_sensor:
      - name: "PV curtail window starts soon"
        unique_id: pv_curtail_window_starts_soon
        icon: mdi:weather-sunset-down
        state: >-
          {% set thr = states('input_number.pv_curtail_price_threshold') | float(-0.15) %}
          {% set prices = state_attr('sensor.nordpool_ceh_prices', 'prices') or [] %}
          {% set start = (now() + timedelta(minutes=10)).timestamp() %}
          {% set end = (now() + timedelta(minutes=130)).timestamp() %}
          {% set ns = namespace(count=0, max_p=-9.99) %}
          {% for p in prices %}
            {% set ts = as_timestamp(p.start) %}
            {% if ts is not none and ts >= start and ts < end %}
              {% set ns.count = ns.count + 1 %}
              {% if (p.price | float(0)) > ns.max_p %}
                {% set ns.max_p = p.price | float(0) %}
              {% endif %}
            {% endif %}
          {% endfor %}
          {{ 'on' if ns.count >= 8 and ns.max_p < thr else 'off' }}

      - name: "PV curtail current below threshold"
        unique_id: pv_curtail_current_below
        icon: mdi:cash-minus
        state: >-
          {% set thr = states('input_number.pv_curtail_price_threshold') | float(-0.15) %}
          {% set price = states('sensor.nordpool_nl_current_price') | float(99) %}
          {{ 'on' if price < thr else 'off' }}

(8 quarter-slots = 2 h minimum window.)

Automations

- id: pv_curtail_predictive
  alias: PV - Curtail (predictive, 10min lead, ≥2h window)
  triggers:
    - trigger: state
      entity_id: binary_sensor.pv_curtail_window_starts_soon
      to: "on"
      for: "00:05:00"
  conditions:
    - condition: numeric_state
      entity_id: sensor.marstek_venus_battery_soc
      above: 90
    - condition: state
      entity_id: switch.envoy_production
      state: "on"
  actions:
    - action: switch.turn_off
      target: { entity_id: switch.envoy_production }
    - action: notify.mobile_app_<your_device>
      data:
        title: "🌞 PV curtail (lead time)"
        message: >-
          Negative ≥2h window incoming. SoC {{ states('sensor.marstek_venus_battery_soc') }}%. Envoy OFF.
  mode: single
  max_exceeded: silent

- id: pv_curtail_resume
  alias: PV - Resume (after window or once SoC has room)
  triggers:
    - trigger: state
      entity_id: binary_sensor.pv_curtail_current_below_threshold
      to: "off"
      for: "00:05:00"
    - trigger: numeric_state
      entity_id: sensor.marstek_venus_battery_soc
      below: 85
      for: "00:05:00"
  conditions:
    - condition: state
      entity_id: switch.envoy_production
      state: "off"
  actions:
    - action: switch.turn_on
      target: { entity_id: switch.envoy_production }
  mode: single

Helper:

input_number:
  pv_curtail_price_threshold:
    name: PV curtail price threshold (€/kWh)
    icon: mdi:solar-power-variant-outline
    min: -0.50
    max: 0.10
    step: 0.005
    initial: -0.15
    mode: box

Self-stabilising loop

If price stays negative but SoC drops below 85 % → resume automation fires → Envoy back on → PV flows into the battery first (anti-feed works in stop state too). Once full and price still negative → window-detection sensor stays on → curtail triggers again. No export during negative prices.

for: debounces prevent flapping on rapid price changes or SoC oscillations.

Estimated impact

For 3 kW PV under Tibber dynamic post-2026-07-01 with net metering: ~€5–15 per year (50–150 negative-price hours × small kWh amounts). Post-2027: grows to €20–80 per year depending on how often deep negative prices occur. Not huge in absolute terms, but free once the mechanism is in place — and ready for future capacity-tariff or feed-in restrictions.


10. References


Replies welcome — especially if you've found a better fix for the #214 quirk, a smarter break-even for curtailing, or a conflict with your own setup.

Disclaimers: this is my setup for my specific Marstek version (EMS v148) on a 1-phase connection. YMMV. Test in shadow mode first.

Good luck :battery::high_voltage:

Thanks for this nice writeup. I'm still experimenting with the marstek/modbus integration, and have some semi-automatic management. But I'ld like to checkout EMHass in the near future, so this will probably be a good reference.

I also want to share my approach concerning the 'RS485-control'. I'm currently using it in a slightly different way. Most of the time the RS485-control is off, and the mode always is anti-feed-in (selfconsumption).
If during the day, energy-price will go below 1.5€c (the 'injection'-margin/cost of my energy provider), then early in the morning I set 'max charge power' to 0, as soon as solar generates some excess, it's feeded into the grid, but if a consumption-peak occurs, the battery still discharges to keep a 0-on-meter (basically a 1-way 0 on meter). As soon as injection 'costs' me money, the 'max charge power' is restored. I also throttle the solar inverter to avoid a higher production than the battery can charge, and aim for a charging-power level at which the SoC would reach 100% at the time injection-costs become earnings. At this point, the solar-inverter is restored to full capacity.
This way I don't switch the Venus to RS485 (but keep 'self-consumption' and 'stop'-mode), I throttle solar inverter, and limit charging by setting max charging power.
When I want to 'force discharge' any unneeded but stored energy (the evening/morning before, and only if prices are high enough), I just enable RS485, and set the 'desired SoC' to a low percentage. (a high percentage would force-charge).
(if the 'max discharge power' is set to a value higher than was configured in the app, it also gets overruled by the firmware after a while, but I think that's a good thing, it's about safety and legislation/certification)

To summarize, the only write-actions to the Venus I'm using are:

  • "RS485 On, followed by 'set desired SoC'" to force charge/discharge (writing SoC is required)
  • "RS485 Off" to revert to self-consumption
  • "set 'Max charging power'" to enable/disable discharge-only self-consumption