Skip to content

external_openwb: durchgehende MQTT-Verbindung statt Neuverbindung pro Zyklus - #3996

Draft
seaspotter wants to merge 3 commits into
openWB:masterfrom
seaspotter:fix-external-openwb-persistent-connection
Draft

seaspotter wants to merge 3 commits into
openWB:masterfrom
seaspotter:fix-external-openwb-persistent-connection

Conversation

@seaspotter

Copy link
Copy Markdown
Collaborator

Hintergrund

Auf einem Produktivsystem trat die ganze Nacht wiederholt folgender Fehler für einen externen Ladepunkt (external_openwb, Secondary per Netzwerk angebunden) auf:

set values cp5 konnte nicht innerhalb des Timeouts abgearbeitet werden.

Ursache: get_values() baut pro Regelzyklus 6 einzelne, kurzlebige MQTT-Verbindungen zur Gegenstelle auf (5x pub_single() über paho.mqtt.publish.single(), das bei jedem Aufruf neu verbindet/trennt, plus 1x BrokerClient(...).start_finite_loop() zum Abonnieren). All das musste bisher gemeinsam in das enge Timeout aus loadvars.py passen (control_interval/3, bei Standard 10s also ~3,3s) - schon normaler Netzwerk-Jitter zur Gegenstelle konnte das reißen.

Das wurde durch #3976 verschärft, nicht verursacht: #3976 hat das interne Timeout von start_finite_loop() bewusst von 1s auf 2s erhöht, um ein anderes, echtes Problem zu beheben (gelegentlich fehlende retained Topics unter Last). Das war für sich genommen richtig - aber niemand hat das äußere Timeout in loadvars.py entsprechend mit angepasst. Vor #3976 blieben von den ~3,3s noch ~2,3s Puffer für die 6 Verbindungsaufbauten übrig, nach #3976 nur noch ~1,3s. Damit wurde genau der Fehler, den dieser PR behebt, durch #3976 spürbar wahrscheinlicher.

Die Lösung

Statt pro Zyklus neu zu verbinden, hält PersistentBrokerClient (neu, helpermodules/broker.py) eine einzelne, dauerhafte, automatisch wiederverbindende MQTT-Verbindung pro Gegenstelle offen - wird von allen pub_single()-Aufrufen zu diesem Host genutzt und bleibt durchgehend abonniert, statt pro Zyklus neu zu verbinden/abzumelden. Das ist kein neues Muster: Der komplette Datenkern von openWB (SubData, command.py, setdata.py, der simpleAPI-MQTT-Daemon) funktioniert schon heute nach exakt diesem Prinzip - nur bisher ausschließlich für den lokalen Broker. Neu ist hier lediglich, dieses Prinzip auch für Verbindungen zu einer entfernten Gegenstelle zu nutzen (inkl. reconnect_delay_set, das die bestehenden lokalen Verbindungen nicht brauchen, weil der lokale Broker als praktisch immer verfügbar gilt).

Bestehende Sicherheitsmechanismen bleiben erhalten:

  • publish() wirft jetzt explizit einen Fehler, wenn die Verbindung nicht steht (pahos Client.publish() schlägt bei fehlender Verbindung sonst nur mit einem Rückgabecode fehl, ohne Exception) - sonst hätte die bestehende client_error_context-Sicherheitsabschaltung (Soll-Strom nach 60s anhaltendem Fehler auf 0) nie mehr ausgelöst.
  • wait_for_fresh_data() liefert weiterhin ein leeres Dict (nicht die letzten bekannten Werte), wenn in diesem Zyklus nichts Neues angekommen ist - die bestehende "keine Daten empfangen"-Warnung funktioniert dadurch unverändert.
  • Die Fehlermeldung bei einer nicht erreichbaren Gegenstelle nutzt jetzt den vorhandenen, übersetzten OSError-Handler (Die Verbindung zum Host ist fehlgeschlagen. Überprüfe Adresse und Netzwerk.) statt einer nichtssagenden Fallback-Meldung.

