Tesla mit PV-Überschuss laden: NEOOM lokal + Hysterese
Die Idee war simpel und klang nach einem Nachmittagsprojekt: Der Tesla soll laden, solange die Sonne liefert — und aufhören, bevor er mir den Hausspeicher leersaugt. Meine Regel dafür war schnell formuliert: fällt der Speicher unter 50 %, wird das Laden gestoppt.
Was daraus wurde, war kein Nachmittagsprojekt. Es wurde eine Lektion darüber, warum ein einzelner Schwellwert die falsche Antwort auf eine schwankende Messgröße ist — und warum es einen enormen Unterschied macht, woher der Messwert kommt und wie oft du ihn bekommst.
Der naive erste Versuch: Cron alle 15 Minuten
Mein erster Aufbau war so unspektakulär, wie er klingt. Ein Shell-Skript, das per REST-API den Ladezustand des Speichers abfragt, ihn mit 50 vergleicht und bei Unterschreitung das Laden beendet. Dazu ein Cron-Eintrag:
*/15 * * * * /usr/local/bin/stop_tesla_charge.sh
Alle 15 Minuten nachschauen. Das reicht doch, dachte ich.
Es reichte nicht. Der Satz, den ich mir irgendwann selbst in den Log geschrieben habe, war: “Never happened but charging been stopped” — das Laden wurde gestoppt, obwohl die Bedingung nie erfüllt war. Und wenn ein Automatismus etwas tut, was er nicht tun sollte, ist er schlimmer als gar kein Automatismus. Denn dann steht der Wagen morgens mit 40 % da, und du weißt nicht einmal, warum.
Fehler 1: Der Vergleich war gar kein Zahlenvergleich
Der eigentliche Bug war peinlich klein. Home Assistant liefert Entitäts-Zustände über die API grundsätzlich als String. Ein Abruf sieht ungefähr so aus:
{
"entity_id": "sensor.neoom_battery_soc",
"state": "47.0",
"attributes": { "unit_of_measurement": "%", "device_class": "battery" }
}
Beachte die Anführungszeichen: "47.0", nicht 47.0. Wer das ungeprüft in
einen Vergleich wirft, vergleicht Text, nicht Zahlen — und Textvergleiche
sortieren lexikografisch. "9" ist dann größer als "50", weil die 9
hinter der 5 im Alphabet kommt. Bei manchen Werten stimmt das Ergebnis
zufällig, bei anderen nicht. Genau dieses “manchmal richtig” hat mich Tage
gekostet, weil der Fehler eben nicht reproduzierbar auftrat — Tage, in denen ich
alles verdächtigt habe außer meinen eigenen Vergleichsoperator.
Die Lehre: In Home Assistant immer explizit casten. In Jinja-Templates
heißt das | float(0), in numerischen Triggern nimmt HA die Konvertierung
selbst vor — ein weiterer Grund, native Trigger statt Skripte zu benutzen.
Fehler 2: Die Abfrage hing an einem fragilen Dienst
Mein Skript lief nicht in Home Assistant, sondern auf einem Server daneben, und ein Teil der Kette hing an einem alten FHEM-Dienst. Der quittierte irgendwann mit:
Failed to restart fhem.service: Connection timed out
Das Skript bekam keine Antwort, hatte aber keine saubere Fehlerbehandlung — ein leerer Wert wurde wie “unter Schwelle” behandelt und das Laden abgebrochen. Ein Timeout in einem Dienst, der mit dem Auto überhaupt nichts zu tun hat, hat also die Ladung beendet. Danke dafür, Ich von damals.
Jede externe Abhängigkeit in dieser Kette ist ein zusätzlicher Grund, warum dein Auto nachts nicht lädt. Halte die Kette kurz.
Fehler 3: Kein Hystereseband — der Lader flattert
Und selbst mit korrektem Zahlenvergleich und stabiler Abfrage bleibt das eigentliche Designproblem: ein einzelner Schwellwert kann nicht funktionieren.
Der Speicherstand pendelt um die Grenze. 49,8 → Stopp. Zwei Minuten später 50,2 → Start. Wieder 49,7 → Stopp. Das nennt sich Flattern, und für eine Wallbox ist es echter Verschleiß: Schütze schalten, das Auto handshaked neu, die Ladung braucht jedes Mal 30 Sekunden bis sie wieder anläuft. Bei ziehenden Wolken kannst du dem stundenlang zusehen.
Die Lösung heißt Hysterese: zwei getrennte Schwellen plus eine Mindestlaufzeit.
- Start erst bei Überschuss über der oberen Schwelle
- Stopp erst bei Unterschreiten der unteren Schwelle
- beides erst, wenn der Zustand eine Weile angehalten hat
Genau dafür hat Home Assistant numeric_state mit for: — und das ist der
Grund, warum die native Automation dem Cron-Job strukturell überlegen ist.
Die Voraussetzung: du musst den Überschuss überhaupt messen können
Bevor irgendeine Automation Sinn ergibt, brauchst du einen ehrlichen Messwert für Erzeugung, Verbrauch und Netzübergabe. Der Wechselrichter allein reicht nicht — der weiß, was er produziert, aber nicht, was der Rest des Hauses gerade zieht. Genau diese Differenz ist der Überschuss.
Bei mir hängt ein NEOOM-Energiemanagement am Hausanschluss, und der
entscheidende Punkt für dieses Projekt war die Entdeckung, dass das Ding
eine lokale API im LAN anbietet. Kein Cloud-Konto, kein Umweg über einen
Herstellerserver, keine Rate-Limits. Ein Energiemanager, der einfach im LAN
antwortet — ich hatte fast vergessen, dass es das noch gibt. Bei mir liegt die Box auf
192.168.1.50 — setz hier deine eigene IP ein, das ist keine
allgemeingültige Adresse, sondern schlicht die meiner Installation.
Der Endpunkt, der bei mir live liefert, ist:
http://192.168.1.50/api/v1/site/state
Die Antwort enthält unter anderem energyFlow.states[] — eine Liste aller
Messwerte. Bevor du irgendetwas kopierst, schau sie dir einmal roh an:
curl -s http://192.168.1.50/api/v1/site/state | jq '.energyFlow.states'
Das ist wichtig, weil ich dir die Feldnamen innerhalb der Einträge nicht
für jede Firmware garantieren kann. Die Schlüssel, die ich bei mir sehe,
sind POWER_GRID, POWER_PRODUCTION, POWER_CONSUMPTION_CALC und
STATE_OF_CHARGE, dazu die kumulierten Energiezähler. Wie das Feld heißt,
in dem dieser Schlüssel steht, prüfst du einmal mit dem curl oben — dann
passt du die Templates unten einmalig an und hast Ruhe.
Falls dein NEOOM-Setup für die lokale API eine Authentifizierung verlangt: Das ist von Installation zu Installation unterschiedlich, und ich rate hier nicht. Schau in die NEOOM-Dokumentation bzw. frag deinen Installateur.
Die NEOOM-Integration in Home Assistant
Ich halte die komplette Integration in einer Paketdatei unter
/config/packages/neoom.yaml. Das hat sich bewährt: alles zu einem Thema an
einem Ort, und du kannst es am Stück löschen oder neu aufsetzen, ohne deine
configuration.yaml zu zerlegen. Falls du Pakete noch nicht aktiviert hast,
gehört das einmalig in die configuration.yaml:
homeassistant:
packages: !include_dir_named packages
Der Trick beim Rest ist, nicht zehn REST-Sensoren anzulegen, die alle
denselben Endpunkt abfragen. Ich mache genau einen Request und lege die
komplette states-Liste als JSON-Attribut ab. Alles Weitere sind
Template-Sensoren, die aus diesem einen Attribut lesen — das kostet keine
zusätzliche Netzwerklast:
# /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
# Der State selbst ist uninteressant — die Nutzdaten stecken im Attribut.
value_template: "{{ now().timestamp() | int }}"
json_attributes_path: "$.energyFlow"
json_attributes:
- states
scan_interval: 30 ist der eigentliche Gewinn gegenüber meinem alten
Cron-Job: Weil die API lokal ist, kostet ein Abruf alle 30 Sekunden
niemanden etwas — kein Cloud-Kontingent, keine Latenz über das Internet,
keine Abhängigkeit davon, ob der Hersteller gerade Wartung hat.
Dann die Template-Sensoren. Wichtig sind device_class und state_class,
sonst nimmt das Energie-Dashboard die Sensoren später nicht an:
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 }}
Die kumulierten Zähler laufen nach demselben Muster, nur mit
device_class: energy und state_class: total_increasing — das ist die
Kombination, die das Energie-Dashboard sehen will:
- 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) %}
{# Schlüsselnamen der Energiezähler aus deinem curl-Dump übernehmen #}
{% set v = s | selectattr('key', 'eq', 'ENERGY_IMPORTED')
| map(attribute='value') | list %}
{{ v[0] | float(0) if v | count > 0 else none }}
Analog dazu neoom_energy_exported, neoom_energy_produced,
neoom_energy_charged und neoom_energy_discharged.
Eine Falle, die mich Zeit gekostet hat: Achte darauf, in welcher Einheit
deine Box liefert. Bei mir standen sensor.neoom_power_production und
sensor.neoom_power_consumption am Ende in kW, während
sensor.neoom_power_grid in W kam. Wenn du dann in einem Trigger
> 2500 schreibst, aber der Sensor in kW rechnet, wartest du bis zum
Sankt-Nimmerleins-Tag auf 2500 kW Überschuss. Leg dich auf eine Einheit
fest und zieh sie konsequent durch.
Das Energie-Dashboard
Wenn die Zähler stehen, ist die Zuordnung unter Einstellungen → Dashboards → Energie trivial. So sieht sie bei mir aus:
| Feld im Energie-Dashboard | Entität |
|---|---|
| Netzbezug | sensor.neoom_energy_imported |
| Einspeisung ins Netz | sensor.neoom_energy_exported |
| Solarproduktion | sensor.neoom_energy_produced |
| Batterie geladen | sensor.neoom_energy_charged |
| Batterie entladen | sensor.neoom_energy_discharged |
Auftauchen tun die Sensoren dort nur, wenn device_class: energy und
state_class: total_increasing gesetzt sind. Fehlt eins von beidem, ist das
Dropdown leer und du suchst den Fehler an der falschen Stelle.
Cron-Polling vs. lokale API: der eigentliche Sprung
Rückblickend war mein Grundfehler nicht der Schwellwert, sondern die Architektur der Messung. Ein externer Cron-Job alle 15 Minuten ist ein Polling-Modell mit der schlechtestmöglichen Auflösung, und jede Schicht darin ist eine Fehlerquelle.
| Kriterium | Externer Cron alle 15 min | Lokale NEOOM-API + HA-Trigger |
|---|---|---|
| Auflösung | 15 Minuten | 30 Sekunden (scan_interval) |
| Wo läuft die Logik | Shell-Skript neben HA | in Home Assistant selbst |
| Reaktion auf Wolken | verpasst kurze Einbrüche komplett | reagiert, mit for: entprellt |
| Typkonvertierung | Fehlerquelle (String!) | HA castet in numeric_state selbst |
| Hysterese | musst du selbst bauen | zwei Trigger + for: |
| Cloud-Abhängigkeit | je nach Quelle vorhanden | keine, alles im LAN |
| Sichtbarkeit bei Fehlern | Logfile, wenn überhaupt | Automations-Trace in der UI |
| Empfehlung | nein | ja |
Der Cron-Weg ist nicht “einfacher”, er verlagert nur die Komplexität dorthin, wo du sie nicht siehst.
Ehrlich bleiben: was die lokale API kann und was nicht
Die lokale API ist ein echter Vorteil: Fällt die Hersteller-Cloud aus, läuft mein Überschussladen weiter, weil nichts davon das Haus verlässt. Das ist genau die Art Unabhängigkeit, die ich von nichts mehr erwarte, was mit einer App geliefert wird.
Aber sie ist kein offener Standard, sondern eine herstellerspezifische
Schnittstelle. Sie kann sich mit einem Firmware-Update ändern — Feldnamen,
Struktur, im Extremfall der Pfad selbst. Meine Konsequenz: nach jedem Update
einmal den curl von oben laufen lassen und prüfen, ob die Sensoren noch
Werte liefern. Und weil alles in /config/packages/neoom.yaml liegt, ist die
Reparatur eine Datei und nicht eine Schnitzeljagd durch die Konfiguration.
Die Automation, die bei mir läuft
Zuerst zwei Helfer, damit die Schwellen ohne YAML-Editieren verstellbar sind:
# configuration.yaml
input_number:
pv_ueberschuss_start:
name: PV-Überschuss Startschwelle
min: 500
max: 11000
step: 100
unit_of_measurement: W
pv_ueberschuss_stopp:
name: PV-Überschuss Stoppschwelle
min: 0
max: 10000
step: 100
unit_of_measurement: W
input_boolean:
pv_laden_aktiv:
name: PV-Überschussladen aktiv
Dazu ein Sensor für den Überschuss selbst — Produktion minus Verbrauch, aus den NEOOM-Werten:
template:
- sensor:
- name: "PV Überschuss"
unique_id: pv_ueberschuss
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) }}
Dann die Start-Automation. Der entscheidende Teil ist for: — der
Überschuss muss fünf Minuten am Stück über der Schwelle liegen, eine
einzelne Sonnensekunde reicht nicht:
automation:
- id: pv_tesla_laden_start
alias: "PV-Überschuss: Laden starten"
mode: single
trigger:
- platform: numeric_state
entity_id: sensor.pv_ueberschuss
above: input_number.pv_ueberschuss_start
for:
minutes: 5
condition:
- condition: state
entity_id: input_boolean.pv_laden_aktiv
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
Und die Stopp-Automation mit der niedrigeren Schwelle und einer längeren Karenzzeit — Stoppen darf ruhig träger sein als Starten:
- id: pv_tesla_laden_stopp
alias: "PV-Überschuss: Laden stoppen"
mode: single
trigger:
- platform: numeric_state
entity_id: sensor.pv_ueberschuss
below: input_number.pv_ueberschuss_stopp
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
Die 50-%-Regel aus meinem allerersten Versuch ist also erhalten geblieben —
sie steckt heute nur im zweiten Trigger, mit for: minutes: 10 entprellt
und gespeist aus sensor.neoom_battery_soc, statt in einem Shell-Skript
alle 15 Minuten.
Zu den Namen: Die sensor.neoom_*-Entitäten heißen bei dir genauso,
wenn du die Templates oben übernimmst. switch.tesla_charger,
number.tesla_charging_amps und binary_sensor.tesla_charger_connected
sind dagegen Platzhalter — wie deine Tesla-Integration die Entitäten
benennt, siehst du unter Entwicklerwerkzeuge → Zustände. Meine heißen nach
dem Autonamen und garantiert anders als deine.
An zusätzlicher Hardware brauchst du für den Softwareteil nichts. Wenn du mangels Wallbox an einer normalen Schuko-Dose lädst, ist ein ordentliches Typ-2-Ladekabel* allerdings die Investition, die sich sofort bemerkbar macht.
Was NICHT funktioniert
- Zu langsam pollen. 15 Minuten sind bei Wolken eine Ewigkeit. Du reagierst auf einen Zustand, den es längst nicht mehr gibt. Mit einer lokalen API gibt es keinen Grund, langsamer als alle 30 Sekunden zu sein.
- Zustände als Text vergleichen. Der HA-State ist ein String. Ohne
| float(0)bekommst du sporadisch falsche Ergebnisse — der fieseste Fehlertyp, weil er nur manchmal auftritt. - Einheiten mischen. kW und W im selben Vergleich, und der Trigger feuert nie. Einmal festlegen, überall durchziehen.
- Eine einzige Schwelle für Start und Stopp. Garantiertes Flattern. Zwischen Start- und Stoppschwelle gehört ein spürbarer Abstand.
for:weglassen. Ohne Mindestdauer feuert jede Wolkenlücke.- Von einem fremden Dienst abhängen. Ein Timeout in einem Nebendienst darf nicht dazu führen, dass das Auto nicht lädt.
- Zehn REST-Sensoren auf denselben Endpunkt. Einmal abfragen, als Attribut ablegen, per Template zerlegen.
- Leere oder
unavailableWerte ignorieren. Fällt ein Sensor aus, darf das nicht als “unter Schwelle” durchgehen. Prüf den Zustand explizit ab. - Ladestrom im Sekundentakt nachregeln. Das Auto kommt nicht hinterher. Ein bis zwei Anpassungen pro Minute sind das Maximum.
Fazit
Der interessante Teil beim Überschussladen ist nicht der Schwellwert. Den hatte ich vom ersten Tag an richtig — 50 % Speicher, klare Regel. Der interessante Teil ist alles drumherum: woher der Wert kommt, wie oft du misst, wie du ihn interpretierst, und wie du verhinderst, dass ein Grenzwert zum Schalter wird, der im Sekundentakt klappert.
Der größte Einzelfortschritt war, die Messung vom externen Cron-Job auf die lokale NEOOM-API umzustellen: 30 Sekunden statt 15 Minuten, alles im eigenen Netz, und die Logik dort, wo ich sie im Trace nachvollziehen kann.
Wenn du nur eine Sache mitnimmst: zwei Schwellen und ein for:. Das
ist der Unterschied zwischen einer Automation, der du vertraust, und einer,
die du morgens misstrauisch kontrollierst.