ZeroSpill: zero-export controller for Hoymiles / OpenDTU (battery-friendly)

Open your Home Assistant instance and show the blueprint import dialog with a specific blueprint pre-filled.

This blueprint drives a Hoymiles microinverter (through OpenDTU / OpenDTU-OnBattery) so it delivers what your house is using and doesn’t export to the grid. I built it for a battery-fed setup (48 V LiFePO4 → HM-1200NT), but it works with any inverter that exposes a non-persistent absolute power-limit number entity.

Full build log, Home Assistant package, ESPHome configs and real-world numbers: GitHub - jakeshake/zerospill: Battery-buffered zero-export solar: Hoymiles + OpenDTU-OnBattery + Home Assistant. Feeds only what your house uses, shifts solar into peak TOU hours. DIY build log, not a UL 3700 plug-in kit. · GitHub

How it controls

  • Cuts fast when the grid goes negative. It acts immediately on a raw meter reading of more than 150 W export, or on export in a 3-reading median.
  • Raises slowly. The target has to stay above the current limit for 20 s, each raise is capped at +400 W, and it waits 30 s between raises. Short load spikes (dryer, kettle) don’t chase the inverter up.
  • Waits for settled readings. After each change it ignores grid readings taken before the inverter could respond, so a stale meter value never fakes a load spike or a dip.
  • Applies a learned correction factor between the commanded limit and actual AC output.
  • Turns the inverter off when the target drops below 40 W, or when the target sensor has been unavailable for 5 minutes.

What you need

  • A grid power sensor (W, + import / − export). I use an Emporia Vue Utility Connect flashed with ESPHome. It updates every few seconds and only works with supported smart meters.
  • A 3-reading median of that sensor (filter platform, median, window_size: 3)
  • A target power sensor: what the inverter should deliver right now. Usually house load (grid + inverter output) minus a small buffer, plus your own battery/time-of-use rules. The repo’s package has a full version with peak-hour pacing, Solcast-based overnight reserve and SoC floors.
  • OpenDTU entities: AC power, non-persistent absolute limit, producing, and the turn-on/turn-off buttons. Don’t use the persistent limit, which writes to the inverter’s flash every time.
  • Three helpers: input_number limit correction (0.8–1.4, start at 1.0), input_number max power (W), and input_boolean raise-pending.

Set max power to what your DC side can safely carry. Mine is 1,150 W, which keeps a 48 V bus at about 24 A on a 30 A breaker/fuse.

Results

On sunny late-October days (6 × 200 W panels, 5 kWh battery, SCE TOU-D-5-8PM), solar + battery covered 35–41 % of the house with export effectively zero. The only export is brief spikes of about 1 Wh when the AC compressor shuts off, because the inverter takes a few seconds to apply a new limit. Screenshots are in the repo.

Here’s the Home Energy dashboard from the repo running in the HA companion app (live flow, today’s totals, last 7 days). It’s built to fit a phone with no sideways scrolling:

Please read before copying the hardware

This is not a UL 3700 plug-in (“balcony”) solar kit, and it doesn’t fall under California’s SB 868 or similar plug-in solar laws. My inverter is on a dedicated 240 V circuit and runs from a DIY battery bus. The repo’s Safety & Legal page (docs/safety-and-legal.md) covers what that means, plus the DC gotchas I hit: pre-charging inverter input caps, and reversed polarity when feeding MC4 Y-splitters backwards. The blueprint itself is just control logic and works with any inverter setup.

Blueprint

blueprint:
  name: Zero-Export Inverter Controller (OpenDTU / Hoymiles)
  description: >
    Drives a microinverter's non-persistent power limit to follow a target
    sensor without exporting. Cuts fast on export, and raises slowly only after
    the target has stayed above the limit for 20 s. It acts only once grid
    readings have settled after its last change, and caps each raise at +400 W.
    Controller only: you supply the target sensor (house load minus a buffer,
    plus your own battery/time rules) and three helpers. The full system with
    forecasting is in the repo's package.
    https://github.com/jakeshake/zerospill
  domain: automation
  source_url: https://github.com/jakeshake/zerospill/blob/main/home-assistant/blueprints/zero_export_controller.yaml
  input:
    target_sensor:
      name: Target power sensor (W)
      description: What the inverter should deliver right now. Unavailable for 5 min turns the inverter off; below 40 W turns it off.
      selector:
        entity:
          domain: sensor
    grid_sensor:
      name: Grid power sensor (W, + import / - export)
      selector:
        entity:
          domain: sensor
    grid_median_sensor:
      name: Grid power median (3 readings)
      description: A filter-platform median of the grid sensor.
      selector:
        entity:
          domain: sensor
    inverter_power:
      name: Inverter AC power sensor
      selector:
        entity:
          domain: sensor
    inverter_limit:
      name: Inverter non-persistent absolute limit
      selector:
        entity:
          domain: number
    inverter_producing:
      name: Inverter producing
      selector:
        entity:
          domain: binary_sensor
    inverter_on:
      name: Inverter turn-on button
      selector:
        entity:
          domain: button
    inverter_off:
      name: Inverter turn-off button
      selector:
        entity:
          domain: button
    correction:
      name: Limit correction (input_number, ~1.0)
      selector:
        entity:
          domain: input_number
    peak_power:
      name: Max power (input_number, W)
      description: Hard ceiling. Size it to your DC fuse/breaker/wire (80 % rule).
      selector:
        entity:
          domain: input_number
    raise_pending:
      name: Raise-pending flag (input_boolean)
      selector:
        entity:
          domain: input_boolean