Getestet

  • Unit-Tests für PersistentBrokerClient (broker_test.py, gemockter mqtt.Client, keine echten Sockets): Verbindungsprüfung/Exception, Abo-Deduplizierung, Resubscribe nach Reconnect, Timing-Logik von wait_for_fresh_data().
  • Komplette bestehende Testsuite grün, flake8 sauber.
  • Live gegen ein echtes Testsystem mit echtem Secondary-openWB über Netzwerk getestet:
    • Ladepunkt frisch angelegt, IP noch nicht gespeichert → sauberer, sich selbst korrigierender Zustand, kein Absturz.
    • Ursprünglicher Fehler (Timeout) über mehrere Regelzyklen: nicht ein einziges Mal aufgetreten.
    • Neustart des Secondary-Dienstes: unterbrechungsfrei erkannt, selbstständig erholt.
    • Kurze (~1 min) und lange (~3 min) echte Netzwerkunterbrechung (Firewall-Block): korrekte Eskalationskette - Warnung → erkannter Verbindungsfehler → Sicherheitsabschaltung nach 60s (Soll-Strom auf 0) → FaultState eskaliert auf ERROR → vollständige, selbstständige Erholung nach Freigabe, ohne Neustart des Primary-Dienstes.

Was nicht getestet wurde

  • Zwei Ladepunkte an einem Secondary (duo_num 0/1, gemeinsame Verbindung) - die Testumgebung hatte nur einen konfiguriert. Sollte durch das Verbindungs-Caching pro Host funktionieren, war aber nicht live zu verifizieren.
  • Eine tatsächlich laufende Ladung, die durch einen Verbindungsabbruch unterbrochen wird - verifiziert wurde, dass der Soll-Strom korrekt auf 0 gesetzt/gesendet wird, nicht der tatsächliche Ladeabbruch an der Hardware.

…connection

cp threads were timing out (loadvars.py's control_interval/3 budget)
because get_values() opened 6 fresh TCP+MQTT connections per cycle to
the remote host - already tight before openWB#3976 raised the internal wait
from 1s to 2s to fix missed retained topics, which shrank the margin
further and made timeouts common on any network jitter.

Replaces this with one long-lived, auto-reconnecting connection per
remote host (PersistentBrokerClient), shared by all pub_single() calls
to that host and kept continuously subscribed instead of resubscribing
every cycle. Preserves existing safety semantics: publish() now raises
when the socket is down (paho's Client.publish() fails silently
otherwise, which would have broken the exception-driven
client_error_context shutoff), and wait_for_fresh_data() still returns
an empty dict - not stale cached data - when nothing new arrived this
cycle, so the "no data received" fault_state warning still fires
correctly.
…d of "Unbekannter Fehler"

publish() raised a plain ConnectionError(message), but the existing
OSError fault-state translator (helpermodules/exceptions/os.py) only
reads e.errno/e.strerror, so the message was silently discarded and
fell back to its generic "OSError None: Unbekannter Fehler None"
template - confirmed live during today's 3-minute network-outage test.

Construct it as ConnectionError(errno.EHOSTUNREACH, message) instead,
so it hits that translator's existing EHOSTUNREACH special case and
produces the already-established "Die Verbindung zum Host ist
fehlgeschlagen. Überprüfe Adresse und Netzwerk." message.
@seaspotter
seaspotter force-pushed the fix-external-openwb-persistent-connection branch from 4947868 to 320e386 Compare September 23, 2026 07:27
@LKuemmel
LKuemmel requested a lite review from Copilot September 25, 2026 12:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Several moderate issues remain in connection startup, shared-message synchronization, retained-message collection, and client lifecycle.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 3 Medium severity

Open (3)
What changed in this PR

This PR replaces per-cycle MQTT reconnections for external openWB chargepoints with persistent, automatically reconnecting clients.

Changes:

  • Adds cached persistent MQTT connections and subscriptions.
  • Routes external publishing through persistent clients.
  • Adds broker connection and message-handling tests.
File Description
packages/​modules/​chargepoints/​external_openwb/​chargepoint_module.py Uses persistent MQTT subscriptions and data collection.
packages/​helpermodules/​pub.py Publishes through cached external clients.
packages/​helpermodules/​broker.py Implements persistent connections, reconnection, caching, and data collection.
packages/​helpermodules/​broker_test.py Tests persistent broker client behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/helpermodules/broker.py Resolved
Comment thread packages/helpermodules/broker.py Resolved
ip_address, 1886 if ip_address == "localhost" else 1883)
broker_client.ensure_subscribed(
f"openWB/internal_chargepoint/{self.config.configuration.duo_num}/get/#")
received_topics = broker_client.wait_for_fresh_data(max_wait=2)
…adepunkten beheben (Review Copilot)

publish() wertete is_connected() sofort nach dem Konstruktor aus, obwohl
connect_async()/loop_start() asynchron verbinden - der allererste Aufruf an
einen neuen Host loeste dadurch nach jedem Neustart praktisch garantiert
einen falschen Fehler aus. Neu: 5s Kulanzfrist fuer die Erstverbindung,
ein bereits gestandener und dann abgebrochener Connect loest weiterhin
sofort aus.

