Sending solar back to the grid usually pays a fraction of what you pay to buy it back, so the cheapest electricity your car can drink is the surplus your own roof makes but the house is not using. This recipe points that surplus at the charger. When production climbs past the threshold you set and holds there, charging starts, and when production drops back it stops.
The two thresholds and the hold time are yours to set. A gap between the start and stop levels keeps the charger from flicking on and off as a cloud passes, and the hold time makes sure a brief peak does not start a session that a cloud then ends a minute later. It controls the charger through its own start and stop in Home Assistant, not by cutting power, so the car and the charger stay in step.
Bringing in the grid meter and the battery
Solar production alone does not know what the house is doing. Add a power sensor at your grid connection as the optional energy-monitor role and the recipe keeps the car off the grid rather than merely waiting for a clear moment to start. Production on its own cannot see your house: 1.5 kW on the roof with 1.4 kW already being used is not a surplus, but it clears the start level, and the car would quietly run on imported power. With the meter picked, a session that starts importing is stopped.
That check is deliberately patient. The car is part of what the meter reads, so the recipe waits for import to stay above the ceiling for the hold time before acting, and then leaves the charger off for the hold time before reconsidering. Otherwise it would stop the moment the car drew anything and start again the moment it stopped.
Add a home battery as the optional role and the recipe waits until the battery is above the reserve level you set before it starts the car. The house battery fills first, and only the surplus beyond it goes to the car.
Files Selora AI's pipeline reads to install this recipe. The manifest declares roles, inputs, and integrations; the package files are Jinja-templated YAML applied to Home Assistant.
# Solar Surplus EV Charging — start the EV charger when solar production runs
# a surplus and stop it when production drops.
#
# solar_power is the PV production sensor; ev_charger is the charger's start
# and stop switch. Two optional gates refine it: a grid power sensor so the car
# only charges while the house is not importing, and a home battery charge
# sensor so it waits until the battery is above a reserve. Both apply for the
# whole session. The grid ceiling needs a sustain window and a restart cooldown
# to do that, because the charger is part of what its meter reads. The
# start and stop thresholds differ (hysteresis) so a passing cloud does not
# flap the charger, and both sides wait out a sustain window.
slug: solar-surplus-ev-charging
version: 1.0.0
title: Solar Surplus EV Charging
tagline: Charge the car on the surplus your roof makes, and stop when the sun drops.
description: >-
Starts the EV charger when solar production climbs past the surplus threshold
and holds there, and stops it when production falls back. Optional gates keep
the car off the grid and off a home battery's reserve, stopping a session that
starts importing as well as holding one back.
author: Selora AI
released: "2026-08-28"
min_integration_version: "0.12.0"
tags: [energy, solar, ev-charging]
roles:
# Solar production power sensor. sensor + device_class power. Single: one
# array total drives the decision.
- id: solar_power
title: Solar production
kind: sensor
device_class: power
min_count: 1
max_count: 1
selection: required
description: >-
The solar production power sensor the recipe watches for a surplus.
# EV charger start/stop switch. kind switch. Single.
- id: ev_charger
title: EV charger
kind: switch
min_count: 1
max_count: 1
selection: required
description: >-
The charger's start and stop switch. The recipe pauses and resumes
charging rather than cutting power.
# Optional grid connection power sensor. Positive when importing. Single.
- id: grid_power
title: Grid power
kind: sensor
device_class: power
min_count: 0
max_count: 1
selection: required
description: >-
Optional. A power sensor at the grid connection so the car only charges
while the house is not importing. Leave empty to gate on solar alone.
# Optional home battery charge level. sensor + device_class battery. Single.
- id: battery_soc
title: Home battery charge
kind: sensor
device_class: battery
min_count: 0
max_count: 1
selection: required
description: >-
Optional. A home battery's charge level so the car waits until the
battery is above its reserve. Leave empty to charge on solar alone.
inputs:
# Production above this starts charging. 1400 W is roughly the floor a
# Level 2 charger draws, so below it there is not enough to bother.
- id: surplus_watts
type: number
label: Start above (watts)
description: >-
Solar production above this level starts charging.
default: 1400
min: 0
max: 20000
# Production below this stops charging. Lower than the start level so a
# passing cloud does not flap the charger.
- id: stop_watts
type: number
label: Stop below (watts)
description: >-
Solar production below this level stops charging. Keep it below the start
level: that gap is what stops a passing cloud flapping the charger. Set at
or above the start level it is treated as equal to it, which is valid but
leaves no gap at all.
default: 800
min: 0
max: 20000
# How long production must hold past a threshold before the recipe acts, so a
# brief spike or dip does not start or stop a session.
- id: sustain_minutes
type: number
label: Hold time (minutes)
description: >-
How long production must stay past a threshold before charging starts or
stops.
default: 3
min: 0
max: 60
# Grid import ceiling, used only when a grid sensor is picked. The car only
# charges while grid power is below this.
- id: grid_import_max
type: number
label: Grid import ceiling (watts)
description: >-
With a grid sensor picked, the car only charges while grid import is
below this.
default: 200
min: 0
max: 20000
# How long the grid guard holds the car off after it stops a session. The
# latch also clears whenever the surplus itself goes away, so this only
# governs the case where production stays high but the house is using it.
- id: grid_retry_minutes
type: number
label: Retry after grid stop (minutes)
description: >-
After the grid meter stops a session, how long to wait before trying
again. The wait is skipped if the surplus goes away and returns.
default: 60
min: 5
max: 720
# Battery reserve, used only when a battery is picked. The car waits until the
# battery charge is above this.
- id: battery_reserve
type: number
label: Battery reserve (percent)
description: >-
With a home battery picked, the car waits until the battery is above this
charge level.
default: 80
min: 0
max: 100
package_files:
- package/helpers/surplus_state.yaml.j2
- package/automations/surplus.yaml.j2
{# Solar Surplus EV Charging: a flag tracks whether there is a real, sustained
surplus, and two automations start and stop the charger from it.
The flag rises when solar production has held above the surplus threshold for
the hold time, and falls when it has held below the stop threshold for the
same. The stop threshold sits lower than the start threshold so a passing
cloud does not flap the charger.
Putting the hold on the flag rather than on the start automation is what
makes the periodic re-check safe. That re-check exists so charging still
starts when the battery reaches its reserve or grid import drops after solar
has already passed the threshold, rather than waiting for solar to dip and
cross again. Evaluated against the raw sensor it also skipped the hold
entirely, so a momentary spike that happened to land on the five-minute mark
started the charger. Against the flag it cannot: the flag only rises after a
sustained surplus, whatever asks about it.
A numeric_state trigger only fires on a crossing, so the flag alone would
strand a home that restarted in the middle of a sunny day: production is
already above the threshold, no crossing is left to fire, and charging would
wait until the next morning. The seed automation covers that. It samples the
sensor at startup and again a hold time later, and raises the flag only if
both readings are above the threshold. Two samples is weaker than the
trigger's continuous hold, but it applies only on the restart path and is far
better than either seeding blind or losing the day.
The optional grid and battery gates are added only when those sensors are
picked, and both apply for as long as a session runs, not only at its start.
They need different machinery to do that, because the charger changes one of
them and not the other.
Production alone cannot see the house. With 1.5 kW on the roof and 1.4 kW of
household load there is no real surplus, yet production clears the start
threshold, and the car then runs almost entirely on imported power. The grid
meter is what catches this, so it has to keep applying after the charger is
on. It cannot be judged instantly, though: the car is part of what the meter
reads, so any draw at all looks like a breach. It therefore gets its own
automation with a sustain window, which separates "charging on sunshine" from
"charging on the grid".
Stopping on it also has to latch. The charger was the load, so the moment it
goes off the meter reads healthy again: a cooldown alone only paces the cycle,
it does not end it, and production can stay above the start threshold all
afternoon while the house quietly eats it. The latch holds the car off until
the surplus genuinely restarts, or until the retry window lapses and it is
worth another look because the household load may have dropped.
The battery reserve needs none of that. It moves slowly and the car draining
it is precisely what the reserve exists to prevent, so it rides the ordinary
five-minute re-check. #}
{% set su = slug | replace('-', '_') %}
{% set surplus = 'input_boolean.' ~ su ~ '_surplus' %}
{% set grid_block = 'input_boolean.' ~ su ~ '_grid_block' %}
{# The stop threshold has to sit at or below the start threshold. The two inputs
are range-checked independently, so nothing stops a homeowner setting stop
above start, and that overlap is not merely untidy: with start 1000 and stop
1500, production of 1200 satisfies "above start" and "below stop" at the same
time, so both automations fire and the flag races between on and off. Clamping
here makes the overlap impossible to express, at the cost of collapsing the
hysteresis band to nothing when the inputs contradict each other. #}
{% set stop_watts = [inputs.stop_watts, inputs.surplus_watts] | min %}
{# device_class power allows watts, kilowatts or megawatts, and numeric_state
compares the raw state with no unit conversion: a 5.2 kW inverter reads as
5.2 against a watt threshold and never crosses it. This renders the reading
normalised to watts, so the thresholds mean what their labels say whatever
the sensor reports. Template triggers still support for:, so the hold times
below are unchanged. #}
{% macro watts(e) -%}
states('{{ e }}') | float(0) * (1000 if state_attr('{{ e }}', 'unit_of_measurement') == 'kW' else (1000000 if state_attr('{{ e }}', 'unit_of_measurement') == 'MW' else 1))
{%- endmacro %}
{# A gate can only protect anything while its sensor is readable. is_number is
false for unknown, unavailable and any non-numeric state, which lets each
gate below be written to fail in the safe direction rather than silently
passing: start requires a readable, in-range reading, and stop treats an
unreadable one as out of range. Without this the float(0) fallback reads an
unavailable grid meter as zero import, which looks exactly like a house
consuming nothing and lets the car charge on. #}
{% macro readable(e) -%}
states('{{ e }}') | is_number
{%- endmacro %}
{% macro num(e) -%}
states('{{ e }}') | float(0)
{%- endmacro %}
automation:
- id: selora_recipe_{{ su }}_surplus_on
alias: "Solar Surplus EV Charging: surplus held"
description: >-
Raise the surplus flag once solar production has held above the threshold
for the hold time.
mode: single
trigger:
- platform: template
value_template: "{% raw %}{{{% endraw %} {{ watts(roles.solar_power[0]) }} > {{ inputs.surplus_watts }} {% raw %}}}{% endraw %}"
for:
minutes: {{ inputs.sustain_minutes }}
action:
- service: input_boolean.turn_on
target:
entity_id: {{ surplus }}
- id: selora_recipe_{{ su }}_surplus_off
alias: "Solar Surplus EV Charging: surplus gone"
description: >-
Clear the surplus flag once solar production has held below the stop
threshold for the hold time.
mode: single
trigger:
- platform: template
value_template: "{% raw %}{{{% endraw %} {{ watts(roles.solar_power[0]) }} < {{ stop_watts }} {% raw %}}}{% endraw %}"
for:
minutes: {{ inputs.sustain_minutes }}
# Also reconcile on startup. numeric_state only fires on a crossing, so a
# restart that comes back with the flag restored on and production already
# below the stop threshold leaves no edge left to fire. The flag would
# stay up with no surplus behind it and the start automation's periodic
# re-check would then run the charger off the grid, overnight. The seed
# automation covers the mirror case on the way up; this covers the way
# down.
- platform: homeassistant
event: start
condition:
# Already true whenever the trigger above fires, so in practice this only
# gates the startup path. float(0) is deliberate: a sensor still
# unavailable just after a restart reads as no production and clears the
# flag, because a stale flag left up is the failure that starts the
# charger, and not clearing it is the expensive direction to be wrong in.
- condition: template
value_template: "{% raw %}{{{% endraw %} {{ watts(roles.solar_power[0]) }} < {{ stop_watts }} {% raw %}}}{% endraw %}"
action:
- service: input_boolean.turn_off
target:
entity_id: {{ surplus }}
{% if roles.grid_power %}
# A new surplus deserves a fresh attempt, so the grid latch clears with
# the surplus that it was holding off.
- service: input_boolean.turn_off
target:
entity_id: {{ grid_block }}
{% endif %}
- id: selora_recipe_{{ su }}_surplus_seed
alias: "Solar Surplus EV Charging: re-check after a restart"
description: >-
After a restart, raise the surplus flag once production has been seen
above the threshold for the hold time.
mode: restart
trigger:
- platform: homeassistant
event: start
condition:
- condition: template
value_template: "{% raw %}{{{% endraw %} {{ watts(roles.solar_power[0]) }} > {{ inputs.surplus_watts }} {% raw %}}}{% endraw %}"
action:
- delay:
minutes: {{ inputs.sustain_minutes }}
- condition: template
value_template: "{% raw %}{{{% endraw %} {{ watts(roles.solar_power[0]) }} > {{ inputs.surplus_watts }} {% raw %}}}{% endraw %}"
- service: input_boolean.turn_on
target:
entity_id: {{ surplus }}
- id: selora_recipe_{{ su }}_start
alias: "Solar Surplus EV Charging: start"
description: >-
While the surplus flag is up, and any grid and battery gates are
satisfied, start charging.
mode: single
trigger:
# The surplus becoming sustained.
- platform: state
entity_id: {{ surplus }}
to: "on"
# Periodic re-check, so charging still starts when the battery reaches its
# reserve or grid import drops while the surplus is already up.
- platform: time_pattern
minutes: "/5"
condition:
- condition: state
entity_id: {{ surplus }}
state: "on"
{% if roles.grid_power %}
- condition: template
value_template: "{% raw %}{{{% endraw %} {{ readable(roles.grid_power[0]) }} and {{ watts(roles.grid_power[0]) }} < {{ inputs.grid_import_max }} {% raw %}}}{% endraw %}"
{% endif %}
{% if roles.battery_soc %}
- condition: template
value_template: "{% raw %}{{{% endraw %} {{ readable(roles.battery_soc[0]) }} and {{ num(roles.battery_soc[0]) }} > {{ inputs.battery_reserve }} {% raw %}}}{% endraw %}"
{% endif %}
# for: is the cooldown. After the import guard below stops a session, the
# charger's own load vanishes and grid import immediately looks healthy
# again, so without this the next five-minute tick would start straight
# back up and the pair would cycle. Requiring the charger to have been off
# for the hold time makes a stop actually settle. It costs nothing on a
# normal day, when the charger has been off all night anyway.
- condition: state
entity_id: {{ roles.ev_charger[0] }}
state: "off"
for:
minutes: {{ inputs.sustain_minutes }}
{% if roles.grid_power %}
- condition: state
entity_id: {{ grid_block }}
state: "off"
{% endif %}
action:
- service: switch.turn_on
target:
entity_id: {{ roles.ev_charger[0] }}
{% if roles.grid_power %}
- id: selora_recipe_{{ su }}_grid_stop
alias: "Solar Surplus EV Charging: stop on sustained import"
description: >-
Stop charging when the house has been importing above the ceiling for the
hold time.
mode: single
trigger:
# Sustained, and on its own trigger rather than the five-minute tick,
# because the charger is part of what this meter reads. Judged instantly
# it would stop the moment the car drew anything and restart as soon as it
# stopped. Held for the hold time, it distinguishes "the car is running on
# sunshine" from "the car is running on the grid", which is the whole
# promise of picking a grid meter. An unreadable meter counts as over the
# ceiling: a gate that cannot be read must not quietly permit charging.
- platform: template
value_template: "{% raw %}{{{% endraw %} not {{ readable(roles.grid_power[0]) }} or {{ watts(roles.grid_power[0]) }} > {{ inputs.grid_import_max }} {% raw %}}}{% endraw %}"
for:
minutes: {{ inputs.sustain_minutes }}
# A template trigger fires on a false-to-true change, so a restart that
# comes back with the charger already on and import already over the
# ceiling has no transition left to fire. Nothing else watches import, so
# the car would keep charging off the grid indefinitely. The condition
# below is true whenever the sustained trigger fires, so it only really
# gates this startup path.
- platform: homeassistant
event: start
condition:
- condition: state
entity_id: {{ roles.ev_charger[0] }}
state: "on"
- condition: template
value_template: "{% raw %}{{{% endraw %} not {{ readable(roles.grid_power[0]) }} or {{ watts(roles.grid_power[0]) }} > {{ inputs.grid_import_max }} {% raw %}}}{% endraw %}"
action:
- service: switch.turn_off
target:
entity_id: {{ roles.ev_charger[0] }}
# Latch, because stopping removes the very load that tripped the guard.
# Without it the meter reads healthy within seconds and the car restarts
# as soon as the cooldown lapses, all afternoon.
- service: input_boolean.turn_on
target:
entity_id: {{ grid_block }}
- id: selora_recipe_{{ su }}_grid_retry
alias: "Solar Surplus EV Charging: retry after a grid stop"
description: >-
Release the grid latch once the retry window has passed, in case the
household load that caused it has since dropped.
mode: single
trigger:
- platform: state
entity_id: {{ grid_block }}
to: "on"
for:
minutes: {{ inputs.grid_retry_minutes }}
# The latch survives a restart but this for: timer does not, and a helper
# restored straight to on never transitions to on again. Without this the
# latch would sit there for good and the car would never charge again.
# Releasing it at startup costs at most one more attempt: if the house is
# still eating the surplus, the guard simply latches again a hold time
# later, which is the self-correcting direction to fail in.
- platform: homeassistant
event: start
condition:
- condition: state
entity_id: {{ grid_block }}
state: "on"
action:
- service: input_boolean.turn_off
target:
entity_id: {{ grid_block }}
{% endif %}
- id: selora_recipe_{{ su }}_stop
alias: "Solar Surplus EV Charging: stop"
description: >-
When the surplus is gone, stop charging.
mode: single
trigger:
# The surplus going away.
- platform: state
entity_id: {{ surplus }}
to: "off"
# Periodic re-check on the same cadence as start, so the battery reserve
# keeps applying for as long as the session runs rather than only at the
# instant it began. The grid ceiling is enforced too, but by its own
# automation above rather than here, because it needs a sustain window
# that a condition on this tick cannot express.
- platform: time_pattern
minutes: "/5"
condition:
- condition: state
entity_id: {{ roles.ev_charger[0] }}
state: "on"
# Stop when the surplus is gone, or when a picked battery falls under its
# reserve. Battery charge needs no sustain window: it moves slowly, and the
# car draining it is exactly what the reserve exists to stop. start
# requires the charger off and every gate satisfied, so the two automations
# share the periodic tick without fighting: for any given state at most one
# of them can pass its conditions.
- condition: or
conditions:
- condition: state
entity_id: {{ surplus }}
state: "off"
{% if roles.battery_soc %}
# <= not <, because battery sensors report whole percentages and land
# exactly on the reserve constantly. Starting needs strictly above the
# reserve and stopping happens at it, so the reserve is a floor the car
# is never allowed to eat into rather than one it may sit on.
- condition: template
value_template: "{% raw %}{{{% endraw %} not {{ readable(roles.battery_soc[0]) }} or {{ num(roles.battery_soc[0]) }} <= {{ inputs.battery_reserve }} {% raw %}}}{% endraw %}"
{% endif %}
action:
- service: switch.turn_off
target:
entity_id: {{ roles.ev_charger[0] }}
{# Solar Surplus EV Charging helper: a flag that is up only while solar
production has genuinely held above the surplus threshold for the hold time.
Every decision to start or stop charging reads the flag rather than the raw
sensor, so the hold is enforced on every path, including the periodic
re-check. It survives a restart, so a surplus in progress is not forgotten.
The second flag latches the grid guard. Production can sit above the start
threshold all afternoon while the house eats it, and then stopping the
charger immediately makes the grid meter look healthy again, because the
charger was the load. An off-duration cooldown alone therefore just paces the
cycle rather than ending it. Once the guard has fired, this latch holds the
car off until the surplus genuinely restarts, or until the retry window has
passed and it is worth testing again because the house load may have dropped.
#}
input_boolean:
{{ slug | replace('-', '_') }}_surplus:
name: "Solar Surplus: sustained surplus"
icon: mdi:solar-power-variant
{{ slug | replace('-', '_') }}_grid_block:
name: "Solar Surplus: held off by grid import"
icon: mdi:transmission-tower-import