pvteslahome-assistantsolarneoom

Charging a Tesla from PV Surplus: NEOOM + Hysteresis

/Published/Updated

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.50substitute 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 unavailable values. 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.

I'm René, a CTO based near Linz, Austria. If your problem is bigger than a roller shutter — streaming, cloud, local LLMs, technical leadership — here's what I do for a living.