wait_for_fresh_data() nutzte ein einziges Event fuer alle Aufrufer. Bei
zwei Ladepunkten an einem Secondary (duo_num 0/1, gemeinsame Verbindung)
laufen mehrere Aufrufe gleichzeitig - ein Aufrufer konnte dem anderen per
eigenem clear() den Wakeup wegnehmen. Jetzt bekommt jeder Aufruf ein
eigenes Event.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved MQTT publish, synchronization, retained-data, and client-lifecycle issues remain.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity

Open (2)
Resolved since last review (2)
Previously missed (4)

In code that hasn't changed since last review

Medium severity Waiters are notified by messages from unrelated topics

packages/​helpermodules/​broker.py:106

Every waiter is notified for every topic, not just the prefix it requested. When two external chargepoints share this cached client, a message for duo 1 wakes duo 0's wait, which then returns the accumulated duo-0 values even though no duo-0 message arrived in this cycle; stale readings can therefore be accepted and the no-data path suppressed. Associate each waiter with its topic filter (or otherwise wait only for a matching message).

This issue also appears on line 153 of the same file.

Medium severity Ignored publish errors can silently lose MQTT messages

packages/​helpermodules/​broker.py:123

Auch nach der is_connected()-Prüfung kann die Verbindung vor diesem Aufruf abbrechen. In paho-mqtt 1.6.1 liefert Client.publish() dann nur einen Fehlercode (MQTT_ERR_NO_CONN) zurück; der Rückgabewert wird hier ignoriert, sodass Nachrichten verloren gehen, ohne dass ErrorTimerContext den Fehler erkennt. Bitte den rc prüfen und bei jedem nicht erfolgreichen Ergebnis eine Exception auslösen.

Medium severity Cached MQTT clients leak reconnecting threads

packages/​helpermodules/​broker.py:171

Clients placed in this process-wide cache are never disconnected or removed. Deleting or changing an external chargepoint creates a new client while the old loop_start() thread continues auto-reconnecting to the obsolete host forever; repeated configuration changes can leak threads and generate needless connection attempts/logs. Add lifecycle cleanup/ref-counting when a host is no longer used.

Medium severity Retained messages can arrive before waiter registration

packages/​modules/​chargepoints/​external_openwb/​chargepoint_module.py:65

subscribe() delivers retained messages asynchronously, but wait_for_fresh_data() registers its waiter only after this call. A retained batch can therefore arrive between these lines, be stored without waking a waiter, and make the first read wait the full 2 seconds before returning {} despite having received the data. Register the waiter before subscribing or make subscription and the first-data wait atomic (for example with a message sequence/generation).

Comment on lines +113 to +117
if not self.client.is_connected():
if not self._ever_connected and time.monotonic() - self._created_at < INITIAL_CONNECT_GRACE:
# Erstverbindung läuft im Hintergrund noch (siehe INITIAL_CONNECT_GRACE weiter oben) -
# das ist kein Fehlerfall, Nachricht wird verworfen, naechster Zyklus greift wieder.
return
@seaspotter

Copy link
Copy Markdown
Collaborator Author

@LKuemmel bevor ich hier weitermache, würd ich gerne deine Meinung bekommen.
#3996 soll die Ursache von #3976 beheben (Crash/KeyError, wenn Verbindungsaufbau + Zustellung aller retained Topics nicht ins enge Zeitfenster von control_interval/3 passt). Der Ansatz war eine dauerhafte, automatisch wiederverbindende MQTT-Verbindung statt Neuverbindung pro Zyklus.

Auslöser war eben das ich seit #3976 dauerhaft diese Errors im Log habe:

2026-09-29 21:19:24,563 - {helpermodules.utils._thread_handler:43} - {ERROR:MainThread} - set values cp5 konnte nicht innerhalb des Timeouts abgearbeitet werden.
2026-09-29 21:16:26,214 - {helpermodules.utils._thread_handler:43} - {ERROR:MainThread} - set values cp5 konnte nicht innerhalb des Timeouts abgearbeitet werden.
2026-09-29 21:06:06,465 - {helpermodules.utils._thread_handler:43} - {ERROR:MainThread} - set values cp5 konnte nicht innerhalb des Timeouts abgearbeitet werden.
2026-09-29 21:06:06,462 - {helpermodules.utils._thread_handler:43} - {ERROR:MainThread} - set values cp4 konnte nicht innerhalb des Timeouts abgearbeitet werden.
2026-09-29 21:00:54,400 - {helpermodules.utils._thread_handler:43} - {ERROR:MainThread} - set values cp5 konnte nicht innerhalb des Timeouts abgearbeitet werden.
2026-09-29 21:00:54,256 - {helpermodules.utils._thread_handler:43} - {ERROR:MainThread} - set values cp4 konnte nicht innerhalb des Timeouts abgearbeitet werden.
2026-09-29 20:58:03,796 - {helpermodules.utils._thread_handler:43} - {ERROR:MainThread} - set values cp5 konnte nicht innerhalb des Timeouts abgearbeitet werden.
2026-09-29 20:57:03,636 - {helpermodules.utils._thread_handler:43} - {ERROR:MainThread} - set values cp5 konnte nicht innerhalb des Timeouts abgearbeitet werden.

