From 062c8e71f6782bbc0e16f59db233dd064f72143d Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 19 Jun 2026 10:10:11 +0200 Subject: [PATCH 01/13] draft --- packages/control/consumer/consumer.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 540ceb30d8..d88259ac97 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -8,7 +8,7 @@ from control.consumer.consumer_data import ConsumerData, ConsumerUsage, MeterOnlyConfig from control.load_protocol import Load from helpermodules import timecheck -from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase +from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase, voltages_mean from modules.common.configurable_consumer import ConfigurableConsumer log = logging.getLogger(__name__) @@ -107,6 +107,9 @@ def _parse_required_current_by_usage(self, required_current: float) -> float: else: return required_current + def _convert_power_to_current(self, power: float) -> float: + return power / self.data.config.connected_phases / voltages_mean(self.data.get.voltages) + def scheduled_charging(self) -> Tuple[int, str, Optional[str], int]: required_current = 0 message = None @@ -135,12 +138,14 @@ def time_charging(self) -> Tuple[int, str, Optional[str], int]: else: log.debug( "Zeitladen: minimaler Speicher-SoC überschritten, Laden mit Zeitladen möglich.") - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) submode = Chargemode.TIME_CHARGING else: message = self.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL else: - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) submode = Chargemode.TIME_CHARGING else: message = self.TIME_CHARGING_NO_PLAN_ACTIVE @@ -149,7 +154,8 @@ def time_charging(self) -> Tuple[int, str, Optional[str], int]: return required_current, message, None, submode def instant_charging(self) -> Tuple[int, str, Optional[str], int]: - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) message = None mode = Chargemode.INSTANT_CHARGING submode = Chargemode.INSTANT_CHARGING @@ -162,7 +168,8 @@ def eco_charging(self) -> Tuple[int, str, Optional[str], int]: mode = Chargemode.ECO_CHARGING if data.data.optional_data.data.electricity_pricing.configured: if data.data.optional_data.ep_is_charging_allowed_price_threshold(self.data.usage.eco_charging.price_limit): - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) message = self.CHARGING_PRICE_LOW submode = Chargemode.INSTANT_CHARGING else: From 9ebb708b2a5d6a9448467833d9f38eec978d50fc Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 19 Jun 2026 15:51:51 +0200 Subject: [PATCH 02/13] Anlauferkennung --- packages/control/consumer/consumer.py | 47 ++++---- packages/control/consumer/consumer_data.py | 14 +-- packages/control/consumer/consumer_test.py | 123 +++++++++++++++++++++ packages/helpermodules/setdata.py | 7 ++ packages/helpermodules/subdata.py | 2 + 5 files changed, 165 insertions(+), 28 deletions(-) create mode 100644 packages/control/consumer/consumer_test.py diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index d88259ac97..e88c34f453 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -22,6 +22,8 @@ class WaitForStartStates(Enum): class Consumer(Load): + PAUSE_BETWEEN_WAIT_FOR_START_TEST_RUNS = 3600 + def __init__(self, index: int): self.num = index self.data: ConsumerData = ConsumerData() @@ -37,7 +39,11 @@ def set_state_and_log(self, message: str) -> None: elif message not in self.data.get.state_str: self.data.get.state_str += f" {message}" + def setup_values_at_start(self): + self.data.get.state_str = None + def update(self): + self.setup_values_at_start() if isinstance(self.data.usage, MeterOnlyConfig): return else: @@ -72,7 +78,7 @@ def update(self): WAIT_FOR_START_SIGNAL_RECEIVED = "Startsignal für kontinuierlichen Verbraucher empfangen." def get_parameter(self) -> Tuple[int, int, Optional[str], Chargemode, Chargemode]: - if timecheck.create_timestamp() < self.data.set.timestamp_last_current_set + self.data.usage.min_intervall: + if timecheck.create_timestamp() < self.data.set.timestamp_last_current_set + self.data.config.min_intervall: log.debug("Intervall für neuen Schaltbefehl nicht abgelaufen.") return (self.data.control_parameter.min_current, self.data.control_parameter.required_current, @@ -80,7 +86,7 @@ def get_parameter(self) -> Tuple[int, int, Optional[str], Chargemode, Chargemode self.data.control_parameter.chargemode, self.data.control_parameter.submode) - min_current = self.data.usage.min_current + min_current = self.data.config.min_current if self.data.usage.chargemode == Chargemode.SCHEDULED_CHARGING: required_current, message, mode, submode = self.wait_for_start_handler(self.scheduled_charging) elif self.data.usage.chargemode == Chargemode.TIME_CHARGING: @@ -103,7 +109,7 @@ def get_parameter(self) -> Tuple[int, int, Optional[str], Chargemode, Chargemode def _parse_required_current_by_usage(self, required_current: float) -> float: if self.data.usage.type in [ConsumerUsage.CONTINUOUS, ConsumerUsage.SUSPENDABLE_ONOFF]: return get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + self.data.get.currents) if self.data.get.charge_state else self.data.config.min_current else: return required_current @@ -173,18 +179,18 @@ def eco_charging(self) -> Tuple[int, str, Optional[str], int]: message = self.CHARGING_PRICE_LOW submode = Chargemode.INSTANT_CHARGING else: - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage(self.data.config.min_current) message = self.CHARGING_PRICE_EXCEEDED if self.data.control_parameter.state in CHARGING_STATES: message += "Lädt mit Überschuss. " submode = Chargemode.PV_CHARGING else: - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage(self.data.config.min_current) submode = Chargemode.PV_CHARGING return required_current, message, mode, submode def pv_charging(self) -> Tuple[int, str, Optional[str], int]: - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage(self.data.config.min_current) message = None mode = Chargemode.PV_CHARGING submode = Chargemode.PV_CHARGING @@ -193,46 +199,49 @@ def pv_charging(self) -> Tuple[int, str, Optional[str], int]: def wait_for_start_handler( self, func: Callable[[], Tuple[int, str, Optional[str], int]]) -> Tuple[int, str, Optional[str], int]: if self.data.usage.wait_for_start_active: - timestamp_next_test = self.data.set.wait_for_start_last_test_timestamp + 60*60 - wait_for_start_state = self._wait_for_start_singal(timestamp_next_test) + wait_for_start_state = self._wait_for_start_signal() if wait_for_start_state == WaitForStartStates.START_SIGNAL_RECEIVED: + self.data.set.wait_for_start_signal_received = True + self.data.set.wait_for_start_test_running = False required_current, message, mode, submode = func() elif (wait_for_start_state == WaitForStartStates.START_TEST_RUN or wait_for_start_state == WaitForStartStates.TEST_RUNNIG): - required_current = self.data.usage.min_current + if wait_for_start_state == WaitForStartStates.START_TEST_RUN: + self.data.set.wait_for_start_test_running = True + self.data.set.wait_for_start_last_test_timestamp = timecheck.create_timestamp() + required_current = self.data.config.min_current message = self.WAIT_FOR_START_SIGNAL_TEST_RUN mode = Chargemode.INSTANT_CHARGING submode = Chargemode.INSTANT_CHARGING elif wait_for_start_state == WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN: + self.data.set.wait_for_start_test_running = False required_current = 0 message = self.WAIT_FOR_START_SIGNAL.format( - timecheck.convert_timestamp_delta_to_time_string(timestamp_next_test, 0)) + timecheck.convert_timestamp_delta_to_time_string(self.data.set.wait_for_start_last_test_timestamp, + self.PAUSE_BETWEEN_WAIT_FOR_START_TEST_RUNS)) mode = Chargemode.STOP submode = Chargemode.STOP else: required_current, message, mode, submode = func() return required_current, message, mode, submode - def _wait_for_start_singal(self, timestamp_next_test: float) -> WaitForStartStates: + def _wait_for_start_signal(self) -> WaitForStartStates: if self.data.set.wait_for_start_signal_received: return WaitForStartStates.START_SIGNAL_RECEIVED else: if self.data.set.wait_for_start_test_running: if self.data.get.charge_state: - self.data.set.wait_for_start_signal_received = True - self.data.set.wait_for_start_test_running = False return WaitForStartStates.START_SIGNAL_RECEIVED - elif timecheck.create_timestamp() < self.data.set.wait_for_start_last_test_timestamp + 60: - return WaitForStartStates.START_TEST_RUN else: self.data.set.wait_for_start_test_running = False log.debug("Testlauf für kontinuierlichen Verbraucher beendet, kein Startsignal empfangen.") return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN - elif timecheck.create_timestamp() > timestamp_next_test: - self.data.set.wait_for_start_last_test_timestamp = timecheck.create_timestamp() - return WaitForStartStates.TEST_RUNNIG else: - return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN + if (timecheck.create_timestamp() > self.data.set.wait_for_start_last_test_timestamp + + self.PAUSE_BETWEEN_WAIT_FOR_START_TEST_RUNS): + return WaitForStartStates.START_TEST_RUN + else: + return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN def set_control_parameter(self, min_current, required_current, phases, submode, mode): self.data.control_parameter.min_current = min_current diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index aa389e8a32..9728135c84 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -39,8 +39,6 @@ class MeterOnlyConfig: @dataclass class SuspendableTunableDeviceConfig: chargemode: Chargemode = Chargemode.INSTANT_CHARGING - min_current: float = 0.5 - min_intervall: int = 60 eco_charging: EcoCharging = field(default_factory=lambda: EcoCharging()) scheduled_charging: ScheduledSuspendableCharging = field(default_factory=lambda: ScheduledSuspendableCharging()) time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) @@ -51,8 +49,6 @@ class SuspendableTunableDeviceConfig: @dataclass class SuspendableOnOffDeviceConfig: chargemode: Chargemode = Chargemode.INSTANT_CHARGING - min_current: float = 0.5 - min_intervall: int = 60 eco_charging: EcoCharging = field(default_factory=lambda: EcoCharging()) scheduled_charging: ScheduledSuspendableCharging = field(default_factory=lambda: ScheduledSuspendableCharging()) time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) @@ -63,8 +59,6 @@ class SuspendableOnOffDeviceConfig: @dataclass class ContinuousDeviceConfig: chargemode: Chargemode = Chargemode.INSTANT_CHARGING - min_current: float = 0.5 - min_intervall: int = 2*60*60 eco_charging: EcoCharging = field(default_factory=lambda: EcoCharging()) scheduled_charging: ScheduledContinuousCharging = field(default_factory=lambda: ScheduledContinuousCharging()) time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) @@ -101,6 +95,8 @@ class ConsumerConfig: connected_phases: int = 1 phase_1: int = 1 max_power: float = 5000 + min_current: float = 0.5 + min_intervall: int = 60 @dataclass @@ -124,10 +120,10 @@ class Get: @dataclass class Set: - current: float = 0 + current: float = field(default=0, metadata={"topic": "set/current"}) loadmanagement_available: bool = False - phases_to_use: int = 0 - plug_time: Optional[float] = 0 + phases_to_use: int = field(default=1, metadata={"topic": "set/phases_to_use"}) + plug_time: Optional[float] = field(default=None, metadata={"topic": "set/plug_time"}) required_power: float = 0 current_prev: float = 0 target_current: float = 0 diff --git a/packages/control/consumer/consumer_test.py b/packages/control/consumer/consumer_test.py new file mode 100644 index 0000000000..d8fe40a29c --- /dev/null +++ b/packages/control/consumer/consumer_test.py @@ -0,0 +1,123 @@ +from unittest.mock import Mock + +import pytest + +from control import data +from control.chargemode import Chargemode +from control.consumer.consumer import Consumer, WaitForStartStates +from control.consumer.consumer_data import ContinuousDeviceConfig +from helpermodules import timecheck + + +@pytest.fixture(autouse=True) +def mock_data() -> None: + data.data_init(Mock()) + + +@pytest.fixture +def consumer() -> Consumer: + load = Consumer(1) + load.data.usage = ContinuousDeviceConfig() + load.data.config.min_current = 6 + return load + + +def test_wait_for_start_handler_runs_func_when_disabled(consumer: Consumer): + consumer.data.usage.wait_for_start_active = False + charging_func = Mock(return_value=(11, "ok", Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING)) + + required_current, message, mode, submode = consumer.wait_for_start_handler(charging_func) + + assert required_current == 11 + assert message == "ok" + assert mode == Chargemode.ECO_CHARGING + assert submode == Chargemode.PV_CHARGING + charging_func.assert_called_once_with() + + +def test_wait_for_start_handler_start_signal_received(monkeypatch: pytest.MonkeyPatch, consumer: Consumer): + consumer.data.usage.wait_for_start_active = True + consumer.data.set.wait_for_start_test_running = True + consumer.data.set.wait_for_start_signal_received = False + monkeypatch.setattr(consumer, "_wait_for_start_signal", Mock(return_value=WaitForStartStates.START_SIGNAL_RECEIVED)) + charging_func = Mock(return_value=(9, "running", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING)) + + required_current, message, mode, submode = consumer.wait_for_start_handler(charging_func) + + assert required_current == 9 + assert message == "running" + assert mode == Chargemode.PV_CHARGING + assert submode == Chargemode.PV_CHARGING + assert consumer.data.set.wait_for_start_signal_received is True + assert consumer.data.set.wait_for_start_test_running is False + charging_func.assert_called_once_with() + + +def test_wait_for_start_handler_starts_test_run(monkeypatch: pytest.MonkeyPatch, consumer: Consumer): + consumer.data.usage.wait_for_start_active = True + monkeypatch.setattr(consumer, "_wait_for_start_signal", Mock(return_value=WaitForStartStates.START_TEST_RUN)) + monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=1000)) + + required_current, message, mode, submode = consumer.wait_for_start_handler(Mock()) + + assert required_current == 6 + assert message == consumer.WAIT_FOR_START_SIGNAL_TEST_RUN + assert mode == Chargemode.INSTANT_CHARGING + assert submode == Chargemode.INSTANT_CHARGING + assert consumer.data.set.wait_for_start_test_running is True + assert consumer.data.set.wait_for_start_last_test_timestamp == 1000 + + +def test_wait_for_start_signal_wait_for_next_test_run(monkeypatch: pytest.MonkeyPatch, consumer: Consumer): + consumer.data.usage.wait_for_start_active = True + consumer.data.set.wait_for_start_last_test_timestamp = 780 + monkeypatch.setattr( + consumer, + "_wait_for_start_signal", + Mock(return_value=WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN), + ) + monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=800)) + + required_current, message, mode, submode = consumer.wait_for_start_handler(Mock()) + + assert required_current == 0 + assert message == consumer.WAIT_FOR_START_SIGNAL.format("59 Min. 40 Sek.") + assert mode == Chargemode.STOP + assert submode == Chargemode.STOP + assert consumer.data.set.wait_for_start_test_running is False + + +@pytest.mark.parametrize( + "signal_received,test_running,charge_state,now,last_test_timestamp,expected_state", + [ + pytest.param(True, False, True, 0, 0, WaitForStartStates.START_SIGNAL_RECEIVED, + id="already-received"), + pytest.param(False, True, True, 0, 0, WaitForStartStates.START_SIGNAL_RECEIVED, + id="charge-state-during-test-run"), + pytest.param(False, True, False, 0, 0, WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN, + id="test-run-finished-no-signal"), + pytest.param(False, False, False, 5000, 500, WaitForStartStates.START_TEST_RUN, + id="pause-elapsed"), + pytest.param(False, False, False, 1000, 500, WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN, + id="pause-not-elapsed"), + ], +) +def test_wait_for_start_signal_state_machine( + monkeypatch: pytest.MonkeyPatch, + consumer: Consumer, + signal_received: bool, + test_running: bool, + charge_state: bool, + now: int, + last_test_timestamp: int, + expected_state: WaitForStartStates, +): + consumer.data.set.wait_for_start_signal_received = signal_received + consumer.data.set.wait_for_start_test_running = test_running + consumer.data.get.charge_state = charge_state + consumer.data.set.wait_for_start_last_test_timestamp = last_test_timestamp + monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=now)) + + state = consumer._wait_for_start_signal() + + assert state == expected_state diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 7e8cf263d8..529dabfd80 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -1249,6 +1249,11 @@ def process_consumer_topic(self, msg): elif (re.search("consumer/[0-9]+/get/power$", msg.topic) is not None or re.search("consumer/[0-9]+/set/power$", msg.topic) is not None or re.search("consumer/[0-9]+/set/current$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/plug_time$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/timestamp_last_current_set$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/wait_for_start_signal_received$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/wait_for_start_test_running$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/wait_for_start_last_test_timestamp$", msg.topic) is not None or re.search("consumer/[0-9]+/get/set_power$", msg.topic) is not None): self._validate_value(msg, float) elif (re.search("consumer/[0-9]+/get/currents$", msg.topic) is not None or @@ -1267,6 +1272,8 @@ def process_consumer_topic(self, msg): self._validate_value(msg, str) elif re.search("consumer/[0-9]+/get/state$", msg.topic) is not None: self._validate_value(msg, bool) + elif re.search("consumer/[0-9]+/set/phases_to_use$", msg.topic) is not None: + self._validate_value(msg, int, [(0, 3)]) elif re.search("consumer/[0-9]+/control_parameter/", msg.topic) is not None: self.process_control_parameter_topics(msg) else: diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index a53f736d4f..803fd0b5a4 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -1187,6 +1187,8 @@ def process_consumer_topic(self, client: mqtt.Client, var: Dict[str, Consumer], self.set_json_payload_class(var["consumer"+index].data.config, msg) elif re.search("openWB/consumer/[0-9]+/get", msg.topic) is not None: self.set_json_payload_class(var["consumer"+index].data.get, msg) + elif re.search("openWB/consumer/[0-9]+/set", msg.topic) is not None: + self.set_json_payload_class(var["consumer"+index].data.set, msg) elif re.search("openWB/consumer/[0-9]+/extra_meter", msg.topic) is not None: self.set_json_payload_class(var[f"consumer{index}"].data, msg) elif re.search("openWB/consumer/[0-9]+/usage$", msg.topic) is not None: From b13f7f5045613800ab6f12e236338dd2cc62927e Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 22 Jun 2026 09:01:30 +0200 Subject: [PATCH 03/13] =?UTF-8?q?Betriebsmodus=20zur=C3=BCcksetzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/control/consumer/consumer.py | 33 ++++++++++++++++++++-- packages/control/consumer/consumer_data.py | 15 ++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index e88c34f453..dc6d2a5ce1 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -5,7 +5,7 @@ from control.algorithm.utils import get_medium_charging_current from control.chargemode import Chargemode from control.chargepoint.chargepoint_state import CHARGING_STATES -from control.consumer.consumer_data import ConsumerData, ConsumerUsage, MeterOnlyConfig +from control.consumer.consumer_data import ConsumerData, ConsumerUsage, MeterOnlyConfig, ResetModes from control.load_protocol import Load from helpermodules import timecheck from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase, voltages_mean @@ -59,9 +59,9 @@ def update(self): self.data.set.phases_to_use = self.data.config.connected_phases self.data.get.charge_state = True if self.data.get.power > 0 else False min_current, required_current, message, mode, submode = self.get_parameter() + self.set_mode_changed(submode, mode) self.set_control_parameter(min_current, required_current, self.data.config.connected_phases, submode, mode) self.set_state_and_log(message) - self.set_mode_changed(submode, mode) log.debug( f"Verbraucher {self.num}: Sollstrom {required_current}, min. Ist-Strom {max(self.data.get.currents)}," f" Modus {mode}, Submodus {submode}, {message}") @@ -243,6 +243,26 @@ def _wait_for_start_signal(self) -> WaitForStartStates: else: return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN + def reset_wait_for_start(self): + self.data.set.wait_for_start_signal_received = False + self.data.set.wait_for_start_test_running = False + self.data.set.wait_for_start_last_test_timestamp = 0 + + def reset_chargemode_at_midnight(self): + if self.data.usage.reset_chargemode.mode == ResetModes.MIDNIGHT: + if self.data.usage.chargemode != self.data.usage.reset_chargemode.chargemode: + log.info(f"Zurücksetzen des Lademodus auf {self.data.usage.reset_chargemode.chargemode} " + f"für Verbraucher {self.num} um Mitternacht.") + self.data.usage.chargemode = self.data.usage.reset_chargemode.chargemode + + def reset_chargemode_at_time(self): + if self.data.usage.reset_chargemode.mode == ResetModes.TIME and self.data.usage.reset_chargemode.time is not None: + if timecheck.create_timestamp() > self.data.usage.reset_chargemode.time: + if self.data.usage.chargemode != self.data.usage.reset_chargemode.chargemode: + log.info(f"Zurücksetzen des Lademodus auf {self.data.usage.reset_chargemode.chargemode} " + f"für Verbraucher {self.num} um definierte Zeit.") + self.data.usage.chargemode = self.data.usage.reset_chargemode.chargemode + def set_control_parameter(self, min_current, required_current, phases, submode, mode): self.data.control_parameter.min_current = min_current self.data.control_parameter.required_current = required_current @@ -267,4 +287,11 @@ def is_charging_stop_allowed(self) -> bool: def set_mode_changed(self, submode: Chargemode, mode: Chargemode) -> None: self.submode_changed = (submode != self.data.control_parameter.submode) - self.chargemode_changed = (mode != self.data.control_parameter.chargemode) + if ((submode == "time_charging" and self.data.control_parameter.chargemode != "time_charging") or + (submode != "time_charging" and + self.data.control_parameter.chargemode != mode)): + self.chargemode_changed = True + log.debug("Änderung des Lademodus") + self.data.control_parameter.timestamp_chargemode_changed = timecheck.create_timestamp() + else: + self.chargemode_changed = False diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index 9728135c84..54cb203a4b 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +from enum import Enum from typing import Callable, Dict, List, Optional, Union from control.chargemode import Chargemode from control.chargepoint.control_parameter import ControlParameter, control_parameter_factory @@ -31,6 +32,19 @@ class TimeCharging: plans: List[TimeChargingPlanConsumer] = field(default_factory=empty_list_factory) +class ResetModes(Enum): + NEVER = "never" + MIDNIGHT = "midnight" + TIME = "time" + + +@dataclass +class ResetChargemode: + mode: ResetModes = ResetModes.NEVER + time: Optional[int] = None + chargemode: Chargemode = Chargemode.INSTANT_CHARGING + + @dataclass class MeterOnlyConfig: type: ConsumerUsage = ConsumerUsage.METER_ONLY @@ -64,6 +78,7 @@ class ContinuousDeviceConfig: time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) type: ConsumerUsage = ConsumerUsage.CONTINUOUS wait_for_start_active: bool = True + reset_chargemode: ResetChargemode = field(default_factory=lambda: ResetChargemode()) GET_DEFAULTS_BY_USAGE: Dict[ConsumerUsage, Union[MeterOnlyConfig, From 13b238b4a67ca19e17d7573c1bda7bf7c90ec743 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 22 Jun 2026 09:56:39 +0200 Subject: [PATCH 04/13] ammend --- packages/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/main.py b/packages/main.py index 3385139ba9..1e9570706d 100755 --- a/packages/main.py +++ b/packages/main.py @@ -234,6 +234,10 @@ def handler_midnight(self): thread_errors_path = Path(Path(__file__).resolve().parents[1]/"ramdisk"/"thread_errors.log") with thread_errors_path.open("w") as f: f.write("") + with ChangedValuesContext(loadvars_.event_module_update_completed): + for consumer in data.data.consumer_data.values(): + consumer.reset_wait_for_start() + consumer.reset_chargemode_at_midnight() except Exception: log.exception("Fehler im Main-Modul") From 9f6aa50825a6054746e73d8464c55828947d546e Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 22 Jun 2026 15:29:35 +0200 Subject: [PATCH 05/13] Anlauferkennung --- packages/control/consumer/consumer.py | 101 +++++------ packages/control/consumer/consumer_data.py | 14 +- packages/control/consumer/consumer_test.py | 186 ++++++++++----------- packages/helpermodules/setdata.py | 6 +- packages/helpermodules/subdata.py | 3 + packages/helpermodules/update_config.py | 5 + 6 files changed, 156 insertions(+), 159 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index dc6d2a5ce1..f41d09b3f6 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -1,11 +1,10 @@ -from enum import Enum import logging from typing import Callable, Optional, Tuple from control import data from control.algorithm.utils import get_medium_charging_current from control.chargemode import Chargemode from control.chargepoint.chargepoint_state import CHARGING_STATES -from control.consumer.consumer_data import ConsumerData, ConsumerUsage, MeterOnlyConfig, ResetModes +from control.consumer.consumer_data import ConsumerData, ConsumerUsage, MeterOnlyConfig, ResetModes, WaitForStartStates from control.load_protocol import Load from helpermodules import timecheck from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase, voltages_mean @@ -14,13 +13,6 @@ log = logging.getLogger(__name__) -class WaitForStartStates(Enum): - START_TEST_RUN = "start_test_run" - TEST_RUNNIG = "test_running" - START_SIGNAL_RECEIVED = "start_singal_received" - WAIT_FOR_NEXT_TEST_RUN = "wait_for_next_test_run" - - class Consumer(Load): PAUSE_BETWEEN_WAIT_FOR_START_TEST_RUNS = 3600 @@ -73,9 +65,6 @@ def update(self): BELOW_PRICE_LIMIT = "Preislimit für Verbraucher aktiv, aktueller Preis in Ordnung." SURPLUS_CONTINOUS_STILL_RUNNING = ("Verbraucher läuft ggf auch ohne ausreichend Überschuss weiter, da der " "Verbraucher nicht abgeschaltet werden darf.") - WAIT_FOR_START_SIGNAL = "Warte auf Startsignal für kontinuierlichen Verbraucher. Nächster Testlauf in {}" - WAIT_FOR_START_SIGNAL_TEST_RUN = "Verbraucher eingeschaltet, um zu testen, ob ein Startsignal empfangen wird." - WAIT_FOR_START_SIGNAL_RECEIVED = "Startsignal für kontinuierlichen Verbraucher empfangen." def get_parameter(self) -> Tuple[int, int, Optional[str], Chargemode, Chargemode]: if timecheck.create_timestamp() < self.data.set.timestamp_last_current_set + self.data.config.min_intervall: @@ -196,57 +185,56 @@ def pv_charging(self) -> Tuple[int, str, Optional[str], int]: submode = Chargemode.PV_CHARGING return required_current, message, mode, submode + WAIT_FOR_DEVICE_START = "Warte auf Gerätestart, Gerät eingeschaltet lassen." + WAIT_FOR_STOPPED_DEVICE = "Gerätestart erkannt, warte auf gestopptes Gerät." + DEVICE_WAITING_FOR_START = "Gerätestart erkannt. Warte auf ausreichend Überschuss, um das Gerät zu starten." + def wait_for_start_handler( - self, func: Callable[[], Tuple[int, str, Optional[str], int]]) -> Tuple[int, str, Optional[str], int]: + self, func: Callable[[], Tuple[int, str, Optional[str], int]] + ) -> Tuple[int, Chargemode, Optional[Chargemode], int]: if self.data.usage.wait_for_start_active: - wait_for_start_state = self._wait_for_start_signal() - if wait_for_start_state == WaitForStartStates.START_SIGNAL_RECEIVED: - self.data.set.wait_for_start_signal_received = True - self.data.set.wait_for_start_test_running = False + if self.data.set.wait_for_start_state == WaitForStartStates.WAIT_FOR_DEVICE_START: + if self.data.get.charge_state: + self.data.set.wait_for_start_state = WaitForStartStates.WAIT_FOR_STOPPED_DEVICE + required_current = 0 + message = self.WAIT_FOR_STOPPED_DEVICE + mode = Chargemode.STOP + submode = Chargemode.STOP + else: + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) + message = self.WAIT_FOR_DEVICE_START + mode = Chargemode.INSTANT_CHARGING + submode = Chargemode.INSTANT_CHARGING + elif self.data.set.wait_for_start_state == WaitForStartStates.WAIT_FOR_STOPPED_DEVICE: + if self.data.get.charge_state is False: + self.data.set.wait_for_start_state = WaitForStartStates.DEVICE_WAITING_FOR_START + required_current, message, mode, submode = func() + message = self.DEVICE_WAITING_FOR_START + " " + (message if message else "") + else: + self.data.set.wait_for_start_state = WaitForStartStates.WAIT_FOR_STOPPED_DEVICE + required_current = 0 + message = self.WAIT_FOR_STOPPED_DEVICE + mode = Chargemode.STOP + submode = Chargemode.STOP + elif self.data.set.wait_for_start_state == WaitForStartStates.DEVICE_WAITING_FOR_START: + if self.data.get.charge_state: + self.data.set.wait_for_start_state = WaitForStartStates.START_SIGNAL_RECEIVED + required_current, message, mode, submode = func() + else: + required_current, message, mode, submode = func() + message = self.DEVICE_WAITING_FOR_START + " " + (message if message else "") + elif self.data.set.wait_for_start_state == WaitForStartStates.START_SIGNAL_RECEIVED: required_current, message, mode, submode = func() - elif (wait_for_start_state == WaitForStartStates.START_TEST_RUN or - wait_for_start_state == WaitForStartStates.TEST_RUNNIG): - if wait_for_start_state == WaitForStartStates.START_TEST_RUN: - self.data.set.wait_for_start_test_running = True - self.data.set.wait_for_start_last_test_timestamp = timecheck.create_timestamp() - required_current = self.data.config.min_current - message = self.WAIT_FOR_START_SIGNAL_TEST_RUN - mode = Chargemode.INSTANT_CHARGING - submode = Chargemode.INSTANT_CHARGING - elif wait_for_start_state == WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN: - self.data.set.wait_for_start_test_running = False - required_current = 0 - message = self.WAIT_FOR_START_SIGNAL.format( - timecheck.convert_timestamp_delta_to_time_string(self.data.set.wait_for_start_last_test_timestamp, - self.PAUSE_BETWEEN_WAIT_FOR_START_TEST_RUNS)) - mode = Chargemode.STOP - submode = Chargemode.STOP + else: + raise ValueError( + f"Ungültiger wait_for_start_state {self.data.set.wait_for_start_state} für Verbraucher {self.num}") else: required_current, message, mode, submode = func() return required_current, message, mode, submode - def _wait_for_start_signal(self) -> WaitForStartStates: - if self.data.set.wait_for_start_signal_received: - return WaitForStartStates.START_SIGNAL_RECEIVED - else: - if self.data.set.wait_for_start_test_running: - if self.data.get.charge_state: - return WaitForStartStates.START_SIGNAL_RECEIVED - else: - self.data.set.wait_for_start_test_running = False - log.debug("Testlauf für kontinuierlichen Verbraucher beendet, kein Startsignal empfangen.") - return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN - else: - if (timecheck.create_timestamp() > self.data.set.wait_for_start_last_test_timestamp + - self.PAUSE_BETWEEN_WAIT_FOR_START_TEST_RUNS): - return WaitForStartStates.START_TEST_RUN - else: - return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN - def reset_wait_for_start(self): - self.data.set.wait_for_start_signal_received = False - self.data.set.wait_for_start_test_running = False - self.data.set.wait_for_start_last_test_timestamp = 0 + self.data.set.wait_for_start_state = WaitForStartStates.WAIT_FOR_DEVICE_START def reset_chargemode_at_midnight(self): if self.data.usage.reset_chargemode.mode == ResetModes.MIDNIGHT: @@ -256,7 +244,8 @@ def reset_chargemode_at_midnight(self): self.data.usage.chargemode = self.data.usage.reset_chargemode.chargemode def reset_chargemode_at_time(self): - if self.data.usage.reset_chargemode.mode == ResetModes.TIME and self.data.usage.reset_chargemode.time is not None: + if (self.data.usage.reset_chargemode.mode == ResetModes.TIME and + self.data.usage.reset_chargemode.time is not None): if timecheck.create_timestamp() > self.data.usage.reset_chargemode.time: if self.data.usage.chargemode != self.data.usage.reset_chargemode.chargemode: log.info(f"Zurücksetzen des Lademodus auf {self.data.usage.reset_chargemode.chargemode} " diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index 54cb203a4b..641f0557ba 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -32,6 +32,13 @@ class TimeCharging: plans: List[TimeChargingPlanConsumer] = field(default_factory=empty_list_factory) +class WaitForStartStates(Enum): + WAIT_FOR_DEVICE_START = "wait_for_device_start" + WAIT_FOR_STOPPED_DEVICE = "wait_for_stopped_device" + DEVICE_WAITING_FOR_START = "device_waiting_for_start" + START_SIGNAL_RECEIVED = "start_signal_received" + + class ResetModes(Enum): NEVER = "never" MIDNIGHT = "midnight" @@ -145,11 +152,8 @@ class Set: charge_state_prev: bool = False power: Optional[float] = None timestamp_last_current_set: float = field(default=0, metadata={"topic": "set/timestamp_last_current_set"}) - wait_for_start_signal_received: bool = field( - default=False, metadata={"topic": "set/wait_for_start_signal_received"}) - wait_for_start_test_running: bool = field(default=False, metadata={"topic": "set/wait_for_start_test_running"}) - wait_for_start_last_test_timestamp: float = field( - default=0, metadata={"topic": "set/wait_for_start_last_test_timestamp"}) + wait_for_start_state: WaitForStartStates = field( + default=WaitForStartStates.WAIT_FOR_DEVICE_START, metadata={"topic": "set/wait_for_start_state"}) @dataclass diff --git a/packages/control/consumer/consumer_test.py b/packages/control/consumer/consumer_test.py index d8fe40a29c..6e6228f585 100644 --- a/packages/control/consumer/consumer_test.py +++ b/packages/control/consumer/consumer_test.py @@ -1,12 +1,12 @@ +from typing import Optional, Tuple from unittest.mock import Mock import pytest from control import data from control.chargemode import Chargemode -from control.consumer.consumer import Consumer, WaitForStartStates -from control.consumer.consumer_data import ContinuousDeviceConfig -from helpermodules import timecheck +from control.consumer.consumer import Consumer +from control.consumer.consumer_data import ContinuousDeviceConfig, WaitForStartStates @pytest.fixture(autouse=True) @@ -19,105 +19,103 @@ def consumer() -> Consumer: load = Consumer(1) load.data.usage = ContinuousDeviceConfig() load.data.config.min_current = 6 + load.data.config.max_power = 2300 + load.data.config.connected_phases = 1 + load.data.get.voltages = [230] return load -def test_wait_for_start_handler_runs_func_when_disabled(consumer: Consumer): - consumer.data.usage.wait_for_start_active = False - charging_func = Mock(return_value=(11, "ok", Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING)) - - required_current, message, mode, submode = consumer.wait_for_start_handler(charging_func) - - assert required_current == 11 - assert message == "ok" - assert mode == Chargemode.ECO_CHARGING - assert submode == Chargemode.PV_CHARGING - charging_func.assert_called_once_with() - - -def test_wait_for_start_handler_start_signal_received(monkeypatch: pytest.MonkeyPatch, consumer: Consumer): - consumer.data.usage.wait_for_start_active = True - consumer.data.set.wait_for_start_test_running = True - consumer.data.set.wait_for_start_signal_received = False - monkeypatch.setattr(consumer, "_wait_for_start_signal", Mock(return_value=WaitForStartStates.START_SIGNAL_RECEIVED)) - charging_func = Mock(return_value=(9, "running", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING)) - - required_current, message, mode, submode = consumer.wait_for_start_handler(charging_func) - - assert required_current == 9 - assert message == "running" - assert mode == Chargemode.PV_CHARGING - assert submode == Chargemode.PV_CHARGING - assert consumer.data.set.wait_for_start_signal_received is True - assert consumer.data.set.wait_for_start_test_running is False - charging_func.assert_called_once_with() - - -def test_wait_for_start_handler_starts_test_run(monkeypatch: pytest.MonkeyPatch, consumer: Consumer): - consumer.data.usage.wait_for_start_active = True - monkeypatch.setattr(consumer, "_wait_for_start_signal", Mock(return_value=WaitForStartStates.START_TEST_RUN)) - monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=1000)) - - required_current, message, mode, submode = consumer.wait_for_start_handler(Mock()) - - assert required_current == 6 - assert message == consumer.WAIT_FOR_START_SIGNAL_TEST_RUN - assert mode == Chargemode.INSTANT_CHARGING - assert submode == Chargemode.INSTANT_CHARGING - assert consumer.data.set.wait_for_start_test_running is True - assert consumer.data.set.wait_for_start_last_test_timestamp == 1000 - - -def test_wait_for_start_signal_wait_for_next_test_run(monkeypatch: pytest.MonkeyPatch, consumer: Consumer): - consumer.data.usage.wait_for_start_active = True - consumer.data.set.wait_for_start_last_test_timestamp = 780 - monkeypatch.setattr( - consumer, - "_wait_for_start_signal", - Mock(return_value=WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN), - ) - monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=800)) - - required_current, message, mode, submode = consumer.wait_for_start_handler(Mock()) - - assert required_current == 0 - assert message == consumer.WAIT_FOR_START_SIGNAL.format("59 Min. 40 Sek.") - assert mode == Chargemode.STOP - assert submode == Chargemode.STOP - assert consumer.data.set.wait_for_start_test_running is False - - @pytest.mark.parametrize( - "signal_received,test_running,charge_state,now,last_test_timestamp,expected_state", + ("wait_for_start_active", + "state", "charge_state", + "func_result", + "expected_result", + "expected_state", + "func_calls"), [ - pytest.param(True, False, True, 0, 0, WaitForStartStates.START_SIGNAL_RECEIVED, - id="already-received"), - pytest.param(False, True, True, 0, 0, WaitForStartStates.START_SIGNAL_RECEIVED, - id="charge-state-during-test-run"), - pytest.param(False, True, False, 0, 0, WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN, - id="test-run-finished-no-signal"), - pytest.param(False, False, False, 5000, 500, WaitForStartStates.START_TEST_RUN, - id="pause-elapsed"), - pytest.param(False, False, False, 1000, 500, WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN, - id="pause-not-elapsed"), + pytest.param( + False, + WaitForStartStates.WAIT_FOR_DEVICE_START, + False, + (11, "ok", Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING), + (11, "ok", Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING), + WaitForStartStates.WAIT_FOR_DEVICE_START, + 1, + id="wait-for-start-disabled-passthrough", + ), + pytest.param( + True, + WaitForStartStates.WAIT_FOR_DEVICE_START, + True, + (9, "running", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), + (0, Consumer.WAIT_FOR_STOPPED_DEVICE, Chargemode.STOP, Chargemode.STOP), + WaitForStartStates.WAIT_FOR_STOPPED_DEVICE, + 0, + id="wait-for-device-start-charge-detected", + ), + pytest.param( + True, + WaitForStartStates.WAIT_FOR_DEVICE_START, + False, + (9, "running", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), + (6, Consumer.WAIT_FOR_DEVICE_START, Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING), + WaitForStartStates.WAIT_FOR_DEVICE_START, + 0, + id="wait-for-device-start-no-charge", + ), + pytest.param( + True, + WaitForStartStates.WAIT_FOR_STOPPED_DEVICE, + False, + (8, "pv ok", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), + (8, f"{Consumer.DEVICE_WAITING_FOR_START} pv ok", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), + WaitForStartStates.DEVICE_WAITING_FOR_START, + 1, + id="wait-for-stopped-device-started-waiting", + ), + pytest.param( + True, + WaitForStartStates.DEVICE_WAITING_FOR_START, + True, + (7, "started", Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING), + (7, "started", Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING), + WaitForStartStates.START_SIGNAL_RECEIVED, + 1, + id="device-waiting-start-signal-received", + ), + pytest.param( + True, + WaitForStartStates.START_SIGNAL_RECEIVED, + True, + (10, "continue", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), + (10, "continue", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), + WaitForStartStates.START_SIGNAL_RECEIVED, + 1, + id="start-signal-received-passthrough", + ), ], ) -def test_wait_for_start_signal_state_machine( - monkeypatch: pytest.MonkeyPatch, - consumer: Consumer, - signal_received: bool, - test_running: bool, - charge_state: bool, - now: int, - last_test_timestamp: int, - expected_state: WaitForStartStates, +def test_wait_for_start_handler( + consumer: Consumer, + wait_for_start_active: bool, + state: WaitForStartStates, + charge_state: bool, + func_result: Tuple[int, str, Chargemode, Optional[Chargemode]], + expected_result: Tuple[int, str, Chargemode, Optional[Chargemode]], + expected_state: WaitForStartStates, + func_calls: int, ): - consumer.data.set.wait_for_start_signal_received = signal_received - consumer.data.set.wait_for_start_test_running = test_running + # setup + assert isinstance(consumer.data.usage, ContinuousDeviceConfig) + consumer.data.usage.wait_for_start_active = wait_for_start_active + consumer.data.set.wait_for_start_state = state consumer.data.get.charge_state = charge_state - consumer.data.set.wait_for_start_last_test_timestamp = last_test_timestamp - monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=now)) + charging_func = Mock(return_value=func_result) - state = consumer._wait_for_start_signal() + # execution + result = consumer.wait_for_start_handler(charging_func) - assert state == expected_state + # evaluation + assert result == expected_result + assert consumer.data.set.wait_for_start_state == expected_state + assert charging_func.call_count == func_calls diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 529dabfd80..87630f128d 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -1251,9 +1251,6 @@ def process_consumer_topic(self, msg): re.search("consumer/[0-9]+/set/current$", msg.topic) is not None or re.search("consumer/[0-9]+/set/plug_time$", msg.topic) is not None or re.search("consumer/[0-9]+/set/timestamp_last_current_set$", msg.topic) is not None or - re.search("consumer/[0-9]+/set/wait_for_start_signal_received$", msg.topic) is not None or - re.search("consumer/[0-9]+/set/wait_for_start_test_running$", msg.topic) is not None or - re.search("consumer/[0-9]+/set/wait_for_start_last_test_timestamp$", msg.topic) is not None or re.search("consumer/[0-9]+/get/set_power$", msg.topic) is not None): self._validate_value(msg, float) elif (re.search("consumer/[0-9]+/get/currents$", msg.topic) is not None or @@ -1268,7 +1265,8 @@ def process_consumer_topic(self, msg): elif re.search("consumer/[0-9]+/get/fault_state$", msg.topic) is not None: self._validate_value(msg, int, [(0, 2)]) elif (re.search("consumer/[0-9]+/get/fault_str$", msg.topic) is not None or - re.search("consumer/[0-9]+/get/state_str$", msg.topic) is not None): + re.search("consumer/[0-9]+/get/state_str$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/wait_for_start_state$", msg.topic) is not None): self._validate_value(msg, str) elif re.search("consumer/[0-9]+/get/state$", msg.topic) is not None: self._validate_value(msg, bool) diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 803fd0b5a4..251de5419e 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -1,5 +1,6 @@ """ Modul, um die Daten vom Broker zu erhalten. """ +from enum import Enum import importlib import logging from pathlib import Path @@ -253,6 +254,8 @@ def set_json_payload_class(self, class_obj: Dict, msg: mqtt.MQTTMessage): if isinstance(payload, Dict): for key, value in payload.items(): setattr(class_obj, key, value) + elif isinstance(getattr(class_obj, key, None), Enum): + setattr(class_obj, key, type(getattr(class_obj, key))(payload)) else: setattr(class_obj, key, decode_payload(msg.payload)) else: diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 4c2bf7d754..3607f5e46f 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -201,6 +201,11 @@ class UpdateConfig: "^openWB/consumer/[0-9]+/get/state$", "^openWB/consumer/[0-9]+/get/state_str$", "^openWB/consumer/[0-9]+/get/voltages$", + "^openWB/consumer/[0-9]+/set/current$", + "^openWB/consumer/[0-9]+/set/phases_to_use$", + "^openWB/consumer/[0-9]+/set/plug_time$", + "^openWB/consumer/[0-9]+/set/timestamp_last_current_set$", + "^openWB/consumer/[0-9]+/set/wait_for_start_state$", "^openWB/counter/config/consider_less_charging$", "^openWB/counter/config/home_consumption_source_id$", From b507898d6cb5ed63e3dc671c1876fdfd866f3411 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 23 Jun 2026 09:56:38 +0200 Subject: [PATCH 06/13] draft --- packages/control/consumer/consumer.py | 378 +++++++++++++++++++++++++- 1 file changed, 373 insertions(+), 5 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index f41d09b3f6..2046548f0c 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -106,11 +106,379 @@ def _convert_power_to_current(self, power: float) -> float: return power / self.data.config.connected_phases / voltages_mean(self.data.get.voltages) def scheduled_charging(self) -> Tuple[int, str, Optional[str], int]: - required_current = 0 - message = None - mode = None - submode = None - return required_current, message, mode, submode + + BUFFER_AFTER_END_TIME = -1200 # nach mehr als 20 Min Überschreitung wird der Termin als verpasst angesehen + BUFFER_START_EARLIER = 600 # 10 Min vor dem geplanten Start kann begonnen werden + + def _find_recent_plan(self, + plans: List[ScheduledChargingPlan], + soc: float, + ev_template: EvTemplate, + used_amount: float, + max_hw_phases: int, + phase_switch_supported: bool, + charging_type: str, + control_parameter: ControlParameter, + soc_request_interval_offset: int, + hw_bidi: bool): + plans_diff_end_date = [] + for p in plans: + if p.active: + try: + plans_diff_end_date.append( + {p.id: timecheck.check_end_time(p, self.BUFFER_AFTER_END_TIME)}) + log.debug(f"Verbleibende Zeit bis zum Zieltermin [s]: {plans_diff_end_date}") + except Exception: + log.exception("Fehler im ev-Modul "+str(self.data.id)) + if plans_diff_end_date: + # ermittle den Key vom kleinsten value in plans_diff_end_date + filtered_plans = [d for d in plans_diff_end_date if list(d.values())[0] is not None] + if filtered_plans: + sorted_plans = sorted(filtered_plans, key=lambda x: list(x.values())[0]) + for plan in sorted_plans: + if self.BUFFER_AFTER_END_TIME < list(plan.values())[0]: + plan_dict = plan + break + else: + return None + plan_id = list(plan_dict.keys())[0] + plan_end_time = list(plan_dict.values())[0] + + for p in plans: + if p.id == plan_id: + plan = p + + remaining_time, missing_amount, phases, duration = self._calc_remaining_time( + plan, plan_end_time, soc, ev_template, used_amount, max_hw_phases, phase_switch_supported, + charging_type, control_parameter.phases, soc_request_interval_offset, hw_bidi) + + return SelectedPlan(remaining_time=remaining_time, + duration=duration, + missing_amount=missing_amount, + phases=phases, + plan=plan) + else: + return None + + def scheduled_charging(self, + soc: float, + ev_template: EvTemplate, + used_amount: float, + max_hw_phases: int, + phase_switch_supported: bool, + charging_type: str, + control_parameter: ControlParameter, + soc_request_interval_offset: int, + bidi_state: BidiState, + charge_state: bool) -> Optional[SelectedPlan]: + plan_data = self._find_recent_plan(self.data.chargemode.scheduled_charging.plans, + soc, + ev_template, + used_amount, + max_hw_phases, + phase_switch_supported, + charging_type, + control_parameter, + soc_request_interval_offset, + bidi_state) + if plan_data: + control_parameter.current_plan = plan_data.plan.id + else: + control_parameter.current_plan = None + return self.scheduled_charging_calc_current( + plan_data, + soc, + used_amount, + max_hw_phases, + control_parameter.phases, + control_parameter.min_current, + soc_request_interval_offset, + charging_type, + ev_template, + bidi_state, + charge_state) + + def _calc_remaining_time(self, + plan: ScheduledChargingPlan, + plan_end_time: float, + soc: Optional[float], + ev_template: EvTemplate, + used_amount: float, + max_hw_phases: int, + phase_switch_supported: bool, + charging_type: str, + control_parameter_phases: int, + soc_request_interval_offset: int, + bidi_state: BidiState) -> Tuple[float, float, int, float]: + bidi = bidi_state == BidiState.BIDI_CAPABLE and plan.bidi_charging_enabled + battery_capacity = ev_template.data.battery_capacity + efficiency = ev_template.data.efficiency + + def calc_for_phases(phases_to_use: int) -> Tuple[float, float]: + return self._calculate_duration( + plan, + soc, + battery_capacity, + efficiency, + used_amount, + phases_to_use, + charging_type, + ev_template, + bidi) + + if bidi: + duration, missing_amount = calc_for_phases(control_parameter_phases) + remaining_time = plan_end_time - duration + phases = control_parameter_phases + elif plan.phases_to_use == 0: + if max_hw_phases == 1: + duration, missing_amount = calc_for_phases(1) + remaining_time = plan_end_time - duration + phases = 1 + elif phase_switch_supported is False: + duration, missing_amount = calc_for_phases(control_parameter_phases) + phases = control_parameter_phases + remaining_time = plan_end_time - duration + elif plan.et_active: + duration, missing_amount = calc_for_phases(max_hw_phases) + phases = max_hw_phases + remaining_time = plan_end_time - duration + else: + duration_3p, missing_amount = calc_for_phases(max_hw_phases) + remaining_time_3p = plan_end_time - duration_3p + duration_1p, missing_amount = calc_for_phases(1) + remaining_time_1p = plan_end_time - duration_1p + # Kurz vor dem nächsten Abfragen des SoC, wenn noch der alte SoC da ist, kann es sein, dass die Zeit + # für 1p nicht mehr reicht, weil die Regelung den neuen SoC noch nicht kennt. + if remaining_time_1p - (soc_request_interval_offset if plan.limit.selected == "soc" else 0) < 0: + # Zeit reicht nicht mehr für einphasiges Laden + remaining_time = remaining_time_3p + duration = duration_3p + phases = max_hw_phases + else: + remaining_time = remaining_time_1p + duration = duration_1p + phases = 1 + log.debug(f"Dauer 1p: {duration_1p}, Dauer 3p: {duration_3p}") + elif plan.phases_to_use == 3 or plan.phases_to_use == 1: + phases = min(plan.phases_to_use, max_hw_phases) + duration, missing_amount = calc_for_phases(phases) + remaining_time = plan_end_time - duration + + log.debug(f"Verbleibende Zeit bis zum Ladestart [s]:{remaining_time}, Dauer [h]: {duration/3600}") + return remaining_time, missing_amount, phases, duration + + def _calculate_duration(self, + plan: ScheduledChargingPlan, + soc: Optional[float], + battery_capacity: float, + efficiency: float, + used_amount: float, + phases: int, + charging_type: str, + ev_template: EvTemplate, + bidi: bool) -> Tuple[float, float]: + + if plan.limit.selected == "soc": + if soc is not None: + missing_amount = ((plan.limit.soc_scheduled - soc) / 100) * battery_capacity / (efficiency / 100) + else: + raise ValueError("Um Zielladen mit SoC-Ziel nutzen zu können, bitte ein SoC-Modul konfigurieren.") + else: + missing_amount = plan.limit.amount - used_amount + if bidi: + duration = missing_amount/plan.bidi_power * 3600 + else: + current = plan.current if charging_type == ChargingType.AC.value else plan.dc_current + current = max(current, ev_template.data.min_current if charging_type == + ChargingType.AC.value else ev_template.data.dc_min_current) + duration = missing_amount/(current * phases*230) * 3600 + return duration, missing_amount + + SCHEDULED_REACHED_MAX_SOC = ("Zielladen ausstehend, da noch Zeit bis zum Zieltermin ist. " + "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden " + + "erreicht wurde. ") + SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC = ( + "Zielladen abgeschlossen, da das Limit für Fahrzeug Laden mit Überschuss (SoC-Limit)" + " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde. ") + SCHEDULED_CHARGING_REACHED_AMOUNT = "Zielladen abgeschlossen, da die Energiemenge bereits erreicht wurde. " + SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC = ("Falls vorhanden wird mit EVU-Überschuss geladen, da der Ziel-Soc " + "für Zielladen bereits erreicht wurde. ") + SCHEDULED_CHARGING_BIDI = ("Der Ziel-Soc für Zielladen wurde bereits erreicht. Das Auto wird " + "bidirektional ge-/entladen, sodass möglichst weder Bezug noch " + "Einspeisung erfolgt. ") + SCHEDULED_CHARGING_NO_PLANS_CONFIGURED = "Kein Zielladen, da keine Ziel-Termine konfiguriert sind." + SCHEDULED_CHARGING_NO_DATE_PENDING = "Kein Zielladen, da kein Ziel-Termin ansteht. " + SCHEDULED_CHARGING_USE_PV = "Zielladen startet {}. Falls vorhanden, wird mit Überschuss geladen. " + SCHEDULED_CHARGING_MAX_CURRENT = "Zielladen mit {}A. Der Ladestrom wurde erhöht, um das Ziel zu erreichen. " + SCHEDULED_CHARGING_LIMITED_BY_SOC = 'einen SoC von {}%' + SCHEDULED_CHARGING_LIMITED_BY_AMOUNT = '{}kWh geladene Energie' + SCHEDULED_CHARGING_IN_TIME = ('Zielladen mit mindestens {}A, um {} um {} zu erreichen. Falls vorhanden wird ' + 'zusätzlich EVU-Überschuss geladen. ') + SCHEDULED_CHARGING_CHEAP_HOUR = "Zielladen, da ein günstiger Zeitpunkt zum preisbasierten Laden ist. {}" + SCHEDULED_CHARGING_EXPENSIVE_HOUR = ( + "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " + "Laden ist. {} Falls vorhanden, wird mit Überschuss geladen. ") + SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC = ( + "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " + "Laden ist. {} " + + "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden erreicht wurde.") + + def scheduled_charging_calc_current(self, + selected_plan: Optional[SelectedPlan], + soc: int, + used_amount: float, + max_phases_hw: int, + control_parameter_phases: int, + min_current: int, + soc_request_interval_offset: int, + charging_type: str, + ev_template: EvTemplate, + bidi_state: BidiState, + charge_state: bool) -> Tuple[float, str, str, int]: + current = 0 + submode = "stop" + if selected_plan is None: + if len(self.data.chargemode.scheduled_charging.plans) == 0: + return current, submode, self.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED, control_parameter_phases + else: + return current, submode, self.SCHEDULED_CHARGING_NO_DATE_PENDING, control_parameter_phases + plan = selected_plan.plan + limit = plan.limit + phases = selected_plan.phases + if charging_type == ChargingType.AC.value: + plan_current = plan.current + max_current = ev_template.data.max_current_multi_phases + else: + plan_current = plan.dc_current + max_current = ev_template.data.dc_max_current + if plan.limit.selected != "soc" or charge_state is False: + # das Abfrageintervall nur einbeziehen, wenn die Ladung bereits gestartet wurde und + # SoC-basiertes Laden aktiv ist, sonst wird einfach alles + # um das Abfrageintervall früher gestartet, und nicht die Pufferzeit um das Abfrageintervall erhöht. + soc_request_interval_offset = 0 + log.debug("Verwendeter Plan: "+str(plan.name)) + if (limit.selected == "soc" and + (soc > limit.soc_limit if (plan.bidi_charging_enabled and bidi_state == BidiState.BIDI_CAPABLE) + else soc >= limit.soc_limit) and + soc >= limit.soc_scheduled): + message = self.SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC + elif limit.selected == "soc" and limit.soc_scheduled <= soc <= limit.soc_limit: + if plan.bidi_charging_enabled and bidi_state == BidiState.BIDI_CAPABLE: + message = self.SCHEDULED_CHARGING_BIDI + current = min_current + submode = "bidi_charging" + phases = control_parameter_phases + else: + message = self.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC + if plan.bidi_charging_enabled and bidi_state != BidiState.BIDI_CAPABLE: + message += bidi_state.value + current = min_current + submode = "pv_charging" + # bei Überschuss-Laden mit der Phasenzahl aus den control_parameter laden, + # um die Umschaltung zu berücksichtigen. + phases = plan.phases_to_use_pv + elif limit.selected == "amount" and used_amount >= limit.amount: + message = self.SCHEDULED_CHARGING_REACHED_AMOUNT + elif (0 - soc_request_interval_offset < selected_plan.remaining_time < + self.BUFFER_START_EARLIER + soc_request_interval_offset): + # Wenn der SoC ein paar Minuten alt ist, kann der Termin trotzdem gehalten werden. + # Zielladen kann nicht genauer arbeiten, als das Abfrageintervall vom SoC. + # 5 Min vor spätestem Ladestart + if limit.selected == "soc": + limit_string = self.SCHEDULED_CHARGING_LIMITED_BY_SOC.format(limit.soc_scheduled) + else: + limit_string = self.SCHEDULED_CHARGING_LIMITED_BY_AMOUNT.format(limit.amount/1000) + message = self.SCHEDULED_CHARGING_IN_TIME.format(plan_current, limit_string, plan.time) + current = plan_current + submode = "instant_charging" + # weniger als die berechnete Zeit verfügbar + elif selected_plan.remaining_time <= 0 - soc_request_interval_offset: + if selected_plan.duration + selected_plan.remaining_time < 0: + current = max_current + else: + current = min(selected_plan.missing_amount/((selected_plan.duration + selected_plan.remaining_time) / + 3600)/(phases*230), max_current) + message = self.SCHEDULED_CHARGING_MAX_CURRENT.format(round(current, 2)) + submode = "instant_charging" + else: + # Wenn dynamische Tarife aktiv sind, prüfen, ob jetzt ein günstiger Zeitpunkt zum Laden + # ist. + if plan.et_active: + def get_hours_message() -> str: + def end_of_today_timestamp() -> int: + return datetime.datetime.now().replace( + hour=23, minute=59, second=59, microsecond=999000).timestamp() + + def is_loading_hour(hour: int) -> bool: + return data.data.optional_data.ep_is_charging_allowed_hours_list(hour) + + def convert_loading_hours_to_string(hour_list: List[int]) -> str: + if 1 < len(hour_list): + times_string = ", ".join(hour.strftime('%-H:%M') for hour in hour_list[:-1]) + return times_string + " und " + hour_list[-1].strftime('%-H:%M') + else: + return ", ".join(hour.strftime('%-H:%M') for hour in hour_list) + midnight = end_of_today_timestamp() + loading_times_today = [datetime.datetime.fromtimestamp(hour) + for hour in sorted(hour_list) if hour <= midnight] + loading_times_today = (loading_times_today[1:] + if is_loading_hour(hour_list) else loading_times_today) + loading_times_tomorrow = [datetime.datetime.fromtimestamp(hour) + for hour in sorted(hour_list) if hour > midnight] + + parts = [] + + if is_loading_hour(hour_list): + parts.append("jetzt") + + if 0 < len(loading_times_today): + if parts: + parts.append(" und ") + parts.append(f"heute {convert_loading_hours_to_string(loading_times_today)}") + + if 0 < len(loading_times_tomorrow): + if parts: + parts.append(" sowie ") + parts.append(f"morgen {convert_loading_hours_to_string(loading_times_tomorrow)}") + + loading_message = "Geladen wird " + "".join(parts) + return loading_message + '.' + + hour_list = data.data.optional_data.ep_get_loading_hours( + selected_plan.duration, selected_plan.duration + selected_plan.remaining_time) + + log.debug(f"Günstige Ladezeiten: {hour_list}") + if data.data.optional_data.ep_is_charging_allowed_hours_list(hour_list): + message = self.SCHEDULED_CHARGING_CHEAP_HOUR.format(get_hours_message()) + current = plan_current + phases = max_phases_hw if plan.phases_to_use == 0 else plan.phases_to_use + submode = "instant_charging" + elif ((limit.selected == "soc" and soc <= limit.soc_limit) or + (limit.selected == "amount" and used_amount < limit.amount)): + message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(get_hours_message()) + current = min_current + submode = "pv_charging" + phases = plan.phases_to_use_pv + else: + message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format(get_hours_message()) + else: + # Wenn SoC-Limit erreicht wurde, soll nicht mehr mit Überschuss geladen werden + if limit.selected == "soc" and soc >= limit.soc_limit: + message = self.SCHEDULED_REACHED_MAX_SOC + else: + now = datetime.datetime.today() + start_time = now + datetime.timedelta(seconds=selected_plan.remaining_time) + if start_time.year == now.year and start_time.month == now.month and start_time.day == now.day: + message = self.SCHEDULED_CHARGING_USE_PV.format( + f"um {start_time.strftime('%-H:%M')} Uhr") + else: + message = self.SCHEDULED_CHARGING_USE_PV.format( + f"am {start_time.strftime('%d.%m')} um {start_time.strftime('%-H:%M')} Uhr") + current = min_current + submode = "pv_charging" + phases = plan.phases_to_use_pv + return current, submode, message, phases TIME_CHARGING_MIN_BAT_SOC_REACHED = ("Betrieb mit Zeitladen nach Speicher-SoC nicht möglich, da der SoC des" " Speichers unter dem minimalen SoC liegt.") From 4a9987a6ff5531b96e12e8acfcffc683831cfad1 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 23 Jun 2026 15:02:39 +0200 Subject: [PATCH 07/13] draft scheduled charging --- packages/control/consumer/consumer.py | 526 +++++++-------------- packages/control/consumer/consumer_data.py | 6 + packages/helpermodules/timecheck.py | 6 +- 3 files changed, 169 insertions(+), 369 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 2046548f0c..0c5ca25ad0 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -105,380 +105,172 @@ def _parse_required_current_by_usage(self, required_current: float) -> float: def _convert_power_to_current(self, power: float) -> float: return power / self.data.config.connected_phases / voltages_mean(self.data.get.voltages) - def scheduled_charging(self) -> Tuple[int, str, Optional[str], int]: - - BUFFER_AFTER_END_TIME = -1200 # nach mehr als 20 Min Überschreitung wird der Termin als verpasst angesehen + BUFFER_AFTER_END_TIME = -1200 # nach mehr als 20 Min Überschreitung wird der Termin als verpasst angesehen BUFFER_START_EARLIER = 600 # 10 Min vor dem geplanten Start kann begonnen werden - def _find_recent_plan(self, - plans: List[ScheduledChargingPlan], - soc: float, - ev_template: EvTemplate, - used_amount: float, - max_hw_phases: int, - phase_switch_supported: bool, - charging_type: str, - control_parameter: ControlParameter, - soc_request_interval_offset: int, - hw_bidi: bool): - plans_diff_end_date = [] - for p in plans: - if p.active: - try: - plans_diff_end_date.append( - {p.id: timecheck.check_end_time(p, self.BUFFER_AFTER_END_TIME)}) - log.debug(f"Verbleibende Zeit bis zum Zieltermin [s]: {plans_diff_end_date}") - except Exception: - log.exception("Fehler im ev-Modul "+str(self.data.id)) - if plans_diff_end_date: - # ermittle den Key vom kleinsten value in plans_diff_end_date - filtered_plans = [d for d in plans_diff_end_date if list(d.values())[0] is not None] - if filtered_plans: - sorted_plans = sorted(filtered_plans, key=lambda x: list(x.values())[0]) - for plan in sorted_plans: - if self.BUFFER_AFTER_END_TIME < list(plan.values())[0]: - plan_dict = plan - break - else: - return None - plan_id = list(plan_dict.keys())[0] - plan_end_time = list(plan_dict.values())[0] - - for p in plans: - if p.id == plan_id: - plan = p - - remaining_time, missing_amount, phases, duration = self._calc_remaining_time( - plan, plan_end_time, soc, ev_template, used_amount, max_hw_phases, phase_switch_supported, - charging_type, control_parameter.phases, soc_request_interval_offset, hw_bidi) - - return SelectedPlan(remaining_time=remaining_time, - duration=duration, - missing_amount=missing_amount, - phases=phases, - plan=plan) - else: - return None - - def scheduled_charging(self, - soc: float, - ev_template: EvTemplate, - used_amount: float, - max_hw_phases: int, - phase_switch_supported: bool, - charging_type: str, - control_parameter: ControlParameter, - soc_request_interval_offset: int, - bidi_state: BidiState, - charge_state: bool) -> Optional[SelectedPlan]: - plan_data = self._find_recent_plan(self.data.chargemode.scheduled_charging.plans, - soc, - ev_template, - used_amount, - max_hw_phases, - phase_switch_supported, - charging_type, - control_parameter, - soc_request_interval_offset, - bidi_state) + def scheduled_charging(self) -> Tuple[Chargemode, str]: + return self.data.config.min_current, "Zielladen noch nicht implementiert", Chargemode.STOP, Chargemode.STOP + plan_data, remaining_time, duration = self._find_recent_plan(self.data.usage.scheduled_charging.plans) if plan_data: - control_parameter.current_plan = plan_data.plan.id + control_parameter.current_plan = plan_data.id else: control_parameter.current_plan = None return self.scheduled_charging_calc_current( plan_data, - soc, - used_amount, - max_hw_phases, - control_parameter.phases, - control_parameter.min_current, - soc_request_interval_offset, - charging_type, - ev_template, - bidi_state, - charge_state) - - def _calc_remaining_time(self, - plan: ScheduledChargingPlan, - plan_end_time: float, - soc: Optional[float], - ev_template: EvTemplate, - used_amount: float, - max_hw_phases: int, - phase_switch_supported: bool, - charging_type: str, - control_parameter_phases: int, - soc_request_interval_offset: int, - bidi_state: BidiState) -> Tuple[float, float, int, float]: - bidi = bidi_state == BidiState.BIDI_CAPABLE and plan.bidi_charging_enabled - battery_capacity = ev_template.data.battery_capacity - efficiency = ev_template.data.efficiency - - def calc_for_phases(phases_to_use: int) -> Tuple[float, float]: - return self._calculate_duration( - plan, - soc, - battery_capacity, - efficiency, - used_amount, - phases_to_use, - charging_type, - ev_template, - bidi) - - if bidi: - duration, missing_amount = calc_for_phases(control_parameter_phases) - remaining_time = plan_end_time - duration - phases = control_parameter_phases - elif plan.phases_to_use == 0: - if max_hw_phases == 1: - duration, missing_amount = calc_for_phases(1) - remaining_time = plan_end_time - duration - phases = 1 - elif phase_switch_supported is False: - duration, missing_amount = calc_for_phases(control_parameter_phases) - phases = control_parameter_phases - remaining_time = plan_end_time - duration - elif plan.et_active: - duration, missing_amount = calc_for_phases(max_hw_phases) - phases = max_hw_phases - remaining_time = plan_end_time - duration - else: - duration_3p, missing_amount = calc_for_phases(max_hw_phases) - remaining_time_3p = plan_end_time - duration_3p - duration_1p, missing_amount = calc_for_phases(1) - remaining_time_1p = plan_end_time - duration_1p - # Kurz vor dem nächsten Abfragen des SoC, wenn noch der alte SoC da ist, kann es sein, dass die Zeit - # für 1p nicht mehr reicht, weil die Regelung den neuen SoC noch nicht kennt. - if remaining_time_1p - (soc_request_interval_offset if plan.limit.selected == "soc" else 0) < 0: - # Zeit reicht nicht mehr für einphasiges Laden - remaining_time = remaining_time_3p - duration = duration_3p - phases = max_hw_phases - else: - remaining_time = remaining_time_1p - duration = duration_1p - phases = 1 - log.debug(f"Dauer 1p: {duration_1p}, Dauer 3p: {duration_3p}") - elif plan.phases_to_use == 3 or plan.phases_to_use == 1: - phases = min(plan.phases_to_use, max_hw_phases) - duration, missing_amount = calc_for_phases(phases) - remaining_time = plan_end_time - duration - - log.debug(f"Verbleibende Zeit bis zum Ladestart [s]:{remaining_time}, Dauer [h]: {duration/3600}") - return remaining_time, missing_amount, phases, duration - - def _calculate_duration(self, - plan: ScheduledChargingPlan, - soc: Optional[float], - battery_capacity: float, - efficiency: float, - used_amount: float, - phases: int, - charging_type: str, - ev_template: EvTemplate, - bidi: bool) -> Tuple[float, float]: - - if plan.limit.selected == "soc": - if soc is not None: - missing_amount = ((plan.limit.soc_scheduled - soc) / 100) * battery_capacity / (efficiency / 100) - else: - raise ValueError("Um Zielladen mit SoC-Ziel nutzen zu können, bitte ein SoC-Modul konfigurieren.") - else: - missing_amount = plan.limit.amount - used_amount - if bidi: - duration = missing_amount/plan.bidi_power * 3600 - else: - current = plan.current if charging_type == ChargingType.AC.value else plan.dc_current - current = max(current, ev_template.data.min_current if charging_type == - ChargingType.AC.value else ev_template.data.dc_min_current) - duration = missing_amount/(current * phases*230) * 3600 - return duration, missing_amount - - SCHEDULED_REACHED_MAX_SOC = ("Zielladen ausstehend, da noch Zeit bis zum Zieltermin ist. " - "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden " + - "erreicht wurde. ") - SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC = ( - "Zielladen abgeschlossen, da das Limit für Fahrzeug Laden mit Überschuss (SoC-Limit)" - " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde. ") - SCHEDULED_CHARGING_REACHED_AMOUNT = "Zielladen abgeschlossen, da die Energiemenge bereits erreicht wurde. " - SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC = ("Falls vorhanden wird mit EVU-Überschuss geladen, da der Ziel-Soc " - "für Zielladen bereits erreicht wurde. ") - SCHEDULED_CHARGING_BIDI = ("Der Ziel-Soc für Zielladen wurde bereits erreicht. Das Auto wird " - "bidirektional ge-/entladen, sodass möglichst weder Bezug noch " - "Einspeisung erfolgt. ") - SCHEDULED_CHARGING_NO_PLANS_CONFIGURED = "Kein Zielladen, da keine Ziel-Termine konfiguriert sind." - SCHEDULED_CHARGING_NO_DATE_PENDING = "Kein Zielladen, da kein Ziel-Termin ansteht. " - SCHEDULED_CHARGING_USE_PV = "Zielladen startet {}. Falls vorhanden, wird mit Überschuss geladen. " - SCHEDULED_CHARGING_MAX_CURRENT = "Zielladen mit {}A. Der Ladestrom wurde erhöht, um das Ziel zu erreichen. " - SCHEDULED_CHARGING_LIMITED_BY_SOC = 'einen SoC von {}%' - SCHEDULED_CHARGING_LIMITED_BY_AMOUNT = '{}kWh geladene Energie' - SCHEDULED_CHARGING_IN_TIME = ('Zielladen mit mindestens {}A, um {} um {} zu erreichen. Falls vorhanden wird ' - 'zusätzlich EVU-Überschuss geladen. ') - SCHEDULED_CHARGING_CHEAP_HOUR = "Zielladen, da ein günstiger Zeitpunkt zum preisbasierten Laden ist. {}" - SCHEDULED_CHARGING_EXPENSIVE_HOUR = ( - "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " - "Laden ist. {} Falls vorhanden, wird mit Überschuss geladen. ") - SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC = ( - "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " - "Laden ist. {} " + - "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden erreicht wurde.") - - def scheduled_charging_calc_current(self, - selected_plan: Optional[SelectedPlan], - soc: int, - used_amount: float, - max_phases_hw: int, - control_parameter_phases: int, - min_current: int, - soc_request_interval_offset: int, - charging_type: str, - ev_template: EvTemplate, - bidi_state: BidiState, - charge_state: bool) -> Tuple[float, str, str, int]: - current = 0 - submode = "stop" - if selected_plan is None: - if len(self.data.chargemode.scheduled_charging.plans) == 0: - return current, submode, self.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED, control_parameter_phases - else: - return current, submode, self.SCHEDULED_CHARGING_NO_DATE_PENDING, control_parameter_phases - plan = selected_plan.plan - limit = plan.limit - phases = selected_plan.phases - if charging_type == ChargingType.AC.value: - plan_current = plan.current - max_current = ev_template.data.max_current_multi_phases - else: - plan_current = plan.dc_current - max_current = ev_template.data.dc_max_current - if plan.limit.selected != "soc" or charge_state is False: - # das Abfrageintervall nur einbeziehen, wenn die Ladung bereits gestartet wurde und - # SoC-basiertes Laden aktiv ist, sonst wird einfach alles - # um das Abfrageintervall früher gestartet, und nicht die Pufferzeit um das Abfrageintervall erhöht. - soc_request_interval_offset = 0 - log.debug("Verwendeter Plan: "+str(plan.name)) - if (limit.selected == "soc" and - (soc > limit.soc_limit if (plan.bidi_charging_enabled and bidi_state == BidiState.BIDI_CAPABLE) - else soc >= limit.soc_limit) and - soc >= limit.soc_scheduled): - message = self.SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC - elif limit.selected == "soc" and limit.soc_scheduled <= soc <= limit.soc_limit: - if plan.bidi_charging_enabled and bidi_state == BidiState.BIDI_CAPABLE: - message = self.SCHEDULED_CHARGING_BIDI - current = min_current - submode = "bidi_charging" - phases = control_parameter_phases - else: - message = self.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC - if plan.bidi_charging_enabled and bidi_state != BidiState.BIDI_CAPABLE: - message += bidi_state.value - current = min_current - submode = "pv_charging" - # bei Überschuss-Laden mit der Phasenzahl aus den control_parameter laden, - # um die Umschaltung zu berücksichtigen. - phases = plan.phases_to_use_pv - elif limit.selected == "amount" and used_amount >= limit.amount: - message = self.SCHEDULED_CHARGING_REACHED_AMOUNT - elif (0 - soc_request_interval_offset < selected_plan.remaining_time < - self.BUFFER_START_EARLIER + soc_request_interval_offset): - # Wenn der SoC ein paar Minuten alt ist, kann der Termin trotzdem gehalten werden. - # Zielladen kann nicht genauer arbeiten, als das Abfrageintervall vom SoC. - # 5 Min vor spätestem Ladestart - if limit.selected == "soc": - limit_string = self.SCHEDULED_CHARGING_LIMITED_BY_SOC.format(limit.soc_scheduled) - else: - limit_string = self.SCHEDULED_CHARGING_LIMITED_BY_AMOUNT.format(limit.amount/1000) - message = self.SCHEDULED_CHARGING_IN_TIME.format(plan_current, limit_string, plan.time) - current = plan_current - submode = "instant_charging" - # weniger als die berechnete Zeit verfügbar - elif selected_plan.remaining_time <= 0 - soc_request_interval_offset: - if selected_plan.duration + selected_plan.remaining_time < 0: - current = max_current - else: - current = min(selected_plan.missing_amount/((selected_plan.duration + selected_plan.remaining_time) / - 3600)/(phases*230), max_current) - message = self.SCHEDULED_CHARGING_MAX_CURRENT.format(round(current, 2)) - submode = "instant_charging" - else: - # Wenn dynamische Tarife aktiv sind, prüfen, ob jetzt ein günstiger Zeitpunkt zum Laden - # ist. - if plan.et_active: - def get_hours_message() -> str: - def end_of_today_timestamp() -> int: - return datetime.datetime.now().replace( - hour=23, minute=59, second=59, microsecond=999000).timestamp() - - def is_loading_hour(hour: int) -> bool: - return data.data.optional_data.ep_is_charging_allowed_hours_list(hour) - - def convert_loading_hours_to_string(hour_list: List[int]) -> str: - if 1 < len(hour_list): - times_string = ", ".join(hour.strftime('%-H:%M') for hour in hour_list[:-1]) - return times_string + " und " + hour_list[-1].strftime('%-H:%M') - else: - return ", ".join(hour.strftime('%-H:%M') for hour in hour_list) - midnight = end_of_today_timestamp() - loading_times_today = [datetime.datetime.fromtimestamp(hour) - for hour in sorted(hour_list) if hour <= midnight] - loading_times_today = (loading_times_today[1:] - if is_loading_hour(hour_list) else loading_times_today) - loading_times_tomorrow = [datetime.datetime.fromtimestamp(hour) - for hour in sorted(hour_list) if hour > midnight] - - parts = [] - - if is_loading_hour(hour_list): - parts.append("jetzt") - - if 0 < len(loading_times_today): - if parts: - parts.append(" und ") - parts.append(f"heute {convert_loading_hours_to_string(loading_times_today)}") - - if 0 < len(loading_times_tomorrow): - if parts: - parts.append(" sowie ") - parts.append(f"morgen {convert_loading_hours_to_string(loading_times_tomorrow)}") - - loading_message = "Geladen wird " + "".join(parts) - return loading_message + '.' - - hour_list = data.data.optional_data.ep_get_loading_hours( - selected_plan.duration, selected_plan.duration + selected_plan.remaining_time) - - log.debug(f"Günstige Ladezeiten: {hour_list}") - if data.data.optional_data.ep_is_charging_allowed_hours_list(hour_list): - message = self.SCHEDULED_CHARGING_CHEAP_HOUR.format(get_hours_message()) - current = plan_current - phases = max_phases_hw if plan.phases_to_use == 0 else plan.phases_to_use - submode = "instant_charging" - elif ((limit.selected == "soc" and soc <= limit.soc_limit) or - (limit.selected == "amount" and used_amount < limit.amount)): - message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(get_hours_message()) - current = min_current - submode = "pv_charging" - phases = plan.phases_to_use_pv - else: - message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format(get_hours_message()) - else: - # Wenn SoC-Limit erreicht wurde, soll nicht mehr mit Überschuss geladen werden - if limit.selected == "soc" and soc >= limit.soc_limit: - message = self.SCHEDULED_REACHED_MAX_SOC - else: - now = datetime.datetime.today() - start_time = now + datetime.timedelta(seconds=selected_plan.remaining_time) - if start_time.year == now.year and start_time.month == now.month and start_time.day == now.day: - message = self.SCHEDULED_CHARGING_USE_PV.format( - f"um {start_time.strftime('%-H:%M')} Uhr") - else: - message = self.SCHEDULED_CHARGING_USE_PV.format( - f"am {start_time.strftime('%d.%m')} um {start_time.strftime('%-H:%M')} Uhr") - current = min_current - submode = "pv_charging" - phases = plan.phases_to_use_pv - return current, submode, message, phases + remaining_time, + duration) + + # def _find_recent_plan(self, + # plans: List[Union[ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer]]): + # plans_diff_end_date = [] + # for p in plans: + # try: + # plans_diff_end_date.append( + # {p.id: timecheck.check_end_time(p, self.BUFFER_AFTER_END_TIME)}) + # log.debug(f"Verbleibende Zeit bis zum Zieltermin [s]: {plans_diff_end_date}") + # except Exception: + # log.exception("Fehler im ev-Modul "+str(self.data.id)) + # if plans_diff_end_date: + # # ermittle den Key vom kleinsten value in plans_diff_end_date + # filtered_plans = [d for d in plans_diff_end_date if list(d.values())[0] is not None] + # if filtered_plans: + # sorted_plans = sorted(filtered_plans, key=lambda x: list(x.values())[0]) + # for plan in sorted_plans: + # if self.BUFFER_AFTER_END_TIME < list(plan.values())[0]: + # plan_dict = plan + # break + # else: + # return None + # plan_id = list(plan_dict.keys())[0] + # diff_end_date = list(plan_dict.values())[0] + + # for p in plans: + # if p.id == plan_id: + # plan = p + + # remaining_time, duration = self._calc_remaining_time(plan, diff_end_date) + + # return plan, remaining_time, duration + # else: + # return None + + # def _calc_remaining_time(self, + # plan: Union[ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer], + # diff_end_date: float) -> Tuple[float, float]: + # duration = plan.duration - self.data.set.log.on_time + # remaining_time = diff_end_date - duration + + # log.debug(f"Verbleibende Zeit bis zum Ladestart [s]:{remaining_time}, Dauer [h]: {duration/3600}") + # return remaining_time, duration + + # SCHEDULED_REACHED_MAX_ON_TIME = ("Zielladen abgeschlossen, da die geplante Ladedauer bereits überschritten wurde. ") + # SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC = ( + # "Zielladen abgeschlossen, da das Limit für Fahrzeug Laden mit Überschuss (SoC-Limit)" + # " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde. ") + # SCHEDULED_CHARGING_REACHED_AMOUNT = "Zielladen abgeschlossen, da die Energiemenge bereits erreicht wurde. " + # SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC = ("Falls vorhanden wird mit EVU-Überschuss geladen, da der Ziel-Soc " + # "für Zielladen bereits erreicht wurde. ") + # SCHEDULED_CHARGING_BIDI = ("Der Ziel-Soc für Zielladen wurde bereits erreicht. Das Auto wird " + # "bidirektional ge-/entladen, sodass möglichst weder Bezug noch " + # "Einspeisung erfolgt. ") + # SCHEDULED_CHARGING_NO_PLANS_CONFIGURED = "Kein Zielladen, da keine Ziel-Termine konfiguriert sind." + # SCHEDULED_CHARGING_NO_DATE_PENDING = "Kein Zielladen, da kein Ziel-Termin ansteht. " + # SCHEDULED_CHARGING_USE_PV = "Zielladen startet {}. Falls vorhanden, wird mit Überschuss geladen. " + # SCHEDULED_CHARGING_MAX_CURRENT = "Zielladen mit {}A. Der Ladestrom wurde erhöht, um das Ziel zu erreichen. " + # SCHEDULED_CHARGING_LIMITED_BY_SOC = 'einen SoC von {}%' + # SCHEDULED_CHARGING_LIMITED_BY_AMOUNT = '{}kWh geladene Energie' + # SCHEDULED_CHARGING_IN_TIME = ('Zielladen mit mindestens {}A, um {} um {} zu erreichen. Falls vorhanden wird ' + # 'zusätzlich EVU-Überschuss geladen. ') + # SCHEDULED_CHARGING_CHEAP_HOUR = "Zielladen, da ein günstiger Zeitpunkt zum preisbasierten Laden ist. {}" + # SCHEDULED_CHARGING_EXPENSIVE_HOUR = ( + # "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " + # "Laden ist. {} Falls vorhanden, wird mit Überschuss geladen. ") + # SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC = ( + # "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " + # "Laden ist. {} " + + # "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden erreicht wurde.") + + # def scheduled_charging_calc_current(self, + # plan: Optional[Union[ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer]], + # remaining_time: float, + # duration: float) -> Tuple[str, str]: + # submode = "stop" + # if plan is None: + # if len(self.data.usage.scheduled_charging.plans) == 0: + # return submode, self.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED + # else: + # return submode, self.SCHEDULED_CHARGING_NO_DATE_PENDING + # if self.data.set.log.on_time >= duration: + # message = self.SCHEDULED_REACHED_MAX_ON_TIME + # elif (0 < remaining_time < self.BUFFER_START_EARLIER): + # submode = "instant_charging" + # # weniger als die berechnete Zeit verfügbar + # elif remaining_time <= 0: + # submode = "instant_charging" + # else: + # # Wenn dynamische Tarife aktiv sind, prüfen, ob jetzt ein günstiger Zeitpunkt zum Laden + # # ist. + # if plan.et_active: + # def get_hours_message() -> str: + # def end_of_today_timestamp() -> int: + # return datetime.datetime.now().replace( + # hour=23, minute=59, second=59, microsecond=999000).timestamp() + + # def is_loading_hour(hour: int) -> bool: + # return data.data.optional_data.ep_is_charging_allowed_hours_list(hour) + + # def convert_loading_hours_to_string(hour_list: List[int]) -> str: + # if 1 < len(hour_list): + # times_string = ", ".join(hour.strftime('%-H:%M') for hour in hour_list[:-1]) + # return times_string + " und " + hour_list[-1].strftime('%-H:%M') + # else: + # return ", ".join(hour.strftime('%-H:%M') for hour in hour_list) + # midnight = end_of_today_timestamp() + # loading_times_today = [datetime.datetime.fromtimestamp(hour) + # for hour in sorted(hour_list) if hour <= midnight] + # loading_times_today = (loading_times_today[1:] + # if is_loading_hour(hour_list) else loading_times_today) + # loading_times_tomorrow = [datetime.datetime.fromtimestamp(hour) + # for hour in sorted(hour_list) if hour > midnight] + + # parts = [] + + # if is_loading_hour(hour_list): + # parts.append("jetzt") + + # if 0 < len(loading_times_today): + # if parts: + # parts.append(" und ") + # parts.append(f"heute {convert_loading_hours_to_string(loading_times_today)}") + + # if 0 < len(loading_times_tomorrow): + # if parts: + # parts.append(" sowie ") + # parts.append(f"morgen {convert_loading_hours_to_string(loading_times_tomorrow)}") + + # loading_message = "Geladen wird " + "".join(parts) + # return loading_message + '.' + + # hour_list = data.data.optional_data.ep_get_loading_hours(duration, duration + remaining_time) + + # log.debug(f"Günstige Ladezeiten: {hour_list}") + # if data.data.optional_data.ep_is_charging_allowed_hours_list(hour_list): + # message = self.SCHEDULED_CHARGING_CHEAP_HOUR.format(get_hours_message()) + # submode = "instant_charging" + # else: + # message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(get_hours_message()) + # submode = "pv_charging" + # else: + # now = datetime.datetime.today() + # start_time = now + datetime.timedelta(seconds=remaining_time) + # if start_time.year == now.year and start_time.month == now.month and start_time.day == now.day: + # message = self.SCHEDULED_CHARGING_USE_PV.format( + # f"um {start_time.strftime('%-H:%M')} Uhr") + # else: + # message = self.SCHEDULED_CHARGING_USE_PV.format( + # f"am {start_time.strftime('%d.%m')} um {start_time.strftime('%-H:%M')} Uhr") + # submode = "pv_charging" + # return submode, message TIME_CHARGING_MIN_BAT_SOC_REACHED = ("Betrieb mit Zeitladen nach Speicher-SoC nicht möglich, da der SoC des" " Speichers unter dem minimalen SoC liegt.") diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index 641f0557ba..787d857955 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -52,6 +52,11 @@ class ResetChargemode: chargemode: Chargemode = Chargemode.INSTANT_CHARGING +@dataclass +class Log: + on_time: float = 0 + + @dataclass class MeterOnlyConfig: type: ConsumerUsage = ConsumerUsage.METER_ONLY @@ -150,6 +155,7 @@ class Set: current_prev: float = 0 target_current: float = 0 charge_state_prev: bool = False + log: Log = field(default_factory=lambda: Log()) power: Optional[float] = None timestamp_last_current_set: float = field(default=0, metadata={"topic": "set/timestamp_last_current_set"}) wait_for_start_state: WaitForStartStates = field( diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index e0ac8bc70e..bac6a4dab1 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -9,7 +9,7 @@ with ImportErrorContext(): from dateutil.relativedelta import relativedelta -from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan, TimeChargingPlanConsumer +from helpermodules.abstract_plans import AutolockPlan, ContinuousScheduledPlanConsumer, ScheduledChargingPlan, SuspendableScheduledPlanConsumer, TimeChargingPlan, TimeChargingPlanConsumer log = logging.getLogger(__name__) @@ -113,7 +113,9 @@ def is_timeframe_valid(now: datetime.datetime, begin: datetime.datetime, end: da return state -def check_end_time(plan: ScheduledChargingPlan, +def check_end_time(plan: Union[ContinuousScheduledPlanConsumer, + ScheduledChargingPlan, + SuspendableScheduledPlanConsumer], buffer: Optional[float]) -> Optional[float]: """ gibt die verbleibende Zeit in Sekunden zurück. From 315017170e38f1e8edaba6393a3996b5546a4889 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 23 Jun 2026 16:08:12 +0200 Subject: [PATCH 08/13] add scheduled und time plan commands for adding and removing --- packages/control/consumer/consumer_data.py | 2 +- packages/helpermodules/abstract_plans.py | 6 +- packages/helpermodules/command.py | 93 +++++++++++++++++----- packages/helpermodules/timecheck.py | 3 +- 4 files changed, 79 insertions(+), 25 deletions(-) diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index 787d857955..318a6f98db 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -110,7 +110,7 @@ class ContinuousDeviceConfig: ConsumerUsage.CONTINUOUS: ContinuousDeviceConfig, } -get_plan_class_for_usage: Dict[ConsumerUsage, Callable] = { +GET_PLAN_CLASS_FOR_USAGE: Dict[ConsumerUsage, Callable] = { ConsumerUsage.SUSPENDABLE_TUNABLE: SuspendableScheduledPlanConsumer, ConsumerUsage.SUSPENDABLE_ONOFF: SuspendableScheduledPlanConsumer, ConsumerUsage.CONTINUOUS: ContinuousScheduledPlanConsumer, diff --git a/packages/helpermodules/abstract_plans.py b/packages/helpermodules/abstract_plans.py index 8e2c2b7dd1..6e89a39879 100644 --- a/packages/helpermodules/abstract_plans.py +++ b/packages/helpermodules/abstract_plans.py @@ -112,17 +112,17 @@ class AutolockPlan(TimeframePlan): class TimeChargingPlanConsumer(TimeframePlan): id: Optional[int] = None min_bat_soc: Optional[int] = None - name: str = "neuer Verbraucher-Plan" + name: str = "neuer Zeit-Plan" @dataclass class SuspendableScheduledPlanConsumer(TimeframePlan): id: Optional[int] = None - name: str = "neuer Verbraucher-Plan" + name: str = "neuer Ziel-Plan" duration: int = 60 @dataclass class ContinuousScheduledPlanConsumer(TimeframePlan): id: Optional[int] = None - name: str = "neuer Verbraucher-Plan" + name: str = "neuer Ziel-Plan" diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index ce91698197..e5d8a98975 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -17,14 +17,14 @@ from control.chargepoint import chargepoint from control.chargepoint.chargepoint_template import get_chargepoint_template_default -from control.consumer.consumer_data import GET_DEFAULTS_BY_USAGE, get_plan_class_for_usage +from control.consumer.consumer_data import GET_DEFAULTS_BY_USAGE, GET_PLAN_CLASS_FOR_USAGE from control.consumer.usage import ConsumerUsage from control.counter_all import counter_all from control.ev.charge_template import ChargeTemplate, get_new_charge_template from control.ev.ev_template import EvTemplateData from control.consumer.consumer_data import ConsumerConfig from helpermodules import pub -from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan +from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan, TimeChargingPlanConsumer from helpermodules.utils.run_command import run_command # ToDo: move to module commands if implemented from modules.backup_clouds.onedrive.api import generateMSALAuthCode, retrieveMSALTokens @@ -64,7 +64,8 @@ class Command: [("autolock_plan", "openWB/chargepoint/template/[0-9]+$", -1), ("charge_template_scheduled_plan", "openWB/vehicle/template/charge_template/[0-9]+$", -1), ("charge_template_time_charging_plan", "openWB/vehicle/template/charge_template/[0-9]+$", -1), - ("usage_plan", "openWB/consumer/[0-9]+/usage$", -1)], + ("consumer_scheduled_plan", "openWB/consumer/[0-9]+/usage$", -1), + ("consumer_time_plan", "openWB/consumer/[0-9]+/usage$", -1)], "topic": [("mqtt_bridge", "openWB/system/mqtt/bridge", -1), ("vehicle", "openWB/vehicle/[0-9]+/name$", 0)], @@ -92,7 +93,8 @@ def _get_max_ids(self) -> None: "charge_template_scheduled_plan": lambda p: p.get("chargemode", {}).get("scheduled_charging", {}).get("plans", []), "charge_template_time_charging_plan": lambda p: p.get("time_charging", {}).get("plans", []), - "usage_plan": lambda p: p.get("plans", []) + "consumer_scheduled_plan": lambda p: p.get("scheduled_charging", {}).get("plans", []), + "consumer_time_plan": lambda p: p.get("time_charging", {}).get("plans", []), } try: received_topics = ProcessBrokerBranch("").get_max_id() @@ -1207,41 +1209,92 @@ def selectUsage(self, connection_id: str, payload: dict) -> None: f'\'{payload["data"]["usage"]}\' zugewiesen.', MessageType.SUCCESS) - def addUsagePlan(self, connection_id: str, payload: dict) -> None: + def addConsumerSchedulePlan(self, connection_id: str, payload: dict) -> None: """ sendet das Topic, zu dem ein neuer Zielladen-Plan erstellt werden soll. """ usage = SubData.consumer_data[f'consumer{payload["data"]["consumer_id"]}'].data.usage - plan = get_plan_class_for_usage(usage.type)() - new_id = self.max_id_usage_plan + 1 - plan.id = new_id - usage.plans.append(plan) + # check if "payload" contains "data.copy" + if "data" in payload and "copy" in payload["data"] and usage.scheduled_charging.plans is not None: + for plan in usage.scheduled_charging.plans: + if plan.id == payload["data"]["copy"]: + new_consumer_schedule_plan = copy.deepcopy(plan) + break + new_consumer_schedule_plan.name = f'Kopie von {new_consumer_schedule_plan.name}' + else: + new_consumer_schedule_plan = GET_PLAN_CLASS_FOR_USAGE[usage.type]() + new_id = self.max_id_consumer_scheduled_plan + 1 + new_consumer_schedule_plan.id = new_id + usage.scheduled_charging.plans.append(new_consumer_schedule_plan) + self.max_id_consumer_scheduled_plan = new_id + Pub().pub("openWB/set/command/max_id/consumer_scheduled_plan", new_id) Pub().pub(f'openWB/set/consumer/{payload["data"]["consumer_id"]}/usage', dataclass_utils.asdict(usage)) - self.max_id_usage_plan = new_id - Pub().pub("openWB/set/command/max_id/usage_plan", new_id) pub_user_message( payload, connection_id, - f'Neuer Zielladen-Plan mit ID \'{new_id}\' zu Profil ' - f'\'{payload["data"]["template"]}\' hinzugefügt.', + f'Neuer Zielladen-Plan mit ID \'{new_id}\' zu Verbraucher ' + f'\'{payload["data"]["consumer_id"]}\' hinzugefügt.', MessageType.SUCCESS) - def removeUsagePlan(self, connection_id: str, payload: dict) -> None: + def removeConsumerSchedulePlan(self, connection_id: str, payload: dict) -> None: """ löscht einen Zielladen-Plan. """ - if self.max_id_usage_plan < payload["data"]["plan"]: + usage = SubData.consumer_data[f'consumer{payload["data"]["consumer_id"]}'].data.usage + if self.max_id_consumer_scheduled_plan < payload["data"]["plan"]: log.error( payload, connection_id, f'Die ID \'{payload["data"]["plan"]}\' ist größer als die maximal vergebene ' - f'ID \'{self.max_id_usage_plan}\'.', MessageType.ERROR) + f'ID \'{self.max_id_consumer_scheduled_plan}\'.', MessageType.ERROR) + for plan in usage.scheduled_charging.plans: + if plan.id == payload["data"]["plan"]: + usage.scheduled_charging.plans.remove(plan) + break + Pub().pub(f'openWB/set/consumer/{payload["data"]["consumer_id"]}/usage', dataclass_utils.asdict(usage)) + pub_user_message( + payload, connection_id, + (f'Zielladen-Plan mit ID \'{payload["data"]["plan"]}\' von Verbraucher ' + f'\'{payload["data"]["consumer_id"]}\' gelöscht.'), + MessageType.SUCCESS) + + def addConsumerTimePlan(self, connection_id: str, payload: dict) -> None: + """ sendet das Topic, zu dem ein neuer Zeitladen-Plan erstellt werden soll. + """ + usage = SubData.consumer_data[f'consumer{payload["data"]["consumer_id"]}'].data.usage + # check if "payload" contains "data.copy" + if "data" in payload and "copy" in payload["data"]: + for plan in usage.time_charging.plans: + if plan.id == payload["data"]["copy"]: + new_time_charging_plan = copy.deepcopy(plan) + break + new_time_charging_plan.name = f'Kopie von {new_time_charging_plan.name}' + else: + new_time_charging_plan = TimeChargingPlanConsumer() + new_id = self.max_id_consumer_time_plan + 1 + new_time_charging_plan.id = new_id + usage.time_charging.plans.append(new_time_charging_plan) + Pub().pub(f'openWB/set/consumer/{payload["data"]["consumer_id"]}/usage', dataclass_utils.asdict(usage)) + self.max_id_consumer_time_plan = new_id + Pub().pub( + "openWB/set/command/max_id/consumer_time_plan", new_id) + pub_user_message( + payload, connection_id, + f'Neuer Zeitladen-Plan mit ID \'{new_id}\' zu Verbraucher ' + f'\'{payload["data"]["consumer_id"]}\' hinzugefügt.', MessageType.SUCCESS) + + def removeConsumerTimePlan(self, connection_id: str, payload: dict) -> None: + """ löscht einen Zeitladen-Plan. + """ usage = SubData.consumer_data[f'consumer{payload["data"]["consumer_id"]}'].data.usage - for plan in usage.plans: + if self.max_id_consumer_time_plan < payload["data"]["plan"]: + log.error(payload, connection_id, "Die ID ist größer als die maximal vergebene ID.", + MessageType.ERROR) + for plan in usage.time_charging.plans: if plan.id == payload["data"]["plan"]: - usage.plans.remove(plan) + usage.time_charging.plans.remove(plan) break Pub().pub(f'openWB/set/consumer/{payload["data"]["consumer_id"]}/usage', dataclass_utils.asdict(usage)) pub_user_message( payload, connection_id, - f'Zielladen-Plan mit ID \'{payload["data"]["plan"]}\' von Profil ' - f'{payload["data"]["template"]}\' gelöscht.', + (f'Zeitladen-Plan mit ID \'{payload["data"]["plan"]}\' zu Verbraucher ' + f'\'{payload["data"]["consumer_id"]}\' gelöscht.'), MessageType.SUCCESS) diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index bac6a4dab1..ccc37b5606 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -9,7 +9,8 @@ with ImportErrorContext(): from dateutil.relativedelta import relativedelta -from helpermodules.abstract_plans import AutolockPlan, ContinuousScheduledPlanConsumer, ScheduledChargingPlan, SuspendableScheduledPlanConsumer, TimeChargingPlan, TimeChargingPlanConsumer +from helpermodules.abstract_plans import (AutolockPlan, ContinuousScheduledPlanConsumer, ScheduledChargingPlan, + SuspendableScheduledPlanConsumer, TimeChargingPlan, TimeChargingPlanConsumer) log = logging.getLogger(__name__) From 2a0d3812c2339d2116ae83b3d4084741c987434f Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 23 Jun 2026 16:15:16 +0200 Subject: [PATCH 09/13] flake8 --- packages/control/consumer/consumer.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 0c5ca25ad0..967fa35a36 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -112,9 +112,9 @@ def scheduled_charging(self) -> Tuple[Chargemode, str]: return self.data.config.min_current, "Zielladen noch nicht implementiert", Chargemode.STOP, Chargemode.STOP plan_data, remaining_time, duration = self._find_recent_plan(self.data.usage.scheduled_charging.plans) if plan_data: - control_parameter.current_plan = plan_data.id + self.data.config.control_parameter.current_plan = plan_data.id else: - control_parameter.current_plan = None + self.data.config.control_parameter.current_plan = None return self.scheduled_charging_calc_current( plan_data, remaining_time, @@ -163,7 +163,8 @@ def scheduled_charging(self) -> Tuple[Chargemode, str]: # log.debug(f"Verbleibende Zeit bis zum Ladestart [s]:{remaining_time}, Dauer [h]: {duration/3600}") # return remaining_time, duration - # SCHEDULED_REACHED_MAX_ON_TIME = ("Zielladen abgeschlossen, da die geplante Ladedauer bereits überschritten wurde. ") + # SCHEDULED_REACHED_MAX_ON_TIME = ("Zielladen abgeschlossen, da die geplante Ladedauer bereits überschritten " + # "wurde.") # SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC = ( # "Zielladen abgeschlossen, da das Limit für Fahrzeug Laden mit Überschuss (SoC-Limit)" # " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde. ") @@ -191,7 +192,8 @@ def scheduled_charging(self) -> Tuple[Chargemode, str]: # "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden erreicht wurde.") # def scheduled_charging_calc_current(self, - # plan: Optional[Union[ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer]], + # plan: Optional[Union[ContinuousScheduledPlanConsumer, + # SuspendableScheduledPlanConsumer]], # remaining_time: float, # duration: float) -> Tuple[str, str]: # submode = "stop" From e24f3db5a0be5117bf59cec1b5db6c03a34eb766 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 23 Jun 2026 16:17:03 +0200 Subject: [PATCH 10/13] update config --- packages/helpermodules/update_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 3607f5e46f..5c0a568fb7 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -173,6 +173,8 @@ class UpdateConfig: "^openWB/command/max_id/charge_template$", "^openWB/command/max_id/charge_template_scheduled_plan$", "^openWB/command/max_id/charge_template_time_charging_plan$", + "^openWB/command/max_id/consumer_scheduled_plan$", + "^openWB/command/max_id/consumer_time_plan$", "^openWB/command/max_id/chargepoint_template$", "^openWB/command/max_id/device$", "^openWB/command/max_id/ev_template$", From f98e5b28e21eab95e9d841da006cd17b7aecd344 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:17:55 +0200 Subject: [PATCH 11/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/control/consumer/consumer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 967fa35a36..3f705c04c4 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -102,8 +102,10 @@ def _parse_required_current_by_usage(self, required_current: float) -> float: else: return required_current - def _convert_power_to_current(self, power: float) -> float: - return power / self.data.config.connected_phases / voltages_mean(self.data.get.voltages) +def _convert_power_to_current(self, power: float) -> float: + phases = self.data.config.connected_phases or 1 + voltage = voltages_mean(self.data.get.voltages) or 230.0 + return power / phases / voltage BUFFER_AFTER_END_TIME = -1200 # nach mehr als 20 Min Überschreitung wird der Termin als verpasst angesehen BUFFER_START_EARLIER = 600 # 10 Min vor dem geplanten Start kann begonnen werden From 2fd08865d97c59460304f021c956ba471daa03dd Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 23 Jun 2026 16:31:59 +0200 Subject: [PATCH 12/13] review --- packages/control/consumer/consumer.py | 7 ++++--- packages/control/consumer/consumer_data.py | 2 ++ packages/helpermodules/command.py | 2 ++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 967fa35a36..3259312c4c 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -353,7 +353,7 @@ def pv_charging(self) -> Tuple[int, str, Optional[str], int]: def wait_for_start_handler( self, func: Callable[[], Tuple[int, str, Optional[str], int]] - ) -> Tuple[int, Chargemode, Optional[Chargemode], int]: + ) -> Tuple[float, str, Optional[Chargemode], Chargemode]: if self.data.usage.wait_for_start_active: if self.data.set.wait_for_start_state == WaitForStartStates.WAIT_FOR_DEVICE_START: if self.data.get.charge_state: @@ -438,8 +438,9 @@ def is_charging_stop_allowed(self) -> bool: def set_mode_changed(self, submode: Chargemode, mode: Chargemode) -> None: self.submode_changed = (submode != self.data.control_parameter.submode) - if ((submode == "time_charging" and self.data.control_parameter.chargemode != "time_charging") or - (submode != "time_charging" and + if ((submode == Chargemode.TIME_CHARGING and + self.data.control_parameter.chargemode != Chargemode.TIME_CHARGING) or + (submode != Chargemode.TIME_CHARGING and self.data.control_parameter.chargemode != mode)): self.chargemode_changed = True log.debug("Änderung des Lademodus") diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index 318a6f98db..fb765d43a8 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -69,6 +69,7 @@ class SuspendableTunableDeviceConfig: scheduled_charging: ScheduledSuspendableCharging = field(default_factory=lambda: ScheduledSuspendableCharging()) time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) type: ConsumerUsage = ConsumerUsage.SUSPENDABLE_TUNABLE + reset_chargemode: ResetChargemode = field(default_factory=lambda: ResetChargemode()) wait_for_start_active: bool = True @@ -79,6 +80,7 @@ class SuspendableOnOffDeviceConfig: scheduled_charging: ScheduledSuspendableCharging = field(default_factory=lambda: ScheduledSuspendableCharging()) time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) type: ConsumerUsage = ConsumerUsage.SUSPENDABLE_ONOFF + reset_chargemode: ResetChargemode = field(default_factory=lambda: ResetChargemode()) wait_for_start_active: bool = True diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index e5d8a98975..0095c6d60f 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -1243,6 +1243,7 @@ def removeConsumerSchedulePlan(self, connection_id: str, payload: dict) -> None: payload, connection_id, f'Die ID \'{payload["data"]["plan"]}\' ist größer als die maximal vergebene ' f'ID \'{self.max_id_consumer_scheduled_plan}\'.', MessageType.ERROR) + return for plan in usage.scheduled_charging.plans: if plan.id == payload["data"]["plan"]: usage.scheduled_charging.plans.remove(plan) @@ -1286,6 +1287,7 @@ def removeConsumerTimePlan(self, connection_id: str, payload: dict) -> None: if self.max_id_consumer_time_plan < payload["data"]["plan"]: log.error(payload, connection_id, "Die ID ist größer als die maximal vergebene ID.", MessageType.ERROR) + return for plan in usage.time_charging.plans: if plan.id == payload["data"]["plan"]: usage.time_charging.plans.remove(plan) From 55791449ca608246e6d0f517627f74a79f13f24f Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 24 Jun 2026 08:44:54 +0200 Subject: [PATCH 13/13] fix pytest --- packages/control/consumer/consumer.py | 2 +- .../helpermodules/mosquitto_dynsec/missing_role_topics_test.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 7b84890a8f..03499cc25f 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -102,7 +102,7 @@ def _parse_required_current_by_usage(self, required_current: float) -> float: else: return required_current -def _convert_power_to_current(self, power: float) -> float: + def _convert_power_to_current(self, power: float) -> float: phases = self.data.config.connected_phases or 1 voltage = voltages_mean(self.data.get.voltages) or 230.0 return power / phases / voltage diff --git a/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py b/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py index b06822ab14..b1c74c086e 100644 --- a/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py +++ b/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py @@ -24,6 +24,8 @@ def _get_data_path() -> Path: 'openWB/command/max_id/charge_template', 'openWB/command/max_id/charge_template_scheduled_plan', 'openWB/command/max_id/charge_template_time_charging_plan', + 'openWB/command/max_id/consumer_scheduled_plan', + 'openWB/command/max_id/consumer_time_plan', 'openWB/command/max_id/chargepoint_template', 'openWB/command/max_id/device', 'openWB/command/max_id/ev_template',