Charging a Tesla from PV Surplus: NEOOM + Hysteresis
The idea was simple and sounded like an afternoon project: let the Tesla charge while the sun is delivering, and stop before it drains my house battery. My rule was easy to state: if the battery drops below 50 %, stop charging.
It did not stay an afternoon project. It turned into a lesson about why a single threshold is the wrong answer to a fluctuating measurement — and why it makes an enormous difference where the value comes from and how often you get it.
The naive first attempt: cron every 15 minutes
My first build was as unremarkable as it sounds. A shell script that pulled the battery state of charge over the REST API, compared it to 50, and stopped charging if it was below. Plus a cron entry:
*/15 * * * * /usr/local/bin/stop_tesla_charge.sh
Check every 15 minutes. That’s plenty, I figured.
It was not plenty. The line I eventually wrote into my own log was: “Never happened but charging been stopped” — charging had been stopped even though the condition was never met. And an automation that does things it shouldn’t is worse than no automation at all, because then the car sits there at 40 % in the morning and you don’t even know why.
Mistake 1: the comparison wasn’t a numeric comparison
The actual bug was embarrassingly small. Home Assistant always returns entity states over the API as strings. A fetch looks roughly like this:
{
"entity_id": "sensor.neoom_battery_soc",
"state": "47.0",
"attributes": { "unit_of_measurement": "%", "device_class": "battery" }
}
Note the quotes: "47.0", not 47.0. Feed that into a comparison unchecked
and you’re comparing text, not numbers — and text compares lexicographically.
"9" then sorts as greater than "50", because 9 comes after 5. For
some values the result happens to be correct, for others it isn’t. That
“correct sometimes” behavior cost me days, precisely because the fault
wasn’t reproducible — days in which I suspected everything except my own
comparison operator.
The lesson: always cast explicitly in Home Assistant. In Jinja templates
that means | float(0); in numeric triggers HA does the conversion for
you — another reason to prefer native triggers over scripts.
Mistake 2: the query hung off a fragile service
My script didn’t run inside Home Assistant, it ran on a server next to it, and part of the chain depended on an old FHEM service. At some point that one answered with:
Failed to restart fhem.service: Connection timed out
The script got no response, but had no proper error handling — an empty value was treated as “below threshold” and charging was cut. So a timeout in a service that has nothing whatsoever to do with the car ended the charge. Thanks for that, past me.
Every external dependency in that chain is one more reason your car won’t charge overnight. Keep the chain short.
Mistake 3: no hysteresis band — the charger flaps
And even with a correct numeric comparison and a stable query, the real design flaw remains: a single threshold cannot work.
The battery level oscillates around the boundary. 49.8 → stop. Two minutes later 50.2 → start. Then 49.7 → stop. That’s called flapping, and for a wallbox it’s genuine wear: contactors switch, the car re-handshakes, and every restart costs 30 seconds before power actually flows. With drifting clouds you can watch that go on for hours.
The fix is hysteresis: two separate thresholds plus a minimum dwell time.
- start only when surplus is above the upper threshold
- stop only when it falls below the lower threshold
- and in both cases only after the condition has persisted for a while
That is exactly what Home Assistant’s numeric_state with for: is for —
and why the native automation is structurally superior to the cron job.
The prerequisite: you have to measure the surplus at all
Before any automation makes sense, you need an honest reading of production, consumption and the grid connection point. The inverter alone isn’t enough — it knows what it produces, but not what the rest of the house is drawing. That difference is the surplus.
In my house a NEOOM energy manager sits at the connection point, and the
decisive discovery for this project was that it exposes a local API on the
LAN. No cloud account, no detour via a vendor server, no rate limits. An energy
manager that just answers on the LAN — I had almost forgotten that was still
allowed. My
box lives at 192.168.1.50 — substitute your own IP here; that is not
some universal address, it’s simply mine.
The endpoint that serves live data for me is:
http://192.168.1.50/api/v1/site/state
The response contains, among other things, energyFlow.states[] — a list of
all measured values. Before you copy anything, look at it raw once:
curl -s http://192.168.1.50/api/v1/site/state | jq '.energyFlow.states'
That matters, because I can’t guarantee the field names inside each entry
for every firmware. The keys I see on my system are POWER_GRID,
POWER_PRODUCTION, POWER_CONSUMPTION_CALC and STATE_OF_CHARGE, plus the
cumulative energy counters. Check once with the curl above which field
holds that key, adjust the templates below a single time, and you’re done.
If your NEOOM setup requires authentication for the local API: that varies between installations and I’m not going to guess. Check NEOOM’s own documentation or ask your installer.
The NEOOM integration in Home Assistant
I keep the entire integration in one package file at
/config/packages/neoom.yaml. That has served me well: everything on one
topic in one place, and you can delete or rebuild it wholesale without
taking your configuration.yaml apart. If you haven’t enabled packages yet,
this goes into configuration.yaml once:
homeassistant:
packages: !include_dir_named packages
The trick for the rest is to not create ten REST sensors all hitting the
same endpoint. I make exactly one request and store the whole states list
as a JSON attribute. Everything else is template sensors reading from
that single attribute — which costs no additional network traffic at all:
# /config/packages/neoom.yaml
rest:
- resource: "http://192.168.1.50/api/v1/site/state"
scan_interval: 30
timeout: 10
sensor:
- name: "neoom_raw_state"
unique_id: neoom_raw_state
# The state itself is irrelevant — the payload lives in the attribute.
value_template: "{{ now().timestamp() | int }}"
json_attributes_path: "$.energyFlow"
json_attributes:
- states
scan_interval: 30 is the real win over my old cron job: because the API is
local, polling every 30 seconds costs nobody anything — no cloud quota,
no round trip across the internet, no dependency on whether the vendor
happens to be doing maintenance.
Then the template sensors. device_class and state_class matter, otherwise
the Energy Dashboard will refuse the sensors later:
template:
- sensor:
- name: "neoom Power Grid"
unique_id: neoom_power_grid
unit_of_measurement: "W"
device_class: power
state_class: measurement
state: >-
{% set s = state_attr('sensor.neoom_raw_state', 'states') | default([], true) %}
{% set v = s | selectattr('key', 'eq', 'POWER_GRID')
| map(attribute='value') | list %}
{{ v[0] | float(0) if v | count > 0 else none }}
availability: >-
{{ state_attr('sensor.neoom_raw_state', 'states') is iterable }}
- name: "neoom Power Production"
unique_id: neoom_power_production
unit_of_measurement: "W"
device_class: power
state_class: measurement
state: >-
{% set s = state_attr('sensor.neoom_raw_state', 'states') | default([], true) %}
{% set v = s | selectattr('key', 'eq', 'POWER_PRODUCTION')
| map(attribute='value') | list %}
{{ v[0] | float(0) if v | count > 0 else none }}
- name: "neoom Power Consumption"
unique_id: neoom_power_consumption
unit_of_measurement: "W"
device_class: power
state_class: measurement
state: >-
{% set s = state_attr('sensor.neoom_raw_state', 'states') | default([], true) %}
{% set v = s | selectattr('key', 'eq', 'POWER_CONSUMPTION_CALC')
| map(attribute='value') | list %}
{{ v[0] | float(0) if v | count > 0 else none }}
- name: "neoom Battery SOC"
unique_id: neoom_battery_soc
unit_of_measurement: "%"
device_class: battery
state_class: measurement
state: >-
{% set s = state_attr('sensor.neoom_raw_state', 'states') | default([], true) %}
{% set v = s | selectattr('key', 'eq', 'STATE_OF_CHARGE')
| map(attribute='value') | list %}
{{ v[0] | float(0) if v | count > 0 else none }}
The cumulative counters follow the same pattern, just with
device_class: energy and state_class: total_increasing — that’s the
combination the Energy Dashboard wants to see:
- name: "neoom Energy Imported"
unique_id: neoom_energy_imported
unit_of_measurement: "kWh"
device_class: energy
state_class: total_increasing
state: >-
{% set s = state_attr('sensor.neoom_raw_state', 'states') | default([], true) %}
{# Take the energy counter key names from your own curl dump #}
{% set v = s | selectattr('key', 'eq', 'ENERGY_IMPORTED')
| map(attribute='value') | list %}
{{ v[0] | float(0) if v | count > 0 else none }}
Same again for neoom_energy_exported, neoom_energy_produced,
neoom_energy_charged and neoom_energy_discharged.
One trap that cost me time: watch which unit your box actually reports.
On my system sensor.neoom_power_production and
sensor.neoom_power_consumption ended up in kW, while
sensor.neoom_power_grid came in as W. Write > 2500 in a trigger
against a sensor that counts in kW and you’ll be waiting for 2500 kW of
surplus until the heat death of the universe. Pick one unit and carry it
through consistently.
The Energy Dashboard
Once the counters exist, wiring them up under Settings → Dashboards → Energy is trivial. This is the mapping I run:
| Energy Dashboard field | Entity |
|---|---|
| Grid consumption | sensor.neoom_energy_imported |
| Return to grid | sensor.neoom_energy_exported |
| Solar production | sensor.neoom_energy_produced |
| Battery charged | sensor.neoom_energy_charged |
| Battery discharged | sensor.neoom_energy_discharged |
The sensors only show up there if device_class: energy and
state_class: total_increasing are both set. Miss either one and the
dropdown stays empty while you hunt for the bug in the wrong place.
Cron polling vs. local API: the actual leap
In hindsight my fundamental mistake wasn’t the threshold, it was the architecture of the measurement. An external cron job every 15 minutes is a polling model with the worst possible resolution, and every layer in it is a failure mode.
| Criterion | External cron every 15 min | Local NEOOM API + HA triggers |
|---|---|---|
| Resolution | 15 minutes | 30 seconds (scan_interval) |
| Where the logic runs | shell script beside HA | inside Home Assistant |
| Reaction under clouds | misses short dips entirely | reacts, debounced with for: |
| Type conversion | failure mode (strings!) | HA casts inside numeric_state |
| Hysteresis | you build it yourself | two triggers + for: |
| Cloud dependency | present, depending on source | none, all on the LAN |
| Visibility when it fails | a log file, if you’re lucky | automation trace in the UI |
| Verdict | no | yes |
The cron route isn’t “simpler”, it just moves the complexity somewhere you can’t see it.
Staying honest: what the local API does and doesn’t buy you
The local API is a genuine advantage: if the vendor’s cloud goes down, my surplus charging keeps running, because none of it leaves the house. That is exactly the kind of independence I have stopped expecting from anything that ships with an app.
But it is not an open standard — it’s a vendor-specific interface. It can
change with a firmware update: field names, structure, in the worst case the
path itself. My habit as a result: after every update, run the curl from
above once and check the sensors are still producing values. And because it
all lives in /config/packages/neoom.yaml, the repair is one file rather
than a scavenger hunt through the configuration.
The automation I actually run
First, two helpers so the thresholds are adjustable without editing YAML:
# configuration.yaml
input_number:
pv_surplus_start:
name: PV surplus start threshold
min: 500
max: 11000
step: 100
unit_of_measurement: W
pv_surplus_stop:
name: PV surplus stop threshold
min: 0
max: 10000
step: 100
unit_of_measurement: W
input_boolean:
pv_charging_enabled:
name: PV surplus charging enabled
Plus a sensor for the surplus itself — production minus consumption, from the NEOOM values:
template:
- sensor:
- name: "PV Surplus"
unique_id: pv_surplus
unit_of_measurement: "W"
device_class: power
state_class: measurement
state: >-
{{ (states('sensor.neoom_power_production') | float(0)
- states('sensor.neoom_power_consumption') | float(0)) | round(0) }}
Then the start automation. The critical part is for: — the surplus has to
stay above the threshold for five continuous minutes; one sunny second
doesn’t count:
automation:
- id: pv_tesla_charge_start
alias: "PV surplus: start charging"
mode: single
trigger:
- platform: numeric_state
entity_id: sensor.pv_surplus
above: input_number.pv_surplus_start
for:
minutes: 5
condition:
- condition: state
entity_id: input_boolean.pv_charging_enabled
state: "on"
- condition: numeric_state
entity_id: sensor.neoom_battery_soc
above: 60
- condition: state
entity_id: binary_sensor.tesla_charger_connected
state: "on"
action:
- service: number.set_value
target:
entity_id: number.tesla_charging_amps
data:
value: 8
- service: switch.turn_on
target:
entity_id: switch.tesla_charger
And the stop automation with the lower threshold and a longer dwell time — stopping is allowed to be lazier than starting:
- id: pv_tesla_charge_stop
alias: "PV surplus: stop charging"
mode: single
trigger:
- platform: numeric_state
entity_id: sensor.pv_surplus
below: input_number.pv_surplus_stop
for:
minutes: 10
- platform: numeric_state
entity_id: sensor.neoom_battery_soc
below: 50
for:
minutes: 10
condition:
- condition: state
entity_id: switch.tesla_charger
state: "on"
action:
- service: switch.turn_off
target:
entity_id: switch.tesla_charger
So the 50 % rule from my very first attempt survived — it just lives in the
second trigger now, debounced with for: minutes: 10 and fed from
sensor.neoom_battery_soc, instead of in a shell script running every 15
minutes.
About the names: the sensor.neoom_* entities will be called exactly
that on your system too if you take the templates above. But
switch.tesla_charger, number.tesla_charging_amps and
binary_sensor.tesla_charger_connected are placeholders — how your Tesla
integration names its entities is visible under Developer Tools → States.
Mine are named after the car and are guaranteed to differ from yours.
You don’t need any extra hardware for the software side of this. If you charge from a regular socket rather than a wallbox, a decent Type 2 charging cable* is the one purchase you’ll notice immediately.
What does NOT work
- Polling too slowly. Fifteen minutes is an eternity under moving clouds. You end up reacting to a state that no longer exists. With a local API there’s no reason to be slower than every 30 seconds.
- Comparing states as text. The HA state is a string. Without
| float(0)you get sporadically wrong results — the nastiest class of bug, because it only shows up sometimes. - Mixing units. kW and W in the same comparison and the trigger never fires. Decide once, apply everywhere.
- One threshold for both start and stop. Guaranteed flapping. There needs to be a meaningful gap between the two.
- Omitting
for:. Without a minimum dwell time every gap in the clouds fires the automation. - Depending on an unrelated service. A timeout in some side service must never be the reason the car doesn’t charge.
- Ten REST sensors against the same endpoint. Fetch once, store as an attribute, split it up with templates.
- Ignoring empty or
unavailablevalues. If a sensor drops out, that must not silently count as “below threshold”. Check the state explicitly. - Re-adjusting charge current every second. The car can’t keep up. One or two adjustments per minute is the practical ceiling.
Verdict
The interesting part of surplus charging isn’t the threshold. I had that right from day one — 50 % battery, clear rule. The interesting part is everything around it: where the value comes from, how often you measure, how you interpret it, and how you stop a limit from turning into a switch that clatters once a second.
The single biggest step forward was moving the measurement off the external cron job and onto the local NEOOM API: 30 seconds instead of 15 minutes, everything on my own network, and the logic somewhere I can follow it in a trace.
If you take one thing away: two thresholds and a for:. That’s the
difference between an automation you trust and one you check on suspiciously
every morning.