Die Ursache vermutlich: loadvars.pys _set_values() bündelt alle Geräte/Ladepunkte/Verbraucher in eine gemeinsame Thread-Liste mit einem gemeinsamen Timeout (control_interval/3, bei Default 10s also ~3,33s fürs gesamte System). wait_for_fresh_data() in external_openwb kann dabei by design bis zu ~2,1s pro Zyklus blockieren (2s max_wait + 0,1s settle) - nicht als Fehlerfall, sondern als normales Verhalten, gerade unter der Last/Latenz-Bedingung, die #3976 eigentlich abfangen sollte (einzelne nachtrudelnde Nachrichten setzen den Settle-Timer immer wieder zurück). Das frisst den Großteil des gemeinsamen 3,33s-Budgets fürs ganze System - im Log sieht man auch cp4 (ein interner, komplett unabhängiger Ladepunkt) zum exakt gleichen Zeitpunkt timeouten, was genau dazu passt.

Copilot findet daneben auch mehrfach echte Bugs in dem Ansatz. Das lässt sich zwar jeweils beheben, aber die Komplexität summiert sich, und ich bin nicht mehr sicher, ob der Ansatz in der jetzigen Form das wert ist.

Ein Revert auf die alte Logik würde direkt das alte Problem von oben wieder mit sich bringen. Aber weniger Fehler, könnte man mit leben?
Aber mir fällt auf: fast alle zuletzt gefundenen Bugs von Copilot betreffen konkret das Teilen einer Verbindung zwischen zwei Ladepunkten am selben Host (Duo-Fall), nicht den Grundmechanismus selbst. Würde man stattdessen pro Ladepunkt eine eigene Verbindung halten statt eine pro Host/Port, würde diese ganze Klasse von Bugs von vornherein wegfallen - auf Kosten einer zusätzlichen MQTT-Verbindung pro Duo, was vernachlässigbar sein sollte. Denkbar?

Was denkst du, was ist hier der richtige/gewünschte Weg?

@seaspotter
seaspotter marked this pull request as draft October 4, 2026 06:50
@LKuemmel

Copy link
Copy Markdown
Contributor

Grundsätzlich halte ich den Ansatz für richtig. Den Code habe ich mir angeschaut, aber noch nicht im Detail auf potenzielle Probleme untersucht.

Neben der Duo gibt es noch weitere Fälle:

  • AddOn-Platine als IO-Gerät
  • Wallbox-Steckdose als Verbraucher (für 2.4.0)

Dort wird auch bei jeder Abfrage eine MQTT-Verbindung geöffnet.

Wie willst Du die Verbindungen unterscheiden?

Was mir noch eingefallen ist: Was passiert mit persistenten Verbindungen, wenn die IP geändert wird oder ein Ladepunkte gelöscht wird? Ein grundsätzliches Werkzeug haben wir dafür nicht.

@seaspotter

Copy link
Copy Markdown
Collaborator Author

Ehrlich gesagt ist das echt keine kleine Nummer und größer geworden, als ich das allein sauber lösen kann. Mein eigentliches Problem ist der Timeout-Fehler-Spam im Log bei external_openwb (#3976-Folge). Die eigene Verbindung pro Ladepunkt war nur eine Idee von mir, weil mir aufgefallen ist, dass fast alle Copilot-Findings genau den Duo-Fall (geteilte Verbindung) betreffen – kein ausgereiftes Konzept.

AddOn und die Wallbox-Steckdose danke für die Ergänzung zur Duo, aber das ist echt keine kleine Baustelle (Verbindungs-IDs, close()-Hooks für alle Modultypen etc.) – da fühle ich mich nicht sicher genug, das allein richtig zu entwerfen. Und vor allem ist das ja auch schon eine gravierende Änderung. Was wäre denn dein Vorschlag, hast du da eine Idee zu wie man das umsetzen könnte?

Oder vielleicht ne andere Idee zu den permanten Fehlermeldungen, dass man die anders noch abstellen kann?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants