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_modewrite) — 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: /5keeps things in sync even during quiet periods.±100 W deadband(theif p_batt > 100 / < -100template) prevents flutter around plan ≈ 0.for: 00:00:30on the state trigger debounces short fluctuations.mode: singleprevents 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.92charge ×0.92discharge 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
- ViperRNMC/MarstekVenusModbus (HACS): https://github.com/ViperRNMC/MarstekVenusModbus
- #214 thread: https://github.com/ViperRNMC/MarstekVenusModbus/issues/214
- EMHASS docs: https://emhass.readthedocs.io/
- EMHASS HA add-on: GitHub - davidusb-geek/emhass-add-on: The Home Assistant Add-on for EMHASS: Energy Management Optimization for Home Assistant · GitHub
- Tibber NL pricing: Salderen en terugleveren bij Tibber | Tibber Support Center
- Enphase Envoy Installer (HACS): https://github.com/sarrios/ha-enphase-envoy
- apexcharts-card (HACS): https://github.com/RomRider/apexcharts-card
- Nord Pool (HACS): GitHub - custom-components/nordpool: This component allows you to pull in the energy prices into Home-Assistant. · GitHub
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 ![]()
![]()