mode: single
max_exceeded: silent

variables:
  t_ent: !input target_sensor
  g_ent: !input grid_sensor
  gm_ent: !input grid_median_sensor
  p_ent: !input inverter_power
  l_ent: !input inverter_limit
  prod_ent: !input inverter_producing
  on_ent: !input inverter_on
  off_ent: !input inverter_off
  corr_ent: !input correction
  pmax_ent: !input peak_power
  rp_ent: !input raise_pending

triggers:
  - trigger: time_pattern
    seconds: "/20"
  - trigger: state
    entity_id: !input target_sensor
  - trigger: state
    entity_id: !input grid_sensor

actions:
  - condition: template
    value_template: >
      {% set A = states[p_ent] %}
      {{ not (A is not none and A.state in ['unknown','unavailable'] and (now() - A.last_changed).total_seconds() < 600) }}
  - variables:
      tstate: "{{ states(t_ent) }}"
      target: "{{ states(t_ent) | float(0) }}"
      corr: "{{ states(corr_ent) | float(1.0) }}"
      pmax: "{{ states(pmax_ent) | float(1000) }}"
      cmd: "{{ (([[target * corr, 30] | max, pmax] | min) | round(0) | int) }}"
      cur: "{{ states(l_ent) | float(0) | round(0) | int }}"
      since: "{{ (now() - states[l_ent].last_changed).total_seconds() }}"
      stale_for: "{{ (now() - states[t_ent].last_changed).total_seconds() }}"
      producing: "{{ is_state(prod_ent, 'on') }}"
      off_age: "{{ (now() - (states(off_ent) | as_datetime(none) or (now() - timedelta(hours=1)))).total_seconds() }}"
      on_age: "{{ (now() - (states(on_ent) | as_datetime(none) or (now() - timedelta(hours=1)))).total_seconds() }}"
      exporting: "{{ states(gm_ent) | float(0) < 0 or states(g_ent) | float(0) < 0 }}"
      settled: >
        {% set L = states[l_ent] %}{% set G = states[g_ent] %}
        {% if L is none or G is none %}true{% else %}
        {{ (now() - L.last_changed).total_seconds() >= 12 and (G.last_reported - L.last_changed).total_seconds() > 11 }}{% endif %}
      hard_export: >
        {% set L = states[l_ent] %}{% set G = states[g_ent] %}
        {{ G is not none and (G.state | float(0)) < -150 and (L is none or (G.last_reported - L.last_changed).total_seconds() > 4) }}
  - variables:
      cmd: "{{ [cmd, cur + 400] | min if cmd > cur else cmd }}"
  - variables:
      want_up: "{{ (cmd - cur) >= ([15, 0.04 * cmd] | max) }}"
  - if: "{{ want_up and is_state(rp_ent, 'off') }}"
    then:
      - action: input_boolean.turn_on
        target:
          entity_id: "{{ rp_ent }}"
    else:
      - if: "{{ not want_up and is_state(rp_ent, 'on') }}"
        then:
          - action: input_boolean.turn_off
            target:
              entity_id: "{{ rp_ent }}"
  - choose:
      - conditions: "{{ tstate in ['unavailable','unknown'] }}"
        sequence:
          - if: "{{ stale_for > 300 and producing and off_age > 60 }}"
            then:
              - action: button.press
                target:
                  entity_id: "{{ off_ent }}"
      - conditions: "{{ target < 40 and settled }}"
        sequence:
          - if: "{{ producing and off_age > 60 }}"
            then:
              - action: button.press
                target:
                  entity_id: "{{ off_ent }}"
    default:
      - if: "{{ not producing and on_age > 60 }}"
        then:
          - action: button.press
            target:
              entity_id: "{{ on_ent }}"
      - if: >
          {{ (cur - cmd >= [8, 0.03 * cmd] | max and (hard_export or (settled and (since >= 15 or exporting))))
             or (cmd - cur >= [15, 0.04 * cmd] | max and settled and since >= 30
                 and is_state(rp_ent, 'on')
                 and (now() - states[rp_ent].last_changed).total_seconds() >= 20) }}
        then:
          - action: number.set_value
            target:
              entity_id: "{{ l_ent }}"
            data:
              value: "{{ cmd }}"

Feedback and improvements are welcome, here or as GitHub issues/PRs.