pvteslahome-assistantsolarneoom

Tesla mit PV-Überschuss laden: NEOOM lokal + Hysterese

/Veröffentlicht am/Aktualisiert am

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.50setz 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 unavailable Werte 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.

Ich bin René, CTO aus Pasching bei Linz. Wenn dein Problem größer ist als ein Rollladen — Streaming, Cloud, lokale LLMs, Technikführung — dann steht hier, was ich beruflich mache.