diff --git a/packages/modules/chargepoints/openwb_series2_satellit/chargepoint_module.py b/packages/modules/chargepoints/openwb_series2_satellit/chargepoint_module.py index e21609ef54..82c0e6b13a 100644 --- a/packages/modules/chargepoints/openwb_series2_satellit/chargepoint_module.py +++ b/packages/modules/chargepoints/openwb_series2_satellit/chargepoint_module.py @@ -9,6 +9,7 @@ from helpermodules.utils.run_command import run_command from modules.chargepoints.openwb_series2_satellit.config import OpenWBseries2Satellit from modules.common import modbus +from modules.common import evse_transition_filter from modules.common.abstract_chargepoint import AbstractChargepoint from modules.common.abstract_device import DeviceDescriptor from modules.common.component_context import SingleComponentUpdateContext @@ -141,11 +142,16 @@ def set_current(self, current: float) -> None: def switch_phases(self, phases_to_use: int) -> None: if self.version is not None: + # Ausserhalb des Client-Kontextes warten, damit die Modbus-Verbindung nicht minutenlang offen steht. + if not evse_transition_filter.wait_for_window(self._client.evse_client.id, 0): + log.error(f"Phasenumschaltung an LP{self.config.id} abgebrochen: die EVSE darf noch nicht " + "abgeschaltet werden. Die Umschaltung wird später erneut angefordert.") + return with SingleComponentUpdateContext(self.fault_state, update_always=False): with self.client_error_context: try: with self._client.client: - self._client.evse_client.set_current(0) + self._client.evse_client.set_current(0, wait=True) time.sleep(5) if phases_to_use == 1: self._client.client.write_single_register_raw( diff --git a/packages/modules/common/evse.py b/packages/modules/common/evse.py index 187a72cfd0..3a58f704cd 100644 --- a/packages/modules/common/evse.py +++ b/packages/modules/common/evse.py @@ -6,6 +6,7 @@ from helpermodules.logger import ModifyLoglevelContext from modules.common import modbus +from modules.common import evse_transition_filter from modules.common.component_state import EvseState from modules.common.modbus import ModbusDataType @@ -111,7 +112,8 @@ def deactivate_precise_current(self) -> None: else: return - def set_current(self, current: int, phases_in_use: Optional[int] = None) -> None: + def set_current(self, current: int, phases_in_use: Optional[int] = None, + wait: bool = False, force: bool = False) -> None: time.sleep(0.1) if self.max_current == 20 and phases_in_use is not None and phases_in_use != 0: # Bei 20A EVSE und bekannter Phasenzahl auf 16A begrenzen, sonst erstmal Ladung mit Minimalstrom starten, @@ -120,4 +122,12 @@ def set_current(self, current: int, phases_in_use: Optional[int] = None) -> None current = 16 formatted_current = round(current*100) if self._precise_current else round(current) if self.evse_current != formatted_current: + # wait=True nur für Aufrufer, die unmittelbar danach Hardware schalten und den + # Schreibzugriff daher nicht in den nächsten Zyklus verschieben können. + if wait: + evse_transition_filter.wait_for_window(self.id, formatted_current) + if not evse_transition_filter.allow_write(self.id, formatted_current, force=force): + return self.client.write_register(1000, formatted_current, unit=self.id) + # Ein abgewarteter Schreibzugriff darf den Wiederanlauf nach der Umschaltung nicht sperren. + evse_transition_filter.record_write(self.id, formatted_current, arm_window=not wait) diff --git a/packages/modules/common/evse_transition_filter.py b/packages/modules/common/evse_transition_filter.py new file mode 100644 index 0000000000..27a111b91b --- /dev/null +++ b/packages/modules/common/evse_transition_filter.py @@ -0,0 +1,236 @@ +"""Begrenzt die Rate der Lade-/Stopp-Wechsel an der EVSE. + +Manche Fahrzeuge gehen in einen Fehlerzustand, aus dem sie sich nicht mehr +selbst befreien, wenn die Ladefreigabe zu schnell hintereinander entzogen und +wieder erteilt wird. Dieser Filter erzwingt daher eine Mindest-Ein- und eine +Mindest-Aus-Zeit auf Register 1000. + +**Der Filter hat Vorrang vor der Regelung.** Ein Schreibzugriff, der die +Mindestzeiten verletzen würde, findet nicht statt -- auch nicht für +Phasenumschaltung, CP-Unterbrechung oder Fehlerabschaltung. Wer zwingend +schreiben muss, wartet über :func:`wait_for_window`, bis er darf. Es gibt +genau eine dokumentierte Ausnahme, siehe Punkt 5. + +Fünf Eigenschaften, die gelten müssen: + +1. **Nur der Wechsel 0 <-> ungleich 0 wird begrenzt.** Eine Änderung von 6A auf + 10A läuft immer durch, sonst wäre die Überschussregelung ausgehebelt. +2. **Der Filter verzögert, er verwirft nicht.** Bei ``False`` kehrt + ``set_current`` vor ``write_register`` zurück, ``evse_current`` behält also + weiterhin den zuletzt vom Gerät *gelesenen* Wert. Die Regelung stellt ihren + Wunsch im nächsten Zyklus erneut, ein dauerhafter Befehl landet also, sobald + das Fenster abgelaufen ist. Ein Ein-Zyklus-Zappler wird vollständig + geschluckt: der 0-Schreibzugriff wird unterdrückt, der darauf folgende + ungleich-0-Schreibzugriff ist wegen des unveränderten ``evse_current`` ein + No-Op. +3. **Wer nicht einfach den nächsten Zyklus abwarten kann, wartet aktiv.** + Phasenumschaltung und CP-Unterbrechung schalten Hardware in dem Moment, in + dem sie den Strom auf 0 setzen. Würde dieser Schreibzugriff nur unterdrückt + und die Prozedur liefe weiter, schaltete das Relais unter Last. Diese + Prozeduren laufen ohnehin in eigenen Threads (siehe + ``control/phase_switch.py``) und blockieren daher über + :func:`wait_for_window`, bis die Abschaltung erlaubt ist. Kommt die Freigabe + nicht rechtzeitig, unterbleibt die Umschaltung -- sie wird später erneut + angefordert. +4. **Die abgewartete Abschaltung zieht kein neues Fenster auf.** Sie wird zwar + protokolliert und für die Relay-Loop-Diagnose gezählt, setzt aber + ``last_zero_ts`` nicht (``arm_window=False`` in :func:`record_write`). + Andernfalls wäre der Wiederanlauf direkt nach der Umschaltung für + ``MIN_OFF_TIME_S`` gesperrt, das Fahrzeug bekäme also minutenlang 0A + angeboten -- obwohl der Aufrufer die Mindest-Ein-Zeit bereits voll + abgewartet hat. Das ist keine Umgehung: geschaltet wird erst, wenn das + Fenster offen ist; nur die Buchführung danach unterbleibt. +5. **Einzige Ausnahme: Verlust des Heartbeats.** Fällt die Verbindung zur + übergeordneten openWB aus, muss die Ladung sofort stoppen; darauf zu warten, + dass ein Fenster aufgeht, wäre hier falsch. Dieser Fall ist selten und + erzeugt daher keine Schleife. ``force=True`` in :func:`allow_write` lässt den + 0-Schreibzugriff sofort durch und protokolliert ihn als Ausnahme. Das Fenster + wird dabei aufgezogen, der anschließende Wiederanlauf ist also wie üblich + begrenzt -- der Schutz für das Fahrzeug bleibt vollständig erhalten. + +Der Zustand liegt bewusst auf Modul-Ebene und wird über ``evse_id`` getrennt +gehalten. +""" +import logging +from dataclasses import dataclass, field +from logging.handlers import RotatingFileHandler +from threading import Lock +from typing import Dict, List, Optional +import time + +from helpermodules.logger import FORMAT_STR_SHORT, PERSISTENT_LOG_PATH + +# Aus dem alten Fork übernommen und im Feld bestätigt. Die Fenster sind +# absichtlich deutlich länger als switch_off_delay der Regelung; siehe +# Abschnitt 4.4 des Portierungsplans. Nicht "passend" kürzen. +MIN_ON_TIME_S = 300.0 # wie lange ein ungleich-0-Wert stehen muss, bevor 0 geschrieben werden darf +MIN_OFF_TIME_S = 300.0 # wie lange eine 0 stehen muss, bevor wieder ungleich 0 geschrieben werden darf + +# Obergrenze für wait_for_window. Ein berechtigtes Warten dauert nie länger als +# das größere der beiden Fenster; der Rest ist Sicherheitsmarge für den Fall, +# dass ein anderer Thread das Fenster zwischendurch neu aufzieht. +MAX_WAIT_S = max(MIN_ON_TIME_S, MIN_OFF_TIME_S) + 30.0 +WAIT_POLL_S = 1.0 + +# Reine Diagnose, ohne Einfluss auf das Verhalten. +TOGGLE_WINDOW_S = 120.0 +TOGGLE_WARN_COUNT = 3 + +LOG_FILE = "openwb_evse_relay.log" + +log = logging.getLogger("evse_relay") + + +def _setup_logger() -> None: + log.propagate = False + try: + handler = RotatingFileHandler(PERSISTENT_LOG_PATH + LOG_FILE, maxBytes=1000000, backupCount=1) + except OSError: + # Kein Log-Verzeichnis (z.B. Entwicklungsrechner): an den Root-Logger + # durchreichen, statt den Import scheitern zu lassen. + log.propagate = True + return + handler.setFormatter(logging.Formatter(FORMAT_STR_SHORT)) + log.addHandler(handler) + + +if not log.handlers: + _setup_logger() + + +@dataclass +class _EvseState: + last_zero_ts: float = 0.0 + last_nonzero_ts: float = 0.0 + # None: es wurde noch nichts geschrieben, es gibt also nichts zu begrenzen. + last_was_zero: Optional[bool] = None + transitions: List[float] = field(default_factory=list) + + +_states: Dict[int, _EvseState] = {} +# Der interne Ladepunkt, die Phasenumschaltung und die CP-Unterbrechung laufen +# jeweils in eigenen Threads, daher ein Lock. +_lock = Lock() + + +def _state(evse_id: int) -> _EvseState: + state = _states.get(evse_id) + if state is None: + state = _EvseState() + _states[evse_id] = state + return state + + +def remaining(evse_id: int, formatted_current: int) -> float: + """Wie lange ist dieser Schreibzugriff noch gesperrt? + + :return: Restzeit in Sekunden, 0.0 wenn sofort geschrieben werden darf. + Protokolliert bewusst nichts, damit Warteschleifen das Log nicht + fluten. + """ + with _lock: + state = _state(evse_id) + is_zero = formatted_current == 0 + if state.last_was_zero is None or is_zero == state.last_was_zero: + # Erster Schreibzugriff, oder reine Änderung der Stromstärke. + return 0.0 + now = time.monotonic() + if is_zero: + return max(0.0, MIN_ON_TIME_S - (now - state.last_nonzero_ts)) + return max(0.0, MIN_OFF_TIME_S - (now - state.last_zero_ts)) + + +def allow_write(evse_id: int, formatted_current: int, force: bool = False) -> bool: + """Darf dieser Schreibzugriff auf Register 1000 jetzt raus? + + :param force: Nur für den Verlust des Heartbeats, siehe Punkt 5 im + Modul-Docstring. Lässt den Schreibzugriff sofort durch. + :return: True -> schreiben. False -> diesen Zyklus überspringen, die + Regelung stellt den Wunsch im nächsten Zyklus erneut. + """ + try: + left = remaining(evse_id, formatted_current) + if left > 0: + vorgang = "Abschaltung" if formatted_current == 0 else "Einschaltung" + if force: + log.warning(f"EVSE id={evse_id}: {vorgang} trotz gesperrtem Fenster geschrieben " + f"(Ausnahme Heartbeat-Verlust), {left:.1f}s verblieben") + return True + log.warning(f"EVSE id={evse_id}: {vorgang} unterdrückt, Fenster noch {left:.1f}s gesperrt") + return False + return True + except Exception: + # Im Zweifel schreiben lassen: Upstream-Verhalten ist das sichere Fallback. + log.exception("Fehler im EVSE-Übergangsfilter") + return True + + +def wait_for_window(evse_id: int, formatted_current: int, timeout: float = MAX_WAIT_S) -> bool: + """Blockiert, bis der Schreibzugriff erlaubt ist. + + Nur für Aufrufer, die den Schreibzugriff nicht einfach im nächsten Zyklus + wiederholen können, weil sie unmittelbar danach Hardware schalten. + + :return: True -> es darf geschrieben werden. False -> das Fenster ging + innerhalb von ``timeout`` nicht auf; der Aufrufer muss seine + Prozedur abbrechen und darf **nicht** trotzdem schalten. + """ + try: + left = remaining(evse_id, formatted_current) + if left <= 0: + return True + log.warning(f"EVSE id={evse_id}: warte {left:.1f}s auf das Freigabefenster, bevor geschaltet wird") + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + time.sleep(min(WAIT_POLL_S, left)) + left = remaining(evse_id, formatted_current) + if left <= 0: + log.warning(f"EVSE id={evse_id}: Freigabefenster offen, Schaltvorgang wird fortgesetzt") + return True + log.error(f"EVSE id={evse_id}: Freigabefenster ging nicht innerhalb von {timeout:.0f}s auf, " + "Schaltvorgang wird abgebrochen") + return False + except Exception: + log.exception("Fehler im EVSE-Übergangsfilter") + # Fail-safe statt fail-open: hier hängt ein Schaltvorgang dran, der + # unter Last nicht stattfinden darf. + return False + + +def record_write(evse_id: int, formatted_current: int, arm_window: bool = True) -> None: + """Meldet einen tatsächlich erfolgten Schreibzugriff zurück. + + :param arm_window: False für Schreibzugriffe, die über :func:`wait_for_window` + bereits auf das Fenster gewartet haben. Sie werden + protokolliert und gezählt, ziehen aber kein neues Fenster + auf -- siehe Punkt 4 im Modul-Docstring. + """ + try: + with _lock: + state = _state(evse_id) + is_zero = formatted_current == 0 + now = time.monotonic() + # Einzige positive Spur eines echten Schreibzugriffs auf Register 1000: ohne sie ist im Log + # nicht unterscheidbar, ob nichts geschrieben wurde oder nur nichts unterdrückt wurde. + log.warning(f"EVSE id={evse_id}: Register 1000 geschrieben, Wert={formatted_current}" + f"{'' if arm_window else ' (abgewartet, Fenster wird nicht neu aufgezogen)'}") + if arm_window: + if is_zero: + state.last_zero_ts = now + else: + state.last_nonzero_ts = now + if state.last_was_zero is not None and is_zero != state.last_was_zero: + state.transitions.append(now) + state.transitions = [t for t in state.transitions if now - t <= TOGGLE_WINDOW_S] + if len(state.transitions) >= TOGGLE_WARN_COUNT: + log.warning(f"EVSE id={evse_id}: {len(state.transitions)} Wechsel zwischen 0 und " + f"ungleich 0 in {TOGGLE_WINDOW_S:.0f}s — mögliche Relaisschleife!") + state.last_was_zero = is_zero + except Exception: + log.exception("Fehler im EVSE-Übergangsfilter") + + +def reset() -> None: + """Verwirft den kompletten Zustand. Nur für Tests.""" + with _lock: + _states.clear() diff --git a/packages/modules/common/evse_transition_filter_test.py b/packages/modules/common/evse_transition_filter_test.py new file mode 100644 index 0000000000..4ca06f666d --- /dev/null +++ b/packages/modules/common/evse_transition_filter_test.py @@ -0,0 +1,329 @@ +from typing import List +from unittest.mock import Mock + +import pytest + +from modules.common import evse_transition_filter as filt + + +@pytest.fixture(autouse=True) +def reset_state() -> None: + # Der Zustand lebt auf Modul-Ebene und muss zwischen den Tests weg. + filt.reset() + + +@pytest.fixture +def clock(monkeypatch) -> List[float]: + """Ersetzt time.monotonic durch eine steuerbare Uhr. clock[0] ist "jetzt".""" + now = [1000.0] + monkeypatch.setattr(filt.time, "monotonic", lambda: now[0]) + return now + + +def write(evse_id: int, current: int) -> bool: + """Bildet die Aufrufsequenz in evse.set_current nach. + + :return: True, wenn der Schreibzugriff tatsächlich rausgegangen wäre. + """ + if not filt.allow_write(evse_id, current): + return False + filt.record_write(evse_id, current) + return True + + +# Eigenschaft 1: nur 0 <-> ungleich 0 wird begrenzt + + +def test_erster_schreibzugriff_geht_immer_durch(clock): + assert write(1, 6) is True + + +def test_erster_schreibzugriff_darf_auch_null_sein(clock): + assert write(1, 0) is True + + +def test_stromaenderung_wird_nicht_begrenzt(clock): + assert write(1, 6) is True + clock[0] += 1 + assert write(1, 10) is True + clock[0] += 1 + assert write(1, 16) is True + clock[0] += 1 + assert write(1, 6) is True + + +def test_wiederholte_null_wird_nicht_begrenzt(clock): + assert write(1, 0) is True + clock[0] += 1 + assert write(1, 0) is True + + +# Mindest-Ein-Zeit + + +def test_abschaltung_vor_ablauf_der_mindest_ein_zeit_wird_unterdrueckt(clock): + assert write(1, 6) is True + clock[0] += filt.MIN_ON_TIME_S - 1 + assert write(1, 0) is False + + +def test_abschaltung_nach_ablauf_der_mindest_ein_zeit_geht_durch(clock): + assert write(1, 6) is True + clock[0] += filt.MIN_ON_TIME_S + assert write(1, 0) is True + + +def test_mindest_ein_zeit_laeuft_ab_dem_letzten_ungleich_null_wert(clock): + assert write(1, 6) is True + clock[0] += filt.MIN_ON_TIME_S - 10 + # Eine Stromänderung setzt das Fenster neu auf, denn sie schreibt einen + # neuen ungleich-0-Wert. + assert write(1, 10) is True + clock[0] += 20 + assert write(1, 0) is False + + +# Mindest-Aus-Zeit + + +def test_einschaltung_vor_ablauf_der_mindest_aus_zeit_wird_unterdrueckt(clock): + assert write(1, 0) is True + clock[0] += filt.MIN_OFF_TIME_S - 1 + assert write(1, 6) is False + + +def test_einschaltung_nach_ablauf_der_mindest_aus_zeit_geht_durch(clock): + assert write(1, 0) is True + clock[0] += filt.MIN_OFF_TIME_S + assert write(1, 6) is True + + +# Eigenschaft 2: der Filter verzögert, er verwirft nicht + + +def test_dauerhafter_wunsch_landet_nach_ablauf_des_fensters(clock): + assert write(1, 6) is True + # Die Regelung stellt den Abschaltwunsch in jedem Zyklus erneut. + for _ in range(10): + clock[0] += 10 + assert write(1, 0) is False + clock[0] += filt.MIN_ON_TIME_S + assert write(1, 0) is True + + +def test_ein_zyklus_zappler_wird_geschluckt(clock): + """0 unterdrückt -> der Zustand bleibt "ein" -> das folgende 6A ist frei.""" + assert write(1, 6) is True + clock[0] += 5 + assert write(1, 0) is False + clock[0] += 5 + # Nicht durch die Mindest-Aus-Zeit blockiert, denn es gab keine Abschaltung. + assert write(1, 6) is True + + +def test_unterdrueckter_schreibzugriff_bewaffnet_die_timer_nicht(clock): + assert write(1, 6) is True + clock[0] += 10 + assert write(1, 0) is False + clock[0] += filt.MIN_ON_TIME_S - 10 + # Insgesamt MIN_ON_TIME_S seit dem 6A, der Versuch dazwischen ändert nichts. + assert write(1, 0) is True + + +# Eigenschaft 3: es gibt keine Umgehung, nur Warten + + +def test_regelung_hat_keinen_bypass(clock, monkeypatch): + """Ohne force bleibt der Filter unumgehbar; die Regelung bekommt nie ein force.""" + import inspect + assert "force" not in inspect.signature(filt.record_write).parameters + assert "force" not in inspect.signature(filt.remaining).parameters + assert inspect.signature(filt.allow_write).parameters["force"].default is False + + +def test_wait_kehrt_sofort_zurueck_wenn_das_fenster_offen_ist(clock, monkeypatch): + sleeps = [] + monkeypatch.setattr(filt.time, "sleep", lambda s: sleeps.append(s)) + assert write(1, 6) is True + clock[0] += filt.MIN_ON_TIME_S + assert filt.wait_for_window(1, 0) is True + assert sleeps == [] + + +def test_wait_wartet_das_fenster_ab(clock, monkeypatch): + # Die Uhr läuft mit jedem sleep weiter, wie in echt. + def fake_sleep(seconds): + clock[0] += seconds + monkeypatch.setattr(filt.time, "sleep", fake_sleep) + assert write(1, 6) is True + clock[0] += 10 + start = clock[0] + assert filt.wait_for_window(1, 0) is True + # Gewartet wurde exakt bis zum Ablauf der Mindest-Ein-Zeit, nicht länger. + assert clock[0] - start == pytest.approx(filt.MIN_ON_TIME_S - 10, abs=filt.WAIT_POLL_S) + # Und danach darf auch wirklich geschrieben werden. + assert write(1, 0) is True + + +def test_wait_bricht_nach_timeout_ab(clock, monkeypatch): + """Wenn das Fenster nicht aufgeht, wird nicht geschaltet.""" + def fake_sleep(seconds): + clock[0] += seconds + monkeypatch.setattr(filt.time, "sleep", fake_sleep) + # remaining() gibt nie 0 zurück -> das Fenster geht nie auf. + monkeypatch.setattr(filt, "remaining", lambda *a: 999.0) + monkeypatch.setattr(filt, "log", Mock()) + assert filt.wait_for_window(1, 0, timeout=10) is False + + +def test_wait_ist_fail_safe_nicht_fail_open(clock, monkeypatch): + """Anders als allow_write: an wait_for_window haengt ein Schaltvorgang.""" + monkeypatch.setattr(filt, "remaining", Mock(side_effect=RuntimeError("boom"))) + monkeypatch.setattr(filt, "log", Mock()) + assert filt.wait_for_window(1, 0) is False + + +# Eigenschaft 4: die abgewartete Abschaltung zieht kein neues Fenster auf + + +def switch(evse_id: int) -> bool: + """Bildet die Abschaltung einer Phasenumschaltung nach (set_current(0, wait=True)).""" + if not filt.wait_for_window(evse_id, 0): + return False + filt.record_write(evse_id, 0, arm_window=False) + return True + + +def test_wiederanlauf_nach_phasenumschaltung_ist_nicht_gesperrt(clock, monkeypatch): + def fake_sleep(seconds): + clock[0] += seconds + monkeypatch.setattr(filt.time, "sleep", fake_sleep) + assert write(1, 6) is True + clock[0] += 10 + assert switch(1) is True + # Umschaltung dauert ein paar Sekunden, danach will die Regelung sofort wieder laden. + clock[0] += 12 + assert write(1, 6) is True + + +def test_normale_abschaltung_sperrt_den_wiederanlauf_weiterhin(clock): + """Die Ausnahme gilt nur fuer abgewartete Schreibzugriffe.""" + assert write(1, 6) is True + clock[0] += filt.MIN_ON_TIME_S + assert write(1, 0) is True + clock[0] += 12 + assert write(1, 6) is False + + +def test_abgewartete_abschaltung_umgeht_die_mindest_ein_zeit_nicht(clock, monkeypatch): + """arm_window ist keine Umgehung: geht das Fenster nicht auf, wird nicht geschaltet.""" + def fake_sleep(seconds): + clock[0] += seconds + monkeypatch.setattr(filt.time, "sleep", fake_sleep) + monkeypatch.setattr(filt, "remaining", lambda *a: 999.0) + monkeypatch.setattr(filt, "log", Mock()) + assert switch(1) is False + + +def test_abgewartete_abschaltung_zaehlt_fuer_die_relay_loop_diagnose(clock, monkeypatch): + mock_log = Mock() + monkeypatch.setattr(filt, "log", mock_log) + for current in (6, 0, 6, 0): + filt.record_write(1, current, arm_window=False) + clock[0] += 1 + assert "Relaisschleife" in warnings(mock_log) + + +# Eigenschaft 5: einzige Ausnahme ist der Verlust des Heartbeats + + +def test_heartbeat_verlust_schaltet_sofort_ab(clock, monkeypatch): + monkeypatch.setattr(filt, "log", Mock()) + assert write(1, 6) is True + clock[0] += 10 + # Ohne force waere die Abschaltung gesperrt. + assert filt.allow_write(1, 0) is False + assert filt.allow_write(1, 0, force=True) is True + + +def test_heartbeat_ausnahme_zieht_das_fenster_auf(clock, monkeypatch): + """Der Wiederanlauf nach der Ausnahme ist wie ueblich begrenzt.""" + monkeypatch.setattr(filt, "log", Mock()) + assert write(1, 6) is True + clock[0] += 10 + assert filt.allow_write(1, 0, force=True) is True + filt.record_write(1, 0) + clock[0] += 10 + assert write(1, 6) is False + + +def test_force_wird_als_ausnahme_protokolliert(clock, monkeypatch): + mock_log = Mock() + monkeypatch.setattr(filt, "log", mock_log) + assert write(1, 6) is True + clock[0] += 10 + filt.allow_write(1, 0, force=True) + assert "Heartbeat-Verlust" in warnings(mock_log) + + +def test_force_ohne_gesperrtes_fenster_aendert_nichts(clock, monkeypatch): + mock_log = Mock() + monkeypatch.setattr(filt, "log", mock_log) + assert filt.allow_write(1, 0, force=True) is True + assert "Heartbeat-Verlust" not in warnings(mock_log) + + +# Trennung nach evse_id + + +def test_zustand_wird_je_evse_getrennt_gefuehrt(clock): + assert write(1, 6) is True + assert write(2, 6) is True + clock[0] += 1 + assert write(1, 0) is False + clock[0] += filt.MIN_ON_TIME_S + assert write(2, 0) is True + + +# Diagnose + + +def warnings(mock_log) -> str: + return " ".join(str(call.args[0]) for call in mock_log.warning.call_args_list) + + +def test_relay_loop_warnung_ab_drei_wechseln(clock, monkeypatch): + mock_log = Mock() + monkeypatch.setattr(filt, "log", mock_log) + # record_write direkt aufrufen: allow_write würde die schnellen Wechsel + # unterdrücken, gezählt werden aber nur tatsächlich erfolgte Schreibzugriffe. + for current in (6, 0, 6, 0): + filt.record_write(1, current) + clock[0] += 1 + assert "Relaisschleife" in warnings(mock_log) + + +def test_keine_relay_loop_warnung_ausserhalb_des_fensters(clock, monkeypatch): + mock_log = Mock() + monkeypatch.setattr(filt, "log", mock_log) + for current in (6, 0, 6, 0): + filt.record_write(1, current) + clock[0] += filt.TOGGLE_WINDOW_S + 1 + assert "Relaisschleife" not in warnings(mock_log) + + +# Fail-open + + +def test_fehler_im_filter_laesst_den_schreibzugriff_durch(monkeypatch, clock): + assert write(1, 6) is True + clock[0] += 1 + monkeypatch.setattr(filt, "remaining", Mock(side_effect=RuntimeError("boom"))) + monkeypatch.setattr(filt, "log", Mock()) + assert filt.allow_write(1, 0) is True + + +def test_fehler_beim_protokollieren_wird_nicht_durchgereicht(monkeypatch, clock): + monkeypatch.setattr(filt, "_state", Mock(side_effect=RuntimeError("boom"))) + monkeypatch.setattr(filt, "log", Mock()) + filt.record_write(1, 0) diff --git a/packages/modules/internal_chargepoint_handler/chargepoint_module.py b/packages/modules/internal_chargepoint_handler/chargepoint_module.py index 751c7cd544..ba570ede6f 100644 --- a/packages/modules/internal_chargepoint_handler/chargepoint_module.py +++ b/packages/modules/internal_chargepoint_handler/chargepoint_module.py @@ -9,6 +9,7 @@ from modules.common.abstract_chargepoint import AbstractChargepoint from modules.common.component_context import SingleComponentUpdateContext from modules.common.component_state import ChargepointState +from modules.common import evse_transition_filter from modules.common.fault_state import ComponentInfo, FaultState from modules.common.store import get_internal_chargepoint_value_store, get_chargepoint_value_store from modules.internal_chargepoint_handler.clients import ClientHandler @@ -71,9 +72,9 @@ def on_message(client, userdata, message): BrokerClient(f"subscribeInternalCp{self.local_charge_point_num}", on_connect, on_message).start_finite_loop() - def set_current(self, current: float) -> None: + def set_current(self, current: float, force: bool = False) -> None: with SingleComponentUpdateContext(self.fault_state, update_always=False): - self._client.evse_client.set_current(current, phases_in_use=self.old_phases_in_use) + self._client.evse_client.set_current(current, phases_in_use=self.old_phases_in_use, force=force) def get_values(self, phase_switch_cp_active: bool, last_tag: str) -> ChargepointState: def store_state(chargepoint_state: ChargepointState) -> None: @@ -144,14 +145,19 @@ def store_state(chargepoint_state: ChargepointState) -> None: def perform_phase_switch(self, phases_to_use: int) -> None: gpio_cp, gpio_relay = self._client.get_pins_phase_switch(phases_to_use) evse = self._client.evse_client + # Vor dem Relais erst das Freigabefenster abwarten; unter Last darf nicht geschaltet werden. + if not evse_transition_filter.wait_for_window(evse.id, 0): + log.error(f"Phasenumschaltung an LP{self.local_charge_point_num} abgebrochen: die EVSE darf noch " + "nicht abgeschaltet werden. Die Umschaltung wird später erneut angefordert.") + return with SingleComponentUpdateContext(self.fault_state, update_always=False, reraise=True): - evse.set_current(0) + evse.set_current(0, wait=True) for _ in range(20): # poll up to 10s (20 × 0.5s) for EVSE to confirm 0 A _, _, evse_current = evse.get_plug_charge_state() if evse_current == 0: break time.sleep(0.5) - evse.set_current(0) + evse.set_current(0, wait=True) else: raise Exception("Ladung konnte nicht gestoppt werden - Phasenumschaltung abgebrochen.") GPIO.output(gpio_cp, GPIO.HIGH) # CP off @@ -165,8 +171,13 @@ def perform_phase_switch(self, phases_to_use: int) -> None: def perform_cp_interruption(self, duration: int) -> None: gpio_cp = self._client.get_pins_cp_interruption() + # Wie bei der Phasenumschaltung: erst das Freigabefenster abwarten, dann den CP unterbrechen. + if not evse_transition_filter.wait_for_window(self._client.evse_client.id, 0): + log.error(f"CP-Unterbrechung an LP{self.local_charge_point_num} abgebrochen: die EVSE darf noch " + "nicht abgeschaltet werden.") + return with SingleComponentUpdateContext(self.fault_state, update_always=False): - self._client.evse_client.set_current(0) + self._client.evse_client.set_current(0, wait=True) GPIO.setwarnings(False) GPIO.setmode(GPIO.BOARD) GPIO.setup(gpio_cp, GPIO.OUT) diff --git a/packages/modules/internal_chargepoint_handler/internal_chargepoint_handler.py b/packages/modules/internal_chargepoint_handler/internal_chargepoint_handler.py index 30fcf6b12b..131d2ad2f6 100644 --- a/packages/modules/internal_chargepoint_handler/internal_chargepoint_handler.py +++ b/packages/modules/internal_chargepoint_handler/internal_chargepoint_handler.py @@ -53,7 +53,8 @@ def update_state(self, data: InternalChargepoint, heartbeat_expired: bool) -> No log.debug("Thread zur CP-Unterbrechung an LP"+str(self.cp_module.local_charge_point_num) + " noch aktiv. Es muss erst gewartet werden, bis die CP-Unterbrechung abgeschlossen ist.") return - self.cp_module.set_current(set_current) + # Bei Heartbeat-Verlust muss die Ladung sofort stoppen, der Übergangsfilter darf das nicht verzögern. + self.cp_module.set_current(set_current, force=heartbeat_expired) Pub().pub(f"openWB/set/chargepoint/{self.hierarchy_id}/set/current", payload=set_current) if data.trigger_phase_switch: log.debug("Switch Phases from "+str(self.old_phases_to_use) + " to " + str(data.phases_to_use)) diff --git a/packages/modules/internal_chargepoint_handler/socket.py b/packages/modules/internal_chargepoint_handler/socket.py index d8d252aca3..2d3d92349c 100644 --- a/packages/modules/internal_chargepoint_handler/socket.py +++ b/packages/modules/internal_chargepoint_handler/socket.py @@ -58,14 +58,14 @@ def __init__(self, log.info(f"Konfiguration als Buchse mit maximal {self.socket_max_current}A Ladestrom je Phase.") super().__init__(local_charge_point_num, client_handler, internal_cp, hierarchy_id) - def set_current(self, current: float) -> None: + def set_current(self, current: float, force: bool = False) -> None: with SingleComponentUpdateContext(self.fault_state, update_always=False): with self.client_error_context: actor = ActorState(GPIO.input(19)) if actor == ActorState.OPENED: current = 0 - super().set_current(min(current, self.socket_max_current)) + super().set_current(min(current, self.socket_max_current), force=force) if actor == ActorState.OPENED and self.chargepoint_state.plug_state is True: raise ValueError("Buchse hat nicht verriegelt, obwohl ein Fahrzeug angesteckt ist.")