A Home Assistant custom integration that keeps your net grid power close to 0 W by automatically controlling PV inverter output limits — so you never export more than you need to, and never buy more than necessary.
Zero Grid Controller continuously reads your grid power sensor and adjusts one or more PV inverter setpoints to keep the net exchange with the grid as close to zero as possible.
Signal model:
grid_w = consumption − PV_delivered − battery_net
- Positive
grid_w= drawing from grid → too much consumption or too little PV - Negative
grid_w= exporting to grid → too much PV - Goal: always steer towards
grid_w = 0
Control priority:
- When exporting (grid < 0): charge batteries → increase controllable loads → curtail PV arrays
- When importing (grid > 0): open PV arrays → reduce controllable loads → discharge batteries (last resort)
- Home Assistant 2024.11 or newer (subentries API required)
- A sensor entity that measures your grid power exchange
- At least one PV inverter controllable via a
numberorswitchentity
- Open HACS → Integrations → ⋮ → Custom repositories.
- Add
https://github.com/bvweerd/Zero-Grid-Controlleras an Integration. - Search for Zero Grid Controller and click Download.
- Restart Home Assistant.
- Download or clone this repository.
- Copy the
custom_components/zero_grid_controllerfolder to your<config>/custom_components/directory. - Restart Home Assistant.
Go to Settings → Devices & Services → Add Integration and search for Zero Grid Controller.
- Grid import sensors: one or more power sensors that measure how much power you draw from the grid.
- Grid export sensors: one or more power sensors that measure how much power you deliver to the grid.
- Enable switch (optional): an
input_booleanorswitchthat must be on for the controller to be active. - Deadband: grid error below this value (W) is ignored — the controller does nothing.
- EWM filter alpha: smoothing factor for the grid signal. Lower = smoother but slower.
- Control aggressiveness: how aggressively PID gains are set after calibration (cautious / normal / fast).
After setup, go to the integration card and choose Add entry → PV array:
Step 1: type selection
- Array name: a friendly name (e.g. "Roof South").
- Output type: percentage (0–100 %), Watts (absolute limit), or On/Off switch.
Step 2a — numeric output:
- Setpoint entity: the
number.*orinput_number.*entity that sets the power limit. - Setpoint min / max: the valid range for the setpoint entity.
Step 2b — switch output:
- Switch entity: the
switch.*entity to toggle. - Turn on threshold: grid import above this value (W) turns the switch on.
- Turn off threshold: grid export above this value (W) turns the switch off.
- Minimum on/off time: debounce time in seconds before switching again.
You can add multiple arrays. The controller distributes corrections proportionally to available headroom.
Go to the integration card and choose Add entry → Load:
Step 1: type selection
- Load name: a friendly name (e.g. "EV Charger", "Boiler").
- Load type:
numeric(variable power) orswitch(fixed power on/off).
Step 2a — numeric load:
- Setpoint entity: the
number.*orinput_number.*entity that sets the load level (e.g. charging current in amps). - Setpoint min / max: valid range for the entity.
- W per unit: watts per setpoint unit (e.g. 230 W/A for a single-phase EV charger at 230 V).
- Minimum active power (optional): minimum watts the load requires when on. If the controller would set it below this, it snaps to off instead. Use this for EV chargers that require at least 6 A (1380 W) when on.
- Settling time: seconds to wait after a setpoint change before adjusting again.
- Power sensor (optional): sensor measuring actual load power (for monitoring).
- Priority: lower number = higher priority. The highest-priority load absorbs surplus first and is the last to be reduced.
Step 2b — switch load:
- Switch entity: the
switch.*orinput_boolean.*entity to toggle. - Fixed power (W): power consumption when the load is on. The controller uses this to decide when there is enough surplus to turn it on.
- Debounce time: minimum seconds between switching actions.
- Priority: as above.
Switch load behaviour: turns on when export surplus ≥ fixed power, turns off when any grid import occurs.
You can add multiple loads. Higher-priority loads absorb surplus first and are the last to be reduced when the grid imports.
Go to the integration card and choose Add entry → Battery:
- Battery name: a friendly name.
- Battery power sensor: negative = charging, positive = discharging.
- Maximum charge power: maximum W the battery can absorb.
- Maximum discharge power: maximum W the battery can deliver.
- Battery setpoint entity: the
number.*entity to command the battery target power. - Optimizer schedule sensor (optional): a sensor that provides a pre-planned battery setpoint from an external optimizer (e.g. battery_controller). See Using an external optimizer below.
| Configuration | Behaviour |
|---|---|
| No schedule sensor | Reactive: charges when exporting (grid < 0), discharges as a last resort when all PV arrays are at maximum and the grid is still importing. |
| Schedule sensor configured | Scheduled + corrected: follows the optimizer's recommended setpoint as a feed-forward target; the real-time grid error is added on top as a proportional correction. |
When a schedule sensor is configured, the battery target is computed as:
target_w = clamp(schedule_w + grid_correction_w, −max_charge, +max_discharge)
schedule_w: the optimizer's planned setpoint (e.g. −500 W = charge at 500 W).grid_correction_w: a real-time adjustment proportional to the filtered grid error, so short-term PV fluctuations or unexpected consumption peaks are compensated without waiting for the next optimizer cycle.- The result is clamped to the configured max charge / discharge limits.
Sign convention: the schedule sensor must use the same sign convention as the setpoint entity — negative = charging, positive = discharging. Verify this matches what your optimizer publishes before enabling.
Go to Settings → Devices & Services → Zero Grid Controller → Configure.
| Mode | Behaviour |
|---|---|
| Zero grid (default) | Keeps net grid at 0 W — prevents both import and export. |
| Zero import | Allows export freely; only activates the controller to prevent import. Useful when selling is always OK but buying is not. |
| Zero export | Allows import freely; only activates the controller to prevent export. Useful when net-metering rules prohibit export. |
| Maximize export | Immediately sets all PV arrays to maximum output, turns controllable loads off, and discharges batteries at full rate. Use when feed-in tariffs are high. No PID — static positions. |
| Maximize import | Immediately sets all PV arrays to minimum output (off), turns controllable loads on at maximum, and charges batteries at full rate. Use when energy prices are negative. No PID — static positions. |
Switching modes takes effect on the next 5-second control cycle.
The Control aggressiveness setting has three positions:
| Setting | Description |
|---|---|
| Cautious | Slow, stable. Suitable for slow inverters or noisy grid measurements. |
| Normal | Balanced. The recommended default. |
| Fast | Quick reaction. May oscillate on slow inverters. |
Changes take effect immediately without restart.
Calibration measures the actual inverter response with a per-array power sensor and then auto-computes global PID gains from the combined calibrated numeric arrays.
For numeric arrays, add an Array power sensor in the array subentry. The calibrator uses that sensor directly and no longer infers array response from net grid power alone.
Run calibration when the inverter is producing stably enough that a 30% → 20% → 30% step is visible on the array power sensor:
- Go to the controller device.
- Press the Recalibrate all arrays button.
Or via service:
service: zero_grid_controller.recalibrate
data: {}Calibration runs one array at a time. For each numeric array it:
- Moves the array to a midpoint workpoint at
30%. - Waits for the array power sensor to stabilise.
- Steps down to
20%and measures the downwards response. - Steps back up to
30%and measures the upwards response. - Restores the original setpoint.
- Computes
w_per_unit, a derived maximum power estimate, and a conservative settling time based on the slowest direction.
Switch arrays are skipped — they have no intermediate setpoint to measure.
Global PID gains are recomputed once per calibration run from the total calibrated numeric plant. A single array calibration result no longer overwrites the global PID on its own.
Calibration confidence is shown in the diagnostics analyzer. Arrays showing estimated still use fallback W/unit values and benefit most from running calibration.
| Entity | Description |
|---|---|
sensor.*_grid_raw_w |
Unfiltered grid power (W) |
sensor.*_grid_filtered_w |
EWM-filtered grid power (W) |
sensor.*_pid_output_w |
PID output / control signal (W) |
sensor.*_status |
Controller status: active / deadband / disabled / idle_import_ok / idle_export_ok / maximizing_export / maximizing_import |
sensor.*_calibration_status |
Calibration lifecycle: idle / running / done / failed |
number.*_deadband_w |
Deadband (W) |
number.*_ewm_alpha |
EWM filter alpha |
switch.*_controller_enabled |
Enable / disable the controller |
button.*_reset_pid |
Reset PID integrator |
button.*_recalibrate |
Re-run step-response calibration |
| Entity | Description |
|---|---|
sensor.*_setpoint |
Current setpoint value (numeric arrays) |
binary_sensor.*_setpoint |
Current commanded on/off state (switch arrays) |
| Entity | Description |
|---|---|
sensor.*_setpoint |
Current commanded setpoint (W) |
| Entity | Description |
|---|---|
sensor.*_setpoint |
Current setpoint value (numeric loads) |
| Service | Description |
|---|---|
zero_grid_controller.reset_pid |
Reset the PID integrator and derivative history |
zero_grid_controller.recalibrate |
Re-run step-response calibration for the configured instance; entry_id is optional |
Download a diagnostics snapshot via Settings → Devices & Services → Zero Grid Controller → ⋮ → Download diagnostics.
Open it in the online analyzer to inspect:
- Current control status and grid measurements
- Per-array setpoints, power sensor, W/unit, derived max power, and calibration confidence
- Downward and upward settling times from bidirectional midpoint calibration
- Battery setpoints and capacity
- PID gains, integrator state, and the calibrated numeric arrays currently used as the PID basis
- Actionable recommendations
Why isn't my inverter running at 100%?
The controller is actively limiting it to keep grid export near zero. If consumption exceeds PV production, the inverter runs at its maximum and the controller does not restrict it further.
My inverter responds slowly and the controller oscillates.
Switch aggressiveness to Cautious via Configure. If that is not enough, run calibration — it will measure the actual settling time and adjust gains accordingly.
I have two inverters — can I control both?
Yes. Add a second PV array via Add entry → PV array. Each array gets its own sub-device with an independent setpoint sensor.
Can I disable the controller temporarily?
Set an Enable switch in the main settings. Turning the switch off puts the controller in safe state (numeric arrays at maximum, batteries at zero).
When does the battery discharge?
Without an optimizer schedule sensor: only when all numeric PV arrays are already at their maximum setpoint and the grid is still importing — the battery is the last resort, not the first.
With an optimizer schedule sensor: the battery follows the planned setpoint at all times and adds a real-time correction for short-term grid fluctuations. Charge and discharge are determined by the optimizer's schedule, not purely by instantaneous grid state.
pid.py— Discrete PID with conditional anti-windup and per-cycle integrator freeze.calibrator.py— Async midpoint calibration with direct array power sensors; measures W/unit, derived max power, and directional settling times.coordinator.py—DataUpdateCoordinatorsubclass; runs every 5 s. HA integration glue — delegates all control logic toControlEngine.control_engine.py— Pure stateful control logic (EWM filter, PID, battery layers, distribution, hysteresis); no HA lifecycle imports, independently unit-testable.array.py—ArrayConfigdataclass; one per PV array subentry.battery.py—BatteryConfigdataclass; one per battery subentry.load.py—LoadConfigdataclass; one per controllable load subentry (numeric or switch).config_flow.py— Main flow + array, battery, and load subentry flows.