Skip to content

Phasenumschaltung series2: auf Zählerstrom 0 A und EVSE-Status B warten (#4289) - #4330

Open
Stefan3934 wants to merge 2 commits into
openWB:masterfrom
Stefan3934:fix-phase-switch-wait-for-zero-current
Open

Stefan3934 wants to merge 2 commits into
openWB:masterfrom
Stefan3934:fix-phase-switch-wait-for-zero-current

Conversation

@Stefan3934

@Stefan3934 Stefan3934 commented Nov 14, 2026 •

Copy link
Copy Markdown

Fixes #4289

Problem

Seit #3326 wartet perform_phase_switch() nur darauf, dass der EVSE-Sollwert (Register 1000) 0 A meldet. Der steht sofort nach set_current(0) auf 0, noch bevor das Fahrzeug die Ladung tatsächlich beendet hat. CP und Schütz werden dann unter Last getrennt; einige Fahrzeuge (bei mir Audi A6 e-tron) gehen danach in einen Ladefehler und laden erst nach Ab- und Anstecken wieder. Zusätzlich brach jeder einzelne Modbus-Fehler auf dem gemeinsamen RS485-Bus (EVSE + Zähler) die Umschaltung ab, mit falscher Phasenzahl im Datenmodell als Folge.

Änderung

Fassung aus der Diskussion in #4289 (@seaspotter) plus zwei Ergänzungen:

  • Vor dem CP-Trennen wird gepollt, bis der Ladepunktzähler auf allen Phasen < 0,5 A zeigt und das EVSE-Statusregister 1002 nicht 3/4 (State C) meldet. Der Zähler beweist 0 A, der Status beweist, dass das Fahrzeug sein Schütz geöffnet hat; im Test lagen zwischen beiden Ereignissen ein Durchlauf (Status schon B, auf L2 noch 0,86 A).
  • "Alle Phasen None" (z. B. B23 über check_nan()) zählt nicht als 0 A, sondern als fehlende Messung.
  • Busfehler beim Rücklesen werden nur geloggt, 0 A wird je Durchlauf erneut gesetzt. Ohne Bestätigung innerhalb von 20 Durchläufen (10 bis 20 s je nach Zählerlesezeit) wird mit Exception abgebrochen, ohne GPIO-Aktion.
  • GPIO-Sequenz in try/finally, der CP wird in jedem Fall wieder verbunden (wie in CP nach Fehler/Abbruch bei Phasenumschaltung und CP-Unterbrechung immer wieder verbinden #4037 vorgesehen).
  • Evse.get_plug_charge_state() merkt sich den Rohstatus zusätzlich in state_number (analog zu evse_current), damit die Schleife ihn ohne zweite Registerlesung bekommt.

Logs: je Durchlauf DEBUG mit Strömen und Status, bei Bestätigung und nach der Umschaltung WARNING mit Dauer (landet im main.log unter dem Thread "perform phase switch").

Test

openWB series2, EVSE-Firmware 18, MPM3PM-Zähler, Audi A6 Avant e-tron, beide Richtungen unter voller Last per "Anzahl Phasen" im Sofortladen (Details und Logs in #4289):

  • 3 → 1 bei 15,6 / 9,7 / 15,9 A: bestätigt nach 3 Durchläufen / 3,5 s, umgeschaltet nach 14,5 s, Auto lädt 18 s später weiter.
  • 1 → 3 bei 14,8 A: bestätigt nach 2 Durchläufen / 2,1 s, umgeschaltet nach 13,1 s, Auto lädt 22 s später weiter.
  • Umschaltung ohne Last beim Start eines Zeitladeplans: unauffällig.

Kein Busfehler, kein Abbruch, kein Ladefehler am Fahrzeug.

🤖 Generated with Claude Code

Summary by Sourcery

Harden EVSE phase switching by confirming that vehicle charging has stopped before changing phases and by recovering safely from transient bus errors.

Bug Fixes:

  • Make phase switching wait for both zero measured charging current and a non-charging EVSE state before disconnecting the control pilot.
  • Prevent transient Modbus read errors from aborting phase switching while still failing safely when charging does not stop within the polling window.
  • Ensure the control pilot is reconnected even if the GPIO phase-switch sequence encounters an error.

Enhancements:

  • Track the raw EVSE status from the latest register read for phase-switch decisions and distinguish missing meter readings from valid zero-current measurements.

Tests:

  • Add coverage for phase-switch readiness conditions, delayed vehicle shutdown, read-error recovery, timeout aborts, and operation without a meter.

…en (openWB#4289)

Seit openWB#3326 wartet perform_phase_switch nur auf den EVSE-Sollwert 0 A. Der steht
sofort nach set_current(0) auf 0, noch bevor das Fahrzeug die Ladung beendet hat.
CP und Schütz werden dann unter Last getrennt, einige Fahrzeuge (z. B. Audi A6
e-tron) gehen danach in einen Ladefehler. Außerdem brach jeder einzelne serielle
Modbus-Fehler beim Rücklesen die Umschaltung ab.

Jetzt wird vor dem CP-Trennen gepollt, bis der Ladepunktzähler auf allen Phasen
< 0,5 A zeigt UND das EVSE-Statusregister 1002 nicht 3/4 (State C) meldet;
"alle Phasen None" zählt nicht als 0 A. Busfehler beim Rücklesen werden nur
geloggt, 0 A wird je Durchlauf erneut gesetzt. Ohne Bestätigung innerhalb von
20 Durchläufen wird abgebrochen, ohne GPIO-Aktion. Der CP wird im finally wieder
verbunden. Dafür merkt sich Evse.get_plug_charge_state() den Rohstatus in
state_number.

Getestet auf series2 (EVSE-FW 18, MPM3PM) mit Audi A6 e-tron unter Last in
beide Richtungen, siehe openWB#4289.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@sourcery-ai

sourcery-ai Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

Linked issues

Checked at commit 9567b58

#4289 [BUG]Phasenumschaltung Series2 seit #3326: CP wird ohne Wartezeit unter Last getrennt (EV meldet Ladefehler), serieller Modbus-Aussetzer bricht die Umschaltung ab · 3 of 3 met

# Requirement Status Notes
1 Wait for charging to stop before disconnecting CP and switching phases. ✅ Met The switch proceeds only when all three meter currents are valid and below 0.5 A and the EVSE reports State A or B.
2 Do not abort phase switching because of a transient Modbus read error. ✅ Met Read errors are logged and polling continues; the switch aborts only if there is no confirmation within the polling window.
3 Reconnect CP even if the GPIO phase-switch sequence fails. ✅ Met The GPIO sequence is enclosed in a try/finally that drives CP back on.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 6 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="packages/modules/internal_chargepoint_handler/chargepoint_module.py" line_range="169" />
<code_context>
+                    state = evse.state_number
+                    log.debug(f"Phasenumschaltung LP{num}: Durchlauf {poll}, Ströme {currents} A, EVSE-Status {state}")
+                    # keine Messwerte (alle None) gelten nicht als 0 A
+                    if werte and max(werte) < self.PHASE_SWITCH_CURRENT_THRESHOLD_A and state not in (3, 4):
+                        log.warning(f"Phasenumschaltung LP{num}: 0 A und EVSE-Status {state} nach {poll} Durchläufen"
+                                    f" / {time.monotonic() - start:.1f} s bestätigt, Ströme {currents} A.")
</code_context>
<issue_to_address>
**Missing readings permit unsafe switches**

When one or more phase readings are unavailable, including when all currents and powers are missing, `werte` filters out `None`, and `CounterState` can normalize wholly missing readings to zero, so the check passes without verified current on every phase and the phase-switch GPIO sequence disconnects the vehicle under unverified load.

Preserve missing-reading status through normalization and require a valid below-threshold current reading for every phase before allowing the switch.

Also at `packages/modules/internal_chargepoint_handler/chargepoint_module.py:170`.
</issue_to_address>

### Comment 2
<location path="packages/modules/internal_chargepoint_handler/chargepoint_module.py" line_range="169" />
<code_context>
+                    state = evse.state_number
+                    log.debug(f"Phasenumschaltung LP{num}: Durchlauf {poll}, Ströme {currents} A, EVSE-Status {state}")
+                    # keine Messwerte (alle None) gelten nicht als 0 A
+                    if werte and max(werte) < self.PHASE_SWITCH_CURRENT_THRESHOLD_A and state not in (3, 4):
+                        log.warning(f"Phasenumschaltung LP{num}: 0 A und EVSE-Status {state} nach {poll} Durchläufen"
+                                    f" / {time.monotonic() - start:.1f} s bestätigt, Ströme {currents} A.")
</code_context>
<issue_to_address>
**Reverse current passes the zero check**

When a meter reports substantial negative current while the EVSE status is not 3 or 4, `max(werte)` compares signed readings, so a substantial negative current is below the threshold and the phase-switch GPIO sequence runs under load.

Compare the magnitude of each valid phase current with the threshold before allowing the switch.
</issue_to_address>

### Comment 3
<location path="packages/modules/internal_chargepoint_handler/chargepoint_module.py" line_range="186-191" />
<code_context>
             else:
-                raise Exception("Ladung konnte nicht gestoppt werden - Phasenumschaltung abgebrochen.")
-        GPIO.output(gpio_cp, GPIO.HIGH)  # CP off
-        GPIO.output(gpio_relay, GPIO.HIGH)  # 3 on/off
-        time.sleep(5)
-        GPIO.output(gpio_relay, GPIO.LOW)  # 3 on/off
-        time.sleep(5)
-        GPIO.output(gpio_cp, GPIO.LOW)  # CP on
+                raise Exception("Fahrzeug hat die Ladung nicht beendet (Zählerstrom > 0 oder EVSE-Status C)"
+                                " - Phasenumschaltung abgebrochen.")
+        try:
+            GPIO.output(gpio_cp, GPIO.HIGH)  # CP off
+            GPIO.output(gpio_relay, GPIO.HIGH)  # 3 on/off
+            time.sleep(5)
+            GPIO.output(gpio_relay, GPIO.LOW)  # 3 on/off
+            time.sleep(5)
+        finally:
+            GPIO.output(gpio_cp, GPIO.LOW)  # CP on - in jedem Fall wieder verbinden (#4037)
         time.sleep(1)
         self.old_phases_in_use = phases_to_use
</code_context>
<issue_to_address>
**Relay failure leaves phase state inconsistent**

When a GPIO operation or delay raises after the relay has changed state, the `finally` block reconnects CP without ensuring the relay is reset, while `old_phases_in_use` remains unchanged; charging can resume with the hardware phase configuration different from the recorded configuration.

On relay-sequence errors, restore or confirm the relay configuration and synchronize `old_phases_in_use` before reconnecting CP.
</issue_to_address>

### Comment 4
<location path="packages/modules/internal_chargepoint_handler/chargepoint_module.py" line_range="186-191" />
<code_context>
             else:
-                raise Exception("Ladung konnte nicht gestoppt werden - Phasenumschaltung abgebrochen.")
-        GPIO.output(gpio_cp, GPIO.HIGH)  # CP off
-        GPIO.output(gpio_relay, GPIO.HIGH)  # 3 on/off
-        time.sleep(5)
-        GPIO.output(gpio_relay, GPIO.LOW)  # 3 on/off
-        time.sleep(5)
-        GPIO.output(gpio_cp, GPIO.LOW)  # CP on
+                raise Exception("Fahrzeug hat die Ladung nicht beendet (Zählerstrom > 0 oder EVSE-Status C)"
+                                " - Phasenumschaltung abgebrochen.")
+        try:
+            GPIO.output(gpio_cp, GPIO.HIGH)  # CP off
+            GPIO.output(gpio_relay, GPIO.HIGH)  # 3 on/off
+            time.sleep(5)
+            GPIO.output(gpio_relay, GPIO.LOW)  # 3 on/off
+            time.sleep(5)
+        finally:
+            GPIO.output(gpio_cp, GPIO.LOW)  # CP on - in jedem Fall wieder verbinden (#4037)
         time.sleep(1)
         self.old_phases_in_use = phases_to_use
</code_context>
<issue_to_address>
**CP interruption overlaps phase switching**

When a single update requests both a phase switch and a positive CP-interruption duration, `UpdateState.update_state()` starts the phase-switch thread and then can start a CP-interruption thread in the same update. Both threads drive the same CP pin; the phase-switch `finally` can reconnect the vehicle while the interruption is still active, and the interruption can likewise change CP during relay switching, leaving the interruption or phase-switch operation in an unintended state.

Serialize the two operations or explicitly coordinate CP ownership so neither thread can change CP while the other operation is active.
</issue_to_address>

### Comment 5
<location path="packages/modules/common/evse.py" line_range="60" />
<code_context>
             1000, [ModbusDataType.UINT_16]*3, unit=self.id)
         # remove leading zeros
         self.evse_current = int(raw_set_current)
+        self.state_number = int(state_number)
         log.debug("Gesetzte Stromstärke EVSE: "+str(self.evse_current) +
                   ", Status: "+str(state_number)+", Modbus-ID: "+str(self.id))
</code_context>
<issue_to_address>
**Concurrent status read goes stale**

When the regular poll and phase-switch thread overlap, and a newer State C read is overwritten by the older call's State B result while the meter reports below-threshold current, a normal chargepoint poll and the phase-switch thread both call `get_plug_charge_state()` on the same EVSE; the older call can overwrite `state_number` after the newer call records State C, so the phase-switch thread reads stale State B and proceeds to disconnect CP.

Return the raw status from the getter and make the phase-switch decision from that call's local result rather than a shared mutable field.

Also at `packages/modules/internal_chargepoint_handler/chargepoint_module.py:166`.
</issue_to_address>

### Comment 6
<location path="packages/modules/internal_chargepoint_handler/chargepoint_module.py" line_range="158-159" />
<code_context>
-                _, _, evse_current = evse.get_plug_charge_state()
-                if evse_current == 0:
-                    break
+            if meter is None:
+                raise Exception("Kein Zähler gefunden, Zählerstrom nicht prüfbar - Phasenumschaltung abgebrochen.")
+            evse.set_current(0)  # Schreibfehler -> Abbruch: ohne 0-A-Vorgabe wird nicht geschaltet (#3326)
+            for poll in range(1, self.PHASE_SWITCH_METER_POLLS + 1):
</code_context>
<issue_to_address>
**Startup loses unmetered chargepoint**

When a chargepoint has no detectable meter and starts while system uptime is under 180 seconds, `perform_phase_switch(1)` raises before switching, and `InternalChargepointHandler.__init__` catches the exception while setting `cp0` to `None`; the chargepoint is therefore unavailable after startup.

Skip the automatic phase switch when no meter is configured, or preserve chargepoint initialization while safely reporting that the switch was not performed.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment on lines +186 to +191
GPIO.output(gpio_relay, GPIO.HIGH) # 3 on/off
time.sleep(5)
GPIO.output(gpio_relay, GPIO.LOW) # 3 on/off
time.sleep(5)
finally:
GPIO.output(gpio_cp, GPIO.LOW) # CP on - in jedem Fall wieder verbinden (#4037)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · Relay failure leaves phase state inconsistent

When a GPIO operation or delay raises after the relay has changed state, the finally block reconnects CP without ensuring the relay is reset, while old_phases_in_use remains unchanged; charging can resume with the hardware phase configuration different from the recorded configuration.

On relay-sequence errors, restore or confirm the relay configuration and synchronize old_phases_in_use before reconnecting CP.

Prompt for AI agents
In `packages/modules/internal_chargepoint_handler/chargepoint_module.py` at lines 186-191:

**Relay failure leaves phase state inconsistent**

When a GPIO operation or delay raises after the relay has changed state, the `finally` block reconnects CP without ensuring the relay is reset, while `old_phases_in_use` remains unchanged; charging can resume with the hardware phase configuration different from the recorded configuration.

On relay-sequence errors, restore or confirm the relay configuration and synchronize `old_phases_in_use` before reconnecting CP.

Comment on lines +186 to +191
GPIO.output(gpio_relay, GPIO.HIGH) # 3 on/off
time.sleep(5)
GPIO.output(gpio_relay, GPIO.LOW) # 3 on/off
time.sleep(5)
finally:
GPIO.output(gpio_cp, GPIO.LOW) # CP on - in jedem Fall wieder verbinden (#4037)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium · CP interruption overlaps phase switching

When a single update requests both a phase switch and a positive CP-interruption duration, UpdateState.update_state() starts the phase-switch thread and then can start a CP-interruption thread in the same update. Both threads drive the same CP pin; the phase-switch finally can reconnect the vehicle while the interruption is still active, and the interruption can likewise change CP during relay switching, leaving the interruption or phase-switch operation in an unintended state.

Serialize the two operations or explicitly coordinate CP ownership so neither thread can change CP while the other operation is active.

Prompt for AI agents
In `packages/modules/internal_chargepoint_handler/chargepoint_module.py` at lines 186-191:

**CP interruption overlaps phase switching**

When a single update requests both a phase switch and a positive CP-interruption duration, `UpdateState.update_state()` starts the phase-switch thread and then can start a CP-interruption thread in the same update. Both threads drive the same CP pin; the phase-switch `finally` can reconnect the vehicle while the interruption is still active, and the interruption can likewise change CP during relay switching, leaving the interruption or phase-switch operation in an unintended state.

Serialize the two operations or explicitly coordinate CP ownership so neither thread can change CP while the other operation is active.

Comment thread packages/modules/common/evse.py Resolved
- Prüfung in _vehicle_stopped_charging(): jede Phase braucht einen gültigen
  Messwert mit abs(i) < 0,5 A, None zählt nicht als 0 A, Vorzeichen egal;
  bestätigt wird nur bei EVSE-Status A/B (5 = Fehler ist keine Bestätigung)
- Evse.get_state_number() liefert den Rohstatus der eigenen Lesung statt
  eines geteilten Attributs (der reguläre Zyklus liest parallel)
- ohne gefundenen Zähler kein Abbruch mehr (sonst fiele LP0 beim Boot
  weg), sondern WARNING und Prüfung nur über den EVSE-Status
- chargepoint_module_test.py: 13 Fälle für die Entscheidung, 4 für die
  Schleife (Restlast, Busfehler, Abbruch ohne GPIO, ohne Zähler)

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@Stefan3934

Copy link
Copy Markdown
Author

Danke für die Review. Vier der sechs Punkte habe ich im zweiten Commit umgesetzt, zwei betreffen Verhalten, das
schon vor dem PR so war und das ich hier nicht mit anfassen möchte:

Umgesetzt

  • Missing readings und Reverse current: Die Prüfung sitzt jetzt in _vehicle_stopped_charging(). Jede der drei
    Phasen braucht einen gültigen Messwert mit abs(i) < 0,5 A; None (z. B. NaN vom B23) zählt nicht als 0 A, und das
    Vorzeichen spielt keine Rolle mehr. Dass CounterState komplett fehlende Ströme über die Leistungen auf 0 normiert,
    kann der Ladepunkt an dieser Stelle nicht mehr erkennen; dafür ist der EVSE-Status als zweites Kriterium da.
  • Concurrent status read: Evse hat kein geteiltes state_number-Attribut mehr. get_state_number() liefert den
    Rohstatus der eigenen Lesung zurück (_read_state() als gemeinsamer Unterbau für get_plug_charge_state()), die
    Schleife entscheidet nur über ihren lokalen Wert. Status 5 (Fehler) ist explizit keine Bestätigung, bestätigt
    wird nur State A/B.
  • Startup loses unmetered chargepoint: Ohne gefundenen Zähler wird nicht mehr abgebrochen, sondern mit WARNING nur
    der EVSE-Status geprüft. Damit bleibt das Verhalten beim Boot (perform_phase_switch(1) bei Uptime < 180 s) für
    Ladepunkte ohne Zähler wie bisher, nur mit Status- statt Sollwertprüfung.

Dazu chargepoint_module_test.py mit 13 Fällen für die Entscheidung und vier für die Schleife (Bestätigung nach
Restlast, Weiterpollen nach Busfehler, Abbruch ohne GPIO-Aktion, Betrieb ohne Zähler).

Nicht geändert

  • Relay failure leaves phase state inconsistent: Die GPIO-Sequenz und old_phases_in_use sind unverändert aus
    dem bisherigen Code, das try/finally stammt aus CP nach Fehler/Abbruch bei Phasenumschaltung und CP-Unterbrechung immer wieder verbinden #4037. Eine Rücknahme des Relais nach einem Fehler mitten in der
    Sequenz wäre ein eigenes Thema; auf dem Pi wirft GPIO.output in der Praxis nicht.
  • CP interruption overlaps phase switching: Dass update_state() Phasenumschaltung und CP-Unterbrechung im selben
    Durchlauf starten kann, gilt seit jeher und ist von diesem PR unabhängig. Falls gewünscht, gern als separates Issue.

Flake8 und pytest laufen lokal durch.

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

Labels

None yet

Projects

None yet

1 participant