From 9a05ff2981c622a69f58cff22fd0f2249be00ed5 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 19 Aug 2026 15:54:10 +0200 Subject: [PATCH 01/84] use chargemode enums for chargepoints --- packages/control/chargelog/chargelog.py | 5 +- packages/control/chargepoint/chargepoint.py | 23 ++-- packages/control/ev/charge_template.py | 83 ++++++++------ packages/control/ev/charge_template_test.py | 114 +++++++++++--------- packages/control/ev/ev.py | 19 ++-- packages/helpermodules/subdata.py | 3 + 6 files changed, 137 insertions(+), 110 deletions(-) diff --git a/packages/control/chargelog/chargelog.py b/packages/control/chargelog/chargelog.py index dcfbbfeb3d..621dbb06d4 100644 --- a/packages/control/chargelog/chargelog.py +++ b/packages/control/chargelog/chargelog.py @@ -1,3 +1,4 @@ +from control.chargemode import Chargemode from helpermodules.utils.json_file_handler import write_and_check from helpermodules import timecheck import copy @@ -99,8 +100,8 @@ def collect_data(chargepoint): if get_value_or_default(lambda: log_data.timestamp_start_charging is None): log_data.timestamp_start_charging = now submode = get_value_or_default(lambda: chargepoint.data.control_parameter.submode, "") - if submode == "time_charging": - log_data.chargemode_log_entry = "time_charging" + if submode == Chargemode.TIME_CHARGING: + log_data.chargemode_log_entry = Chargemode.TIME_CHARGING.value else: log_data.chargemode_log_entry = get_value_or_default( lambda: chargepoint.data.control_parameter.chargemode.value) diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index 8fdf400905..5975ae32ab 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -218,7 +218,7 @@ def _process_charge_stop(self) -> None: def setup_values_at_start(self): self._set_values_at_start() - def set_control_parameter(self, submode: str): + def set_control_parameter(self, submode: Chargemode): """ setzt die Regel-Parameter, die der Algorithmus verwendet. Parameter @@ -227,8 +227,8 @@ def set_control_parameter(self, submode: str): neuer Lademodus, in dem geladen werden soll """ try: - self.data.control_parameter.submode = Chargemode(submode) - if submode == "time_charging": + self.data.control_parameter.submode = submode + if submode == Chargemode.TIME_CHARGING: self.data.control_parameter.chargemode = Chargemode.TIME_CHARGING else: self.data.control_parameter.chargemode = Chargemode( @@ -602,9 +602,10 @@ def set_timestamp_charge_start(self): elif self.data.set.current == 0: self.data.control_parameter.timestamp_charge_start = None - def set_chargemode_changed(self, submode: str) -> None: - if ((submode == "time_charging" and self.data.control_parameter.chargemode != "time_charging") or - (submode != "time_charging" and + def set_chargemode_changed(self, submode: Chargemode) -> None: + 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 != self.data.set.charge_template.data.chargemode.selected)): self.chargemode_changed = True log.debug("Änderung des Lademodus") @@ -612,7 +613,7 @@ def set_chargemode_changed(self, submode: str) -> None: else: self.chargemode_changed = False - def set_submode_changed(self, submode: str) -> None: + def set_submode_changed(self, submode: Chargemode) -> None: self.submode_changed = (submode != self.data.control_parameter.submode) def update_ev(self, ev_list: Dict[str, Ev]) -> None: @@ -700,7 +701,7 @@ def update(self, ev_list: Dict[str, Ev]) -> None: f", mittlerer Ist-Strom: {get_medium_charging_current(self.data.get.currents)}") except Exception: log.exception("Fehler im Prepare-Modul für Ladepunkt "+str(self.num)) - self.data.control_parameter.submode = "stop" + self.data.control_parameter.submode = Chargemode.STOP else: self._process_charge_stop() if vehicle != -1: @@ -807,8 +808,8 @@ def _pub_connected_vehicle(self, vehicle: Ev): self.data.get.connected_vehicle.soc.range = vehicle.data.get.range self.data.get.connected_vehicle.info = ConnectedInfo(id=vehicle.num, name=vehicle.data.name) - if (self.data.set.charge_template.data.chargemode.selected == "time_charging" or - self.data.set.charge_template.data.chargemode.selected == "scheduled_charging"): + if (self.data.set.charge_template.data.chargemode.selected == Chargemode.TIME_CHARGING.value or + self.data.set.charge_template.data.chargemode.selected == Chargemode.SCHEDULED_CHARGING.value): current_plan = self.data.control_parameter.current_plan else: current_plan = None @@ -820,7 +821,7 @@ def _pub_connected_vehicle(self, vehicle: Ev): current_plan=current_plan, average_consumption=vehicle.ev_template.data.average_consump, time_charging_in_use=True if (self.data.control_parameter.submode == - "time_charging") else False) + Chargemode.TIME_CHARGING) else False) except Exception: log.exception("Fehler im Prepare-Modul") diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index 20f7ba88bb..fee8e20d78 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -5,6 +5,7 @@ from typing import List, Optional, Tuple from control import data +from control.chargemode import Chargemode as ChargemodeEnum from control.chargepoint.chargepoint_state import CHARGING_STATES from control.chargepoint.charging_type import ChargingType from control.chargepoint.control_parameter import ControlParameter @@ -91,7 +92,7 @@ def instant_charging_factory() -> InstantCharging: @dataclass class Chargemode: - selected: str = "instant_charging" + selected: str = ChargemodeEnum.INSTANT_CHARGING.value eco_charging: EcoCharging = field(default_factory=eco_charging_factory) pv_charging: PvCharging = field(default_factory=pv_charging_factory) scheduled_charging: ScheduledCharging = field(default_factory=scheduled_charging_factory) @@ -151,11 +152,11 @@ class ChargeTemplate: def time_charging(self, soc: Optional[float], used_amount_time_charging: float, - charging_type: str) -> Tuple[int, str, Optional[str], Optional[str], int]: + charging_type: str) -> Tuple[int, ChargemodeEnum, Optional[str], Optional[str], int]: """ prüft, ob ein Zeitfenster aktiv ist und setzt entsprechend den Ladestrom """ message = None - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP current = 0 id = None phases = None @@ -179,12 +180,12 @@ def time_charging(self, log.debug( "Zeitladen: minimaler Speicher-SoC überschritten, Laden mit Zeitladen möglich.") current = plan.current if charging_type == ChargingType.AC.value else plan.dc_current - sub_mode = "time_charging" + sub_mode = ChargemodeEnum.TIME_CHARGING else: message = self.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL else: current = plan.current if charging_type == ChargingType.AC.value else plan.dc_current - sub_mode = "time_charging" + sub_mode = ChargemodeEnum.TIME_CHARGING else: message = self.TIME_CHARGING_NO_PLAN_ACTIVE else: @@ -192,7 +193,10 @@ def time_charging(self, return current, sub_mode, message, id, phases except Exception: log.exception("Fehler im ev-Modul "+str(self.data.id)) - return (0, "stop", "Keine Ladung, da da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), None, + return (0, + ChargemodeEnum.STOP, + "Keine Ladung, da da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), + None, 0) SOC_REACHED = "Keine Ladung, da das Ladeziel bereits erreicht wurde." @@ -201,11 +205,11 @@ def time_charging(self, def instant_charging(self, soc: Optional[float], used_amount: float, - charging_type: str) -> Tuple[int, str, Optional[str], int]: + charging_type: str) -> Tuple[int, ChargemodeEnum, Optional[str], int]: """ prüft, ob die Lademengenbegrenzung erreicht wurde und setzt entsprechend den Ladestrom. """ message = None - sub_mode = "instant_charging" + sub_mode = ChargemodeEnum.INSTANT_CHARGING try: instant_charging = self.data.chargemode.instant_charging phases = instant_charging.phases_to_use @@ -216,17 +220,20 @@ def instant_charging(self, if instant_charging.limit.selected == "soc" and soc is not None and soc >= instant_charging.limit.soc: current = 0 - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP message = self.SOC_REACHED elif instant_charging.limit.selected == "amount": if used_amount >= self.data.chargemode.instant_charging.limit.amount: current = 0 - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP message = self.AMOUNT_REACHED return current, sub_mode, message, phases except Exception: log.exception("Fehler im ev-Modul "+str(self.data.id)) - return 0, "stop", "Keine Ladung, da da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), 0 + return (0, + ChargemodeEnum.STOP, + "Keine Ladung, da da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), + 0) PV_CHARGING_SOC_CHARGING = ("Ladung evtl. auch ohne PV-Überschuss, da der Mindest-SoC des Fahrzeugs noch nicht " "erreicht wurde.") @@ -236,11 +243,11 @@ def pv_charging(self, soc: Optional[float], min_current: int, charging_type: str, - used_amount: float) -> Tuple[int, str, Optional[str], int]: + used_amount: float) -> Tuple[int, ChargemodeEnum, Optional[str], int]: """ prüft, ob Min-oder Max-Soc erreicht wurden und setzt entsprechend den Ladestrom. """ message = None - sub_mode = "pv_charging" + sub_mode = ChargemodeEnum.PV_CHARGING try: pv_charging = self.data.chargemode.pv_charging phases = pv_charging.phases_to_use @@ -248,11 +255,11 @@ def pv_charging(self, else pv_charging.dc_min_current) if pv_charging.limit.selected == "soc" and soc is not None and soc >= pv_charging.limit.soc: current = 0 - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP message = self.SOC_REACHED elif pv_charging.limit.selected == "amount" and used_amount >= pv_charging.limit.amount: current = 0 - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP message = self.AMOUNT_REACHED else: if pv_charging.min_soc != 0 and soc is not None and soc < pv_charging.min_soc: @@ -260,33 +267,36 @@ def pv_charging(self, current = pv_charging.min_soc_current else: current = pv_charging.dc_min_soc_current - sub_mode = "instant_charging" + sub_mode = ChargemodeEnum.INSTANT_CHARGING message = self.PV_CHARGING_SOC_CHARGING phases = pv_charging.phases_to_use_min_soc elif min_pv_current == 0: # nur PV; Ampere darf nicht 0 sein, wenn geladen werden soll current = min_current - sub_mode = "pv_charging" + sub_mode = ChargemodeEnum.PV_CHARGING else: # Min PV current = min_pv_current - sub_mode = "instant_charging" + sub_mode = ChargemodeEnum.INSTANT_CHARGING message = self.PV_CHARGING_MIN_CURRENT_CHARGING return current, sub_mode, message, phases except Exception: log.exception("Fehler im ev-Modul "+str(self.data.id)) - return 0, "stop", "Keine Ladung, da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), 1 + return (0, + ChargemodeEnum.STOP, + "Keine Ladung, da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), + 1) def eco_charging(self, soc: Optional[float], control_parameter: ControlParameter, charging_type: str, used_amount: float, - max_phases_hw: int) -> Tuple[int, str, Optional[str], int]: + max_phases_hw: int) -> Tuple[int, ChargemodeEnum, Optional[str], int]: """ prüft, ob Min-oder Max-Soc erreicht wurden und setzt entsprechend den Ladestrom. """ message = None - sub_mode = "pv_charging" + sub_mode = ChargemodeEnum.PV_CHARGING try: eco_charging = self.data.chargemode.eco_charging phases = eco_charging.phases_to_use @@ -294,16 +304,16 @@ def eco_charging(self, if eco_charging.limit.selected == "soc" and soc is not None and soc >= eco_charging.limit.soc: current = 0 - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP message = self.SOC_REACHED elif (eco_charging.limit.selected == "amount" and used_amount >= self.data.chargemode.instant_charging.limit.amount): current = 0 - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP message = self.AMOUNT_REACHED elif data.data.optional_data.data.electricity_pricing.configured: if data.data.optional_data.ep_is_charging_allowed_price_threshold(eco_charging.max_price): - sub_mode = "instant_charging" + sub_mode = ChargemodeEnum.INSTANT_CHARGING message = self.CHARGING_PRICE_LOW phases = max_phases_hw else: @@ -316,7 +326,10 @@ def eco_charging(self, return current, sub_mode, message, phases except Exception: log.exception("Fehler im ev-Modul "+str(self.data.id)) - return 0, "stop", "Keine Ladung, da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), 0 + return (0, + ChargemodeEnum.STOP, + "Keine Ladung, da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), + 0) 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 @@ -553,9 +566,9 @@ def scheduled_charging_calc_current(self, charging_type: str, ev_template: EvTemplate, bidi_state: BidiState, - charge_state: bool) -> Tuple[float, str, str, int]: + charge_state: bool) -> Tuple[float, ChargemodeEnum, str, int]: current = 0 - submode = "stop" + submode = ChargemodeEnum.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 @@ -585,14 +598,14 @@ def scheduled_charging_calc_current(self, if plan.bidi_charging_enabled and bidi_state == BidiState.BIDI_CAPABLE: message = self.SCHEDULED_CHARGING_BIDI current = min_current - submode = "bidi_charging" + submode = ChargemodeEnum.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" + submode = ChargemodeEnum.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 @@ -609,7 +622,7 @@ def scheduled_charging_calc_current(self, 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" + submode = ChargemodeEnum.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: @@ -618,7 +631,7 @@ def scheduled_charging_calc_current(self, 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" + submode = ChargemodeEnum.INSTANT_CHARGING else: # Wenn dynamische Tarife aktiv sind, prüfen, ob jetzt ein günstiger Zeitpunkt zum Laden # ist. @@ -671,12 +684,12 @@ def convert_loading_hours_to_string(hour_list: List[int]) -> str: 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" + submode = ChargemodeEnum.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" + submode = ChargemodeEnum.PV_CHARGING phases = plan.phases_to_use_pv else: message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format(get_hours_message()) @@ -694,12 +707,12 @@ def convert_loading_hours_to_string(hour_list: List[int]) -> str: 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" + submode = ChargemodeEnum.PV_CHARGING phases = plan.phases_to_use_pv return current, submode, message, phases def stop(self) -> Tuple[int, str, str]: - return 0, "stop", "Keine Ladung, da der Lademodus Stop aktiv ist." + return 0, ChargemodeEnum.STOP, "Keine Ladung, da der Lademodus Stop aktiv ist." def bidi_charging_allowed(self, selected_plan: int, soc: float): # Wenn über den Limit-SoC geladen wurde, darf nur noch bidirektional entladen werden. diff --git a/packages/control/ev/charge_template_test.py b/packages/control/ev/charge_template_test.py index 5fc859e02b..652da3e6ea 100644 --- a/packages/control/ev/charge_template_test.py +++ b/packages/control/ev/charge_template_test.py @@ -7,6 +7,7 @@ from control import data from control import optional from control.bat_all import BatAll +from control.chargemode import Chargemode from control.chargepoint.control_parameter import ControlParameter from control.ev.charge_template import SelectedPlan from control.chargepoint.charging_type import ChargingType @@ -28,38 +29,39 @@ def data_module() -> None: @pytest.mark.parametrize( "plans, soc, used_amount_time_charging, plan_found, expected", [ - pytest.param({}, 0, 0, None, (0, "stop", ChargeTemplate.TIME_CHARGING_NO_PLAN_CONFIGURED, None, None), + pytest.param({}, 0, 0, None, (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_NO_PLAN_CONFIGURED, None, None), id="no plan defined"), pytest.param({"0": TimeChargingPlan(id=0)}, 0, 0, None, - (0, "stop", ChargeTemplate.TIME_CHARGING_NO_PLAN_ACTIVE, None, None), id="no plan active"), + (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_NO_PLAN_ACTIVE, None, None), + id="no plan active"), pytest.param({"0": TimeChargingPlan(id=0)}, 0, 0, TimeChargingPlan(id=0), - (16, "time_charging", None, 0, 1), id="plan active"), + (16, Chargemode.TIME_CHARGING, None, 0, 1), id="plan active"), pytest.param({"0": TimeChargingPlan(id=0, limit=Limit(selected="soc"))}, 100, 0, TimeChargingPlan(id=0, limit=Limit(selected="soc")), - (0, "stop", ChargeTemplate.TIME_CHARGING_SOC_REACHED, 0, 1), + (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_SOC_REACHED, 0, 1), id="plan active, soc is reached"), pytest.param({"0": TimeChargingPlan(id=0, limit=Limit(selected="soc"))}, 40, 0, TimeChargingPlan(id=0, limit=Limit(selected="soc")), - (16, "time_charging", None, 0, 1), id="plan active, soc is not reached"), + (16, Chargemode.TIME_CHARGING, None, 0, 1), id="plan active, soc is not reached"), pytest.param({"0": TimeChargingPlan(id=0, limit=Limit(selected="soc"))}, None, 0, TimeChargingPlan(id=0, limit=Limit(selected="soc")), - (16, "time_charging", None, 0, 1), id="plan active, soc is not defined"), + (16, Chargemode.TIME_CHARGING, None, 0, 1), id="plan active, soc is not defined"), pytest.param({"0": TimeChargingPlan(id=0, limit=Limit(selected="amount", amount=1000))}, 0, 1500, TimeChargingPlan(id=0, limit=Limit(selected="amount", amount=1000)), - (0, "stop", ChargeTemplate.TIME_CHARGING_AMOUNT_REACHED, 0, 1), + (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_AMOUNT_REACHED, 0, 1), id="plan active, used_amount_time_charging is reached"), pytest.param({"0": TimeChargingPlan(id=0, limit=Limit(selected="amount", amount=1000))}, 0, 500, TimeChargingPlan(id=0, limit=Limit(selected="amount", amount=1000)), - (16, "time_charging", None, 0, 1), + (16, Chargemode.TIME_CHARGING, None, 0, 1), id="plan active, used_amount_time_charging is not reached"), pytest.param({"0": TimeChargingPlan(id=0)}, 0, 0, None, - (0, "stop", ChargeTemplate.TIME_CHARGING_NO_PLAN_ACTIVE, None, None), + (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_NO_PLAN_ACTIVE, None, None), id="plan defined but not found"), ] ) def test_time_charging(plans: Dict[int, TimeChargingPlan], soc: float, used_amount_time_charging: float, plan_found: TimeChargingPlan, - expected: Tuple[int, str, Optional[str], Optional[str]], + expected: Tuple[int, Chargemode, Optional[str], Optional[str], Optional[int]], monkeypatch): # setup ct = ChargeTemplate() @@ -77,19 +79,20 @@ def test_time_charging(plans: Dict[int, TimeChargingPlan], soc: float, used_amou @pytest.mark.parametrize( "min_bat_soc, charging_allowed_mock, soc, expected", [ - pytest.param(80, False, 81, (0, "stop", ChargeTemplate.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL, 0, 1), + pytest.param(80, False, 81, + (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL, 0, 1), id="Konflikt mit aktiver Speichersteuerung -> nicht laden"), - pytest.param(80, True, 79, (0, "stop", ChargeTemplate.TIME_CHARGING_MIN_BAT_SOC_REACHED, 0, 1), + pytest.param(80, True, 79, (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_MIN_BAT_SOC_REACHED, 0, 1), id="Mindest-SoC des Speichers unterschritten -> nicht laden"), - pytest.param(80, True, 80, (16, "time_charging", None, 0, 1), id="laden erlaubt"), - pytest.param(None, True, 80, (16, "time_charging", None, 0, 1), + pytest.param(80, True, 80, (16, Chargemode.TIME_CHARGING, None, 0, 1), id="laden erlaubt"), + pytest.param(None, True, 80, (16, Chargemode.TIME_CHARGING, None, 0, 1), id="Mindest-SoC-Beachtung nicht konfiguriert, laden erlaubt"), ] ) def test_time_charging_min_bat_soc(min_bat_soc: Optional[int], charging_allowed_mock: bool, soc: float, - expected: Tuple[int, str, Optional[str], Optional[str], int], + expected: Tuple[int, Chargemode, Optional[str], Optional[str], int], monkeypatch: pytest.MonkeyPatch): # setup ct = ChargeTemplate() @@ -115,17 +118,18 @@ def test_time_charging_min_bat_soc(min_bat_soc: Optional[int], @pytest.mark.parametrize( "selected, current_soc, used_amount, expected", [ - pytest.param("none", 0, 0, (16, "instant_charging", None, 3), id="without limit"), - pytest.param("soc", None, 0, (16, "instant_charging", None, 3), id="limit soc: soc not defined"), - pytest.param("soc", 49, 0, (16, "instant_charging", None, 3), id="limit soc: soc not reached"), - pytest.param("soc", 50, 0, (0, "stop", ChargeTemplate.SOC_REACHED, 3), + pytest.param("none", 0, 0, (16, Chargemode.INSTANT_CHARGING, None, 3), id="without limit"), + pytest.param("soc", None, 0, (16, Chargemode.INSTANT_CHARGING, None, 3), id="limit soc: soc not defined"), + pytest.param("soc", 49, 0, (16, Chargemode.INSTANT_CHARGING, None, 3), id="limit soc: soc not reached"), + pytest.param("soc", 50, 0, (0, Chargemode.STOP, ChargeTemplate.SOC_REACHED, 3), id="limit soc: soc reached"), - pytest.param("amount", 0, 999, (16, "instant_charging", None, 3), id="limit amount: amount not reached"), - pytest.param("amount", 0, 1000, (0, "stop", ChargeTemplate.AMOUNT_REACHED, 3), + pytest.param("amount", 0, 999, (16, Chargemode.INSTANT_CHARGING, + None, 3), id="limit amount: amount not reached"), + pytest.param("amount", 0, 1000, (0, Chargemode.STOP, ChargeTemplate.AMOUNT_REACHED, 3), id="limit amount: amount reached"), ]) def test_instant_charging(selected: str, current_soc: float, used_amount: float, - expected: Tuple[int, str, Optional[str]]): + expected: Tuple[int, Chargemode, Optional[str], int]): # setup ct = ChargeTemplate() ct.data.chargemode.instant_charging.limit.selected = selected @@ -141,21 +145,24 @@ def test_instant_charging(selected: str, current_soc: float, used_amount: float, @pytest.mark.parametrize( "min_soc, min_current, limit_selected, current_soc, used_amount, expected", [ - pytest.param(0, 0, "amount", 14, 1500, (0, "stop", ChargeTemplate.AMOUNT_REACHED, 0), id="max amount reached"), - pytest.param(0, 0, "soc", 100, 900, (0, "stop", ChargeTemplate.SOC_REACHED, 0), id="max soc reached"), - pytest.param(15, 0, None, 14, 900, (10, "instant_charging", ChargeTemplate.PV_CHARGING_SOC_CHARGING, 3), + pytest.param(0, 0, "amount", 14, 1500, (0, Chargemode.STOP, + ChargeTemplate.AMOUNT_REACHED, 0), id="max amount reached"), + pytest.param(0, 0, "soc", 100, 900, (0, Chargemode.STOP, ChargeTemplate.SOC_REACHED, 0), id="max soc reached"), + pytest.param(15, 0, None, 14, 900, + (10, Chargemode.INSTANT_CHARGING, ChargeTemplate.PV_CHARGING_SOC_CHARGING, 3), id="min soc not reached"), - pytest.param(15, 0, None, None, 900, (6, "pv_charging", None, 0), id="soc not defined"), - pytest.param(15, 8, None, 15, 900, (8, "instant_charging", ChargeTemplate.PV_CHARGING_MIN_CURRENT_CHARGING, 0), + pytest.param(15, 0, None, None, 900, (6, Chargemode.PV_CHARGING, None, 0), id="soc not defined"), + pytest.param(15, 8, None, 15, 900, + (8, Chargemode.INSTANT_CHARGING, ChargeTemplate.PV_CHARGING_MIN_CURRENT_CHARGING, 0), id="min current configured"), - pytest.param(15, 0, None, 15, 900, (6, "pv_charging", None, 0), id="bare pv charging"), + pytest.param(15, 0, None, 15, 900, (6, Chargemode.PV_CHARGING, None, 0), id="bare pv charging"), ]) def test_pv_charging(min_soc: int, min_current: int, limit_selected: str, current_soc: float, used_amount: float, - expected: Tuple[int, str, Optional[str], int]): + expected: Tuple[int, Chargemode, Optional[str], int]): # setup ct = ChargeTemplate() ct.data.chargemode.pv_charging.min_soc = min_soc @@ -267,34 +274,35 @@ def test_scheduled_charging_recent_plan(end_time_mock, @pytest.mark.parametrize( "plan_data, soc, used_amount, selected, bidi_charging_enabled, expected", [ - pytest.param(None, 0, 0, "none", False, (0, "stop", + pytest.param(None, 0, 0, "none", False, (0, Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_NO_DATE_PENDING, 3), id="no date pending"), - pytest.param(SelectedPlan(duration=3600), 90, 0, "soc", False, (0, "stop", + pytest.param(SelectedPlan(duration=3600), 90, 0, "soc", False, (0, Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC, 1), id="reached limit soc"), - pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", False, (6, "pv_charging", + pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", False, (6, Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC, 0), id="reached scheduled soc"), - pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", True, (6, "bidi_charging", + pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", True, (6, Chargemode.BIDI_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_BIDI, 3), id="reached scheduled soc, bidi"), - pytest.param(SelectedPlan(phases=3, duration=3600), 0, 1000, "amount", False, (0, "stop", + pytest.param(SelectedPlan(phases=3, duration=3600), 0, 1000, "amount", False, (0, Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_REACHED_AMOUNT, 3), id="reached amount"), pytest.param(SelectedPlan(remaining_time=299, duration=3600), 0, 999, "amount", - False, (14, "instant_charging", ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format( + False, (14, Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format( 14, ChargeTemplate.SCHEDULED_CHARGING_LIMITED_BY_AMOUNT.format(1.0), "07:00"), 1), id="in time, limited by amount"), pytest.param(SelectedPlan(remaining_time=299, duration=3600), 79, 0, "soc", - False, (14, "instant_charging", ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format( + False, (14, Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format( 14, ChargeTemplate.SCHEDULED_CHARGING_LIMITED_BY_SOC.format(80), "07:00"), 1), id="in time, limited by soc"), pytest.param(SelectedPlan(remaining_time=-500, duration=3600, missing_amount=9000, phases=3), 79, 0, "soc", - False, (15.147265077138847, "instant_charging", + False, (15.147265077138847, Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_MAX_CURRENT.format(15.15), 3), id="too late, but didn't miss for today"), pytest.param(SelectedPlan(remaining_time=-800, duration=780, missing_amount=4600, phases=3), 79, 0, "soc", - False, (16, "instant_charging", + False, (16, Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_MAX_CURRENT.format(16), 3), id="few minutes too late, but didn't miss for today"), pytest.param(SelectedPlan(remaining_time=601, duration=3600), 79, 0, "soc", - False, (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), + False, + (6, Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), id="too early, use pv"), ]) def test_scheduled_charging_calc_current(plan_data: SelectedPlan, @@ -302,7 +310,7 @@ def test_scheduled_charging_calc_current(plan_data: SelectedPlan, used_amount: float, selected: str, bidi_charging_enabled: bool, - expected: Tuple[float, str, str, int]): + expected: Tuple[float, Chargemode, str, int]): # setup ct = ChargeTemplate() plan = ScheduledChargingPlan(active=True, id=0) @@ -330,28 +338,28 @@ def test_scheduled_charging_calc_current_no_plans(): None, 63, 5, 3, 3, 6, 0, ChargingType.AC.value, EvTemplate(), BidiState.BIDI_CAPABLE, True) # evaluation - assert ret == (0, "stop", ChargeTemplate.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED, 3) + assert ret == (0, Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED, 3) @pytest.mark.parametrize( "selected_limit, charge_state, expected", [ pytest.param("soc", False, - (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), + (6, Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), id="soc request interval not considered, use pv"), pytest.param("soc", True, - (14, "instant_charging", + (14, Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format(14, 'einen SoC von 80%', "07:00"), 3), id="soc request interval considered"), pytest.param("amount", True, - (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), + (6, Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), id="amount, charging, soc request interval not considered, no soc limit configured"), pytest.param("amount", False, - (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), + (6, Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), id="amount, not charging, soc request interval not considered, no soc limit configured"), ]) def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( - selected_limit: str, charge_state: bool, expected: Tuple[float, str, str, int]): + selected_limit: str, charge_state: bool, expected: Tuple[float, Chargemode, str, int]): # setup ct = ChargeTemplate() plan = ScheduledChargingPlan() @@ -379,7 +387,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(True, 79, 80, 90, LOADING_HOURS_TODAY + LOADING_HOURS_TOMORROW, ( 14, - "instant_charging", + Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_CHEAP_HOUR.format( "Geladen wird jetzt sowie morgen 8:00."), 3), @@ -387,7 +395,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(True, 79, 80, 70, LOADING_HOURS_TODAY, ( 14, - "instant_charging", + Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_CHEAP_HOUR.format( "Geladen wird jetzt."), 3), @@ -395,7 +403,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(False, 79, 80, 90, LOADING_HOURS_TODAY, ( 6, - "pv_charging", + Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format( "Geladen wird heute 8:00."), 0), @@ -403,7 +411,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(False, 79, 80, 70, LOADING_HOURS_TODAY, ( 0, - "stop", + Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format( "Geladen wird heute 8:00."), 3), @@ -411,7 +419,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(False, 79, 80, 70, LOADING_HOURS_TODAY + LOADING_HOURS_TOMORROW, ( 0, - "stop", + Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format( "Geladen wird heute 8:00 sowie morgen 8:00."), 3), @@ -419,7 +427,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(False, 79, 60, 80, LOADING_HOURS_TODAY, ( 6, - "pv_charging", + Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC.format( ""), 0), @@ -427,7 +435,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(False, 79, 60, 50, LOADING_HOURS_TODAY, ( 0, - "stop", + Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC.format( ""), 3), diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index fab9167328..16af34971d 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -6,6 +6,7 @@ stärke wird auch geprüft, ob sich an diesen Parametern etwas geändert hat. Falls ja, muss das EV in der Regelung neu priorisiert werden und eine neue Zuteilung des Stroms erhalten. """ +from control.chargemode import Chargemode from modules.common.configurable_vehicle import ConfigurableVehicle from modules.common.abstract_vehicle import VehicleUpdateData from helpermodules.constants import DEFAULT_COLORS, NO_ERROR @@ -125,7 +126,7 @@ def get_required_current(self, charging_type: str, imported_since_plugged: float, bidi: BidiState, - charge_state: bool) -> Tuple[bool, Optional[str], str, float, int]: + charge_state: bool) -> Tuple[bool, Optional[str], Chargemode, float, int]: """ ermittelt, ob und mit welchem Strom das EV geladen werden soll (unabhängig vom Lastmanagement) Parameter @@ -150,7 +151,7 @@ def get_required_current(self, tmp_message = None state = True try: - if charge_template.data.chargemode.selected == "stop": + if charge_template.data.chargemode.selected == Chargemode.STOP.value: required_current, submode, message = charge_template.stop() phases = control_parameter.phases or max_phases_hw else: @@ -160,7 +161,7 @@ def get_required_current(self, soc_request_interval_offset = self.soc_module.general_config.request_interval_charging else: soc_request_interval_offset = 0 - if charge_template.data.chargemode.selected == "scheduled_charging": + if charge_template.data.chargemode.selected == Chargemode.SCHEDULED_CHARGING.value: required_current, submode, tmp_message, phases = charge_template.scheduled_charging( self.data.get.soc, self.ev_template, @@ -175,7 +176,7 @@ def get_required_current(self, message = f"{tmp_message or ''}".strip() # Wenn Zielladen auf Überschuss wartet, prüfen, ob Zeitladen aktiv ist. - if (submode != "instant_charging" and + if (submode != Chargemode.INSTANT_CHARGING and charge_template.data.time_charging.active): tmp_current, tmp_submode, tmp_message, plan_id, tmp_phases = charge_template.time_charging( self.data.get.soc, @@ -190,21 +191,21 @@ def get_required_current(self, submode = tmp_submode phases = tmp_phases if (required_current == 0) or (required_current is None): - if charge_template.data.chargemode.selected == "instant_charging": + if charge_template.data.chargemode.selected == Chargemode.INSTANT_CHARGING.value: required_current, submode, tmp_message, phases = charge_template.instant_charging( self.data.get.soc, imported_since_plugged, charging_type) - elif charge_template.data.chargemode.selected == "pv_charging": + elif charge_template.data.chargemode.selected == Chargemode.PV_CHARGING.value: required_current, submode, tmp_message, phases = charge_template.pv_charging( self.data.get.soc, control_parameter.min_current, charging_type, imported_since_plugged) - elif charge_template.data.chargemode.selected == "eco_charging": + elif charge_template.data.chargemode.selected == Chargemode.ECO_CHARGING.value: required_current, submode, tmp_message, phases = charge_template.eco_charging( self.data.get.soc, control_parameter, charging_type, imported_since_plugged, max_phases_hw) else: tmp_message = None message = f"{message or ''} {tmp_message or ''}".strip() - if submode == "stop" or (charge_template.data.chargemode.selected == "stop"): + if submode == Chargemode.STOP or (charge_template.data.chargemode.selected == Chargemode.STOP.value): state = False if phases is None: log.debug("Keine Phasenvorgabe durch Lademodus. Behalte Phasenzahl bei.") @@ -212,7 +213,7 @@ def get_required_current(self, return state, message, submode, required_current, phases except Exception as e: log.exception("Fehler im ev-Modul "+str(self.num)) - return (False, f"Kein Ladevorgang, da ein Fehler aufgetreten ist: {' '.join(e.args)}", "stop", 0, + return (False, f"Kein Ladevorgang, da ein Fehler aufgetreten ist: {' '.join(e.args)}", Chargemode.STOP, 0, control_parameter.phases) def check_min_max_current(self, diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 884ad3e0f4..0f03416ed7 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 @@ -246,6 +247,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: From b26677ab64eb4282d13b6e47a109429bacf64a59 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 19 Aug 2026 16:06:16 +0200 Subject: [PATCH 02/84] fix --- packages/control/chargepoint/chargepoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index 5975ae32ab..6a4beff437 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -606,7 +606,7 @@ def set_chargemode_changed(self, submode: Chargemode) -> None: 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 != self.data.set.charge_template.data.chargemode.selected)): + self.data.control_parameter.chargemode != Chargemode(self.data.set.charge_template.data.chargemode.selected))): self.chargemode_changed = True log.debug("Änderung des Lademodus") self.data.control_parameter.timestamp_chargemode_changed = create_timestamp() From fa1e001d5c0ceffccfc44b85c1afc0f6f779fab6 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 19 Aug 2026 16:10:21 +0200 Subject: [PATCH 03/84] flake8 --- packages/control/chargepoint/chargepoint.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index 6a4beff437..014fd87042 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -606,7 +606,8 @@ def set_chargemode_changed(self, submode: Chargemode) -> None: 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 != Chargemode(self.data.set.charge_template.data.chargemode.selected))): + self.data.control_parameter.chargemode != Chargemode( + self.data.set.charge_template.data.chargemode.selected))): self.chargemode_changed = True log.debug("Änderung des Lademodus") self.data.control_parameter.timestamp_chargemode_changed = create_timestamp() From b2c867db446a147089eb48498672e53e0810da69 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 19 Aug 2026 16:10:35 +0200 Subject: [PATCH 04/84] flake8 --- packages/control/chargepoint/chargepoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index 014fd87042..0ba184310e 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -603,11 +603,11 @@ def set_timestamp_charge_start(self): self.data.control_parameter.timestamp_charge_start = None def set_chargemode_changed(self, submode: Chargemode) -> None: + selected_chargemode = Chargemode(self.data.set.charge_template.data.chargemode.selected) 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 != Chargemode( - self.data.set.charge_template.data.chargemode.selected))): + self.data.control_parameter.chargemode != selected_chargemode)): self.chargemode_changed = True log.debug("Änderung des Lademodus") self.data.control_parameter.timestamp_chargemode_changed = create_timestamp() From 0e9aca32df017fb8f6e77326a05080069e8c3862 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Thu, 20 Aug 2026 09:38:02 +0200 Subject: [PATCH 05/84] fix amount --- packages/control/ev/charge_template.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index fee8e20d78..435f809fe3 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -306,8 +306,7 @@ def eco_charging(self, current = 0 sub_mode = ChargemodeEnum.STOP message = self.SOC_REACHED - elif (eco_charging.limit.selected == "amount" and - used_amount >= self.data.chargemode.instant_charging.limit.amount): + elif eco_charging.limit.selected == "amount" and used_amount >= eco_charging.limit.amount: current = 0 sub_mode = ChargemodeEnum.STOP message = self.AMOUNT_REACHED From 448212de06ddc747bab9e73515eb2bee5d28174d Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Thu, 20 Aug 2026 10:08:58 +0200 Subject: [PATCH 06/84] use enum also for chargemode selected --- packages/control/chargepoint/chargepoint.py | 10 ++++------ packages/control/chargepoint/chargepoint_data.py | 3 ++- packages/control/ev/charge_template.py | 2 +- packages/control/ev/ev.py | 12 ++++++------ 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index 0ba184310e..9f90092234 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -231,8 +231,7 @@ def set_control_parameter(self, submode: Chargemode): if submode == Chargemode.TIME_CHARGING: self.data.control_parameter.chargemode = Chargemode.TIME_CHARGING else: - self.data.control_parameter.chargemode = Chargemode( - self.data.set.charge_template.data.chargemode.selected) + self.data.control_parameter.chargemode = self.data.set.charge_template.data.chargemode.selected self.data.control_parameter.prio = self.data.set.charge_template.data.prio if self.template.data.charging_type == ChargingType.AC.value: self.data.control_parameter.min_current = self.data.set.charging_ev_data.ev_template.data.min_current @@ -603,11 +602,10 @@ def set_timestamp_charge_start(self): self.data.control_parameter.timestamp_charge_start = None def set_chargemode_changed(self, submode: Chargemode) -> None: - selected_chargemode = Chargemode(self.data.set.charge_template.data.chargemode.selected) 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 != selected_chargemode)): + self.data.control_parameter.chargemode != self.data.set.charge_template.data.chargemode.selected)): self.chargemode_changed = True log.debug("Änderung des Lademodus") self.data.control_parameter.timestamp_chargemode_changed = create_timestamp() @@ -809,8 +807,8 @@ def _pub_connected_vehicle(self, vehicle: Ev): self.data.get.connected_vehicle.soc.range = vehicle.data.get.range self.data.get.connected_vehicle.info = ConnectedInfo(id=vehicle.num, name=vehicle.data.name) - if (self.data.set.charge_template.data.chargemode.selected == Chargemode.TIME_CHARGING.value or - self.data.set.charge_template.data.chargemode.selected == Chargemode.SCHEDULED_CHARGING.value): + if (self.data.set.charge_template.data.chargemode.selected == Chargemode.TIME_CHARGING or + self.data.set.charge_template.data.chargemode.selected == Chargemode.SCHEDULED_CHARGING): current_plan = self.data.control_parameter.current_plan else: current_plan = None diff --git a/packages/control/chargepoint/chargepoint_data.py b/packages/control/chargepoint/chargepoint_data.py index 0f41753cfb..429d20bdc3 100644 --- a/packages/control/chargepoint/chargepoint_data.py +++ b/packages/control/chargepoint/chargepoint_data.py @@ -1,6 +1,7 @@ from dataclasses import dataclass, field from threading import Event from typing import Dict, List, Optional, Protocol +from control.chargemode import Chargemode from control.chargepoint.chargepoint_template import CpTemplate from control.chargepoint.control_parameter import ControlParameter, control_parameter_factory @@ -38,7 +39,7 @@ class ConnectedInfo: class ConnectedConfig: average_consumption: float = 17 charge_template: int = 0 - chargemode: str = "stop" + chargemode: Chargemode = Chargemode.STOP current_plan: Optional[int] = 0 ev_template: int = 0 priority: bool = False diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index 435f809fe3..6c53430dce 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -92,7 +92,7 @@ def instant_charging_factory() -> InstantCharging: @dataclass class Chargemode: - selected: str = ChargemodeEnum.INSTANT_CHARGING.value + selected: ChargemodeEnum = ChargemodeEnum.INSTANT_CHARGING eco_charging: EcoCharging = field(default_factory=eco_charging_factory) pv_charging: PvCharging = field(default_factory=pv_charging_factory) scheduled_charging: ScheduledCharging = field(default_factory=scheduled_charging_factory) diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index 16af34971d..c206812cd6 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -151,7 +151,7 @@ def get_required_current(self, tmp_message = None state = True try: - if charge_template.data.chargemode.selected == Chargemode.STOP.value: + if charge_template.data.chargemode.selected == Chargemode.STOP: required_current, submode, message = charge_template.stop() phases = control_parameter.phases or max_phases_hw else: @@ -161,7 +161,7 @@ def get_required_current(self, soc_request_interval_offset = self.soc_module.general_config.request_interval_charging else: soc_request_interval_offset = 0 - if charge_template.data.chargemode.selected == Chargemode.SCHEDULED_CHARGING.value: + if charge_template.data.chargemode.selected == Chargemode.SCHEDULED_CHARGING: required_current, submode, tmp_message, phases = charge_template.scheduled_charging( self.data.get.soc, self.ev_template, @@ -191,21 +191,21 @@ def get_required_current(self, submode = tmp_submode phases = tmp_phases if (required_current == 0) or (required_current is None): - if charge_template.data.chargemode.selected == Chargemode.INSTANT_CHARGING.value: + if charge_template.data.chargemode.selected == Chargemode.INSTANT_CHARGING: required_current, submode, tmp_message, phases = charge_template.instant_charging( self.data.get.soc, imported_since_plugged, charging_type) - elif charge_template.data.chargemode.selected == Chargemode.PV_CHARGING.value: + elif charge_template.data.chargemode.selected == Chargemode.PV_CHARGING: required_current, submode, tmp_message, phases = charge_template.pv_charging( self.data.get.soc, control_parameter.min_current, charging_type, imported_since_plugged) - elif charge_template.data.chargemode.selected == Chargemode.ECO_CHARGING.value: + elif charge_template.data.chargemode.selected == Chargemode.ECO_CHARGING: required_current, submode, tmp_message, phases = charge_template.eco_charging( self.data.get.soc, control_parameter, charging_type, imported_since_plugged, max_phases_hw) else: tmp_message = None message = f"{message or ''} {tmp_message or ''}".strip() - if submode == Chargemode.STOP or (charge_template.data.chargemode.selected == Chargemode.STOP.value): + if submode == Chargemode.STOP or (charge_template.data.chargemode.selected == Chargemode.STOP): state = False if phases is None: log.debug("Keine Phasenvorgabe durch Lademodus. Behalte Phasenzahl bei.") From e2b69866602c208a6df4faade4ffdf7498d36af6 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:55:08 +0200 Subject: [PATCH 07/84] fix asdict import (#3847) * use chargemode enums for chargepoints * fix * flake8 * flake8 * fix amount * use enum also for chargemode selected * fix asdict import * review --- packages/control/chargepoint/chargepoint.py | 6 ++-- .../chargepoint/chargepoint_template.py | 3 +- packages/control/ev/charge_template.py | 3 +- packages/dataclass_utils/_dataclass_asdict.py | 14 ++++++--- .../helpermodules/changed_values_handler.py | 2 +- .../changed_values_handler_test.py | 3 +- packages/helpermodules/command.py | 31 +++++++++---------- packages/helpermodules/setdata.py | 3 +- packages/helpermodules/update_config.py | 24 +++++++------- .../backup_clouds/nextcloud/backup_cloud.py | 2 +- packages/modules/devices/avm/avm/device.py | 2 +- packages/modules/io_devices/eebus/api.py | 2 +- 12 files changed, 48 insertions(+), 47 deletions(-) diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index 9f90092234..4b0252c254 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -1,5 +1,3 @@ -from dataclasses import asdict -import dataclasses import logging from threading import Thread, Event import traceback @@ -21,6 +19,7 @@ from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState from control.limiting_value import loadmanagement_limit_factory from control.text import BidiState +from dataclass_utils import asdict from helpermodules.constants import DEFAULT_COLORS from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase from helpermodules.pub import Pub @@ -781,8 +780,7 @@ def _get_charging_ev(self, vehicle: int, ev_list: Dict[str, Ev]) -> Ev: def update_charge_template(self, charge_template: ChargeTemplate) -> None: # Prüfen, ob ein temporäres Ladeprofil aktiv ist und dieses übernehmen self.data.set.charge_template = charge_template - Pub().pub(f"openWB/set/chargepoint/{self.num}/set/charge_template", - dataclasses.asdict(charge_template.data)) + Pub().pub(f"openWB/set/chargepoint/{self.num}/set/charge_template", asdict(charge_template.data)) def _pub_connected_vehicle(self, vehicle: Ev): """ published die Daten, die zur Anzeige auf der Hauptseite benötigt werden. diff --git a/packages/control/chargepoint/chargepoint_template.py b/packages/control/chargepoint/chargepoint_template.py index b8cf05ba40..fd10a30e85 100644 --- a/packages/control/chargepoint/chargepoint_template.py +++ b/packages/control/chargepoint/chargepoint_template.py @@ -1,4 +1,4 @@ -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field import logging import traceback from typing import List, Tuple @@ -6,6 +6,7 @@ from control import data from control.ev import ev as ev_module from control.chargepoint.charging_type import ChargingType +from dataclass_utils import asdict from dataclass_utils.factories import empty_list_factory from helpermodules.abstract_plans import AutolockPlan from helpermodules import timecheck diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index 6c53430dce..15ccf6af15 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -1,4 +1,4 @@ -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field import datetime import logging import traceback @@ -11,6 +11,7 @@ from control.chargepoint.control_parameter import ControlParameter from control.ev.ev_template import EvTemplate from control.text import BidiState +from dataclass_utils import asdict from dataclass_utils.factories import empty_list_factory from helpermodules.abstract_plans import Limit, limit_factory, ScheduledChargingPlan, TimeChargingPlan from helpermodules import timecheck diff --git a/packages/dataclass_utils/_dataclass_asdict.py b/packages/dataclass_utils/_dataclass_asdict.py index 5d43362065..af13a567ca 100644 --- a/packages/dataclass_utils/_dataclass_asdict.py +++ b/packages/dataclass_utils/_dataclass_asdict.py @@ -1,11 +1,15 @@ from enum import Enum import logging +from typing import Any, Dict, List, Union, cast log = logging.getLogger(__name__) -def asdict(value): +AsDictValue = Union[None, bool, int, float, str, List["AsDictValue"], Dict[Any, "AsDictValue"]] + + +def asdict(value: Any) -> AsDictValue: """Converts an object to a dict This function is a simple replacement for the `dataclasses.asdict` function introduced in Python 3.7. This function @@ -17,7 +21,9 @@ def asdict(value): if isinstance(value, Enum): return value.value if isinstance(value, (list, tuple)): - return [None if v is None else asdict(v) for v in value] + sequence = cast(List[Any], value) + return [None if item is None else asdict(item) for item in sequence] if not isinstance(value, dict): - value = vars(value) - return {key: None if value is None else asdict(value) for key, value in value.items()} + value = vars(cast(object, value)) + mapping = cast(Dict[Any, Any], value) + return {key: None if item is None else asdict(item) for key, item in mapping.items()} diff --git a/packages/helpermodules/changed_values_handler.py b/packages/helpermodules/changed_values_handler.py index e191f5851d..5692cf5762 100644 --- a/packages/helpermodules/changed_values_handler.py +++ b/packages/helpermodules/changed_values_handler.py @@ -6,7 +6,7 @@ from control import data from control.data import Data -from dataclass_utils._dataclass_asdict import asdict +from dataclass_utils import asdict from helpermodules.pub import Pub diff --git a/packages/helpermodules/changed_values_handler_test.py b/packages/helpermodules/changed_values_handler_test.py index 189912e814..48c13a4ba3 100644 --- a/packages/helpermodules/changed_values_handler_test.py +++ b/packages/helpermodules/changed_values_handler_test.py @@ -1,10 +1,11 @@ -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field from enum import IntEnum from typing import Dict, List, Optional, Tuple from unittest.mock import Mock import pytest +from dataclass_utils import asdict from dataclass_utils.factories import currents_list_factory from helpermodules.changed_values_handler import ChangedValuesHandler diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 2b429f135e..e14fd561cc 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -1,5 +1,4 @@ import copy -from dataclasses import asdict import importlib import json import logging @@ -13,12 +12,14 @@ from pathlib import Path import paho.mqtt.client as mqtt +from control import bat, bridge, counter, counter_all, pv from control.chargelog.process_chargelog import get_log_data from control.chargepoint import chargepoint from control.chargepoint.chargepoint_template import get_chargepoint_template_default - +from control.ev import ev from control.ev.charge_template import ChargeTemplate, get_new_charge_template from control.ev.ev_template import EvTemplateData +from dataclass_utils import asdict from helpermodules import pub from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan from helpermodules.utils.run_command import run_command @@ -39,12 +40,9 @@ from helpermodules.pub import Pub, pub_single from helpermodules.subdata import SubData from helpermodules.utils.topic_parser import decode_payload, get_index -from control import bat, bridge, counter, counter_all, pv -from control.ev import ev from modules.chargepoints.internal_openwb.chargepoint_module import ChargepointModule from modules.chargepoints.internal_openwb.config import InternalChargepointMode from modules.common.component_type import ComponentType, special_to_general_type_mapping, type_to_topic_mapping -import dataclass_utils from modules.common.configurable_vehicle import GeneralVehicleConfig @@ -176,7 +174,7 @@ def addDevice(self, connection_id: str, payload: dict) -> None: dev = importlib.import_module(f'.devices.{payload["data"]["vendor"]}' f'.{payload["data"]["type"]}.device', "modules") - device_default = dataclass_utils.asdict(dev.device_descriptor.configuration_factory()) + device_default = asdict(dev.device_descriptor.configuration_factory()) device_default["id"] = new_id Pub().pub(f'openWB/set/system/device/{new_id}/config', device_default) self.max_id_device = self.max_id_device + 1 @@ -204,7 +202,7 @@ def addIoAction(self, connection_id: str, payload: dict) -> None: dev = importlib.import_module(f".io_actions.{'.'.join(payload['data']['type'])}.api", "modules") descriptor = dev.device_descriptor.configuration_factory() - device_default = dataclass_utils.asdict(descriptor) + device_default = asdict(descriptor) device_default["id"] = new_id Pub().pub(f'openWB/set/io/action/{new_id}/config', device_default) self.max_id_io_action = new_id @@ -238,7 +236,7 @@ def addIoDevice(self, connection_id: str, payload: dict) -> None: new_id = self.max_id_io_device + 1 dev = importlib.import_module(".io_devices."+payload["data"]["type"]+".api", "modules") descriptor = dev.device_descriptor.configuration_factory() - device_default = dataclass_utils.asdict(descriptor) + device_default = asdict(descriptor) device_default["id"] = new_id Pub().pub(f'openWB/set/system/io/{new_id}/config', device_default) self.max_id_io_device = new_id @@ -279,7 +277,7 @@ def setup_added_chargepoint(): Pub().pub(f'openWB/chargepoint/{new_id}/config', chargepoint_config) {Pub().pub(f"openWB/chargepoint/{new_id}/get/"+k, v) for (k, v) in asdict(chargepoint.Get()).items()} charge_template = SubData.ev_charge_template_data[f"ct{SubData.ev_data['ev0'].data.charge_template}"] - charge_template = dataclass_utils.asdict(charge_template.data) + charge_template = asdict(charge_template.data) Pub().pub(f'openWB/chargepoint/{new_id}/set/charge_template', charge_template) Pub().pub(f'openWB/chargepoint/{new_id}/set/manual_lock', False) self.max_id_hierarchy = self.max_id_hierarchy + 1 @@ -300,7 +298,7 @@ def setup_added_chargepoint(): chargepoint_config = chargepoint.get_chargepoint_config_default() # chargepoint_default["id"] = new_id module = importlib.import_module(".chargepoints." + payload["data"]["type"] + ".chargepoint_module", "modules") - chargepoint_config.update(dataclass_utils.asdict( + chargepoint_config.update(asdict( module.chargepoint_descriptor.configuration_factory()).items()) check_num_msg = self._check_max_num_of_internal_chargepoints(chargepoint_config) if check_num_msg is not None: @@ -491,7 +489,7 @@ def removeAutolockPlan(self, connection_id: str, payload: dict) -> None: break Pub().pub( f'openWB/set/chargepoint/template/{payload["data"]["template"]}', - dataclass_utils.asdict(SubData.cp_template_data[f'cpt{payload["data"]["template"]}'].data)) + asdict(SubData.cp_template_data[f'cpt{payload["data"]["template"]}'].data)) pub_user_message( payload, connection_id, f'Plan für Sperren nach Uhrzeit mit ID \'{payload["data"]["plan"]}\' vom Profil ' @@ -563,11 +561,11 @@ def _pub_charge_template_to_source(self, payload: dict, charge_template: ChargeT if payload["data"]["changed_in_theme"]: Pub().pub( f'openWB/set/chargepoint/{payload["data"]["chargepoint"]}/set/charge_template', - dataclass_utils.asdict(charge_template.data)) + asdict(charge_template.data)) else: Pub().pub( f'openWB/set/vehicle/template/charge_template/{payload["data"]["template"]}', - dataclass_utils.asdict(charge_template.data)) + asdict(charge_template.data)) def addChargeTemplateSchedulePlan(self, connection_id: str, payload: dict) -> None: """ sendet das Topic, zu dem ein neuer Zielladen-Plan erstellt werden soll. @@ -667,7 +665,7 @@ def set_default(topic: str, defaults: dict): component = importlib.import_module(f'.devices.{payload["data"]["deviceVendor"]}' f'.{payload["data"]["deviceType"]}.{payload["data"]["type"]}', "modules") - component_default = dataclass_utils.asdict(component.component_descriptor.configuration_factory()) + component_default = asdict(component.component_descriptor.configuration_factory()) component_default["id"] = new_id general_type = special_to_general_type_mapping(payload["data"]["type"]) try: @@ -729,7 +727,7 @@ def addEvTemplate(self, connection_id: str, payload: dict) -> None: new_ev_template = asdict(SubData.ev_template_data[f"et{payload['data']['copy']}"].data).copy() new_ev_template["name"] = f'Kopie von {new_ev_template["name"]}' else: - new_ev_template = dataclass_utils.asdict(EvTemplateData()) + new_ev_template = asdict(EvTemplateData()) new_ev_template["name"] = f'{new_ev_template["name"]} {new_id}' new_ev_template["id"] = new_id self.max_id_ev_template = new_id @@ -763,8 +761,7 @@ def addVehicle(self, connection_id: str, payload: dict) -> None: for default in vehicle_default: Pub().pub(f"openWB/set/vehicle/{new_id}/{default}", vehicle_default[default]) Pub().pub(f"openWB/set/vehicle/{new_id}/soc_module/config", {"type": None, "configuration": {}}) - Pub().pub(f"openWB/set/vehicle/{new_id}/soc_module/general_config", - dataclass_utils.asdict(GeneralVehicleConfig())) + Pub().pub(f"openWB/set/vehicle/{new_id}/soc_module/general_config", asdict(GeneralVehicleConfig())) self.max_id_vehicle = self.max_id_vehicle + 1 Pub().pub("openWB/set/command/max_id/vehicle", self.max_id_vehicle) # Default-Mäßig werden die Profile 0 zugewiesen, wenn diese noch nicht existieren -> anlegen diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 7c03072896..09fe9a97a6 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -2,7 +2,6 @@ """ import copy -import dataclasses from pathlib import Path from threading import Event from typing import List, Optional, Tuple @@ -170,7 +169,7 @@ def _validate_value(self, msg: mqtt.MQTTMessage, data_type, ranges=[], collectio elif re.search("^openWB/set/chargepoint/[1-9][0-9]*/config.*$", msg.topic) is not None: event = self.event_cp_config if "cp"+str(index) in subdata.SubData.cp_data: - template = dataclasses.asdict( + template = dataclass_utils.asdict( subdata.SubData.cp_data["cp"+str(index)].chargepoint.data.config) else: template = {} diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index c35ba3b974..a8f1fb7d7e 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -1,6 +1,5 @@ from concurrent.futures import ProcessPoolExecutor import copy -from dataclasses import asdict import datetime import glob import importlib @@ -13,7 +12,6 @@ from paho.mqtt.client import Client as MqttClient, MQTTMessage from control.limiting_value import LoadmanagementLimit -import dataclass_utils from control.chargepoint.chargepoint_template import get_chargepoint_template_default from helpermodules import timecheck @@ -43,6 +41,7 @@ from control.ev.ev_template import EvTemplateData from control.general import Prices, PvCharging from control.optional_data import OcppConfig +from dataclass_utils import asdict from modules.common.abstract_vehicle import GeneralVehicleConfig from modules.common.component_type import ComponentType from modules.devices.sungrow.sungrow_sh.version import Version @@ -594,7 +593,7 @@ class UpdateConfig: ("openWB/vehicle/0/info", {"manufacturer": None, "model": None}), ("openWB/vehicle/0/charge_template", ev.Ev(0).charge_template.data.id), ("openWB/vehicle/0/soc_module/config", NO_MODULE), - ("openWB/vehicle/0/soc_module/general_config", dataclass_utils.asdict(GeneralVehicleConfig())), + ("openWB/vehicle/0/soc_module/general_config", asdict(GeneralVehicleConfig())), ("openWB/vehicle/0/ev_template", ev.Ev(0).ev_template.data.id), ("openWB/vehicle/0/tag_id", ev.Ev(0).data.tag_id), ("openWB/vehicle/0/get/soc", ev.Ev(0).data.get.soc), @@ -632,7 +631,7 @@ class UpdateConfig: ("openWB/general/prices/pv", Prices().pv), ("openWB/general/range_unit", "km"), ("openWB/general/temporary_charge_templates_active", False), - ("openWB/general/web_theme", dataclass_utils.asdict(KoalaWebTheme())), + ("openWB/general/web_theme", asdict(KoalaWebTheme())), ("openWB/graph/config/duration", 120), ("openWB/internal_chargepoint/0/data/parent_cp", None), ("openWB/internal_chargepoint/1/data/parent_cp", None), @@ -645,10 +644,10 @@ class UpdateConfig: ("openWB/optional/int_display/pin_code", "0000"), ("openWB/optional/int_display/standby", 60), ("openWB/optional/int_display/rotation", 0), - ("openWB/optional/int_display/theme", dataclass_utils.asdict(CardsDisplayTheme())), + ("openWB/optional/int_display/theme", asdict(CardsDisplayTheme())), ("openWB/optional/int_display/only_local_charge_points", False), ("openWB/optional/monitoring/config", NO_MODULE), - ("openWB/optional/ocpp/config", dataclass_utils.asdict(OcppConfig())), + ("openWB/optional/ocpp/config", asdict(OcppConfig())), ("openWB/optional/rfid/active", False), ("openWB/pv/config/configured", False), ("openWB/system/backup_password", None), @@ -1095,8 +1094,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: if re.search("openWB/vehicle/[0-9]+/soc_module/config", topic) is not None: payload = decode_payload(payload) index = get_index(topic) - return {f"openWB/set/vehicle/{index}/soc_module/interval_config": - dataclass_utils.asdict(GeneralVehicleConfig())} + return {f"openWB/set/vehicle/{index}/soc_module/interval_config": asdict(GeneralVehicleConfig())} self._loop_all_received_topics(upgrade) self._append_datastore_version(12) @@ -2179,15 +2177,15 @@ def upgrade(topic: str, payload) -> Optional[dict]: {"value": 0, "input_matrix": {"RSE1": True, "RSE2": False}}, {"value": 0, "input_matrix": {"RSE1": True, "RSE2": True}}] - return {'openWB/system/io/0/config': dataclass_utils.asdict(io_device), - 'openWB/io/action/0/config': dataclass_utils.asdict(action)} + return {'openWB/system/io/0/config': asdict(io_device), + 'openWB/io/action/0/config': asdict(action)} self._loop_all_received_topics(upgrade) self._append_datastore_version(75) def upgrade_datastore_76(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: if re.search("openWB/chargepoint/[0-9]+/control_parameter/limit", topic) is not None: - return {topic: dataclass_utils.asdict(LoadmanagementLimit(None, None))} + return {topic: asdict(LoadmanagementLimit(None, None))} self._loop_all_received_topics(upgrade) self._append_datastore_version(76) @@ -2299,7 +2297,7 @@ def get_new_phases_to_use(topic) -> int: payload = decode_payload(payload) charge_template = copy.deepcopy(payload) - charge_template["chargemode"]["eco_charging"] = dataclass_utils.asdict(EcoCharging()) + charge_template["chargemode"]["eco_charging"] = asdict(EcoCharging()) if payload["chargemode"]["selected"] == "standby": charge_template["chargemode"]["selected"] = "stop" if payload["et"]["active"] is True: @@ -2315,7 +2313,7 @@ def get_new_phases_to_use(topic) -> int: charge_template["chargemode"]["pv_charging"]["phases_to_use"] = get_new_phases_to_use( "openWB/general/chargemode_config/pv_charging/phases_to_use") charge_template["chargemode"]["pv_charging"]["phases_to_use_min_soc"] = min(max_phases_ev, 3) - charge_template["chargemode"]["pv_charging"]["limit"] = dataclass_utils.asdict(Limit()) + charge_template["chargemode"]["pv_charging"]["limit"] = asdict(Limit()) if payload["chargemode"]["pv_charging"]["max_soc"] == 101: charge_template["chargemode"]["pv_charging"]["limit"]["selected"] = "none" else: diff --git a/packages/modules/backup_clouds/nextcloud/backup_cloud.py b/packages/modules/backup_clouds/nextcloud/backup_cloud.py index 9a8af0693b..f42be2a166 100644 --- a/packages/modules/backup_clouds/nextcloud/backup_cloud.py +++ b/packages/modules/backup_clouds/nextcloud/backup_cloud.py @@ -3,7 +3,7 @@ import re from typing import Callable, List, Tuple -from dataclass_utils._dataclass_asdict import asdict +from dataclass_utils import asdict from helpermodules.pub import Pub from modules.backup_clouds.nextcloud.config import NextcloudBackupCloud, NextcloudBackupCloudConfiguration from modules.common import req diff --git a/packages/modules/devices/avm/avm/device.py b/packages/modules/devices/avm/avm/device.py index db1fb7c51a..4c370a9ac0 100644 --- a/packages/modules/devices/avm/avm/device.py +++ b/packages/modules/devices/avm/avm/device.py @@ -5,7 +5,7 @@ from typing import Iterable import xml.etree.ElementTree as ET -from dataclass_utils._dataclass_asdict import asdict +from dataclass_utils import asdict from helpermodules.pub import Pub from modules.common import req from modules.common.abstract_device import DeviceDescriptor diff --git a/packages/modules/io_devices/eebus/api.py b/packages/modules/io_devices/eebus/api.py index 12b8a68b8d..f212d6bc30 100644 --- a/packages/modules/io_devices/eebus/api.py +++ b/packages/modules/io_devices/eebus/api.py @@ -7,7 +7,7 @@ from typing import Optional from control import data -from dataclass_utils._dataclass_asdict import asdict +from dataclass_utils import asdict from helpermodules import timecheck from helpermodules.broker import BrokerClient from helpermodules.pub import Pub From 2f13bc23fd4edb815fc0bd058fd9bf3cd7b04799 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 3 Aug 2026 13:42:51 +0200 Subject: [PATCH 08/84] untested --- .../public/default-dynamic-security.json | 74 +++++++++------- packages/control/algorithm/algorithm.py | 1 - .../algorithm/integration_test/conftest.py | 2 +- .../control/algorithm/surplus_controlled.py | 17 ++-- .../algorithm/surplus_controlled_test.py | 42 +++------ packages/control/auto_phase_switch_test.py | 4 +- packages/control/bat_all.py | 28 +++--- packages/control/bat_all_test.py | 58 +++++++------ packages/control/chargepoint/chargepoint.py | 4 +- .../control/chargepoint/chargepoint_test.py | 2 +- .../control/chargepoint/get_phases_test.py | 2 +- packages/control/counter.py | 87 +++++++++---------- packages/control/counter_test.py | 7 +- packages/control/ev/charge_template.py | 1 - packages/control/ev/ev.py | 15 ++-- packages/control/general.py | 72 +++++++++------ packages/control/loadmanagement.py | 18 ++-- packages/control/loadmanagement_test.py | 2 +- packages/helpermodules/create_debug.py | 40 ++++----- packages/helpermodules/setdata.py | 31 +++---- packages/helpermodules/update_config.py | 66 +++++++------- .../display_themes/cards/source/src/App.vue | 2 +- .../components/Battery/BatteryModeModal.vue | 4 +- .../cards/source/src/stores/mqtt.js | 12 +-- .../source/src/views/ChargePointsView.vue | 47 ---------- .../chargePointList/configPanels/ConfigPv.vue | 7 -- .../src/components/chargePointList/model.ts | 10 --- .../chargePointList/cpConfig/ConfigPv.vue | 9 -- .../src/components/chargePointList/model.ts | 11 --- .../src/components/ChargePointPvSettings.vue | 4 +- .../source/src/stores/mqtt-store-model.ts | 1 - .../koala/source/src/stores/mqtt-store.ts | 37 ++------ .../web_themes/standard_legacy/web/index.html | 16 +--- .../standard_legacy/web/processAllMqttMsg.js | 11 +-- .../standard_legacy/web/setupMqttServices.js | 2 +- simpleAPI/simpleAPI_mqtt.py | 2 +- simpleAPI/src/ParameterHandler.php | 2 +- 37 files changed, 302 insertions(+), 448 deletions(-) diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 10ed378e2f..422e796189 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -1045,49 +1045,55 @@ "acls": [ { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/control_range", + "topic": "openWB/set/general/chargemode_config/surplus/control_range", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/feed_in_yield", + "topic": "openWB/set/general/chargemode_config/surplus/feed_in_limit", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/phase_switch_delay", + "topic": "openWB/set/general/chargemode_config/surplus/feed_in_yield", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/switch_on_threshold", + "topic": "openWB/set/general/chargemode_config/surplus/vehicle/phase_switch_delay", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/retry_failed_phase_switches", + "topic": "openWB/set/general/chargemode_config/surplus/vehicle/switch_on_threshold", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/switch_on_delay", + "topic": "openWB/set/general/chargemode_config/surplus/vehicle/retry_failed_phase_switches", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/switch_off_threshold", + "topic": "openWB/set/general/chargemode_config/surplus/vehicle/switch_on_delay", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/switch_off_delay", + "topic": "openWB/set/general/chargemode_config/surplus/vehicle/switch_off_threshold", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/general/chargemode_config/surplus/vehicle/switch_off_delay", "priority": 0, "allow": true }, @@ -1099,49 +1105,55 @@ }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/control_range", + "topic": "openWB/general/chargemode_config/surplus/control_range", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/chargemode_config/surplus/feed_in_limit", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/feed_in_yield", + "topic": "openWB/general/chargemode_config/surplus/feed_in_yield", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/phase_switch_delay", + "topic": "openWB/general/chargemode_config/surplus/vehicle/phase_switch_delay", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/switch_on_threshold", + "topic": "openWB/general/chargemode_config/surplus/vehicle/switch_on_threshold", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/retry_failed_phase_switches", + "topic": "openWB/general/chargemode_config/surplus/vehicle/retry_failed_phase_switches", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/switch_on_delay", + "topic": "openWB/general/chargemode_config/surplus/vehicle/switch_on_delay", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/switch_off_threshold", + "topic": "openWB/general/chargemode_config/surplus/vehicle/switch_off_threshold", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/switch_off_delay", + "topic": "openWB/general/chargemode_config/surplus/vehicle/switch_off_delay", "priority": 0, "allow": true } @@ -1226,43 +1238,43 @@ }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/bat_mode", + "topic": "openWB/set/general/chargemode_config/bat/mode", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/bat_power_discharge", + "topic": "openWB/set/general/chargemode_config/bat/power_discharge", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/bat_power_discharge_active", + "topic": "openWB/set/general/chargemode_config/bat/power_discharge_active", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/bat_power_reserve", + "topic": "openWB/set/general/chargemode_config/bat/power_reserve", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/bat_power_reserve_active", + "topic": "openWB/set/general/chargemode_config/bat/power_reserve_active", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/max_bat_soc", + "topic": "openWB/set/general/chargemode_config/bat/max_soc", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/min_bat_soc", + "topic": "openWB/set/general/chargemode_config/bat/min_soc", "priority": 0, "allow": true }, @@ -1358,43 +1370,43 @@ }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/bat_mode", + "topic": "openWB/general/chargemode_config/bat/mode", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/bat_power_discharge", + "topic": "openWB/general/chargemode_config/bat/power_discharge", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/bat_power_discharge_active", + "topic": "openWB/general/chargemode_config/bat/power_discharge_active", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/bat_power_reserve", + "topic": "openWB/general/chargemode_config/bat/power_reserve", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/bat_power_reserve_active", + "topic": "openWB/general/chargemode_config/bat/power_reserve_active", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/max_bat_soc", + "topic": "openWB/general/chargemode_config/bat/max_soc", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/min_bat_soc", + "topic": "openWB/general/chargemode_config/bat/min_soc", "priority": 0, "allow": true }, @@ -2794,7 +2806,7 @@ }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/bat_mode", + "topic": "openWB/general/chargemode_config/bat/mode", "priority": 0, "allow": true } diff --git a/packages/control/algorithm/algorithm.py b/packages/control/algorithm/algorithm.py index 2bfbeeb2a5..fa4b01ed8c 100644 --- a/packages/control/algorithm/algorithm.py +++ b/packages/control/algorithm/algorithm.py @@ -68,7 +68,6 @@ def _check_auto_phase_switch_delay(self) -> None: # wurden, wieder zurückgegeben. log.debug(f"Ladepunkt {cp.num}: Prüfen, ob Phasenumschaltung durchgeführt werden soll.") phases, current, message = charging_ev.auto_phase_switch( - cp.data.set.charge_template, cp.data.control_parameter, cp.num, cp.data.get.evse_current, diff --git a/packages/control/algorithm/integration_test/conftest.py b/packages/control/algorithm/integration_test/conftest.py index cbc98f72ff..6b6072c603 100644 --- a/packages/control/algorithm/integration_test/conftest.py +++ b/packages/control/algorithm/integration_test/conftest.py @@ -52,7 +52,7 @@ def data_() -> None: data.data.counter_all_data.data.get.hierarchy = NESTED_HIERARCHY data.data.counter_all_data.data.config.consider_less_charging = True data.data.io_actions = IoActions() - data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches = True + data.data.general_data.data.chargemode_config.surplus.vehicle.retry_failed_phase_switches = True @dataclass diff --git a/packages/control/algorithm/surplus_controlled.py b/packages/control/algorithm/surplus_controlled.py index 1a39d27678..cca6647541 100644 --- a/packages/control/algorithm/surplus_controlled.py +++ b/packages/control/algorithm/surplus_controlled.py @@ -33,12 +33,7 @@ def set_surplus_current(self) -> None: for mode_tuple, counter in common.mode_and_counter_generator(CONSIDERED_CHARGE_MODES_SURPLUS): preferenced_chargepoints, preferenced_cps_without_set_current = get_preferenced_chargepoint_charging( get_chargepoints_by_mode_and_counter(mode_tuple, f"counter{counter.num}")) - cp_with_feed_in, cp_without_feed_in = self.filter_by_feed_in_limit(preferenced_chargepoints) - if cp_without_feed_in: - self._set(cp_without_feed_in, 0, mode_tuple, counter) - feed_in_yield = data.data.general_data.data.chargemode_config.pv_charging.feed_in_yield - if cp_with_feed_in: - self._set(cp_with_feed_in, feed_in_yield, mode_tuple, counter) + self._set(preferenced_chargepoints, mode_tuple, counter) if preferenced_cps_without_set_current: for cp in preferenced_cps_without_set_current: cp.data.set.current = cp.data.set.target_current @@ -48,7 +43,6 @@ def set_surplus_current(self) -> None: def _set(self, chargepoints: List[Chargepoint], - feed_in_yield: Optional[int], mode_tuple: Tuple[Optional[str], str, bool], counter: Counter) -> None: log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") @@ -60,20 +54,19 @@ def _set(self, missing_currents, voltages_mean(cp.data.get.voltages), counter, - cp, - feed_in=feed_in_yield + cp ) # im PV-Laden wird der Strom immer durch die Leistung begrenzt if limit.limiting_value is not None and limit.limiting_value != LimitingValue.POWER: cp.data.control_parameter.limit = limit available_for_cp = common.available_current_for_cp(cp, counts, available_currents, missing_currents) - if counter.get_control_range_state(feed_in_yield) == ControlRangeState.MIDDLE: - pv_charging = data.data.general_data.data.chargemode_config.pv_charging + if counter.get_control_range_state() == ControlRangeState.MIDDLE: + surplus = data.data.general_data.data.chargemode_config.surplus dif_to_old_current = available_for_cp + cp.data.set.target_current - cp.data.set.current_prev # Wenn die Differenz zwischen altem und neuem Soll-Strom größer als der Regelbereich ist, trotzdem # nachregeln, auch wenn der Regelbereich eingehalten wird. Sonst würde zB nicht berücksichtigt werden, # wenn noch ein Fahrzeug dazu kommt. - if ((pv_charging.control_range[1] - pv_charging.control_range[0]) / + if ((surplus.control_range[1] - surplus.control_range[0]) / (sum(counter.data.get.voltages) / len(counter.data.get.voltages)) < abs(dif_to_old_current)): current = available_for_cp else: diff --git a/packages/control/algorithm/surplus_controlled_test.py b/packages/control/algorithm/surplus_controlled_test.py index a77060c8b0..5e2d26fa03 100644 --- a/packages/control/algorithm/surplus_controlled_test.py +++ b/packages/control/algorithm/surplus_controlled_test.py @@ -15,6 +15,11 @@ from control.ev.ev import Ev +@pytest.fixture(autouse=True) +def init_data_store(): + data.data_init(Mock()) + + @pytest.fixture(autouse=True) def mock_cp1() -> Chargepoint: return Chargepoint(1, None) @@ -30,29 +35,6 @@ def mock_cp3() -> Chargepoint: return Chargepoint(3, None) -@pytest.mark.parametrize("feed_in_limit_1, feed_in_limit_2, feed_in_limit_3, expected_sorted", - [pytest.param(True, True, True, ([mock_cp1, mock_cp2, mock_cp3], [])), - pytest.param(True, False, True, ([mock_cp1, mock_cp3], [mock_cp2])), - pytest.param(False, False, False, ([], [mock_cp1, mock_cp2, mock_cp3]))]) -def test_filter_by_feed_in_limit(feed_in_limit_1: bool, - feed_in_limit_2: bool, - feed_in_limit_3: bool, - expected_sorted: int): - # setup - def setup_cp(cp: Chargepoint, feed_in_limit: bool) -> Chargepoint: - cp.data = ChargepointData() - cp.data.set.charge_template.data.chargemode.pv_charging.feed_in_limit = feed_in_limit - return cp - - cp1 = setup_cp(mock_cp1, feed_in_limit_1) - cp2 = setup_cp(mock_cp2, feed_in_limit_2) - cp3 = setup_cp(mock_cp3, feed_in_limit_3) - # execution - cp_with_feed_in, cp_without_feed_in = SurplusControlled().filter_by_feed_in_limit([cp1, cp2, cp3]) - # evaluation - assert (cp_with_feed_in, cp_without_feed_in) == expected_sorted - - @pytest.mark.parametrize("new_current, expected_current", [ pytest.param(7, 10), @@ -134,24 +116,28 @@ def test_add_unused_evse_current(evse_current: float, @pytest.mark.parametrize( - "submode_1, submode_2, expected_chargepoints", + "submode_1, submode_2, expected_cp_keys", [ - pytest.param(Chargemode.PV_CHARGING, Chargemode.PV_CHARGING, [mock_cp1, mock_cp2]), - pytest.param(Chargemode.INSTANT_CHARGING, Chargemode.PV_CHARGING, [mock_cp2]), + pytest.param(Chargemode.PV_CHARGING, Chargemode.PV_CHARGING, ["cp1", "cp2"]), + pytest.param(Chargemode.INSTANT_CHARGING, Chargemode.PV_CHARGING, ["cp2"]), pytest.param(Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING, []), ]) def test_get_chargepoints_submode_pv_charging(submode_1: Chargemode, submode_2: Chargemode, - expected_chargepoints: List[Chargepoint]): + expected_cp_keys: List[str], + mock_cp1: Chargepoint, + mock_cp2: Chargepoint): # setup def setup_cp(cp: Chargepoint, submode: str) -> Chargepoint: + cp.data = ChargepointData() cp.data.set.charging_ev_data = Ev(0) cp.data.control_parameter.chargemode = Chargemode.PV_CHARGING cp.data.control_parameter.submode = submode cp.data.control_parameter.required_current = 6 return cp - data.Data.cp_data = {"cp1": setup_cp(mock_cp1, submode_1), + data.data.cp_data = {"cp1": setup_cp(mock_cp1, submode_1), "cp2": setup_cp(mock_cp2, submode_2)} + expected_chargepoints = [data.data.cp_data[key] for key in expected_cp_keys] # evaluation chargepoints = get_chargepoints_with_required_current_by_chargemode(CONSIDERED_CHARGE_MODES_PV_ONLY) diff --git a/packages/control/auto_phase_switch_test.py b/packages/control/auto_phase_switch_test.py index 1c2a887b2c..d69eebad74 100644 --- a/packages/control/auto_phase_switch_test.py +++ b/packages/control/auto_phase_switch_test.py @@ -6,7 +6,6 @@ from control.counter import Counter, CounterData, Set from control.limiting_value import LimitingValue, LoadmanagementLimit -from control.ev.charge_template import ChargeTemplate from control.pv_all import PvAll from control.bat_all import BatAll from control.general import General @@ -135,8 +134,7 @@ def test_auto_phase_switch(monkeypatch: pytest.MonkeyPatch, vehicle: Ev, params: control_parameter.state = params.state # execution - phases_to_use, current, message = vehicle.auto_phase_switch(ChargeTemplate(), - control_parameter, + phases_to_use, current, message = vehicle.auto_phase_switch(control_parameter, 0, max(params.get_currents), params.get_currents, diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index d553be7509..cd846d5f89 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -325,10 +325,10 @@ def get_charging_power_left_diff(self): die zum Laden der EV verwendet werden darf. """ try: - config = data.data.general_data.data.chargemode_config.pv_charging + config = data.data.general_data.data.chargemode_config.bat self.data.set.regulate_up = False - if config.bat_mode == BatConsiderationMode.BAT_MODE.value: + if config.mode == BatConsiderationMode.BAT_MODE.value: if self.data.get.power < 0: # Wenn der Speicher entladen wird, darf diese Leistung nicht zum Laden der Fahrzeuge genutzt werden. # Wenn der Speicher schneller regelt als die LP, würde sonst der Speicher reduziert werden. @@ -337,7 +337,7 @@ def get_charging_power_left_diff(self): charging_power_left = 0 self.data.set.regulate_up = True if self.data.get.soc < 100 else False # ev wird nach Speicher geladen - elif config.bat_mode == BatConsiderationMode.EV_MODE.value: + elif config.mode == BatConsiderationMode.EV_MODE.value: # Speicher sollte weder ge- noch entladen werden. # wenn aktive Speichersteuerung in Höhe PV-Leistung lädt # hat Speicher Priorität vor EV-Ladung @@ -349,7 +349,7 @@ def get_charging_power_left_diff(self): else: absolute_bat_discharge_power = self._absolute_bat_discharge_power() # Speicher soll geladen werden um min SoC zu erreichen - if self.data.get.soc < config.min_bat_soc: + if self.data.get.soc < config.min_soc: self.data.set.hysteresis_discharge = False if self.data.get.power < 0: # Wenn der Speicher entladen wird, darf diese Leistung nicht zum Laden der Fahrzeuge @@ -359,10 +359,10 @@ def get_charging_power_left_diff(self): self.data.set.regulate_up = True else: # Speicher-Vorrang bis zum Min-Soc - if config.bat_power_reserve_active: + if config.power_reserve_active: # Die Differenz zwischen aktueller Batterie-Leistung und Reserveleistung bestimmt, # was fuer EV-Ladung verbleibt (positiv) oder zusaetzlich benoetigt wird (negativ). - charging_power_left = self.data.get.power - config.bat_power_reserve + charging_power_left = self.data.get.power - config.power_reserve if charging_power_left < 0: self.data.set.regulate_up = True else: @@ -370,12 +370,12 @@ def get_charging_power_left_diff(self): charging_power_left = 0 self.data.set.regulate_up = True # Speicher zwischen min und max SoC - elif int(self.data.get.soc) >= config.min_bat_soc and int(self.data.get.soc) < config.max_bat_soc: + elif int(self.data.get.soc) >= config.min_soc and int(self.data.get.soc) < config.max_soc: # Speicher soll aktiv weder ge- noch entladen werden. # Mindest-SoC wird gehalten oder der Speicher mit weiterem vorhanden Überschuss geladen. if self.data.set.hysteresis_discharge is False: charging_power_left = self.data.get.power - # Speicher darf wegen Hysterese bis min_bat_soc entladen werden. + # Speicher darf wegen Hysterese bis min_soc entladen werden. else: if self.data.set.power_limit is None: # set allowed power @@ -393,10 +393,10 @@ def get_charging_power_left_diff(self): (self.data.config.power_limit_condition == BatPowerLimitCondition.PRICE_LIMIT.value and self.data.set.power_limit is None)) - if config.bat_power_discharge_active and power_discharge_allowed: + if config.power_discharge_active and power_discharge_allowed: # max Entladeleistung auf max Ausgangsleistung des WR begrenzen required_absolut_discharge_power = min( - config.bat_power_discharge, absolute_bat_discharge_power) + config.power_discharge, absolute_bat_discharge_power) # Differenz zwischen erlaubter Entladeleistung und aktueller Speicherleistung bestimmen. # Wenn der Speicher lädt, darf die freigegebene Entladeleistung nicht die max # Ausgangsleistung des WR überschreiten. @@ -430,10 +430,10 @@ def get_charging_power_left_diff(self): (self.data.config.power_limit_condition == BatPowerLimitCondition.PRICE_LIMIT.value and self.data.set.power_limit is None)) - if config.bat_power_discharge_active and power_discharge_allowed: + if config.power_discharge_active and power_discharge_allowed: # max Entladeleistung auf max Ausgangsleistung des WR begrenzen required_absolut_discharge_power = min( - config.bat_power_discharge, absolute_bat_discharge_power) + config.power_discharge, absolute_bat_discharge_power) # Differenz zwischen erlaubter Entladeleistung und aktueller Speicherleistung bestimmen. # Wenn der Speicher lädt, darf die freigegebene Entladeleistung nicht die max # Ausgangsleistung des WR überschreiten. @@ -514,8 +514,8 @@ def get_charge_mode_vehicle_charge(self): charge_mode = BatChargeMode.BAT_SELF_REGULATION # Debug Informationen - control_range_low = data.data.general_data.data.chargemode_config.pv_charging.control_range[0] - control_range_high = data.data.general_data.data.chargemode_config.pv_charging.control_range[1] + control_range_low = data.data.general_data.data.chargemode_config.surplus.control_range[0] + control_range_high = data.data.general_data.data.chargemode_config.surplus.control_range[1] control_range_center = control_range_high - (control_range_high - control_range_low) / 2 if len(chargepoint_by_chargemodes) == 0: log.debug("Speicher-Leistung nicht begrenzen, da keine Ladepunkte in einem aktiven Lademodus sind.") diff --git a/packages/control/bat_all_test.py b/packages/control/bat_all_test.py index 38863ccb92..ca409eade4 100644 --- a/packages/control/bat_all_test.py +++ b/packages/control/bat_all_test.py @@ -10,7 +10,7 @@ from control import data from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_all import AllChargepointData, AllChargepoints, AllGet -from control.general import General, PvCharging +from control.general import ChargemodeConfigBat, General from control.pv import Config, Get, Pv, PvData from modules.devices.generic.mqtt.bat import MqttBat from modules.devices.generic.mqtt.config import MqttBatSetup @@ -99,7 +99,7 @@ def test__absolute_bat_discharge_power(hybrid_bat_ids: List[int], @dataclass class Params: name: str - config: PvCharging + config: ChargemodeConfigBat power: float soc: float expected_charging_power_left: float @@ -109,58 +109,60 @@ class Params: cases = [ - Params("Speicher, Speicher lädt", PvCharging(bat_mode="bat_mode"), 500, 90, -100, True), - Params("Speicher, Speicher entlädt", PvCharging(bat_mode="bat_mode"), -500, 90, -600, True), - Params("Speicher, Speicher ist voll", PvCharging(bat_mode="bat_mode"), 0, 100, 0, False), - Params("EV, Speicher lädt", PvCharging(bat_mode="ev_mode"), 500, 90, 500, False), - Params("EV, Speicher entlädt", PvCharging(bat_mode="ev_mode"), -500, 90, -500, False), - Params("EV, Speicher ist voll", PvCharging(bat_mode="ev_mode"), 0, 100, 0, False), + Params("Speicher, Speicher lädt", ChargemodeConfigBat(mode="bat_mode"), 500, 90, -100, True), + Params("Speicher, Speicher entlädt", ChargemodeConfigBat(mode="bat_mode"), -500, 90, -600, True), + Params("Speicher, Speicher ist voll", ChargemodeConfigBat(mode="bat_mode"), 0, 100, 0, False), + Params("EV, Speicher lädt", ChargemodeConfigBat(mode="ev_mode"), 500, 90, 500, False), + Params("EV, Speicher entlädt", ChargemodeConfigBat(mode="ev_mode"), -500, 90, -500, False), + Params("EV, Speicher ist voll", ChargemodeConfigBat(mode="ev_mode"), 0, 100, 0, False), Params("Mindest-SoC, SoC nicht erreicht, Speicher entlädt", - PvCharging(bat_mode="min_soc_bat_mode"), -500, 40, -600, True), + ChargemodeConfigBat(mode="min_soc_bat_mode"), -500, 40, -600, True), Params("Mindest-SoC, SoC nicht erreicht, Speicher lädt", - PvCharging(bat_mode="min_soc_bat_mode"), 500, 40, -100, True), + ChargemodeConfigBat(mode="min_soc_bat_mode"), 500, 40, -100, True), Params("Mindest-SoC, SoC nicht erreicht, Speicher-Reserve, Speicher entlädt", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_reserve=2000, bat_power_reserve_active=True), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_reserve=2000, power_reserve_active=True), -500, 40, -600, True), Params("Mindest-SoC, SoC nicht erreicht, Speicher-Reserve nicht ausgenutzt, Speicher lädt", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_reserve=2000, bat_power_reserve_active=True), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_reserve=2000, power_reserve_active=True), 1600, 40, -500, True), Params("Mindest-SoC, SoC nicht erreicht, Speicher-Reserve ausgenutzt, Speicher lädt", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_reserve=2000, bat_power_reserve_active=True), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_reserve=2000, power_reserve_active=True), 2200, 40, 200, False), - Params("Mindest-SoC, SoC erreicht, Speicher entlädt", PvCharging(bat_mode="min_soc_bat_mode"), -500, 90, -500, + Params("Mindest-SoC, SoC erreicht, Speicher entlädt", ChargemodeConfigBat(mode="min_soc_bat_mode"), -500, 90, -500, False), - Params("Mindest-SoC, SoC erreicht, Speicher lädt", PvCharging(bat_mode="min_soc_bat_mode"), 500, 90, 500, False), - Params("Mindest-SoC, SoC erreicht, Speicher ist voll", PvCharging(bat_mode="min_soc_bat_mode"), 0, 100, 0, False), + Params("Mindest-SoC, SoC erreicht, Speicher lädt", + ChargemodeConfigBat(mode="min_soc_bat_mode"), 500, 90, 500, False), + Params("Mindest-SoC, SoC erreicht, Speicher ist voll", + ChargemodeConfigBat(mode="min_soc_bat_mode"), 0, 100, 0, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher entlädt, Entladeleistung nicht erreicht", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_discharge=500, power_discharge_active=True), -400, 90, 100, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher entlädt, mehr als Entladeleistung", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_discharge=500, power_discharge_active=True), -600, 90, -100, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher entlädt, Entladeleistung erreicht", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_discharge=500, power_discharge_active=True), -500, 90, 0, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher lädt mit mehr als Entladeleistung", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_discharge=500, power_discharge_active=True), 650, 90, 1150, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher lädt mit weniger als Entladeleistung", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_discharge=500, power_discharge_active=True), 400, 90, 900, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher voll", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_reserve=500, bat_power_reserve_active=True, - min_bat_soc=100), 0, 100, 0, False), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_reserve=500, power_reserve_active=True, + min_soc=100), 0, 100, 0, False), Params(("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher lädt mit weniger als Entladeleistung, " "Speicher-Sperre aktiv"), - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_discharge=500, power_discharge_active=True), 400, 90, 0, False, 600), Params(("Mindest-SoC, Hysterese, EV-Vorrang, keine Speichernutzung"), - PvCharging(bat_mode="min_soc_bat_mode"), 400, 60, 400, False, hysteresis_discharge=False), + ChargemodeConfigBat(mode="min_soc_bat_mode"), 400, 60, 400, False, hysteresis_discharge=False), Params(("Mindest-SoC, Hysterese, Speicherentladung, Speichernutzung erlaubt"), - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_discharge=500, power_discharge_active=True), 400, 60, 900, False, hysteresis_discharge=True), Params(("Mindest-SoC, Hysterese, Speicherentladung, Speichernutzung erlaubt, Speicher-Sperre aktiv"), - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode="min_soc_bat_mode", power_discharge=500, power_discharge_active=True), 400, 60, 0, False, 600, hysteresis_discharge=True), ] @@ -176,7 +178,7 @@ def test_get_charging_power_left(params: Params, caplog, data_, monkeypatch): b = Bat(0) b.data.get.power = params.power data.data.bat_data["bat0"] = b - data.data.general_data.data.chargemode_config.pv_charging = params.config + data.data.general_data.data.chargemode_config.bat = params.config mock_absolute_bat_discharge_power = MagicMock(return_value=10000) monkeypatch.setattr(BatAll, "_absolute_bat_discharge_power", mock_absolute_bat_discharge_power) diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index 4b0252c254..81b5434e7e 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -838,14 +838,14 @@ def hw_supports_phase_switch(self) -> bool: self.data.set.log.imported_since_plugged == 0)) def failed_phase_switches_reached(self) -> bool: - if (data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches and + if (data.data.general_data.data.chargemode_config.surplus.vehicle.retry_failed_phase_switches and self.data.control_parameter.failed_phase_switches > self.MAX_FAILED_PHASE_SWITCHES): # bei deaktiverter Wiederholung der Umschaltung nur den gewünschten Umschaltvorgang durchführen, # keinen Korrekturversuch self.set_state_and_log( "Keine Phasenumschaltung, da die maximale Anzahl an Fehlversuchen erreicht wurde. ") return True - elif (data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches is False and + elif (data.data.general_data.data.chargemode_config.surplus.vehicle.retry_failed_phase_switches is False and self.data.control_parameter.failed_phase_switches > 0): return True else: diff --git a/packages/control/chargepoint/chargepoint_test.py b/packages/control/chargepoint/chargepoint_test.py index 3ea9c9e622..57a746649b 100644 --- a/packages/control/chargepoint/chargepoint_test.py +++ b/packages/control/chargepoint/chargepoint_test.py @@ -147,7 +147,7 @@ def test_is_phase_switch_required(params: Params): cp.data.get.charge_state = params.charge_state cp.data.control_parameter.failed_phase_switches = params.failed_phase_switches data.data_init(Mock()) - data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches = ( + data.data.general_data.data.chargemode_config.surplus.vehicle.retry_failed_phase_switches = ( params.retry_failed_phase_switches ) diff --git a/packages/control/chargepoint/get_phases_test.py b/packages/control/chargepoint/get_phases_test.py index b8bec247e0..e4bf1283f4 100644 --- a/packages/control/chargepoint/get_phases_test.py +++ b/packages/control/chargepoint/get_phases_test.py @@ -219,7 +219,7 @@ def test_failed_phase_switches_reached(cp: Chargepoint, failed_phase_switches: int, expected: bool): # setup - data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches = ( + data.data.general_data.data.chargemode_config.surplus.vehicle.retry_failed_phase_switches = ( retry_failed_phase_switches ) cp.data.control_parameter.failed_phase_switches = failed_phase_switches diff --git a/packages/control/counter.py b/packages/control/counter.py index e7351b3cef..8a5a02a471 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -231,10 +231,11 @@ def calc_raw_surplus(self): ranged_surplus = surplus + self._control_range_offset() return ranged_surplus - def get_control_range_state(self, feed_in_yield: int) -> ControlRangeState: - control_range_low = data.data.general_data.data.chargemode_config.pv_charging.control_range[0] - control_range_high = data.data.general_data.data.chargemode_config.pv_charging.control_range[1] - surplus = data.data.counter_all_data.get_evu_counter().data.get.power + feed_in_yield + def get_control_range_state(self) -> ControlRangeState: + control_range_low = data.data.general_data.data.chargemode_config.surplus.control_range[0] + control_range_high = data.data.general_data.data.chargemode_config.surplus.control_range[1] + surplus = (data.data.counter_all_data.get_evu_counter().data.get.power + + data.data.general_data.get_feed_in_yield()) if control_range_low > surplus: return ControlRangeState.BELOW elif surplus > control_range_high: @@ -246,17 +247,17 @@ def _control_range_offset(self): if data.data.bat_all_data.data.set.regulate_up: # Regelmodus ignorieren, denn mit Regelmodus Bezug kann keine Einspeisung für den Speicher erzeugt werden. return 0 - control_range_low = data.data.general_data.data.chargemode_config.pv_charging.control_range[0] - control_range_high = data.data.general_data.data.chargemode_config.pv_charging.control_range[1] + control_range_low = data.data.general_data.data.chargemode_config.surplus.control_range[0] + control_range_high = data.data.general_data.data.chargemode_config.surplus.control_range[1] control_range_center = control_range_high - (control_range_high - control_range_low) / 2 range_offset = control_range_center log.debug(f"Anpassen des Regelbereichs {range_offset}W") return range_offset - def get_usable_surplus(self, feed_in_yield: float) -> float: + def get_usable_surplus(self) -> float: # verbleibender EVU-Überschuss unter Berücksichtigung der Einspeisegrenze und Speicherleistung return (-self.calc_surplus() + self.data.set.released_surplus - - self.data.set.reserved_surplus - feed_in_yield) + self.data.set.reserved_surplus - data.data.general_data.get_feed_in_yield()) SWITCH_ON_FALLEN_BELOW = "Einschaltschwelle während der Wartezeit unterschritten." SWITCH_ON_WAITING = "Die Ladung wird gestartet, sobald in {} die Wartezeit abgelaufen ist." @@ -268,25 +269,23 @@ def get_usable_surplus(self, feed_in_yield: float) -> float: def calc_switch_on_power(self, chargepoint: Chargepoint) -> Tuple[float, float]: surplus = self.calc_raw_surplus() - self.data.set.reserved_surplus control_parameter = chargepoint.data.control_parameter - pv_config = data.data.general_data.data.chargemode_config.pv_charging + surplus_config = data.data.general_data.data.chargemode_config.surplus - if chargepoint.data.set.charge_template.data.chargemode.pv_charging.feed_in_limit: - threshold = pv_config.feed_in_yield + if surplus_config.feed_in_limit: + threshold = surplus_config.feed_in_yield else: - threshold = pv_config.switch_on_threshold*control_parameter.phases + threshold = surplus_config.vehicle.switch_on_threshold*control_parameter.phases return surplus, threshold def switch_on_threshold_reached(self, chargepoint: Chargepoint) -> None: try: message = None control_parameter = chargepoint.data.control_parameter - feed_in_limit = chargepoint.data.set.charge_template.data.chargemode.pv_charging.\ - feed_in_limit - pv_config = data.data.general_data.data.chargemode_config.pv_charging + surplus_config = data.data.general_data.data.chargemode_config.surplus timestamp_switch_on_off = control_parameter.timestamp_switch_on_off surplus, threshold = self.calc_switch_on_power(chargepoint) - power_to_reserve = pv_config.switch_on_threshold*control_parameter.phases + power_to_reserve = surplus_config.vehicle.switch_on_threshold*control_parameter.phases if control_parameter.state == ChargepointState.SWITCH_ON_DELAY: # Wurde die Einschaltschwelle erreicht? Reservierte Leistung aus all_surplus herausrechnen, # da diese Leistung ja schon reserviert wurde, als die Einschaltschwelle erreicht wurde. @@ -294,22 +293,22 @@ def switch_on_threshold_reached(self, chargepoint: Chargepoint) -> None: # Einschaltschwelle wurde unterschritten, Timer zurücksetzen timestamp_switch_on_off = None self.data.set.reserved_surplus -= power_to_reserve - message = self.SWITCH_ON_FALLEN_BELOW.format(pv_config.switch_on_threshold) + message = self.SWITCH_ON_FALLEN_BELOW.format(surplus_config.vehicle.switch_on_threshold) control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED else: # Timer starten - if (surplus >= threshold) and ((feed_in_limit and self.data.set.reserved_surplus == 0) or - not feed_in_limit): + if (surplus >= threshold) and ((surplus_config.feed_in_limit and self.data.set.reserved_surplus == 0) or + not surplus_config.feed_in_limit): timestamp_switch_on_off = timecheck.create_timestamp() self.data.set.reserved_surplus += power_to_reserve message = self.SWITCH_ON_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( - timestamp_switch_on_off, pv_config.switch_on_delay)) - if feed_in_limit: - message += "Die Einspeisegrenze wird berücksichtigt." + timestamp_switch_on_off, surplus_config.vehicle.switch_on_delay)) + if surplus_config.feed_in_limit: + message += " Die Einspeisegrenze wird berücksichtigt." control_parameter.state = ChargepointState.SWITCH_ON_DELAY else: # Einschaltschwelle nicht erreicht - message = self.SWITCH_ON_NOT_EXCEEDED.format(pv_config.switch_on_threshold) + message = self.SWITCH_ON_NOT_EXCEEDED.format(surplus_config.vehicle.switch_on_threshold) if timestamp_switch_on_off != control_parameter.timestamp_switch_on_off: control_parameter.timestamp_switch_on_off = timestamp_switch_on_off @@ -328,31 +327,29 @@ def switch_on_timer_expired(self, chargepoint: Chargepoint) -> None: """ try: msg = None - pv_config = data.data.general_data.data.chargemode_config.pv_charging + surplus_config = data.data.general_data.data.chargemode_config.surplus control_parameter = chargepoint.data.control_parameter charging_ev_data = chargepoint.data.set.charging_ev_data # Timer ist noch nicht abgelaufen if timecheck.check_timestamp(control_parameter.timestamp_switch_on_off, - pv_config.switch_on_delay): + surplus_config.vehicle.switch_on_delay): msg = self.SWITCH_ON_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( - control_parameter.timestamp_switch_on_off, pv_config.switch_on_delay)) + control_parameter.timestamp_switch_on_off, surplus_config.vehicle.switch_on_delay)) # Timer abgelaufen else: control_parameter.timestamp_switch_on_off = None - self.data.set.reserved_surplus -= pv_config.switch_on_threshold*control_parameter.phases - msg = self.SWITCH_ON_EXPIRED.format(pv_config.switch_on_threshold) + self.data.set.reserved_surplus -= ( + surplus_config.vehicle.switch_on_threshold*control_parameter.phases + ) + msg = self.SWITCH_ON_EXPIRED.format(surplus_config.vehicle.switch_on_threshold) control_parameter.state = ChargepointState.WAIT_FOR_USING_PHASES - if chargepoint.data.set.charge_template.data.chargemode.pv_charging.feed_in_limit: - feed_in_yield = pv_config.feed_in_yield - else: - feed_in_yield = 0 ev_template = charging_ev_data.ev_template max_phases_power = ev_template.data.min_current * ev_template.data.max_phases * 230 if (control_parameter.submode == Chargemode.PV_CHARGING and chargepoint.data.set.charge_template.data.chargemode.pv_charging.phases_to_use == 0 and chargepoint.hw_supports_phase_switch() and - self.get_usable_surplus(feed_in_yield) > max_phases_power): + self.get_usable_surplus() > max_phases_power): control_parameter.phases = ev_template.data.max_phases msg += self.SWITCH_ON_MAX_PHASES.format(ev_template.data.max_phases) chargepoint.set_state_and_log(msg) @@ -370,35 +367,35 @@ def switch_on_timer_expired(self, chargepoint: Chargepoint) -> None: def switch_off_check_timer(self, chargepoint: Chargepoint) -> None: try: msg = None - pv_config = data.data.general_data.data.chargemode_config.pv_charging + surplus_config = data.data.general_data.data.chargemode_config.surplus control_parameter = chargepoint.data.control_parameter if control_parameter.timestamp_switch_on_off is not None: if not timecheck.check_timestamp( control_parameter.timestamp_switch_on_off, - pv_config.switch_off_delay): + surplus_config.vehicle.switch_off_delay): control_parameter.timestamp_switch_on_off = None self.data.set.released_surplus -= chargepoint.data.set.required_power msg = self.SWITCH_OFF_STOP control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED else: msg = self.SWITCH_OFF_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( - control_parameter.timestamp_switch_on_off, pv_config.switch_off_delay)) + control_parameter.timestamp_switch_on_off, surplus_config.switch_off_delay)) chargepoint.set_state_and_log(msg) except Exception: log.exception("Fehler im allgemeinen PV-Modul") def calc_switch_off_threshold(self, chargepoint: Chargepoint) -> float: - pv_config = data.data.general_data.data.chargemode_config.pv_charging + surplus_config = data.data.general_data.data.chargemode_config.surplus control_parameter = chargepoint.data.control_parameter - if chargepoint.data.set.charge_template.data.chargemode.pv_charging.feed_in_limit: + if surplus_config.feed_in_limit: # Der EVU-Überschuss muss ggf um die Einspeisegrenze bereinigt werden. # Wnn die Leistung nicht Einspeisegrenze + Einschaltschwelle erreicht, darf die Ladung nicht pulsieren. # Abschaltschwelle um Einschaltschwelle reduzieren. - threshold = (-data.data.general_data.data.chargemode_config.pv_charging.feed_in_yield - + pv_config.switch_on_threshold*control_parameter.phases) + threshold = (-surplus_config.feed_in_yield + + surplus_config.vehicle.switch_on_threshold*control_parameter.phases) else: - threshold = pv_config.switch_off_threshold + threshold = surplus_config.vehicle.switch_off_threshold return threshold def calc_switch_off(self, chargepoint: Chargepoint) -> Tuple[float, float]: @@ -464,7 +461,7 @@ def switch_off_check_threshold(self, chargepoint: Chargepoint) -> bool: self.data.set.released_surplus += chargepoint.data.set.required_power msg = self.SWITCH_OFF_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( timestamp_switch_on_off, - data.data.general_data.data.chargemode_config.pv_charging.switch_off_delay)) + data.data.general_data.data.chargemode_config.surplus.vehicle.switch_off_delay)) control_parameter.state = ChargepointState.SWITCH_OFF_DELAY # Die Abschaltschwelle wird immer noch überschritten und es sollten weitere LP abgeschaltet # werden. @@ -483,12 +480,12 @@ def reset_switch_on_off(self, chargepoint: Chargepoint): # Wenn bereits geladen wird, lief die Abschaltverzögerung -> Leistung, die nach Abschalten frei # geworden wäre, nicht mehr zum zur Verfügung stehenden Überschuss zählen. # Wenn nicht geladen wird, reservierte Leistung freigeben. - pv_config = data.data.general_data.data.chargemode_config.pv_charging + surplus_config = data.data.general_data.data.chargemode_config.surplus if not chargepoint.data.get.charge_state: - evu_counter.data.set.reserved_surplus -= (pv_config.switch_on_threshold + evu_counter.data.set.reserved_surplus -= (surplus_config.vehicle.switch_on_threshold * chargepoint.data.set.phases_to_use) else: - evu_counter.data.set.released_surplus -= (pv_config.switch_on_threshold + evu_counter.data.set.released_surplus -= (surplus_config.vehicle.switch_on_threshold * chargepoint.data.control_parameter.phases) chargepoint.data.control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED except Exception: diff --git a/packages/control/counter_test.py b/packages/control/counter_test.py index 4b3e59466a..06509b89a5 100644 --- a/packages/control/counter_test.py +++ b/packages/control/counter_test.py @@ -122,7 +122,8 @@ class Params: -681, 15000, 1652683250.0, ChargepointState.SWITCH_ON_DELAY, Counter.SWITCH_ON_FALLEN_BELOW.format(1500), None, 0), Params("Feed_in_limit, Timer starten", True, 0, 15001, 15000, None, ChargepointState.NO_CHARGING_ALLOWED, - Counter.SWITCH_ON_WAITING.format("30 Sek."), 1652683252.0, 1500), + Counter.SWITCH_ON_WAITING.format("30 Sek.") + " Die Einspeisegrenze wird berücksichtigt.", + 1652683252.0, 1500), Params("Feed_in_limit, Einschaltschwelle nicht erreicht", True, 0, 14999, 15000, None, ChargepointState.NO_CHARGING_ALLOWED, Counter.SWITCH_ON_NOT_EXCEEDED.format(1500), None, 0), Params("Feed_in_limit, Einschaltschwelle läuft", True, 1500, 15001, @@ -141,7 +142,7 @@ def test_switch_on_threshold_reached(params: Params, caplog, general_data_fixtur cp.data.control_parameter.state = params.state cp.data.control_parameter.timestamp_switch_on_off = params.timestamp_switch_on_off ev.data.charge_template = ChargeTemplate() - ev.data.charge_template.data.chargemode.pv_charging.feed_in_limit = params.feed_in_limit + data.data.general_data.data.chargemode_config.surplus.feed_in_limit = params.feed_in_limit cp.data.set.charging_ev_data = ev mock_calc_switch_on_power = Mock(return_value=[params.surplus, params.threshold]) monkeypatch.setattr(Counter, "calc_switch_on_power", mock_calc_switch_on_power) @@ -170,7 +171,7 @@ def test_control_range(control_range, evu_power, expected_range_offset, general_ get_evu_counter_mock = Mock(return_value=Mock(spec=Counter, data=Mock( spec=CounterData, get=Mock(spec=Get, power=evu_power)))) monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", get_evu_counter_mock) - data.data.general_data.data.chargemode_config.pv_charging.control_range = control_range + data.data.general_data.data.chargemode_config.surplus.control_range = control_range c = Counter(0) # execution diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index 15ccf6af15..e02707f49b 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -66,7 +66,6 @@ class InstantCharging: class PvCharging: dc_min_current: float = 145 dc_min_soc_current: float = 145 - feed_in_limit: bool = False limit: Limit = field(default_factory=limit_factory) min_current: int = 0 min_soc_current: int = 10 diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index c206812cd6..0a1e77abcf 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -303,7 +303,6 @@ def _check_phase_switch_conditions(self, PHASE_SWITCH_DELAY_TEXT = '{} Phasen in {}.' def auto_phase_switch(self, - charge_template: ChargeTemplate, control_parameter: ControlParameter, cp_num: int, evse_current: float, @@ -316,12 +315,8 @@ def auto_phase_switch(self, current = control_parameter.required_current phases_to_use = control_parameter.phases phases_in_use = control_parameter.phases - pv_config = data.data.general_data.data.chargemode_config.pv_charging - if charge_template.data.chargemode.pv_charging.feed_in_limit: - feed_in_yield = pv_config.feed_in_yield - else: - feed_in_yield = 0 - delay = pv_config.phase_switch_delay * 60 + surplus_config = data.data.general_data.data.chargemode_config.surplus + delay = surplus_config.vehicle.phase_switch_delay * 60 if phases_in_use == 1: direction_str = f"Umschaltung von 1 auf {max_phases}" required_reserved_power = (control_parameter.min_current * max_phases * 230 - @@ -329,15 +324,15 @@ def auto_phase_switch(self, new_phase = max_phases new_current = control_parameter.min_current - waiting_time = pv_config.switch_on_delay + waiting_time = surplus_config.vehicle.switch_on_delay else: direction_str = f"Umschaltung von {max_phases} auf 1" # Es kann einphasig mit entsprechend niedriger Leistung gestartet werden. required_reserved_power = 0 new_phase = 1 new_current = self.ev_template.data.max_current_single_phase - waiting_time = pv_config.switch_off_delay - all_surplus = data.data.counter_all_data.get_evu_counter().get_usable_surplus(feed_in_yield) + waiting_time = surplus_config.vehicle.switch_off_delay + all_surplus = data.data.counter_all_data.get_evu_counter().get_usable_surplus() if control_parameter.state == ChargepointState.PHASE_SWITCH_DELAY: # eigene reservierte Leistung addieren, wenn die Verzögerung für die Umschaltung läuft, # da diese Leistung bereits für die Umschaltung reserviert ist. diff --git a/packages/control/general.py b/packages/control/general.py index 80941ead81..13924464a0 100644 --- a/packages/control/general.py +++ b/packages/control/general.py @@ -17,51 +17,60 @@ def control_range_factory() -> List: @dataclass -class PvCharging: - bat_power_reserve: int = field(default=2000, metadata={ - "topic": "chargemode_config/pv_charging/bat_power_reserve"}) - bat_power_reserve_active: bool = field(default=False, metadata={ - "topic": "chargemode_config/pv_charging/bat_power_reserve_active"}) - control_range: List = field(default_factory=control_range_factory, metadata={ - "topic": "chargemode_config/pv_charging/control_range"}) - feed_in_yield: int = field(default=15000, metadata={ - "topic": "chargemode_config/pv_charging/feed_in_yield"}) +class ChargemodeConfigBat: + power_reserve: int = field(default=2000, metadata={ + "topic": "chargemode_config/bat/power_reserve"}) + power_reserve_active: bool = field(default=False, metadata={ + "topic": "chargemode_config/bat/power_reserve_active"}) + power_discharge: int = field(default=1500, metadata={ + "topic": "chargemode_config/bat/power_discharge"}) + power_discharge_active: bool = field(default=False, metadata={ + "topic": "chargemode_config/bat/power_discharge_active"}) + min_soc: int = field(default=50, metadata={ + "topic": "chargemode_config/bat/min_soc"}) + max_soc: int = field(default=70, metadata={ + "topic": "chargemode_config/bat/max_soc"}) + mode: BatConsiderationMode = field(default=BatConsiderationMode.EV_MODE.value, metadata={ + "topic": "chargemode_config/bat/mode"}) + + +@dataclass +class ChargemodeConfigSurplusVehicle: phase_switch_delay: int = field(default=7, metadata={ - "topic": "chargemode_config/pv_charging/phase_switch_delay"}) - bat_power_discharge: int = field(default=1500, metadata={ - "topic": "chargemode_config/pv_charging/bat_power_discharge"}) - bat_power_discharge_active: bool = field(default=False, metadata={ - "topic": "chargemode_config/pv_charging/bat_power_discharge_active"}) - min_bat_soc: int = field(default=50, metadata={ - "topic": "chargemode_config/pv_charging/min_bat_soc"}) - max_bat_soc: int = field(default=70, metadata={ - "topic": "chargemode_config/pv_charging/max_bat_soc"}) - bat_mode: BatConsiderationMode = field(default=BatConsiderationMode.EV_MODE.value, metadata={ - "topic": "chargemode_config/pv_charging/bat_mode"}) + "topic": "chargemode_config/surplus/vehicle/phase_switch_delay"}) retry_failed_phase_switches: bool = field( default=False, - metadata={"topic": "chargemode_config/pv_charging/retry_failed_phase_switches"}) + metadata={"topic": "chargemode_config/surplus/vehicle/retry_failed_phase_switches"}) switch_off_delay: int = field(default=60, metadata={ - "topic": "chargemode_config/pv_charging/switch_off_delay"}) + "topic": "chargemode_config/surplus/vehicle/switch_off_delay"}) switch_off_threshold: int = field(default=0, metadata={ - "topic": "chargemode_config/pv_charging/switch_off_threshold"}) + "topic": "chargemode_config/surplus/vehicle/switch_off_threshold"}) switch_on_delay: int = field(default=30, metadata={ - "topic": "chargemode_config/pv_charging/switch_on_delay"}) + "topic": "chargemode_config/surplus/vehicle/switch_on_delay"}) switch_on_threshold: int = field(default=1500, metadata={ - "topic": "chargemode_config/pv_charging/switch_on_threshold"}) + "topic": "chargemode_config/surplus/vehicle/switch_on_threshold"}) -def pv_charging_factory() -> PvCharging: - return PvCharging() +@dataclass +class ChargemodeConfigSurplus: + control_range: List = field(default_factory=control_range_factory, metadata={ + "topic": "chargemode_config/surplus/control_range"}) + feed_in_limit: bool = field(default=False, metadata={ + "topic": "chargemode_config/surplus/feed_in_limit"}) + feed_in_yield: int = field(default=15000, metadata={ + "topic": "chargemode_config/surplus/feed_in_yield"}) + vehicle: ChargemodeConfigSurplusVehicle = field( + default_factory=lambda: ChargemodeConfigSurplusVehicle()) @dataclass class ChargemodeConfig: - pv_charging: PvCharging = field(default_factory=pv_charging_factory) unbalanced_load_limit: int = field( default=18, metadata={"topic": "chargemode_config/unbalanced_load_limit"}) unbalanced_load: bool = field(default=False, metadata={ "topic": "chargemode_config/unbalanced_load"}) + surplus: ChargemodeConfigSurplus = field(default_factory=lambda: ChargemodeConfigSurplus()) + bat: ChargemodeConfigBat = field(default_factory=lambda: ChargemodeConfigBat()) def chargemode_config_factory() -> ChargemodeConfig: @@ -146,3 +155,10 @@ def grid_protection(self): self.data.grid_protection_random_stop = 0 except Exception: log.exception("Fehler im General-Modul") + + def get_feed_in_yield(self) -> int: + """ Liefert den aktuellen Feed-In-Ertrag in Watt zurück. """ + if self.data.chargemode_config.surplus.feed_in_limit: + return self.data.chargemode_config.surplus.feed_in_yield + else: + return 0 diff --git a/packages/control/loadmanagement.py b/packages/control/loadmanagement.py index ce7ed4e8db..e3fae86d76 100644 --- a/packages/control/loadmanagement.py +++ b/packages/control/loadmanagement.py @@ -17,8 +17,7 @@ class Loadmanagement: def get_available_currents(self, missing_currents: List[float], counter: Counter, - cp: Chargepoint, - feed_in: int = 0) -> Tuple[List[float], LoadmanagementLimit]: + cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: raw_currents_left = counter.data.set.raw_currents_left try: available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, cp) @@ -39,7 +38,7 @@ def get_available_currents(self, limit = new_limit if new_limit.limiting_value is not None else limit available_currents, new_limit = self._limit_by_power( - counter, available_currents, voltages_mean(cp.data.get.voltages), counter.data.set.raw_power_left, feed_in) + counter, available_currents, voltages_mean(cp.data.get.voltages), counter.data.set.raw_power_left) limit = new_limit if new_limit.limiting_value is not None else limit if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str(): @@ -53,8 +52,7 @@ def get_available_currents_surplus(self, missing_currents: List[float], cp_voltage: float, counter: Counter, - cp: Chargepoint, - feed_in: int = 0) -> Tuple[List[float], LoadmanagementLimit]: + cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: raw_currents_left = counter.data.set.raw_currents_left available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, cp) @@ -65,7 +63,7 @@ def get_available_currents_surplus(self, limit = new_limit if new_limit.limiting_value is not None else limit available_currents, new_limit = self._limit_by_power( - counter, available_currents, cp_voltage, counter.data.set.surplus_power_left, feed_in) + counter, available_currents, cp_voltage, counter.data.set.surplus_power_left) limit = new_limit if new_limit.limiting_value is not None else limit if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str(): @@ -99,8 +97,7 @@ def _limit_by_power(self, counter: Counter, available_currents: List[float], cp_voltage: float, - raw_power_left: Optional[float], - feed_in: Optional[float]) -> Tuple[List[float], LoadmanagementLimit]: + raw_power_left: Optional[float]) -> Tuple[List[float], LoadmanagementLimit]: # Mittelwert der Spannungen verwenden, um Phasenverdrehung zu kompensieren # (Probleme bei einphasig angeschlossenen Wallboxen) currents = available_currents.copy() @@ -108,8 +105,9 @@ def _limit_by_power(self, if raw_power_left is None: return currents, limit elif raw_power_left > 0: - if feed_in is not None: - raw_power_left = max(raw_power_left - feed_in, 0) + surplus_config = data.data.general_data.data.chargemode_config.surplus + if surplus_config.feed_in_limit: + raw_power_left = max(raw_power_left - surplus_config.feed_in_yield, 0) log.debug(f"Verbleibende Leistung unter Berücksichtigung der Einspeisegrenze: {raw_power_left}W") if sum([c * cp_voltage for c in available_currents]) > raw_power_left: for i in range(0, 3): diff --git a/packages/control/loadmanagement_test.py b/packages/control/loadmanagement_test.py index 48de9d41e9..9c75fae169 100644 --- a/packages/control/loadmanagement_test.py +++ b/packages/control/loadmanagement_test.py @@ -33,7 +33,7 @@ def test_limit_by_power(available_currents: List[float], counter_name_mock = Mock(return_value=COUNTER_NAME) monkeypatch.setattr(loadmanagement, "get_component_name_by_id", counter_name_mock) # evaluation - ret = Loadmanagement()._limit_by_power(Counter(0), available_currents, 230, raw_power_left, None) + ret = Loadmanagement()._limit_by_power(Counter(0), available_currents, 230, raw_power_left) # assertion assert ret == expected_ret diff --git a/packages/helpermodules/create_debug.py b/packages/helpermodules/create_debug.py index 63ab81457f..1e7e72183d 100644 --- a/packages/helpermodules/create_debug.py +++ b/packages/helpermodules/create_debug.py @@ -35,8 +35,8 @@ def get_common_data(): ip_address = None try: updateAvailable = subdata.SubData.system_data["system"].data["current_branch_commit"] and \ - subdata.SubData.system_data["system"].data["current_branch_commit"] != \ - subdata.SubData.system_data["system"].data["current_commit"] + subdata.SubData.system_data["system"].data["current_branch_commit"] != \ + subdata.SubData.system_data["system"].data["current_commit"] except Exception: updateAvailable = False @@ -113,21 +113,21 @@ def config_and_state(): chargemode_config = data.data.general_data.data.chargemode_config parsed_data += ( "\n## General Charge Config/ PV ##\n" - f"Phase_Switch_Delay: {chargemode_config.pv_charging.phase_switch_delay} min\n" - f"Retry_Failed_Phase_Switches: {chargemode_config.pv_charging.retry_failed_phase_switches}\n" - f"Control_Range: {chargemode_config.pv_charging.control_range}W\n" - f"Switch_On_Threshold: {chargemode_config.pv_charging.switch_on_threshold}W\n" - f"Switch_On_Delay: {chargemode_config.pv_charging.switch_on_delay}s\n" - f"Switch_Off_Threshold: {chargemode_config.pv_charging.switch_off_threshold}W\n" - f"Switch_Off_Delay: {chargemode_config.pv_charging.switch_off_delay}s\n" - f"Feed_In_Yield: {chargemode_config.pv_charging.feed_in_yield}W\n" - f"Bat_Mode: {chargemode_config.pv_charging.bat_mode}\n" - f"Min_Bat_SoC: {chargemode_config.pv_charging.min_bat_soc}%\n" - f"Max_Bat_SoC: {chargemode_config.pv_charging.max_bat_soc}%\n" - f"Bat_Power_Reserve_Active: {chargemode_config.pv_charging.bat_power_reserve_active}\n" - f"Bat_Power_Reserve: {chargemode_config.pv_charging.bat_power_reserve}W\n" - f"Bat_Power_Discharge_Active: {chargemode_config.pv_charging.bat_power_discharge_active}\n" - f"Bat_Power_Discharge: {chargemode_config.pv_charging.bat_power_discharge}W\n") + f"Phase_Switch_Delay: {chargemode_config.surplus.vehicle.phase_switch_delay} min\n" + f"Retry_Failed_Phase_Switches: {chargemode_config.surplus.vehicle.retry_failed_phase_switches}\n" + f"Control_Range: {chargemode_config.surplus.control_range}W\n" + f"Switch_On_Threshold: {chargemode_config.surplus.vehicle.switch_on_threshold}W\n" + f"Switch_On_Delay: {chargemode_config.surplus.vehicle.switch_on_delay}s\n" + f"Switch_Off_Threshold: {chargemode_config.surplus.vehicle.switch_off_threshold}W\n" + f"Switch_Off_Delay: {chargemode_config.surplus.vehicle.switch_off_delay}s\n" + f"Feed_In_Yield: {chargemode_config.surplus.feed_in_yield}W\n" + f"Bat_Mode: {chargemode_config.bat.mode}\n" + f"Min_Bat_SoC: {chargemode_config.bat.min_soc}%\n" + f"Max_Bat_SoC: {chargemode_config.bat.max_soc}%\n" + f"Bat_Power_Reserve_Active: {chargemode_config.bat.power_reserve_active}\n" + f"Bat_Power_Reserve: {chargemode_config.bat.power_reserve}W\n" + f"Bat_Power_Discharge_Active: {chargemode_config.bat.power_discharge_active}\n" + f"Bat_Power_Discharge: {chargemode_config.bat.power_discharge}W\n") if secondary is False: with ErrorHandlingContext(): parsed_data += f"\n## Hierarchy ##\n{get_hierarchy(data.data.counter_all_data.data.get.hierarchy)}\n" @@ -465,9 +465,9 @@ def write_to_file(file_handler, func, default: Optional[Any] = None): json_rsp = req.get_http_session().put("https://debughandler.wb-solution.de", data=data, params={ - 'debugemail': debug_email, - 'ticketnumber': ticketnumber, - 'subject': subject + 'debugemail': debug_email, + 'ticketnumber': ticketnumber, + 'subject': subject }, timeout=10).json() diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 09fe9a97a6..66789d1734 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -762,28 +762,29 @@ def process_general_topic(self, msg: mqtt.MQTTMessage): elif "openWB/set/general/chargemode_config/unbalanced_load_limit" in msg.topic: self._validate_value(msg, int, [(10, 32)]) elif ("openWB/set/general/chargemode_config/unbalanced_load" in msg.topic or - "openWB/set/general/chargemode_config/pv_charging/retry_failed_phase_switches" in msg.topic or - "openWB/set/general/chargemode_config/pv_charging/bat_power_discharge_active" in msg.topic or - "openWB/set/general/chargemode_config/pv_charging/bat_power_reserve_active" in msg.topic): + "openWB/set/general/chargemode_config/surplus/feed_in_limit" in msg.topic or + "openWB/set/general/chargemode_config/surplus/vehicle/retry_failed_phase_switches" in msg.topic or + "openWB/set/general/chargemode_config/bat/power_discharge_active" in msg.topic or + "openWB/set/general/chargemode_config/bat/power_reserve_active" in msg.topic): self._validate_value(msg, bool) - elif ("openWB/set/general/chargemode_config/pv_charging/feed_in_yield" in msg.topic or - "openWB/set/general/chargemode_config/pv_charging/switch_on_threshold" in msg.topic or - "openWB/set/general/chargemode_config/pv_charging/switch_on_delay" in msg.topic or - "openWB/set/general/chargemode_config/pv_charging/switch_off_delay" in msg.topic): + elif ("openWB/set/general/chargemode_config/surplus/feed_in_yield" in msg.topic or + "openWB/set/general/chargemode_config/surplus/vehicle/switch_on_threshold" in msg.topic or + "openWB/set/general/chargemode_config/surplus/vehicle/switch_on_delay" in msg.topic or + "openWB/set/general/chargemode_config/surplus/vehicle/switch_off_delay" in msg.topic): self._validate_value(msg, int, [(0, float("inf"))]) - elif "openWB/set/general/chargemode_config/pv_charging/switch_off_threshold" in msg.topic: + elif "openWB/set/general/chargemode_config/surplus/vehicle/switch_off_threshold" in msg.topic: self._validate_value(msg, float) - elif "openWB/set/general/chargemode_config/pv_charging/phase_switch_delay" in msg.topic: + elif "openWB/set/general/chargemode_config/surplus/vehicle/phase_switch_delay" in msg.topic: self._validate_value(msg, int, [(5, 180)]) - elif "openWB/set/general/chargemode_config/pv_charging/control_range" in msg.topic: + elif "openWB/set/general/chargemode_config/surplus/control_range" in msg.topic: self._validate_value(msg, int, collection=list) - elif ("openWB/set/general/chargemode_config/pv_charging/min_bat_soc" in msg.topic or - "openWB/set/general/chargemode_config/pv_charging/max_bat_soc" in msg.topic): + elif ("openWB/set/general/chargemode_config/bat/min_soc" in msg.topic or + "openWB/set/general/chargemode_config/bat/max_soc" in msg.topic): self._validate_value(msg, int, [(0, 100)]) - elif ("openWB/set/general/chargemode_config/pv_charging/bat_power_discharge" in msg.topic or - "openWB/set/general/chargemode_config/pv_charging/bat_power_reserve" in msg.topic): + elif ("openWB/set/general/chargemode_config/bat/power_discharge" in msg.topic or + "openWB/set/general/chargemode_config/bat/power_reserve" in msg.topic): self._validate_value(msg, float, [(0, float("inf"))]) - elif "openWB/set/general/chargemode_config/pv_charging/bat_mode" in msg.topic: + elif "openWB/set/general/chargemode_config/bat/mode" in msg.topic: self._validate_value(msg, str) elif "openWB/set/general/chargemode_config/" in msg.topic and "/phases_to_use" in msg.topic: self._validate_value(msg, int, [(1, 1), (3, 3)]) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index a8f1fb7d7e..a34ae66953 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -39,7 +39,7 @@ from control.ev.charge_template import EcoCharging, get_charge_template_default from control.ev import ev from control.ev.ev_template import EvTemplateData -from control.general import Prices, PvCharging +from control.general import ChargemodeConfigSurplusVehicle, Prices from control.optional_data import OcppConfig from dataclass_utils import asdict from modules.common.abstract_vehicle import GeneralVehicleConfig @@ -228,21 +228,22 @@ class UpdateConfig: "^openWB/general/temporary_charge_templates_active$", "^openWB/general/chargemode_config/unbalanced_load_limit$", "^openWB/general/chargemode_config/unbalanced_load$", - "^openWB/general/chargemode_config/pv_charging/bat_mode$", - "^openWB/general/chargemode_config/pv_charging/feed_in_yield$", - "^openWB/general/chargemode_config/pv_charging/switch_on_threshold$", - "^openWB/general/chargemode_config/pv_charging/switch_on_delay$", - "^openWB/general/chargemode_config/pv_charging/switch_off_threshold$", - "^openWB/general/chargemode_config/pv_charging/switch_off_delay$", - "^openWB/general/chargemode_config/pv_charging/phase_switch_delay$", - "^openWB/general/chargemode_config/pv_charging/control_range$", - "^openWB/general/chargemode_config/pv_charging/min_bat_soc$", - "^openWB/general/chargemode_config/pv_charging/max_bat_soc$", - "^openWB/general/chargemode_config/pv_charging/bat_power_discharge$", - "^openWB/general/chargemode_config/pv_charging/bat_power_discharge_active$", - "^openWB/general/chargemode_config/pv_charging/bat_power_reserve$", - "^openWB/general/chargemode_config/pv_charging/bat_power_reserve_active$", - "^openWB/general/chargemode_config/pv_charging/retry_failed_phase_switches$", + "^openWB/general/chargemode_config/surplus/feed_in_limit$", + "^openWB/general/chargemode_config/bat/mode$", + "^openWB/general/chargemode_config/surplus/feed_in_yield$", + "^openWB/general/chargemode_config/surplus/vehicle/switch_on_threshold$", + "^openWB/general/chargemode_config/surplus/vehicle/switch_on_delay$", + "^openWB/general/chargemode_config/surplus/vehicle/switch_off_threshold$", + "^openWB/general/chargemode_config/surplus/vehicle/switch_off_delay$", + "^openWB/general/chargemode_config/surplus/vehicle/phase_switch_delay$", + "^openWB/general/chargemode_config/surplus/control_range$", + "^openWB/general/chargemode_config/bat/min_soc$", + "^openWB/general/chargemode_config/bat/max_soc$", + "^openWB/general/chargemode_config/bat/power_discharge$", + "^openWB/general/chargemode_config/bat/power_discharge_active$", + "^openWB/general/chargemode_config/bat/power_reserve$", + "^openWB/general/chargemode_config/bat/power_reserve_active$", + "^openWB/general/chargemode_config/surplus/vehicle/retry_failed_phase_switches$", # obsolet, Daten hieraus müssen nach prices/ überführt werden "^openWB/general/price_kwh$", "^openWB/general/prices/bat$", @@ -602,22 +603,23 @@ class UpdateConfig: ("openWB/vehicle/template/charge_template/0", get_charge_template_default()), ("openWB/general/allow_unencrypted_access", True), ("openWB/general/charge_log_data_config", get_default_charge_log_columns()), - ("openWB/general/chargemode_config/pv_charging/bat_mode", BatConsiderationMode.EV_MODE.value), - ("openWB/general/chargemode_config/pv_charging/bat_power_discharge", 1000), - ("openWB/general/chargemode_config/pv_charging/bat_power_discharge_active", True), - ("openWB/general/chargemode_config/pv_charging/min_bat_soc", 50), - ("openWB/general/chargemode_config/pv_charging/max_bat_soc", 70), - ("openWB/general/chargemode_config/pv_charging/bat_power_reserve", 200), - ("openWB/general/chargemode_config/pv_charging/bat_power_reserve_active", True), - ("openWB/general/chargemode_config/pv_charging/control_range", [0, 230]), - ("openWB/general/chargemode_config/pv_charging/switch_off_threshold", 0), - ("openWB/general/chargemode_config/pv_charging/switch_off_delay", 60), - ("openWB/general/chargemode_config/pv_charging/switch_on_delay", 30), - ("openWB/general/chargemode_config/pv_charging/switch_on_threshold", 1500), - ("openWB/general/chargemode_config/pv_charging/feed_in_yield", 0), - ("openWB/general/chargemode_config/pv_charging/phase_switch_delay", 7), - ("openWB/general/chargemode_config/pv_charging/retry_failed_phase_switches", - PvCharging().retry_failed_phase_switches), + ("openWB/general/chargemode_config/bat/mode", BatConsiderationMode.EV_MODE.value), + ("openWB/general/chargemode_config/bat/power_discharge", 1000), + ("openWB/general/chargemode_config/bat/power_discharge_active", True), + ("openWB/general/chargemode_config/bat/min_soc", 50), + ("openWB/general/chargemode_config/bat/max_soc", 70), + ("openWB/general/chargemode_config/bat/power_reserve", 200), + ("openWB/general/chargemode_config/bat/power_reserve_active", True), + ("openWB/general/chargemode_config/surplus/control_range", [0, 230]), + ("openWB/general/chargemode_config/surplus/feed_in_limit", False), + ("openWB/general/chargemode_config/surplus/vehicle/switch_off_threshold", 0), + ("openWB/general/chargemode_config/surplus/vehicle/switch_off_delay", 60), + ("openWB/general/chargemode_config/surplus/vehicle/switch_on_delay", 30), + ("openWB/general/chargemode_config/surplus/vehicle/switch_on_threshold", 1500), + ("openWB/general/chargemode_config/surplus/feed_in_yield", 0), + ("openWB/general/chargemode_config/surplus/vehicle/phase_switch_delay", 7), + ("openWB/general/chargemode_config/surplus/vehicle/retry_failed_phase_switches", + ChargemodeConfigSurplusVehicle().retry_failed_phase_switches), ("openWB/general/chargemode_config/unbalanced_load", False), ("openWB/general/chargemode_config/unbalanced_load_limit", 18), ("openWB/general/control_interval", 10), diff --git a/packages/modules/display_themes/cards/source/src/App.vue b/packages/modules/display_themes/cards/source/src/App.vue index 6058e5320e..4c315126f4 100644 --- a/packages/modules/display_themes/cards/source/src/App.vue +++ b/packages/modules/display_themes/cards/source/src/App.vue @@ -63,7 +63,7 @@ export default { "openWB/counter/+/get/power", "openWB/counter/get/hierarchy", "openWB/counter/set/home_consumption", - "openWB/general/chargemode_config/pv_charging/bat_mode", + "openWB/general/chargemode_config/bat/mode", "openWB/optional/ep/configured", "openWB/optional/ep/get/prices", "openWB/optional/int_display/only_local_charge_points", diff --git a/packages/modules/display_themes/cards/source/src/components/Battery/BatteryModeModal.vue b/packages/modules/display_themes/cards/source/src/components/Battery/BatteryModeModal.vue index ac813fe499..5c99374478 100644 --- a/packages/modules/display_themes/cards/source/src/components/Battery/BatteryModeModal.vue +++ b/packages/modules/display_themes/cards/source/src/components/Battery/BatteryModeModal.vue @@ -99,8 +99,8 @@ export default { methods: { setBatteryMode(mode) { if(mode !== this.mqttStore.getBatteryMode) { - this.mqttStore.updateState("openWB/general/chargemode_config/pv_charging/bat_mode", mode); - this.$root.sendTopicToBroker("openWB/general/chargemode_config/pv_charging/bat_mode", mode); + this.mqttStore.updateState("openWB/general/chargemode_config/bat/mode", mode); + this.$root.sendTopicToBroker("openWB/general/chargemode_config/bat/mode", mode); } }, }, diff --git a/packages/modules/display_themes/cards/source/src/stores/mqtt.js b/packages/modules/display_themes/cards/source/src/stores/mqtt.js index aa4c0218ba..8bbc97166c 100644 --- a/packages/modules/display_themes/cards/source/src/stores/mqtt.js +++ b/packages/modules/display_themes/cards/source/src/stores/mqtt.js @@ -397,7 +397,7 @@ export const useMqttStore = defineStore("mqtt", { return state.getChartData("openWB/bat/get/soc"); }, getBatteryMode(state) { - return state.topics["openWB/general/chargemode_config/pv_charging/bat_mode"]; + return state.topics["openWB/general/chargemode_config/bat/mode"]; }, getPvConfigured(state) { return state.getValueBool("openWB/pv/config/configured"); @@ -776,16 +776,6 @@ export const useMqttStore = defineStore("mqtt", { return undefined; }; }, - getChargePointConnectedVehiclePvChargingFeedInLimit(state) { - return (chargePointId) => { - if (state.getChargePointConnectedVehicleChargeTemplate(chargePointId)) { - return state.getChargePointConnectedVehicleChargeTemplate( - chargePointId, - ).chargemode.pv_charging.feed_in_limit; - } - return undefined; - }; - }, getChargePointConnectedVehiclePvChargingMinCurrent(state) { return (chargePointId) => { if (state.getChargePointConnectedVehicleChargeTemplate(chargePointId)) { diff --git a/packages/modules/display_themes/cards/source/src/views/ChargePointsView.vue b/packages/modules/display_themes/cards/source/src/views/ChargePointsView.vue index dffaf783bd..2ba286e972 100644 --- a/packages/modules/display_themes/cards/source/src/views/ChargePointsView.vue +++ b/packages/modules/display_themes/cards/source/src/views/ChargePointsView.vue @@ -217,14 +217,6 @@ export default { this.updateChargePointChargeTemplate(id, event, "chargemode.instant_charging.limit.amount"); } }, - setChargePointConnectedVehiclePvChargingFeedInLimit(id, event) { - if ( - event != - this.mqttStore.getChargePointConnectedVehiclePvChargingFeedInLimit(id) - ) { - this.updateChargePointChargeTemplate(id, event, "chargemode.pv_charging.feed_in_limit"); - } - }, setChargePointConnectedVehiclePvChargingMinCurrent(id, event) { let previous_value = this.mqttStore.getChargePointConnectedVehiclePvChargingMinCurrent(id); @@ -1026,45 +1018,6 @@ export default { - - Einspeisegrenze beachten - - - Nein - - - Ja - - - - - - diff --git a/packages/modules/display_themes/colors/source/src/components/chargePointList/model.ts b/packages/modules/display_themes/colors/source/src/components/chargePointList/model.ts index 710d180a37..8eea21a9c2 100755 --- a/packages/modules/display_themes/colors/source/src/components/chargePointList/model.ts +++ b/packages/modules/display_themes/colors/source/src/components/chargePointList/model.ts @@ -156,15 +156,6 @@ export class ChargePoint { updateChargeTemplate(this.id) } } - get pvFeedInLimit() { - return this.chargeTemplate?.chargemode.pv_charging.feed_in_limit ?? false - } - set pvFeedInLimit(setting: boolean) { - if (this.chargeTemplate) { - this.chargeTemplate.chargemode.pv_charging.feed_in_limit = setting - updateChargeTemplate(this.id) - } - } get pvMinCurrent() { return this.chargeTemplate?.chargemode.pv_charging.min_current ?? 0 } @@ -448,7 +439,6 @@ export interface ChargeTemplate { pv_charging: { dc_min_current: number dc_min_soc_current: number - feed_in_limit: boolean limit: { selected: string amount: number diff --git a/packages/modules/web_themes/colors/source/src/components/chargePointList/cpConfig/ConfigPv.vue b/packages/modules/web_themes/colors/source/src/components/chargePointList/cpConfig/ConfigPv.vue index dfdbb43f8d..d820678972 100755 --- a/packages/modules/web_themes/colors/source/src/components/chargePointList/cpConfig/ConfigPv.vue +++ b/packages/modules/web_themes/colors/source/src/components/chargePointList/cpConfig/ConfigPv.vue @@ -130,15 +130,6 @@ unit="kWh" /> - - - diff --git a/packages/modules/web_themes/colors/source/src/components/chargePointList/model.ts b/packages/modules/web_themes/colors/source/src/components/chargePointList/model.ts index e3291f7b61..0ee9fc4a54 100755 --- a/packages/modules/web_themes/colors/source/src/components/chargePointList/model.ts +++ b/packages/modules/web_themes/colors/source/src/components/chargePointList/model.ts @@ -71,7 +71,6 @@ export class ChargePoint implements PowerItem { private _instantTargetSoc = 0 private _instantMaxEnergy = 0 private _instantTargetPhases = 0 - private _pvFeedInLimit = false private _pvMinCurrent = 0 private _pvMaxSoc = 0 private _pvMinSoc = 0 @@ -206,15 +205,6 @@ export class ChargePoint implements PowerItem { updateChargeTemplate(this.id) } } - get pvFeedInLimit() { - return this.chargeTemplate?.chargemode.pv_charging.feed_in_limit ?? false - } - set pvFeedInLimit(setting: boolean) { - if (this.chargeTemplate) { - this.chargeTemplate.chargemode.pv_charging.feed_in_limit = setting - updateChargeTemplate(this.id) - } - } get pvMinCurrent() { return this.chargeTemplate?.chargemode.pv_charging.min_current ?? 0 } @@ -504,7 +494,6 @@ export interface ChargeTemplate { pv_charging: { dc_min_current: number dc_min_soc_current: number - feed_in_limit: boolean limit: ChargeLimit min_current: number min_soc_current: number diff --git a/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue b/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue index fb1bcb4464..fea8e97903 100644 --- a/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue +++ b/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue @@ -169,7 +169,5 @@ const pvMinSocCurrent = computed(() => ), ); -const feedInLimit = computed(() => - mqttStore.chargePointConnectedVehiclePvChargeFeedInLimit(props.chargePointId), -); + diff --git a/packages/modules/web_themes/koala/source/src/stores/mqtt-store-model.ts b/packages/modules/web_themes/koala/source/src/stores/mqtt-store-model.ts index 3810f16ccb..4c4df71bdb 100644 --- a/packages/modules/web_themes/koala/source/src/stores/mqtt-store-model.ts +++ b/packages/modules/web_themes/koala/source/src/stores/mqtt-store-model.ts @@ -84,7 +84,6 @@ export interface ChargeTemplateConfiguration { pv_charging: { dc_min_current: number; dc_min_soc_current: number; - feed_in_limit: boolean; limit: { selected: string; amount: number; diff --git a/packages/modules/web_themes/koala/source/src/stores/mqtt-store.ts b/packages/modules/web_themes/koala/source/src/stores/mqtt-store.ts index a883431127..8177ba6b49 100644 --- a/packages/modules/web_themes/koala/source/src/stores/mqtt-store.ts +++ b/packages/modules/web_themes/koala/source/src/stores/mqtt-store.ts @@ -1796,30 +1796,6 @@ export const useMqttStore = defineStore('mqtt', () => { }); }; - /** - * Get or set the charge point connected vehicle pv feed in limit active identified by the charge point id - * @param chargePointId charge point id - * @returns object | undefined - */ - const chargePointConnectedVehiclePvChargeFeedInLimit = ( - chargePointId: number, - ) => { - return computed({ - get() { - return chargePointConnectedVehicleChargeTemplate(chargePointId).value - ?.chargemode?.pv_charging?.feed_in_limit; - }, - set(newValue: boolean) { - console.debug('set pv feed in limit active', newValue, chargePointId); - return updateTopic( - `openWB/chargepoint/${chargePointId}/set/charge_template`, - newValue, - 'chargemode.pv_charging.feed_in_limit', - true, - ); - }, - }); - }; /** * Get or set the charge point connected vehicle eco charging current identified by the charge point id @@ -1958,10 +1934,10 @@ export const useMqttStore = defineStore('mqtt', () => { const batteryChargePriorityRange = computed({ get() { const minSoc = getValue.value( - 'openWB/general/chargemode_config/pv_charging/min_bat_soc', + 'openWB/general/chargemode_config/bat/min_soc', ) as number | undefined; const maxSoc = getValue.value( - 'openWB/general/chargemode_config/pv_charging/max_bat_soc', + 'openWB/general/chargemode_config/bat/max_soc', ) as number | undefined; return { min: minSoc ?? 0, @@ -1970,13 +1946,13 @@ export const useMqttStore = defineStore('mqtt', () => { }, set(newRange: RangeValue) { updateTopic( - 'openWB/general/chargemode_config/pv_charging/min_bat_soc', + 'openWB/general/chargemode_config/bat/min_soc newRange.min, undefined, true, ); updateTopic( - 'openWB/general/chargemode_config/pv_charging/max_bat_soc', + 'openWB/general/chargemode_config/bat/max_soc', newRange.max, undefined, true, @@ -2887,13 +2863,13 @@ export const useMqttStore = defineStore('mqtt', () => { get() { return ( (getValue.value( - 'openWB/general/chargemode_config/pv_charging/bat_mode', + 'openWB/general/chargemode_config/bat/mode', ) as string) || undefined ); }, set(newValue: string) { return updateTopic( - 'openWB/general/chargemode_config/pv_charging/bat_mode', + 'openWB/general/chargemode_config/bat/mode', newValue, undefined, true, @@ -4191,7 +4167,6 @@ export const useMqttStore = defineStore('mqtt', () => { chargePointConnectedVehiclePvDcChargePower, chargePointConnectedVehiclePvDcMinSocPower, chargePointConnectedVehiclePvChargePhasesMinSoc, - chargePointConnectedVehiclePvChargeFeedInLimit, chargePointConnectedVehicleEcoChargeCurrent, chargePointConnectedVehicleEcoChargeDcPower, chargePointConnectedVehicleEcoChargePhases, diff --git a/packages/modules/web_themes/standard_legacy/web/index.html b/packages/modules/web_themes/standard_legacy/web/index.html index 1cd5f60f8b..1e55b30ab7 100644 --- a/packages/modules/web_themes/standard_legacy/web/index.html +++ b/packages/modules/web_themes/standard_legacy/web/index.html @@ -161,7 +161,7 @@
+ data-topic="openWB/set/general/chargemode_config/bat/mode"> @@ -957,20 +957,6 @@

Einstellungen für "PV"

-
-
- Einspeisegrenze beachten -
-
- -
-
diff --git a/packages/modules/web_themes/standard_legacy/web/processAllMqttMsg.js b/packages/modules/web_themes/standard_legacy/web/processAllMqttMsg.js index d356efa16e..971ba03a0d 100644 --- a/packages/modules/web_themes/standard_legacy/web/processAllMqttMsg.js +++ b/packages/modules/web_themes/standard_legacy/web/processAllMqttMsg.js @@ -218,15 +218,6 @@ function refreshChargeTemplate(chargePointIndex) { // chargemode.pv_charging.limit.amount element = chargePoint.find('.charge-point-pv-charge-limit-amount'); setInputValue(element.attr('id'), chargeTemplate[chargePointIndex].chargemode.pv_charging.limit.amount); - // chargemode.pv_charging.feed_in_limit - var element = chargePoint.find('.charge-point-pv-charge-feed-in-limit'); // now get parents respective child element - if (chargeTemplate[chargePointIndex].chargemode.pv_charging.feed_in_limit == true) { - // element.prop('checked', true); - element.bootstrapToggle('on', true); // do not fire a changed-event to prevent a loop! - } else { - // element.prop('checked', false); - element.bootstrapToggle('off', true); // do not fire a changed-event to prevent a loop! - } // ***** eco_charging ***** // chargemode.eco_charging.X @@ -793,7 +784,7 @@ function processPvMessages(mqttTopic, mqttPayload) { } function processPvConfigMessages(mqttTopic, mqttPayload) { - if (mqttTopic == 'openWB/general/chargemode_config/pv_charging/bat_mode') { + if (mqttTopic == 'openWB/general/chargemode_config/bat/mode') { data = JSON.parse(mqttPayload); var element = $('.bat-consideration-mode input[type=radio][data-option="' + data + '"]'); element.prop('checked', true); // check selected battery mode radio button diff --git a/packages/modules/web_themes/standard_legacy/web/setupMqttServices.js b/packages/modules/web_themes/standard_legacy/web/setupMqttServices.js index 637c6783b8..b29c14da94 100644 --- a/packages/modules/web_themes/standard_legacy/web/setupMqttServices.js +++ b/packages/modules/web_themes/standard_legacy/web/setupMqttServices.js @@ -73,7 +73,7 @@ var topicsToSubscribe = [ // charge mode config - ["openWB/general/chargemode_config/pv_charging/bat_mode", 0], + ["openWB/general/chargemode_config/bat/mode", 0], // electricity tariff ["openWB/optional/ep/configured", 1], // ep provider information diff --git a/simpleAPI/simpleAPI_mqtt.py b/simpleAPI/simpleAPI_mqtt.py index ce394f75fd..fab4c55b6f 100755 --- a/simpleAPI/simpleAPI_mqtt.py +++ b/simpleAPI/simpleAPI_mqtt.py @@ -826,7 +826,7 @@ def _handle_bat_mode_operation(self, payload: str) -> bool: log.error(f"Invalid bat_mode: {payload}. Valid values: {valid_modes}") return False - target_topic = "openWB/set/general/chargemode_config/pv_charging/bat_mode" + target_topic = "openWB/set/general/chargemode_config/bat/mode" self.client.publish(target_topic, payload, qos=0, retain=True) log.info(f"Set bat_mode to {payload}") return True diff --git a/simpleAPI/src/ParameterHandler.php b/simpleAPI/src/ParameterHandler.php index 1967fb8340..d564aa76b9 100644 --- a/simpleAPI/src/ParameterHandler.php +++ b/simpleAPI/src/ParameterHandler.php @@ -1590,7 +1590,7 @@ private function setBatMode($value) } try { - $topic = "openWB/set/general/chargemode_config/pv_charging/bat_mode"; + $topic = "openWB/set/general/chargemode_config/bat/mode"; if ($this->mqttClient->setValue($topic, $value)) { return ['success' => true, 'message' => "Bat mode set to {$value}"]; From b4623f83023fa3aacf62f2e74d6c9425aaad21ab Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 5 Aug 2026 09:55:14 +0200 Subject: [PATCH 09/84] fix --- .../modules/web_themes/koala/source/src/stores/mqtt-store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/web_themes/koala/source/src/stores/mqtt-store.ts b/packages/modules/web_themes/koala/source/src/stores/mqtt-store.ts index 8177ba6b49..959ac0c845 100644 --- a/packages/modules/web_themes/koala/source/src/stores/mqtt-store.ts +++ b/packages/modules/web_themes/koala/source/src/stores/mqtt-store.ts @@ -1946,7 +1946,7 @@ export const useMqttStore = defineStore('mqtt', () => { }, set(newRange: RangeValue) { updateTopic( - 'openWB/general/chargemode_config/bat/min_soc + 'openWB/general/chargemode_config/bat/min_soc', newRange.min, undefined, true, From dfb944579c40311c4cec73c2e1269744821c3bc5 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 5 Aug 2026 09:58:28 +0200 Subject: [PATCH 10/84] fix --- .../source/src/components/ChargePointPvSettings.vue | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue b/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue index fea8e97903..bb48bb4b3e 100644 --- a/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue +++ b/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue @@ -91,21 +91,11 @@
- -
-
Einspeisegrenze beachten
-
- -
-
From 852d6253cbc62b7744233106545fd58db3b64c9f Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Thu, 9 Apr 2026 15:09:15 +0200 Subject: [PATCH 19/84] move files --- packages/conftest.py | 2 +- packages/control/algorithm/common_test.py | 2 +- packages/control/algorithm/filter_chargepoints_test.py | 2 +- packages/control/algorithm/integration_test/conftest.py | 2 +- packages/control/{ => counter_all}/counter_all.py | 5 +++-- .../{ => counter_all}/counter_home_consumption_test.py | 2 +- packages/control/{ => counter_all}/hierarchy_test.py | 2 +- packages/control/data.py | 2 +- packages/helpermodules/command.py | 3 ++- packages/helpermodules/subdata.py | 3 ++- packages/helpermodules/update_config.py | 2 +- packages/modules/common/store/_counter_test.py | 2 +- packages/modules/common/store/_inverter_test.py | 2 +- packages/modules/devices/generic/virtual/counter_test.py | 2 +- 14 files changed, 18 insertions(+), 15 deletions(-) rename packages/control/{ => counter_all}/counter_all.py (99%) rename packages/control/{ => counter_all}/counter_home_consumption_test.py (98%) rename packages/control/{ => counter_all}/hierarchy_test.py (99%) diff --git a/packages/conftest.py b/packages/conftest.py index b58a69d591..fb6cfc8d44 100644 --- a/packages/conftest.py +++ b/packages/conftest.py @@ -12,7 +12,7 @@ from control.counter import Config as CounterConfig from control.counter import Get as CounterGet from control.counter import Set as CounterSet -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from control.pv import Pv, PvData from control.pv import Config as PvConfig from control.pv import Get as PvGet diff --git a/packages/control/algorithm/common_test.py b/packages/control/algorithm/common_test.py index 707d88c0a2..df53dbb7c2 100644 --- a/packages/control/algorithm/common_test.py +++ b/packages/control/algorithm/common_test.py @@ -8,7 +8,7 @@ from control.chargepoint.chargepoint import Chargepoint from control.ev.ev import Ev from control.counter import Counter -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from control.io_device import IoActions diff --git a/packages/control/algorithm/filter_chargepoints_test.py b/packages/control/algorithm/filter_chargepoints_test.py index def2524811..66fbd7f277 100644 --- a/packages/control/algorithm/filter_chargepoints_test.py +++ b/packages/control/algorithm/filter_chargepoints_test.py @@ -10,7 +10,7 @@ from control.chargepoint.chargepoint import Chargepoint, ChargepointData from control.chargepoint.chargepoint_data import Log, Set from control.chargepoint.control_parameter import ControlParameter -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from control.ev.ev import Ev, EvData, Get diff --git a/packages/control/algorithm/integration_test/conftest.py b/packages/control/algorithm/integration_test/conftest.py index 6b6072c603..eeae55f399 100644 --- a/packages/control/algorithm/integration_test/conftest.py +++ b/packages/control/algorithm/integration_test/conftest.py @@ -8,7 +8,7 @@ from control.bat_all import BatAll from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_template import CpTemplate -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from control.counter import Counter from control.ev.ev import Ev from control.io_device import IoActions diff --git a/packages/control/counter_all.py b/packages/control/counter_all/counter_all.py similarity index 99% rename from packages/control/counter_all.py rename to packages/control/counter_all/counter_all.py index e299129839..41a5592b72 100644 --- a/packages/control/counter_all.py +++ b/packages/control/counter_all/counter_all.py @@ -50,8 +50,9 @@ class Set: @dataclass class Get: - hierarchy: List = field(default_factory=empty_list_factory, metadata={ - "topic": "get/hierarchy"}) + hierarchy: List = field(default_factory=empty_list_factory, metadata={"topic": "get/hierarchy"}) + loadmanagement_prios: List[Dict] = field( + default_factory=empty_list_factory, metadata={"topic": "get/loadmanagement_prios"}) def get_factory() -> Get: diff --git a/packages/control/counter_home_consumption_test.py b/packages/control/counter_all/counter_home_consumption_test.py similarity index 98% rename from packages/control/counter_home_consumption_test.py rename to packages/control/counter_all/counter_home_consumption_test.py index 6303dd63e9..034645a50d 100644 --- a/packages/control/counter_home_consumption_test.py +++ b/packages/control/counter_all/counter_home_consumption_test.py @@ -4,7 +4,7 @@ from control import data from packages.conftest import hierarchy_hc_counter, hierarchy_standard, hierarchy_hybrid, hierarchy_nested -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from modules.common.fault_state import FaultStateLevel diff --git a/packages/control/hierarchy_test.py b/packages/control/counter_all/hierarchy_test.py similarity index 99% rename from packages/control/hierarchy_test.py rename to packages/control/counter_all/hierarchy_test.py index 7fa28a3f10..811d9becc6 100644 --- a/packages/control/hierarchy_test.py +++ b/packages/control/counter_all/hierarchy_test.py @@ -5,7 +5,7 @@ from control.counter import Counter -from control.counter_all import CounterAll, get_max_id_in_hierarchy +from control.counter_all.counter_all import CounterAll, get_max_id_in_hierarchy from modules.common.component_type import ComponentType diff --git a/packages/control/data.py b/packages/control/data.py index 298ced12f5..5d73d59ec1 100644 --- a/packages/control/data.py +++ b/packages/control/data.py @@ -19,7 +19,7 @@ from helpermodules.graph import Graph from helpermodules.subdata import SubData from control.counter import Counter -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from control.ev.charge_template import ChargeTemplate from control.ev.ev import Ev from control.ev.ev_template import EvTemplate diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index e14fd561cc..185337c2a4 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -12,10 +12,11 @@ from pathlib import Path import paho.mqtt.client as mqtt -from control import bat, bridge, counter, counter_all, pv +from control import bat, bridge, counter, pv from control.chargelog.process_chargelog import get_log_data from control.chargepoint import chargepoint from control.chargepoint.chargepoint_template import get_chargepoint_template_default +from control.counter_all import counter_all from control.ev import ev from control.ev.charge_template import ChargeTemplate, get_new_charge_template from control.ev.ev_template import EvTemplateData diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index c3e776e914..50a145a424 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -10,12 +10,13 @@ import subprocess import paho.mqtt.client as mqtt -from control import bat_all, bat, counter, counter_all, general, io_device, optional, pv, pv_all +from control import bat_all, bat, counter, general, io_device, optional, pv, pv_all from control.chargepoint import chargepoint from control.chargepoint.chargepoint_all import AllChargepoints from control.chargepoint.chargepoint_data import Log from control.chargepoint.chargepoint_state_update import ChargepointStateUpdate from control.chargepoint.chargepoint_template import CpTemplate, CpTemplateData +from control.counter_all import counter_all from control.ev.charge_template import ChargeTemplate, ChargeTemplateData from control.ev import ev from control.ev.ev_template import EvTemplate, EvTemplateData diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index b70c14f2bc..4b45f6c883 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -32,7 +32,7 @@ from helpermodules.utils.json_file_handler import write_and_check from helpermodules.utils.run_command import run_command from helpermodules.utils.topic_parser import decode_payload, get_index, get_second_index -from control import counter_all +from control.counter_all import counter_all from control.bat_all import BatConsiderationMode from control.chargepoint.charging_type import ChargingType from control.counter import get_counter_default_config diff --git a/packages/modules/common/store/_counter_test.py b/packages/modules/common/store/_counter_test.py index 989e177c12..c238cb0fa7 100644 --- a/packages/modules/common/store/_counter_test.py +++ b/packages/modules/common/store/_counter_test.py @@ -9,7 +9,7 @@ from control import data from control.chargepoint.chargepoint import Chargepoint from control.counter import Counter, CounterData, Get -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from modules.chargepoints.mqtt.chargepoint_module import ChargepointModule from modules.common.component_state import BatState, ChargepointState, CounterState, InverterState from modules.common.component_type import ComponentType diff --git a/packages/modules/common/store/_inverter_test.py b/packages/modules/common/store/_inverter_test.py index 46b2c58092..682c9ca2d8 100644 --- a/packages/modules/common/store/_inverter_test.py +++ b/packages/modules/common/store/_inverter_test.py @@ -1,6 +1,6 @@ from modules.common.store._inverter import PurgeInverterState from modules.common.component_state import InverterState -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from control.bat import Bat, BatData, Get from typing import List, NamedTuple from unittest.mock import Mock diff --git a/packages/modules/devices/generic/virtual/counter_test.py b/packages/modules/devices/generic/virtual/counter_test.py index a19c60436a..645d1da104 100644 --- a/packages/modules/devices/generic/virtual/counter_test.py +++ b/packages/modules/devices/generic/virtual/counter_test.py @@ -6,7 +6,7 @@ from control import data from control.chargepoint.chargepoint import Chargepoint -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from modules.chargepoints.mqtt.chargepoint_module import ChargepointModule from modules.chargepoints.mqtt.config import Mqtt from modules.common.component_state import BatState, ChargepointState, CounterState, InverterState From 09090e2bcc27069ad055ca521250a5736e5ccad2 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 10 Apr 2026 07:57:21 +0200 Subject: [PATCH 20/84] counter all refactoring, mixin --- packages/control/counter_all/counter_all.py | 391 +----------------- .../control/counter_all/counter_all_data.py | 95 +++++ packages/control/counter_all/hierarchy.py | 317 ++++++++++++++ packages/helpermodules/update_config.py | 6 +- 4 files changed, 420 insertions(+), 389 deletions(-) create mode 100644 packages/control/counter_all/counter_all_data.py create mode 100644 packages/control/counter_all/hierarchy.py diff --git a/packages/control/counter_all/counter_all.py b/packages/control/counter_all/counter_all.py index 41a5592b72..9b13ef198d 100644 --- a/packages/control/counter_all/counter_all.py +++ b/packages/control/counter_all/counter_all.py @@ -1,84 +1,26 @@ """Zähler-Logik """ import copy -from dataclasses import dataclass, field import logging -import re -from typing import Callable, Dict, List, Optional, Tuple, Union +from typing import List, Tuple from control import data from control.counter import Counter -from dataclass_utils.factories import empty_list_factory -from helpermodules.messaging import MessageType, pub_system_message +from control.counter_all.counter_all_data import CounterAllData +from control.counter_all.hierarchy import HierarchyMixin from helpermodules.pub import Pub -from modules.common.component_type import ComponentType, component_type_to_readable_text +from modules.common.component_type import ComponentType from modules.common.fault_state import FaultStateLevel from modules.common.simcount import SimCounter log = logging.getLogger(__name__) -@dataclass -class Config: - home_consumption_source_id: Optional[str] = field( - default=None, metadata={"topic": "config/home_consumption_source_id"}) - consider_less_charging: bool = field( - default=False, metadata={"topic": "config/consider_less_charging"}) - - -def config_factory() -> Config: - return Config() - - -@dataclass -class Set: - loadmanagement_active: bool = field( - default=False, metadata={"topic": "set/loadmanagement_active"}) - home_consumption: float = field(default=0, metadata={"topic": "set/home_consumption"}) - smarthome_power_excluded_from_home_consumption: float = field( - default=0, - metadata={"topic": "set/smarthome_power_excluded_from_home_consumption"}) - invalid_home_consumption: int = field( - default=0, metadata={"topic": "set/invalid_home_consumption"}) - daily_yield_home_consumption: float = field( - default=0, metadata={"topic": "set/daily_yield_home_consumption"}) - imported_home_consumption: float = field( - default=0, metadata={"topic": "set/imported_home_consumption"}) - disengageable_smarthome_power: float = field( - default=0, metadata={"topic": "set/disengageable_smarthome_power"}) - - -@dataclass -class Get: - hierarchy: List = field(default_factory=empty_list_factory, metadata={"topic": "get/hierarchy"}) - loadmanagement_prios: List[Dict] = field( - default_factory=empty_list_factory, metadata={"topic": "get/loadmanagement_prios"}) - - -def get_factory() -> Get: - return Get() - - -def set_factory() -> Set: - return Set() - - -@dataclass -class CounterAllData: - config: Config = field(default_factory=config_factory) - get: Get = field(default_factory=get_factory) - set: Set = field(default_factory=set_factory) - - -class CounterAll: +class CounterAll(HierarchyMixin): MISSING_EVU_COUNTER = "Bitte erst einen EVU-Zähler konfigurieren." def __init__(self): self.data = CounterAllData() - # Hilfsvariablen für die rekursiven Funktionen - self.connected_counters = [] - self.connected_chargepoints = [] - self.childless = [] self.sim_counter = SimCounter("", "", ComponentType.COUNTER.value) self.sim_counter.topic = "openWB/set/counter/set/" @@ -193,329 +135,6 @@ def get_elements_for_downstream_calculation(self, id: int): elements_to_sum_up.extend(self._add_hybrid_bat(element['id'])) return elements_to_sum_up - # Hierarchie analysieren - - def get_all_elements_without_children(self, id: int) -> List[Dict]: - self.childless.clear() - self.get_all_elements_without_children_recursive(self.get_entry_of_element(id)) - return self.childless - - def get_all_elements_without_children_recursive(self, child: Dict) -> None: - for child in child["children"]: - try: - if len(child["children"]) != 0: - self.get_all_elements_without_children_recursive(child) - else: - self.childless.append(child) - except Exception: - log.exception("Fehler in der allgemeinen Zähler-Klasse") - - def get_chargepoints_of_counter(self, counter: str) -> List[str]: - """ gibt eine Liste der Ladepunkte, die in den folgenden Zweigen des Zählers sind, zurück. - """ - self.connected_chargepoints.clear() - if counter == self.get_evu_counter_str(): - counter_object = self.data.get.hierarchy[0] - else: - counter_object = self.__get_entry( - self.data.get.hierarchy[0], - int(counter[7:]), - self.__get_entry_of_element) - try: - self._get_all_cp_connected_to_counter(counter_object) - except KeyError: - # Kein Ladepunkt unter dem Zähler - pass - return self.connected_chargepoints - - def _get_all_cp_connected_to_counter(self, child: Dict) -> None: - """ Rekursive Funktion, die alle Ladepunkte ermittelt, die an den angegebenen Zähler angeschlossen sind. - """ - # Alle Objekte der Ebene durchgehen - for child in child["children"]: - try: - if child["type"] == ComponentType.CHARGEPOINT.value: - self.connected_chargepoints.append(f"cp{child['id']}") - # Wenn das Objekt noch Kinder hat, diese ebenfalls untersuchen. - elif len(child["children"]) != 0: - self._get_all_cp_connected_to_counter(child) - except Exception: - log.exception("Fehler in der allgemeinen Zähler-Klasse") - - def get_counters_to_check(self, num: int) -> List[str]: - """ ermittelt alle Zähler im Zweig des Ladepunkts. - """ - self.connected_counters.clear() - self.__get_all_counter_in_branch(self.data.get.hierarchy[0], num) - return self.connected_counters - - def get_entry_of_element(self, id_to_find: int) -> Dict[str, List[Dict[str, Union[int, str]]]]: - item = self.__is_id_in_top_level(id_to_find) - if item: - return item - else: - return self.__get_entry(self.data.get.hierarchy[0], id_to_find, self.__get_entry_of_element) - - def get_entry_of_parent(self, id_to_find: int) -> Dict: - if self.__is_id_in_top_level(id_to_find): - return {} - for child in self.data.get.hierarchy[0]["children"]: - if child["id"] == id_to_find: - return self.data.get.hierarchy[0] - else: - return self.__get_entry(self.data.get.hierarchy[0], id_to_find, self.__get_entry_of_parent) - - def __is_id_in_top_level(self, id_to_find: int) -> Dict: - for item in self.data.get.hierarchy: - if item["id"] == id_to_find: - return item - else: - return {} - - def __get_all_counter_in_branch(self, child: Dict, id_to_find: int) -> bool: - """ Rekursive Funktion, die alle Zweige durchgeht, bis der entsprechende Ladepunkt gefunden wird und dann alle - Zähler in diesem Pfad der Liste anhängt. - """ - parent_id = child["id"] - for child in child["children"]: - if child["id"] == id_to_find: - self.connected_counters.append(f"counter{parent_id}") - return True - if len(child["children"]) != 0: - found = self.__get_all_counter_in_branch(child, id_to_find) - if found: - self.connected_counters.append(f"counter{parent_id}") - return True - else: - return False - - def __get_entry(self, child: Dict, id_to_find: int, func: Callable[[Dict, int], bool]) -> Dict: - for child in child["children"]: - found = func(child, id_to_find) - if found: - return child - if len(child["children"]) != 0: - entry = self.__get_entry(child, id_to_find, func) - if entry: - return entry - else: - return {} - - def __get_entry_of_element(self, child: Dict, id_to_find: int) -> bool: - if child["id"] == id_to_find: - return True - else: - return False - - def __get_entry_of_parent(self, child: Dict, id_to_find: int) -> bool: - for child2 in child["children"]: - if child2["id"] == id_to_find: - return True - else: - return False - - def hierarchy_add_item_aside(self, new_id: int, new_type: ComponentType, id_to_find: int) -> None: - """ ruft die rekursive Funktion zum Hinzufügen eines Zählers oder Ladepunkts in die Zählerhierarchie auf - derselben Ebene wie das angegebene Element. - """ - if self.__is_id_in_top_level(id_to_find): - self.data.get.hierarchy.append({"id": new_id, "type": new_type.value, "children": []}) - else: - if (self.__edit_element_in_hierarchy( - self.data.get.hierarchy[0], - id_to_find, self._add_item_aside, new_id, new_type) is False): - raise IndexError(f"Element {id_to_find} konnte nicht in der Hierarchie gefunden werden.") - - def _add_item_aside( - self, child: Dict, current_entry: Dict, id_to_find: int, new_id: int, new_type: ComponentType) -> bool: - if id_to_find == child["id"]: - current_entry["children"].append({"id": new_id, "type": new_type.value, "children": []}) - return True - else: - return False - - def hierarchy_remove_item(self, id_to_find: int, keep_children: bool = True) -> None: - """ruft die rekursive Funktion zum Löschen eines Elements. Je nach Flag werden die Kinder gelöscht oder auf die - Ebene des gelöschten Elements gehoben. - """ - item = self.__is_id_in_top_level(id_to_find) - if item: - if keep_children: - self.data.get.hierarchy.extend(item["children"]) - self.data.get.hierarchy.remove(item) - else: - if (self.__edit_element_in_hierarchy( - self.data.get.hierarchy[0], - id_to_find, self._remove_item, keep_children) is False): - raise IndexError(f"Element {id_to_find} konnte nicht in der Hierarchie gefunden werden.") - - def _remove_item(self, child: Dict, current_entry: Dict, id: str, keep_children: bool) -> bool: - if id == child["id"]: - if keep_children: - current_entry["children"].extend(child["children"]) - current_entry["children"].remove(child) - return True - else: - return False - - def hierarchy_add_item_below_evu(self, new_id: int, new_type: ComponentType) -> None: - try: - self.hierarchy_add_item_below(new_id, new_type, self.get_id_evu_counter()) - except (TypeError, IndexError): - if new_type == ComponentType.COUNTER: - # es gibt noch keinen EVU-Zähler - hierarchy = [{ - "id": new_id, - "type": ComponentType.COUNTER.value, - "children": self.data.get.hierarchy - }] - self.data.get.hierarchy = hierarchy - else: - raise ValueError(self.MISSING_EVU_COUNTER) - - def hierarchy_add_item_below(self, new_id: int, new_type: ComponentType, id_to_find: int) -> None: - """ruft die rekursive Funktion zum Hinzufügen eines Elements als Kind des angegebenen Elements. - """ - item = self.__is_id_in_top_level(id_to_find) - if item: - item["children"].append({"id": new_id, "type": new_type.value, "children": []}) - else: - if (self.__edit_element_in_hierarchy( - self.data.get.hierarchy[0], - id_to_find, self._add_item_below, new_id, new_type) is False): - raise IndexError(f"Element {id_to_find} konnte nicht in der Hierarchie gefunden werden.") - - def _add_item_below( - self, child: Dict, current_entry: Dict, id_to_find: int, new_id: int, new_type: ComponentType) -> bool: - if id_to_find == child["id"]: - child["children"].append({"id": new_id, "type": new_type.value, "children": []}) - return True - else: - return False - - def __edit_element_in_hierarchy(self, current_entry: Dict, id_to_find: int, func: Callable, *args) -> bool: - for child in current_entry["children"]: - if func(child, current_entry, id_to_find, *args): - return True - else: - if len(child["children"]) != 0: - if self.__edit_element_in_hierarchy(child, id_to_find, func, *args): - return True - else: - return False - - def get_list_of_elements_per_level(self) -> List[List[Dict[str, Union[int, str]]]]: - elements_per_level: List[List[Dict[str, Union[int, str]]]] = [] - for item in self.data.get.hierarchy: - list(zip(elements_per_level, self._get_list_of_elements_per_level(elements_per_level, item, 0))) - return elements_per_level - - def _get_list_of_elements_per_level(self, elements_per_level: List, child: Dict, index: int) -> List: - try: - elements_per_level[index].extend([{"type": child["type"], "id": child["id"]}]) - except IndexError: - elements_per_level.insert(index, [{"type": child["type"], "id": child["id"]}]) - for child in child["children"]: - elements_per_level = self._get_list_of_elements_per_level(elements_per_level, child, index+1) - return elements_per_level - - def get_hybrid_inverter_ids(self) -> List[int]: - inverter_ids = [] - - def collect_hybrid_inverter_ids(elements: List[Dict]): - for element in elements: - if element["type"] == ComponentType.INVERTER.value: - if any(child.get("type") == ComponentType.BAT.value for child in element.get("children", [])): - inverter_ids.append(element["id"]) - collect_hybrid_inverter_ids(element.get("children", [])) - - collect_hybrid_inverter_ids(self.data.get.hierarchy) - return inverter_ids - - def _get_bat_ids_by_hybrid_type(self, hybrid: bool) -> List[int]: - bat_ids = [] - - def collect_bat_ids(elements: List[Dict], parent_type: Optional[str] = None): - for element in elements: - element_type = element.get("type") - if element_type == ComponentType.BAT.value: - is_hybrid_bat = parent_type == ComponentType.INVERTER.value - if is_hybrid_bat == hybrid: - bat_ids.append(element["id"]) - collect_bat_ids(element.get("children", []), element_type) - - collect_bat_ids(self.data.get.hierarchy) - return bat_ids - - def get_hybrid_bat_ids(self) -> List[int]: - return self._get_bat_ids_by_hybrid_type(hybrid=True) - - def get_non_hybrid_bat_ids(self) -> List[int]: - return self._get_bat_ids_by_hybrid_type(hybrid=False) - - def validate_hierarchy(self): - try: - self._delete_obsolete_entries() - self._add_missing_entries() - except Exception: - log.exception("Fehler bei der Validierung der Hierarchie") - - def _delete_obsolete_entries(self): - def check_and_remove(name, type_name: ComponentType, data_structure): - if element["type"] == type_name.value: - if f"{name}{element['id']}" not in data_structure: - self.hierarchy_remove_item(element["id"]) - pub_system_message({}, f"{component_type_to_readable_text(type_name)} mit ID {element['id']} wurde" - " aus der Hierarchie entfernt, da keine gültige Konfiguration gefunden wurde.", - MessageType.WARNING) - - for level in self.get_list_of_elements_per_level(): - for element in level: - check_and_remove("bat", ComponentType.BAT, data.data.bat_data) - check_and_remove("counter", ComponentType.COUNTER, data.data.counter_data) - check_and_remove("cp", ComponentType.CHARGEPOINT, data.data.cp_data) - check_and_remove("pv", ComponentType.INVERTER, data.data.pv_data) - - def _add_missing_entries(self): - def check_and_add(type_name: ComponentType, data_structure): - for entry in data_structure: - break_flag = False - re_result = re.search("[0-9]+", entry) - if re_result is not None: - entry_num = int(re_result.group()) - for level in self.get_list_of_elements_per_level(): - for element in level: - if entry_num == element["id"] and element["type"] == type_name.value: - break_flag = True - break - if break_flag: - break - else: - try: - self.hierarchy_add_item_below_evu(entry_num, type_name) - except ValueError: - pub_system_message({}, "Die Struktur des Lastmanagements ist nicht plausibel. Bitte prüfe die " - "Konfiguration und Anordnung der Komponenten in der Hierarchie.", - MessageType.WARNING) - - pub_system_message({}, f"{component_type_to_readable_text(type_name)} mit ID {element['id']} wurde" - " in der Struktur des Lastmanagements hinzugefügt, da kein Eintrag in der " - "Struktur gefunden wurde. Bitte prüfe die Anordnung der Komponenten in der " - "Struktur.", - MessageType.WARNING) - - # Falls EVU-Zähler fehlt, zuerst hinzufügen. - check_and_add(ComponentType.COUNTER, data.data.counter_data) - try: - self.get_id_evu_counter() - check_and_add(ComponentType.BAT, data.data.bat_data) - check_and_add(ComponentType.CHARGEPOINT, data.data.cp_data) - check_and_add(ComponentType.INVERTER, data.data.pv_data) - except TypeError: - pub_system_message({}, ("Es konnte kein Zähler gefunden werden, der als EVU-Zähler an die Spitze des " - "Lastmanagements gesetzt werden kann. Bitte zuerst einen EVU-Zähler hinzufügen."), - MessageType.ERROR) - def get_max_id_in_hierarchy(current_entry: List, max_id: int) -> int: for item in current_entry: diff --git a/packages/control/counter_all/counter_all_data.py b/packages/control/counter_all/counter_all_data.py new file mode 100644 index 0000000000..1633fb9990 --- /dev/null +++ b/packages/control/counter_all/counter_all_data.py @@ -0,0 +1,95 @@ +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional, Protocol, Union + +from dataclass_utils.factories import empty_list_factory +from modules.common.component_type import ComponentType + + +@dataclass +class Config: + home_consumption_source_id: Optional[str] = field( + default=None, metadata={"topic": "config/home_consumption_source_id"}) + consider_less_charging: bool = field( + default=False, metadata={"topic": "config/consider_less_charging"}) + + +def config_factory() -> Config: + return Config() + + +@dataclass +class Set: + loadmanagement_active: bool = field( + default=False, metadata={"topic": "set/loadmanagement_active"}) + home_consumption: float = field(default=0, metadata={"topic": "set/home_consumption"}) + smarthome_power_excluded_from_home_consumption: float = field( + default=0, + metadata={"topic": "set/smarthome_power_excluded_from_home_consumption"}) + invalid_home_consumption: int = field( + default=0, metadata={"topic": "set/invalid_home_consumption"}) + daily_yield_home_consumption: float = field( + default=0, metadata={"topic": "set/daily_yield_home_consumption"}) + imported_home_consumption: float = field( + default=0, metadata={"topic": "set/imported_home_consumption"}) + disengageable_smarthome_power: float = field( + default=0, metadata={"topic": "set/disengageable_smarthome_power"}) + + +@dataclass +class Get: + hierarchy: List = field(default_factory=empty_list_factory, metadata={"topic": "get/hierarchy"}) + loadmanagement_prios: List[Dict] = field( + default_factory=empty_list_factory, metadata={"topic": "get/loadmanagement_prios"}) + + +def get_factory() -> Get: + return Get() + + +def set_factory() -> Set: + return Set() + + +@dataclass +class CounterAllData: + config: Config = field(default_factory=config_factory) + get: Get = field(default_factory=get_factory) + set: Set = field(default_factory=set_factory) + + +class HierarchyProtocol(Protocol): + @property + def childless(self) -> List: ... + @property + def connected_chargepoints(self) -> List: ... + @property + def connected_counters(self) -> List: ... + @property + def data(self) -> CounterAllData: ... + @property + def MISSING_EVU_COUNTER(self) -> str: ... + + def _add_item_aside(self, child: Dict, current_entry: Dict, id_to_find: int, + new_id: int, new_type: ComponentType) -> bool: ... + def _add_item_below(self, child: Dict, current_entry: Dict, id_to_find: int, + new_id: int, new_type: ComponentType) -> bool: ... + + def _add_missing_entries(self): ... + def _delete_obsolete_entries(self): ... + def _edit_element_in_hierarchy(self, current_entry: Dict, id_to_find: int, func: Callable, *args) -> bool: ... + def _get_all_counter_in_branch(self, child: Dict, id_to_find: int) -> bool: ... + def _get_all_cp_connected_to_counter(self, child: Dict) -> None: ... + def _get_all_elements_without_children_recursive(self, child: Dict) -> None: ... + def _get_entry(self, child: Dict, id_to_find: int, func: Callable[[Dict, int], bool]) -> Dict: ... + def _get_entry_of_element(self, child: Dict, id_to_find: int) -> bool: ... + def _get_entry_of_parent(self, child: Dict, id_to_find: int) -> bool: ... + def _get_list_of_elements_per_level(self, elements_per_level: List, child: Dict, index: int) -> List: ... + def _is_id_in_top_level(self, id_to_find: int) -> Dict: ... + def _remove_item(self, child: Dict, current_entry: Dict, id: str, keep_children: bool) -> bool: ... + def get_entry_of_element(self, id: int) -> Dict: ... + def get_evu_counter_str(self) -> str: ... + def get_id_evu_counter(self) -> int: ... + def get_list_of_elements_per_level(self) -> List[List[Dict[str, Union[int, str]]]]: ... + def hierarchy_add_item_below(self, new_id: int, new_type: ComponentType, id_to_find: int) -> None: ... + def hierarchy_add_item_below_evu(self, new_id: int, new_type: ComponentType) -> None: ... + def hierarchy_remove_item(self, id_to_find: int, keep_children: bool = True) -> None: ... diff --git a/packages/control/counter_all/hierarchy.py b/packages/control/counter_all/hierarchy.py new file mode 100644 index 0000000000..109e3b0397 --- /dev/null +++ b/packages/control/counter_all/hierarchy.py @@ -0,0 +1,317 @@ +import logging +import re +from typing import Callable, Dict, List, Union + +from control import data +from control.counter_all.counter_all_data import HierarchyProtocol +from helpermodules.messaging import MessageType, pub_system_message +from modules.common.component_type import ComponentType, component_type_to_readable_text + +log = logging.getLogger(__name__) + + +class HierarchyMixin: + def get_all_elements_without_children(self: HierarchyProtocol, id: int) -> List[Dict]: + self.childless = [] + self._get_all_elements_without_children_recursive(self.get_entry_of_element(id)) + return self.childless + + def _get_all_elements_without_children_recursive(self: HierarchyProtocol, child: Dict) -> None: + for child in child["children"]: + try: + if len(child["children"]) != 0: + self._get_all_elements_without_children_recursive(child) + else: + self.childless.append(child) + except Exception: + log.exception("Fehler in der allgemeinen Zähler-Klasse") + + def get_chargepoints_of_counter(self: HierarchyProtocol, counter: str) -> List[str]: + """ gibt eine Liste der Ladepunkte, die in den folgenden Zweigen des Zählers sind, zurück. + """ + self.connected_chargepoints = [] + if counter == self.get_evu_counter_str(): + counter_object = self.data.get.hierarchy[0] + else: + counter_object = self._get_entry( + self.data.get.hierarchy[0], + int(counter[7:]), + self._get_entry_of_element) + try: + self._get_all_cp_connected_to_counter(counter_object) + except KeyError: + # Kein Ladepunkt unter dem Zähler + pass + return self.connected_chargepoints + + def _get_all_cp_connected_to_counter(self: HierarchyProtocol, child: Dict) -> None: + """ Rekursive Funktion, die alle Ladepunkte ermittelt, die an den angegebenen Zähler angeschlossen sind. + """ + # Alle Objekte der Ebene durchgehen + for child in child["children"]: + try: + if child["type"] == ComponentType.CHARGEPOINT.value: + self.connected_chargepoints.append(f"cp{child['id']}") + # Wenn das Objekt noch Kinder hat, diese ebenfalls untersuchen. + elif len(child["children"]) != 0: + self._get_all_cp_connected_to_counter(child) + except Exception: + log.exception("Fehler in der allgemeinen Zähler-Klasse") + + def get_counters_to_check(self: HierarchyProtocol, num: int) -> List[str]: + """ ermittelt alle Zähler im Zweig des Ladepunkts. + """ + self.connected_counters = [] + self._get_all_counter_in_branch(self.data.get.hierarchy[0], num) + return self.connected_counters + + def get_entry_of_element(self: HierarchyProtocol, id_to_find: int) -> Dict[str, List[Dict[str, Union[int, str]]]]: + item = self._is_id_in_top_level(id_to_find) + if item: + return item + else: + return self._get_entry(self.data.get.hierarchy[0], id_to_find, self._get_entry_of_element) + + def get_entry_of_parent(self: HierarchyProtocol, id_to_find: int) -> Dict: + if self._is_id_in_top_level(id_to_find): + return {} + for child in self.data.get.hierarchy[0]["children"]: + if child["id"] == id_to_find: + return self.data.get.hierarchy[0] + else: + return self._get_entry(self.data.get.hierarchy[0], id_to_find, self._get_entry_of_parent) + + def _is_id_in_top_level(self: HierarchyProtocol, id_to_find: int) -> Dict: + for item in self.data.get.hierarchy: + if item["id"] == id_to_find: + return item + else: + return {} + + def _get_all_counter_in_branch(self: HierarchyProtocol, child: Dict, id_to_find: int) -> bool: + """ Rekursive Funktion, die alle Zweige durchgeht, bis der entsprechende Ladepunkt gefunden wird und dann alle + Zähler in diesem Pfad der Liste anhängt. + """ + parent_id = child["id"] + for child in child["children"]: + if child["id"] == id_to_find: + self.connected_counters.append(f"counter{parent_id}") + return True + if len(child["children"]) != 0: + found = self._get_all_counter_in_branch(child, id_to_find) + if found: + self.connected_counters.append(f"counter{parent_id}") + return True + else: + return False + + def _get_entry(self: HierarchyProtocol, child: Dict, id_to_find: int, func: Callable[[Dict, int], bool]) -> Dict: + for child in child["children"]: + found = func(child, id_to_find) + if found: + return child + if len(child["children"]) != 0: + entry = self._get_entry(child, id_to_find, func) + if entry: + return entry + else: + return {} + + def _get_entry_of_element(self: HierarchyProtocol, child: Dict, id_to_find: int) -> bool: + if child["id"] == id_to_find: + return True + else: + return False + + def _get_entry_of_parent(self: HierarchyProtocol, child: Dict, id_to_find: int) -> bool: + for child2 in child["children"]: + if child2["id"] == id_to_find: + return True + else: + return False + + def hierarchy_add_item_aside(self: HierarchyProtocol, + new_id: int, new_type: ComponentType, + id_to_find: int) -> None: + """ ruft die rekursive Funktion zum Hinzufügen eines Zählers oder Ladepunkts in die Zählerhierarchie auf + derselben Ebene wie das angegebene Element. + """ + if self._is_id_in_top_level(id_to_find): + self.data.get.hierarchy.append({"id": new_id, "type": new_type.value, "children": []}) + else: + if (self._edit_element_in_hierarchy( + self.data.get.hierarchy[0], + id_to_find, self._add_item_aside, new_id, new_type) is False): + raise IndexError(f"Element {id_to_find} konnte nicht in der Hierarchie gefunden werden.") + + def _add_item_aside(self: HierarchyProtocol, + child: Dict, + current_entry: List, + id_to_find: int, + new_id: int, + new_type: ComponentType) -> bool: + if id_to_find == child["id"]: + current_entry["children"].append({"id": new_id, "type": new_type.value, "children": []}) + return True + else: + return False + + def hierarchy_remove_item(self: HierarchyProtocol, id_to_find: int, keep_children: bool = True) -> None: + """ruft die rekursive Funktion zum Löschen eines Elements. Je nach Flag werden die Kinder gelöscht oder auf die + Ebene des gelöschten Elements gehoben. + """ + item = self._is_id_in_top_level(id_to_find) + if item: + if keep_children: + self.data.get.hierarchy.extend(item["children"]) + self.data.get.hierarchy.remove(item) + else: + if (self._edit_element_in_hierarchy( + self.data.get.hierarchy[0], + id_to_find, self._remove_item, keep_children) is False): + raise IndexError(f"Element {id_to_find} konnte nicht in der Hierarchie gefunden werden.") + + def _remove_item(self: HierarchyProtocol, child: Dict, current_entry: Dict, id: str, keep_children: bool) -> bool: + if id == child["id"]: + if keep_children: + current_entry["children"].extend(child["children"]) + current_entry["children"].remove(child) + return True + else: + return False + + def hierarchy_add_item_below_evu(self: HierarchyProtocol, new_id: int, new_type: ComponentType) -> None: + try: + self.hierarchy_add_item_below(new_id, new_type, self.get_id_evu_counter()) + except (TypeError, IndexError): + if new_type == ComponentType.COUNTER: + # es gibt noch keinen EVU-Zähler + hierarchy = [{ + "id": new_id, + "type": ComponentType.COUNTER.value, + "children": self.data.get.hierarchy + }] + self.data.get.hierarchy = hierarchy + else: + raise ValueError(self.MISSING_EVU_COUNTER) + + def hierarchy_add_item_below(self: HierarchyProtocol, + new_id: int, + new_type: ComponentType, id_to_find: int) -> None: + """ruft die rekursive Funktion zum Hinzufügen eines Elements als Kind des angegebenen Elements. + """ + item = self._is_id_in_top_level(id_to_find) + if item: + item["children"].append({"id": new_id, "type": new_type.value, "children": []}) + else: + if (self._edit_element_in_hierarchy( + self.data.get.hierarchy[0], + id_to_find, self._add_item_below, new_id, new_type) is False): + raise IndexError(f"Element {id_to_find} konnte nicht in der Hierarchie gefunden werden.") + + def _add_item_below(self: HierarchyProtocol, + child: Dict, current_entry: Dict, + id_to_find: int, + new_id: int, + new_type: ComponentType) -> bool: + if id_to_find == child["id"]: + child["children"].append({"id": new_id, "type": new_type.value, "children": []}) + return True + else: + return False + + def _edit_element_in_hierarchy(self: HierarchyProtocol, + current_entry: Dict, + id_to_find: int, + func: Callable, + *args) -> bool: + for child in current_entry["children"]: + if func(child, current_entry, id_to_find, *args): + return True + else: + if len(child["children"]) != 0: + if self._edit_element_in_hierarchy(child, id_to_find, func, *args): + return True + else: + return False + + def get_list_of_elements_per_level(self: HierarchyProtocol) -> List[List[Dict[str, Union[int, str]]]]: + elements_per_level: List[List[Dict[str, Union[int, str]]]] = [] + for item in self.data.get.hierarchy: + list(zip(elements_per_level, self._get_list_of_elements_per_level(elements_per_level, item, 0))) + return elements_per_level + + def _get_list_of_elements_per_level(self: HierarchyProtocol, + elements_per_level: List[List[Dict[str, Union[int, str]]]], + child: Dict, + index: int) -> List: + try: + elements_per_level[index].extend([{"type": child["type"], "id": child["id"]}]) + except IndexError: + elements_per_level.insert(index, [{"type": child["type"], "id": child["id"]}]) + for child in child["children"]: + elements_per_level = self._get_list_of_elements_per_level(elements_per_level, child, index+1) + return elements_per_level + + def validate_hierarchy(self: HierarchyProtocol): + try: + self._delete_obsolete_entries() + self._add_missing_entries() + except Exception: + log.exception("Fehler bei der Validierung der Hierarchie") + + def _delete_obsolete_entries(self: HierarchyProtocol): + def check_and_remove(name, type_name: ComponentType, data_structure): + if element["type"] == type_name.value: + if f"{name}{element['id']}" not in data_structure: + self.hierarchy_remove_item(element["id"]) + pub_system_message({}, f"{component_type_to_readable_text(type_name)} mit ID {element['id']} wurde" + " aus der Hierarchie entfernt, da keine gültige Konfiguration gefunden wurde.", + MessageType.WARNING) + + for level in self.get_list_of_elements_per_level(): + for element in level: + check_and_remove("bat", ComponentType.BAT, data.data.bat_data) + check_and_remove("counter", ComponentType.COUNTER, data.data.counter_data) + check_and_remove("cp", ComponentType.CHARGEPOINT, data.data.cp_data) + check_and_remove("pv", ComponentType.INVERTER, data.data.pv_data) + + def _add_missing_entries(self: HierarchyProtocol): + def check_and_add(type_name: ComponentType, data_structure): + for entry in data_structure: + break_flag = False + re_result = re.search("[0-9]+", entry) + if re_result is not None: + entry_num = int(re_result.group()) + for level in self.get_list_of_elements_per_level(): + for element in level: + if entry_num == element["id"] and element["type"] == type_name.value: + break_flag = True + break + if break_flag: + break + else: + try: + self.hierarchy_add_item_below_evu(entry_num, type_name) + except ValueError: + pub_system_message({}, "Die Struktur des Lastmanagements ist nicht plausibel. Bitte prüfe die " + "Konfiguration und Anordnung der Komponenten in der Hierarchie.", + MessageType.WARNING) + + pub_system_message({}, f"{component_type_to_readable_text(type_name)} mit ID {element['id']} wurde" + " in der Struktur des Lastmanagements hinzugefügt, da kein Eintrag in der " + "Struktur gefunden wurde. Bitte prüfe die Anordnung der Komponenten in der " + "Struktur.", + MessageType.WARNING) + + # Falls EVU-Zähler fehlt, zuerst hinzufügen. + check_and_add(ComponentType.COUNTER, data.data.counter_data) + try: + self.get_id_evu_counter() + check_and_add(ComponentType.BAT, data.data.bat_data) + check_and_add(ComponentType.CHARGEPOINT, data.data.cp_data) + check_and_add(ComponentType.INVERTER, data.data.pv_data) + except TypeError: + pub_system_message({}, ("Es konnte kein Zähler gefunden werden, der als EVU-Zähler an die Spitze des " + "Lastmanagements gesetzt werden kann. Bitte zuerst einen EVU-Zähler hinzufügen."), + MessageType.ERROR) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 4b45f6c883..d80a4014ef 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -32,7 +32,7 @@ from helpermodules.utils.json_file_handler import write_and_check from helpermodules.utils.run_command import run_command from helpermodules.utils.topic_parser import decode_payload, get_index, get_second_index -from control.counter_all import counter_all +from control.counter_all import counter_all, counter_all_data from control.bat_all import BatConsiderationMode from control.chargepoint.charging_type import ChargingType from control.counter import get_counter_default_config @@ -587,8 +587,8 @@ class UpdateConfig: ("openWB/chargepoint/get/power", 0), ("openWB/chargepoint/template/0", get_chargepoint_template_default()), ("openWB/counter/get/hierarchy", []), - ("openWB/counter/config/consider_less_charging", counter_all.Config().consider_less_charging), - ("openWB/counter/config/home_consumption_source_id", counter_all.Config().home_consumption_source_id), + ("openWB/counter/config/consider_less_charging", counter_all_data.Config().consider_less_charging), + ("openWB/counter/config/home_consumption_source_id", counter_all_data.Config().home_consumption_source_id), ("openWB/vehicle/0/name", "Standard-Fahrzeug"), ("openWB/vehicle/0/color", DEFAULT_COLORS.VEHICLE.value), ("openWB/vehicle/0/info", {"manufacturer": None, "model": None}), From 0652ed63a91d1041ceafd19c2a632dc5bb7d3d9e Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 10 Apr 2026 16:06:59 +0200 Subject: [PATCH 21/84] loadmanagement prio operations --- packages/control/counter_all/counter_all.py | 3 +- .../control/counter_all/counter_all_data.py | 11 ++ .../counter_all/loadmanagement_prio.py | 70 +++++++ .../counter_all/loadmanagement_prio_test.py | 187 ++++++++++++++++++ 4 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 packages/control/counter_all/loadmanagement_prio.py create mode 100644 packages/control/counter_all/loadmanagement_prio_test.py diff --git a/packages/control/counter_all/counter_all.py b/packages/control/counter_all/counter_all.py index 9b13ef198d..82faab0667 100644 --- a/packages/control/counter_all/counter_all.py +++ b/packages/control/counter_all/counter_all.py @@ -8,6 +8,7 @@ from control.counter import Counter from control.counter_all.counter_all_data import CounterAllData from control.counter_all.hierarchy import HierarchyMixin +from control.counter_all.loadmanagement_prio import LoadmanagementPrioMixin from helpermodules.pub import Pub from modules.common.component_type import ComponentType from modules.common.fault_state import FaultStateLevel @@ -16,7 +17,7 @@ log = logging.getLogger(__name__) -class CounterAll(HierarchyMixin): +class CounterAll(HierarchyMixin, LoadmanagementPrioMixin): MISSING_EVU_COUNTER = "Bitte erst einen EVU-Zähler konfigurieren." def __init__(self): diff --git a/packages/control/counter_all/counter_all_data.py b/packages/control/counter_all/counter_all_data.py index 1633fb9990..cf5a8ade89 100644 --- a/packages/control/counter_all/counter_all_data.py +++ b/packages/control/counter_all/counter_all_data.py @@ -1,6 +1,7 @@ from dataclasses import dataclass, field from typing import Callable, Dict, List, Optional, Protocol, Union +from control.chargepoint.chargepoint import Chargepoint from dataclass_utils.factories import empty_list_factory from modules.common.component_type import ComponentType @@ -93,3 +94,13 @@ def get_list_of_elements_per_level(self) -> List[List[Dict[str, Union[int, str]] def hierarchy_add_item_below(self, new_id: int, new_type: ComponentType, id_to_find: int) -> None: ... def hierarchy_add_item_below_evu(self, new_id: int, new_type: ComponentType) -> None: ... def hierarchy_remove_item(self, id_to_find: int, keep_children: bool = True) -> None: ... + + +class LoadmanagementPrioProtocol(Protocol): + @property + def data(self) -> CounterAllData: ... + def add_loadmanagement_prio_item(self, type: str, id: int) -> None: ... + def remove_loadmanagement_prio_item(self, id: int) -> None: ... + def _remove_loadmanagement_prio_item(self, id: int, entry: Dict) -> None: ... + def sort_cps_by_loadmanagement_prios_nested(self, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: ... + def sort_cps_by_loadmanagement_prios_flat(self, filtered_cps: List[Chargepoint]) -> List[Chargepoint]: ... diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py new file mode 100644 index 0000000000..c901779f7f --- /dev/null +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -0,0 +1,70 @@ +import logging +from typing import Dict, List + +from control.chargepoint.chargepoint import Chargepoint +from control.counter_all.counter_all_data import LoadmanagementPrioProtocol + + +log = logging.getLogger(__name__) + + +class LoadmanagementPrioMixin: + def add_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, type: str, id: int) -> None: + self.data.get.loadmanagement_prios.append({"type": type, "id": id}) + + def remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, id: int) -> None: + if self._remove_loadmanagement_prio_item(id, self.data.get.loadmanagement_prios) is False: + raise IndexError(f"Element {id} konnte nicht in der Prioritätensteuerung gefunden werden.") + + def _remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, id: int, entry: List[Dict]) -> bool: + for item in entry: + if item["type"] == "vehicle": + if item["id"] == id: + entry.remove(item) + return True + elif item["type"] == "group": + removed_item = self._remove_loadmanagement_prio_item(id, item["children"]) + if removed_item and len(item["children"]) == 0: + entry.remove(item) + if removed_item: + return True + return False + + def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, + filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: + sorted_cps = [] + for entry in self.data.get.loadmanagement_prios: + if entry["type"] == "vehicle": + grouped_cps = [] + for cp in filtered_cps: + if cp.data.config.ev == entry["id"]: + grouped_cps.append(cp) + if len(grouped_cps) > 0: + sorted_cps.append(grouped_cps) + elif entry["type"] == "group": + grouped_cps = [] + for group_entry in entry["children"]: + for cp in filtered_cps: + if cp.data.config.ev == group_entry["id"]: + grouped_cps.append(cp) + if len(grouped_cps) > 0: + sorted_cps.append(grouped_cps) + return sorted_cps + + def sort_cps_by_loadmanagement_prios_flat(self: LoadmanagementPrioProtocol, + filtered_cps: List[Chargepoint]) -> List[Chargepoint]: + sorted_cps = [] + for entry in self.data.get.loadmanagement_prios: + if entry["type"] == "vehicle": + for cp in filtered_cps: + if cp.data.config.ev == entry["id"]: + sorted_cps.append(cp) + elif entry["type"] == "group": + for group_entry in entry["children"]: + for cp in filtered_cps: + if cp.data.config.ev == group_entry["id"]: + sorted_cps.append(cp) + if len(sorted_cps) != len(filtered_cps): + raise ValueError( + "Fahrzeuge der Prioritätensteuerung konnten nicht korrekt den Ladepunkten zugeordnet werden.") + return sorted_cps diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py new file mode 100644 index 0000000000..7e63cb336d --- /dev/null +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -0,0 +1,187 @@ + +from typing import Dict, List + +import pytest + +from control.chargepoint.chargepoint import Chargepoint +from control.counter_all.counter_all import CounterAll + + +@pytest.mark.parametrize( + "loadmanagement_prios, id, type, expected_loadmanagement_prios", + [ + pytest.param([], 2, "vehicle", [{"type": "vehicle", "id": 2}], id="emtpy list"), + pytest.param([{"type": "vehicle", "id": 3}], 2, "vehicle", [{"type": "vehicle", "id": 3}, + {"type": "vehicle", "id": 2}], id="flat list"), + pytest.param([ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + {"type": "vehicle", "id": 1, }, + ] + }, + {"type": "vehicle", "id": 2}, + ], 4, "vehicle", [ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + {"type": "vehicle", "id": 1, }, + ] + }, + {"type": "vehicle", "id": 2}, + {"type": "vehicle", "id": 4}, + ], id="nested list"), + ] +) +def test_add_item(loadmanagement_prios: List[Dict], id: int, type: str, expected_loadmanagement_prios: List[Dict]): + # setup + c = CounterAll() + c.data.get.loadmanagement_prios = loadmanagement_prios + + # execution + c.add_loadmanagement_prio_item(type, id) + + # assert + assert c.data.get.loadmanagement_prios == expected_loadmanagement_prios + + +@pytest.mark.parametrize( + "loadmanagement_prios, id, type, expected_loadmanagement_prios", + [ + pytest.param([{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 2}], + 2, "vehicle", [{"type": "vehicle", "id": 3}], id="flat list"), + pytest.param([ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + {"type": "vehicle", "id": 1, }, + ] + }, + {"type": "vehicle", "id": 2}, + ], 2, "vehicle", [ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + {"type": "vehicle", "id": 1, }, + ] + }, + ], id="nested list"), + pytest.param([ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + {"type": "vehicle", "id": 1, }, + ] + }, + {"type": "vehicle", "id": 2}, + ], 0, "vehicle", [ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 1, }, + ] + }, + {"type": "vehicle", "id": 2}, + ], id="nested list, remove from group"), + pytest.param([ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + ] + }, + {"type": "vehicle", "id": 2}, + ], 0, "vehicle", [{"type": "vehicle", "id": 2}], id="nested list, empty group"), + ] +) +def test_remove_loadmanagement_prio_item(loadmanagement_prios: List[Dict], + id: int, + type: str, + expected_loadmanagement_prios: List[Dict]): + # setup + c = CounterAll() + c.data.get.loadmanagement_prios = loadmanagement_prios + + # execution + c.remove_loadmanagement_prio_item(id) + + # assert + assert c.data.get.loadmanagement_prios == expected_loadmanagement_prios + + +@pytest.mark.parametrize( + "config_ev_list, loadmanagement_prios, sorted_cp_ids", + [ + pytest.param([0]*3, + [{"type": "vehicle", "id": 2}, {"type": "vehicle", "id": 0}], + [1, 2, 3], + id="alle LP haben das gleiche Fahrzeug zugeordnet"), + pytest.param([1, 2, 3], + [{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 1}, {"type": "vehicle", "id": 0}], + [3, 1, 2], + id="alle LP haben unterschiedliche Fahrzeuge"), + pytest.param([1, 2, 3], + [{"type": "vehicle", "id": 3}, { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [{"type": "vehicle", "id": 1}, { + "type": "vehicle", "id": 2}]}], + [3, 1, 2], + id="alle LP haben unterschiedliche Fahrzeuge mit Gruppe"), + ] +) +def test_sort_cps_by_loadmanagement_prios_flat(config_ev_list: List[int], + loadmanagement_prios: List[Dict], + sorted_cp_ids: List[int]): + # setup + cp1 = Chargepoint(1, None) + cp1.data.config.ev = config_ev_list[0] + cp2 = Chargepoint(2, None) + cp2.data.config.ev = config_ev_list[1] + cp3 = Chargepoint(3, None) + cp3.data.config.ev = config_ev_list[2] + + c = CounterAll() + c.data.get.loadmanagement_prios = loadmanagement_prios + + # execution + sorted_cps = c.sort_cps_by_loadmanagement_prios_flat([cp1, cp2, cp3]) + + # assert + for i, cp in enumerate(sorted_cps): + assert cp.num == sorted_cp_ids[i] + + +def test_sort_cps_by_loadmanagement_prios_nested(): + # setup + cp1 = Chargepoint(1, None) + cp1.data.config.ev = 1 + cp2 = Chargepoint(2, None) + cp2.data.config.ev = 2 + cp3 = Chargepoint(3, None) + cp3.data.config.ev = 3 + + c = CounterAll() + c.data.get.loadmanagement_prios = [{"type": "vehicle", "id": 3}, { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [{"type": "vehicle", "id": 1}, { + "type": "vehicle", "id": 2}]}] + + # execution + sorted_cps = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) + + # assert + assert sorted_cps == [[cp3], [cp1, cp2]] From 13d11eb9d6d6eb5251649f610edc2fae98ceabd1 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 13 Apr 2026 09:53:38 +0200 Subject: [PATCH 22/84] fix pytest --- packages/control/counter_all/loadmanagement_prio_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index 7e63cb336d..19c335af50 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -129,7 +129,7 @@ def test_remove_loadmanagement_prio_item(loadmanagement_prios: List[Dict], [1, 2, 3], id="alle LP haben das gleiche Fahrzeug zugeordnet"), pytest.param([1, 2, 3], - [{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 1}, {"type": "vehicle", "id": 0}], + [{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 1}, {"type": "vehicle", "id": 2}], [3, 1, 2], id="alle LP haben unterschiedliche Fahrzeuge"), pytest.param([1, 2, 3], From 7643a6d90d6c1610f33463e1690df5bab8849eb2 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 13 Apr 2026 14:33:27 +0200 Subject: [PATCH 23/84] draft --- .../control/algorithm/additional_current.py | 7 +-- packages/control/algorithm/bidi_charging.py | 6 +- .../control/algorithm/filter_chargepoints.py | 4 +- .../algorithm/filter_chargepoints_test.py | 2 +- packages/control/algorithm/min_current.py | 5 +- .../control/algorithm/surplus_controlled.py | 6 +- .../control/counter_all/counter_all_data.py | 7 ++- .../counter_all/loadmanagement_prio.py | 57 ++++++++++++------- .../counter_all/loadmanagement_prio_test.py | 34 +++-------- 9 files changed, 60 insertions(+), 68 deletions(-) diff --git a/packages/control/algorithm/additional_current.py b/packages/control/algorithm/additional_current.py index 76ee2d0031..3e13202c39 100644 --- a/packages/control/algorithm/additional_current.py +++ b/packages/control/algorithm/additional_current.py @@ -1,5 +1,6 @@ import logging +from control import data from control.algorithm import common from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT from control.limiting_value import LoadmanagementLimit @@ -24,9 +25,8 @@ def set_additional_current(self) -> None: if preferenced_chargepoints: common.update_raw_data(preferenced_chargepoints) log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") - while len(preferenced_chargepoints): - cp = preferenced_chargepoints[0] - missing_currents, counts = common.get_missing_currents_left(preferenced_chargepoints) + for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(preferenced_chargepoints): + missing_currents, counts = common.get_missing_currents_left(group) available_currents, limit = Loadmanagement().get_available_currents(missing_currents, counter, cp) log.debug(f"cp {cp.num} available currents {available_currents} missing currents " f"{missing_currents} limit {limit.message}") @@ -40,7 +40,6 @@ def set_additional_current(self) -> None: cp.data.control_parameter.min_current, current, cp) - preferenced_chargepoints.pop(0) if preferenced_cps_without_set_current: for cp in preferenced_cps_without_set_current: cp.data.set.current = cp.data.set.target_current diff --git a/packages/control/algorithm/bidi_charging.py b/packages/control/algorithm/bidi_charging.py index 4dec5a7b4c..91e4b090fd 100644 --- a/packages/control/algorithm/bidi_charging.py +++ b/packages/control/algorithm/bidi_charging.py @@ -20,9 +20,8 @@ def set_bidi(self): if preferenced_cps: log.info( f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {grid_counter.num}") - while len(preferenced_cps): - cp = preferenced_cps[0] - zero_point_adjustment = grid_counter.data.set.surplus_power_left / len(preferenced_cps) + for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(preferenced_cps): + zero_point_adjustment = grid_counter.data.set.surplus_power_left / len(group) log.debug(f"Nullpunktanpassung für LP{cp.num}: verbleibende Leistung {zero_point_adjustment}W") missing_currents = [zero_point_adjustment / cp.data.get.phases_in_use / 230 for i in range(0, cp.data.get.phases_in_use)] @@ -43,4 +42,3 @@ def set_bidi(self): grid_counter.update_surplus_values_left(missing_currents, voltages_mean(cp.data.get.voltages)) cp.data.set.current = missing_currents[0] log.info(f"LP{cp.num}: Stromstärke {missing_currents}A") - preferenced_cps.pop(0) diff --git a/packages/control/algorithm/filter_chargepoints.py b/packages/control/algorithm/filter_chargepoints.py index f523b1d399..041bd337a6 100644 --- a/packages/control/algorithm/filter_chargepoints.py +++ b/packages/control/algorithm/filter_chargepoints.py @@ -47,7 +47,7 @@ def get_chargepoints_with_required_current_by_chargemode( def get_preferenced_chargepoint_charging( chargepoints: List[Chargepoint]) -> Tuple[List[Chargepoint], List[Chargepoint]]: - preferenced_chargepoints = _get_preferenced_chargepoint(chargepoints) + preferenced_chargepoints = get_preferenced_chargepoint(chargepoints) preferenced_chargepoints_with_set_current = [] preferenced_chargepoints_without_set_current = [] for cp in preferenced_chargepoints: @@ -67,7 +67,7 @@ def get_preferenced_chargepoint_charging( # tested -def _get_preferenced_chargepoint(valid_chargepoints: List[Chargepoint]) -> List: +def get_preferenced_chargepoint(valid_chargepoints: List[Chargepoint]) -> List: """ermittelt die Ladepunkte in der Reihenfolge, in der sie geladen/gestoppt werden sollen. Die Bedingungen sind: geringste Mindeststromstärke, niedrigster SoC, frühester Ansteck-Zeitpunkt(Einschalten)/Lademenge(Abschalten), diff --git a/packages/control/algorithm/filter_chargepoints_test.py b/packages/control/algorithm/filter_chargepoints_test.py index 66fbd7f277..68078b266b 100644 --- a/packages/control/algorithm/filter_chargepoints_test.py +++ b/packages/control/algorithm/filter_chargepoints_test.py @@ -78,7 +78,7 @@ def mock_cp(cp: Chargepoint, num: int): cp2 = mock_cp(mock_cp2, 2) cp3 = mock_cp(mock_cp3, 3) # execution - preferenced_chargepoints = filter_chargepoints._get_preferenced_chargepoint([cp1, cp2, cp3]) + preferenced_chargepoints = filter_chargepoints.get_preferenced_chargepoint([cp1, cp2, cp3]) # evaluation assert preferenced_chargepoints == params.expected_sort diff --git a/packages/control/algorithm/min_current.py b/packages/control/algorithm/min_current.py index 48ce9eba6a..b21279ea02 100644 --- a/packages/control/algorithm/min_current.py +++ b/packages/control/algorithm/min_current.py @@ -3,6 +3,7 @@ from control import data from control.algorithm import common from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_MIN_CURRENT, CONSIDERED_CHARGE_MODES_PV_ONLY +from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_state import ChargepointState from control.loadmanagement import Loadmanagement from control.algorithm.filter_chargepoints import get_chargepoints_by_mode_and_counter @@ -21,8 +22,7 @@ def set_min_current(self) -> None: if preferenced_chargepoints: log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") common.update_raw_data(preferenced_chargepoints, diff_to_zero=True) - while len(preferenced_chargepoints): - cp = preferenced_chargepoints[0] + for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(preferenced_chargepoints): missing_currents, counts = common.get_min_current(cp) if max(missing_currents) > 0: available_currents, limit = Loadmanagement().get_available_currents( @@ -52,4 +52,3 @@ def set_min_current(self) -> None: except Exception: log.exception(f"Fehler in der PV-gesteuerten Ladung bei {cp.num}") cp.data.set.current = 0 - preferenced_chargepoints.pop(0) diff --git a/packages/control/algorithm/surplus_controlled.py b/packages/control/algorithm/surplus_controlled.py index 41669486a3..b7e14096c1 100644 --- a/packages/control/algorithm/surplus_controlled.py +++ b/packages/control/algorithm/surplus_controlled.py @@ -47,9 +47,8 @@ def _set(self, counter: Counter) -> None: log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") common.update_raw_data(chargepoints, surplus=True) - while len(chargepoints): - cp = chargepoints[0] - missing_currents, counts = common.get_missing_currents_left(chargepoints) + for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(chargepoints): + missing_currents, counts = common.get_missing_currents_left(group) available_currents, limit = Loadmanagement().get_available_currents_surplus( missing_currents, voltages_mean(cp.data.get.voltages), @@ -83,7 +82,6 @@ def _set(self, limited_current, cp, surplus=True) - chargepoints.pop(0) def _set_loadmangement_message(self, current: float, diff --git a/packages/control/counter_all/counter_all_data.py b/packages/control/counter_all/counter_all_data.py index cf5a8ade89..c137c5add5 100644 --- a/packages/control/counter_all/counter_all_data.py +++ b/packages/control/counter_all/counter_all_data.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Callable, Dict, List, Optional, Protocol, Union +from typing import Callable, Dict, Generator, List, Optional, Protocol, Tuple, Union from control.chargepoint.chargepoint import Chargepoint from dataclass_utils.factories import empty_list_factory @@ -103,4 +103,7 @@ def add_loadmanagement_prio_item(self, type: str, id: int) -> None: ... def remove_loadmanagement_prio_item(self, id: int) -> None: ... def _remove_loadmanagement_prio_item(self, id: int, entry: Dict) -> None: ... def sort_cps_by_loadmanagement_prios_nested(self, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: ... - def sort_cps_by_loadmanagement_prios_flat(self, filtered_cps: List[Chargepoint]) -> List[Chargepoint]: ... + + def generator_cps_by_loadmanagement_prios( + self, + filtered_cps: List[Chargepoint]) -> Generator[Tuple[Chargepoint, List[Chargepoint]], None, None]: ... diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py index c901779f7f..667c1aa269 100644 --- a/packages/control/counter_all/loadmanagement_prio.py +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -1,6 +1,8 @@ import logging -from typing import Dict, List +from pprint import pprint +from typing import Dict, Generator, Iterable, List, Tuple +from control.algorithm.filter_chargepoints import get_preferenced_chargepoint from control.chargepoint.chargepoint import Chargepoint from control.counter_all.counter_all_data import LoadmanagementPrioProtocol @@ -30,6 +32,17 @@ def _remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, id: int, return True return False + def generator_cps_by_loadmanagement_prios( + self: LoadmanagementPrioProtocol, + filtered_cps: List[Chargepoint]) -> Generator[Tuple[Chargepoint, List[Chargepoint]], None, None]: + sorted_cps = self.sort_cps_by_loadmanagement_prios_nested(filtered_cps) + log.debug("Ladepunkte sortiert nach Prioritätensteuerung: ") + log.debug(pprint({[f"LP {cp.num}" for group in sorted_cps for cp in group]})) + for group in sorted_cps: + cp: Chargepoint + for cp in group: + yield cp, group + def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: sorted_cps = [] @@ -40,31 +53,33 @@ def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, if cp.data.config.ev == entry["id"]: grouped_cps.append(cp) if len(grouped_cps) > 0: - sorted_cps.append(grouped_cps) + sorted_grouped_cps = get_preferenced_chargepoint(grouped_cps) + sorted_cps.append(sorted_grouped_cps) elif entry["type"] == "group": - grouped_cps = [] + grouped_cps, sorted_grouped_cps = [], [] for group_entry in entry["children"]: for cp in filtered_cps: if cp.data.config.ev == group_entry["id"]: grouped_cps.append(cp) + sorted_grouped_cps.extend(get_preferenced_chargepoint(grouped_cps)) if len(grouped_cps) > 0: - sorted_cps.append(grouped_cps) + sorted_cps.append(sorted_grouped_cps) return sorted_cps - def sort_cps_by_loadmanagement_prios_flat(self: LoadmanagementPrioProtocol, - filtered_cps: List[Chargepoint]) -> List[Chargepoint]: - sorted_cps = [] - for entry in self.data.get.loadmanagement_prios: - if entry["type"] == "vehicle": - for cp in filtered_cps: - if cp.data.config.ev == entry["id"]: - sorted_cps.append(cp) - elif entry["type"] == "group": - for group_entry in entry["children"]: - for cp in filtered_cps: - if cp.data.config.ev == group_entry["id"]: - sorted_cps.append(cp) - if len(sorted_cps) != len(filtered_cps): - raise ValueError( - "Fahrzeuge der Prioritätensteuerung konnten nicht korrekt den Ladepunkten zugeordnet werden.") - return sorted_cps + # def sort_cps_by_loadmanagement_prios_flat(self: LoadmanagementPrioProtocol, + # filtered_cps: List[Chargepoint]) -> List[Chargepoint]: + # sorted_cps = [] + # for entry in self.data.get.loadmanagement_prios: + # if entry["type"] == "vehicle": + # for cp in filtered_cps: + # if cp.data.config.ev == entry["id"]: + # sorted_cps.append(cp) + # elif entry["type"] == "group": + # for group_entry in entry["children"]: + # for cp in filtered_cps: + # if cp.data.config.ev == group_entry["id"]: + # sorted_cps.append(cp) + # if len(sorted_cps) != len(filtered_cps): + # raise ValueError( + # "Fahrzeuge der Prioritätensteuerung konnten nicht korrekt den Ladepunkten zugeordnet werden.") + # return sorted_cps diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index 19c335af50..845be3386d 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -142,46 +142,26 @@ def test_remove_loadmanagement_prio_item(loadmanagement_prios: List[Dict], id="alle LP haben unterschiedliche Fahrzeuge mit Gruppe"), ] ) -def test_sort_cps_by_loadmanagement_prios_flat(config_ev_list: List[int], - loadmanagement_prios: List[Dict], - sorted_cp_ids: List[int]): +def test_sort_cps_by_loadmanagement_prios_nested(config_ev_list: List[int], + loadmanagement_prios: List[Dict], + sorted_cp_ids: List[int]): # setup cp1 = Chargepoint(1, None) cp1.data.config.ev = config_ev_list[0] + cp1.data.control_parameter.required_current = 8 cp2 = Chargepoint(2, None) cp2.data.config.ev = config_ev_list[1] + cp2.data.control_parameter.required_current = 7 cp3 = Chargepoint(3, None) cp3.data.config.ev = config_ev_list[2] + cp3.data.control_parameter.required_current = 6 c = CounterAll() c.data.get.loadmanagement_prios = loadmanagement_prios # execution - sorted_cps = c.sort_cps_by_loadmanagement_prios_flat([cp1, cp2, cp3]) + sorted_cps = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) # assert for i, cp in enumerate(sorted_cps): assert cp.num == sorted_cp_ids[i] - - -def test_sort_cps_by_loadmanagement_prios_nested(): - # setup - cp1 = Chargepoint(1, None) - cp1.data.config.ev = 1 - cp2 = Chargepoint(2, None) - cp2.data.config.ev = 2 - cp3 = Chargepoint(3, None) - cp3.data.config.ev = 3 - - c = CounterAll() - c.data.get.loadmanagement_prios = [{"type": "vehicle", "id": 3}, { - "type": "group", - "label": "Wichtige Fahrzeuge", - "children": [{"type": "vehicle", "id": 1}, { - "type": "vehicle", "id": 2}]}] - - # execution - sorted_cps = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) - - # assert - assert sorted_cps == [[cp3], [cp1, cp2]] From e3cf23a28d1d3bdce1c0b87caa2ade86180e0473 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 13 Apr 2026 15:58:51 +0200 Subject: [PATCH 24/84] fixes --- .../control/algorithm/additional_current.py | 3 +- .../algorithm/integration_test/conftest.py | 8 ++ packages/control/algorithm/min_current.py | 4 +- .../counter_all/loadmanagement_prio.py | 10 +- .../counter_all/loadmanagement_prio_test.py | 120 ++++++++++++------ packages/helpermodules/command.py | 6 + packages/helpermodules/setdata.py | 3 +- packages/helpermodules/update_config.py | 2 + 8 files changed, 110 insertions(+), 46 deletions(-) diff --git a/packages/control/algorithm/additional_current.py b/packages/control/algorithm/additional_current.py index 3e13202c39..87893fa10e 100644 --- a/packages/control/algorithm/additional_current.py +++ b/packages/control/algorithm/additional_current.py @@ -25,7 +25,8 @@ def set_additional_current(self) -> None: if preferenced_chargepoints: common.update_raw_data(preferenced_chargepoints) log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") - for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(preferenced_chargepoints): + for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios( + preferenced_chargepoints): missing_currents, counts = common.get_missing_currents_left(group) available_currents, limit = Loadmanagement().get_available_currents(missing_currents, counter, cp) log.debug(f"cp {cp.num} available currents {available_currents} missing currents " diff --git a/packages/control/algorithm/integration_test/conftest.py b/packages/control/algorithm/integration_test/conftest.py index eeae55f399..673e8ae32b 100644 --- a/packages/control/algorithm/integration_test/conftest.py +++ b/packages/control/algorithm/integration_test/conftest.py @@ -27,6 +27,7 @@ def data_() -> None: for i in range(3, 6): data.data.cp_data[f"cp{i}"].template = CpTemplate() data.data.cp_data[f"cp{i}"].data.config.phase_1 = i-2 + data.data.cp_data[f"cp{i}"].data.config.ev = i data.data.cp_data[f"cp{i}"].data.set.charging_ev_data = Ev(i) data.data.cp_data[f"cp{i}"].data.set.charging_ev_data.ev_template.data.max_current_single_phase = 32 data.data.cp_data[f"cp{i}"].data.get.plug_state = True @@ -50,6 +51,13 @@ def data_() -> None: data.data.counter_data["counter6"].data.config.max_total_power = 11000 data.data.counter_all_data = CounterAll() data.data.counter_all_data.data.get.hierarchy = NESTED_HIERARCHY + data.data.counter_all_data.data.get.loadmanagement_prios = [{ + "type": "group", + "label": "Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 4}, {"type": "vehicle", "id": 5} + ] + }] data.data.counter_all_data.data.config.consider_less_charging = True data.data.io_actions = IoActions() data.data.general_data.data.chargemode_config.surplus.vehicle.retry_failed_phase_switches = True diff --git a/packages/control/algorithm/min_current.py b/packages/control/algorithm/min_current.py index b21279ea02..634404d7db 100644 --- a/packages/control/algorithm/min_current.py +++ b/packages/control/algorithm/min_current.py @@ -3,7 +3,6 @@ from control import data from control.algorithm import common from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_MIN_CURRENT, CONSIDERED_CHARGE_MODES_PV_ONLY -from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_state import ChargepointState from control.loadmanagement import Loadmanagement from control.algorithm.filter_chargepoints import get_chargepoints_by_mode_and_counter @@ -22,7 +21,8 @@ def set_min_current(self) -> None: if preferenced_chargepoints: log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") common.update_raw_data(preferenced_chargepoints, diff_to_zero=True) - for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(preferenced_chargepoints): + for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios( + preferenced_chargepoints): missing_currents, counts = common.get_min_current(cp) if max(missing_currents) > 0: available_currents, limit = Loadmanagement().get_available_currents( diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py index 667c1aa269..522328d1bf 100644 --- a/packages/control/counter_all/loadmanagement_prio.py +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -1,6 +1,5 @@ import logging -from pprint import pprint -from typing import Dict, Generator, Iterable, List, Tuple +from typing import Dict, Generator, List, Tuple from control.algorithm.filter_chargepoints import get_preferenced_chargepoint from control.chargepoint.chargepoint import Chargepoint @@ -37,7 +36,7 @@ def generator_cps_by_loadmanagement_prios( filtered_cps: List[Chargepoint]) -> Generator[Tuple[Chargepoint, List[Chargepoint]], None, None]: sorted_cps = self.sort_cps_by_loadmanagement_prios_nested(filtered_cps) log.debug("Ladepunkte sortiert nach Prioritätensteuerung: ") - log.debug(pprint({[f"LP {cp.num}" for group in sorted_cps for cp in group]})) + log.debug([[f"LP {cp.num}" for cp in group] for group in sorted_cps]) for group in sorted_cps: cp: Chargepoint for cp in group: @@ -56,13 +55,14 @@ def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, sorted_grouped_cps = get_preferenced_chargepoint(grouped_cps) sorted_cps.append(sorted_grouped_cps) elif entry["type"] == "group": - grouped_cps, sorted_grouped_cps = [], [] + sorted_grouped_cps = [] for group_entry in entry["children"]: + grouped_cps = [] for cp in filtered_cps: if cp.data.config.ev == group_entry["id"]: grouped_cps.append(cp) sorted_grouped_cps.extend(get_preferenced_chargepoint(grouped_cps)) - if len(grouped_cps) > 0: + if len(sorted_grouped_cps) > 0: sorted_cps.append(sorted_grouped_cps) return sorted_cps diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index 845be3386d..e90ccd9735 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -7,6 +7,27 @@ from control.counter_all.counter_all import CounterAll +@pytest.fixture +def cp1(): + cp = Chargepoint(1, None) + cp.data.control_parameter.required_current = 8 + return cp + + +@pytest.fixture +def cp2(): + cp = Chargepoint(2, None) + cp.data.control_parameter.required_current = 7 + return cp + + +@pytest.fixture +def cp3(): + cp = Chargepoint(3, None) + cp.data.control_parameter.required_current = 6 + return cp + + @pytest.mark.parametrize( "loadmanagement_prios, id, type, expected_loadmanagement_prios", [ @@ -121,47 +142,72 @@ def test_remove_loadmanagement_prio_item(loadmanagement_prios: List[Dict], assert c.data.get.loadmanagement_prios == expected_loadmanagement_prios -@pytest.mark.parametrize( - "config_ev_list, loadmanagement_prios, sorted_cp_ids", - [ - pytest.param([0]*3, - [{"type": "vehicle", "id": 2}, {"type": "vehicle", "id": 0}], - [1, 2, 3], - id="alle LP haben das gleiche Fahrzeug zugeordnet"), - pytest.param([1, 2, 3], - [{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 1}, {"type": "vehicle", "id": 2}], - [3, 1, 2], - id="alle LP haben unterschiedliche Fahrzeuge"), - pytest.param([1, 2, 3], - [{"type": "vehicle", "id": 3}, { - "type": "group", - "label": "Wichtige Fahrzeuge", - "children": [{"type": "vehicle", "id": 1}, { - "type": "vehicle", "id": 2}]}], - [3, 1, 2], - id="alle LP haben unterschiedliche Fahrzeuge mit Gruppe"), +def test_sort_cps_by_loadmanagement_prios_nested_same_vehicle(cp1, cp2, cp3): + """Alle LP haben das gleiche Fahrzeug zugeordnet""" + # setup + cp1.data.config.ev = 0 + cp2.data.config.ev = 0 + cp3.data.config.ev = 0 + + c = CounterAll() + c.data.get.loadmanagement_prios = [{"type": "vehicle", "id": 2}, {"type": "vehicle", "id": 0}] + + # execution + result = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) + + # assert - eine Gruppe mit allen CPs (sortiert nach required_current) + assert len(result) == 1 + assert result[0] == [cp3, cp2, cp1] # direkte Objektvergleiche! + + +def test_sort_cps_by_loadmanagement_prios_nested_different_vehicles(cp1, cp2, cp3): + """Alle LP haben unterschiedliche Fahrzeuge""" + # setup + cp1.data.config.ev = 1 + cp2.data.config.ev = 2 + cp3.data.config.ev = 3 + + c = CounterAll() + c.data.get.loadmanagement_prios = [ + {"type": "vehicle", "id": 3}, + {"type": "vehicle", "id": 1}, + {"type": "vehicle", "id": 2} ] -) -def test_sort_cps_by_loadmanagement_prios_nested(config_ev_list: List[int], - loadmanagement_prios: List[Dict], - sorted_cp_ids: List[int]): + + # execution + result = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) + + # assert - drei separate Gruppen + assert len(result) == 3 + assert result[0] == [cp3] # vehicle id=3 + assert result[1] == [cp1] # vehicle id=1 + assert result[2] == [cp2] # vehicle id=2 + + +def test_sort_cps_by_loadmanagement_prios_nested_with_group(cp1, cp2, cp3): + """LP mit unterschiedlichen Fahrzeugen, einige in Gruppe""" # setup - cp1 = Chargepoint(1, None) - cp1.data.config.ev = config_ev_list[0] - cp1.data.control_parameter.required_current = 8 - cp2 = Chargepoint(2, None) - cp2.data.config.ev = config_ev_list[1] - cp2.data.control_parameter.required_current = 7 - cp3 = Chargepoint(3, None) - cp3.data.config.ev = config_ev_list[2] - cp3.data.control_parameter.required_current = 6 + cp1.data.config.ev = 1 + cp2.data.config.ev = 2 + cp3.data.config.ev = 3 c = CounterAll() - c.data.get.loadmanagement_prios = loadmanagement_prios + c.data.get.loadmanagement_prios = [ + {"type": "vehicle", "id": 3}, + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 1}, + {"type": "vehicle", "id": 2} + ] + } + ] # execution - sorted_cps = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) + result = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) - # assert - for i, cp in enumerate(sorted_cps): - assert cp.num == sorted_cp_ids[i] + # assert - zwei Gruppen + assert len(result) == 2 + assert result[0] == [cp3] # vehicle id=3 einzeln + assert result[1] == [cp1, cp2] # vehicles id=1,2 in der Gruppe diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 185337c2a4..9baca647f8 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -773,6 +773,9 @@ def addVehicle(self, connection_id: str, payload: dict) -> None: # add ACL roles for vehicle access, if user management is active if SubData.system_data["system"].data["security"]["user_management_active"]: add_acl_role("vehicle--access", new_id) + data.data.counter_all_data.add_loadmanagement_prio_item("vehicle", new_id) + Pub().pub("openWB/set/counter/get/loadmanagement_prios", + data.data.counter_all_data.data.get.loadmanagement_prios) pub_user_message(payload, connection_id, f'Neues EV mit ID \'{new_id}\' hinzugefügt.', MessageType.SUCCESS) def removeVehicle(self, connection_id: str, payload: dict) -> None: @@ -788,6 +791,9 @@ def removeVehicle(self, connection_id: str, payload: dict) -> None: if SubData.system_data["system"].data["security"]["user_management_active"]: remove_acl_role("vehicle--access", payload["data"]["id"]) remove_acl_role("vehicle--write-access", payload["data"]["id"]) + data.data.counter_all_data.remove_loadmanagement_prio_item(payload["data"]["id"]) + Pub().pub("openWB/set/counter/get/loadmanagement_prios", + data.data.counter_all_data.data.get.loadmanagement_prios) pub_user_message( payload, connection_id, f'EV mit ID \'{payload["data"]["id"]}\' gelöscht.', MessageType.SUCCESS) diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 66789d1734..d75f26ea53 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -933,7 +933,8 @@ def process_counter_topic(self, msg: mqtt.MQTTMessage): "openWB/set/counter/set/daily_yield_home_consumption" in msg.topic or "openWB/set/counter/set/disengageable_smarthome_power" in msg.topic): self._validate_value(msg, float, [(0, float("inf"))]) - elif "openWB/set/counter/get/hierarchy" in msg.topic: + elif ("openWB/set/counter/get/hierarchy" in msg.topic or + "openWB/set/counter/get/loadmanagement_prios" in msg.topic): self._validate_value(msg, None) elif "openWB/set/counter/config/home_consumption_source_id" in msg.topic: self._validate_value(msg, int) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index d80a4014ef..458f3ae04e 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -187,6 +187,7 @@ class UpdateConfig: "^openWB/counter/config/consider_less_charging$", "^openWB/counter/config/home_consumption_source_id$", "^openWB/counter/get/hierarchy$", + "^openWB/counter/get/loadmanagement_prios$", "^openWB/counter/set/disengageable_smarthome_power$", "^openWB/counter/set/imported_home_consumption$", "^openWB/counter/set/invalid_home_consumption$", @@ -587,6 +588,7 @@ class UpdateConfig: ("openWB/chargepoint/get/power", 0), ("openWB/chargepoint/template/0", get_chargepoint_template_default()), ("openWB/counter/get/hierarchy", []), + ("openWB/counter/get/loadmanagement_prios", [{"type": "vehicle", "id": 0}]), ("openWB/counter/config/consider_less_charging", counter_all_data.Config().consider_less_charging), ("openWB/counter/config/home_consumption_source_id", counter_all_data.Config().home_consumption_source_id), ("openWB/vehicle/0/name", "Standard-Fahrzeug"), From 1d53a28afcfe028fba47e866547109306046d516 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 13 Apr 2026 16:33:43 +0200 Subject: [PATCH 25/84] fixes --- .../counter_all/loadmanagement_prio.py | 6 ++- .../counter_all/loadmanagement_prio_test.py | 52 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py index 522328d1bf..2ac9cbc403 100644 --- a/packages/control/counter_all/loadmanagement_prio.py +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -39,8 +39,10 @@ def generator_cps_by_loadmanagement_prios( log.debug([[f"LP {cp.num}" for cp in group] for group in sorted_cps]) for group in sorted_cps: cp: Chargepoint - for cp in group: - yield cp, group + while len(group) > 0: + cp = group[0] + yield cp, group.copy() + group.pop(0) def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index e90ccd9735..f6a7c01433 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -1,5 +1,6 @@ from typing import Dict, List +from unittest.mock import Mock import pytest @@ -211,3 +212,54 @@ def test_sort_cps_by_loadmanagement_prios_nested_with_group(cp1, cp2, cp3): assert len(result) == 2 assert result[0] == [cp3] # vehicle id=3 einzeln assert result[1] == [cp1, cp2] # vehicles id=1,2 in der Gruppe + + +def test_generator_cps_by_loadmanagement_prios(cp1, cp2, cp3, monkeypatch): + # setup + mock_sort_cps = Mock(return_value=[[cp3], [cp1, cp2]]) + monkeypatch.setattr(CounterAll, "sort_cps_by_loadmanagement_prios_nested", mock_sort_cps) + + c = CounterAll() + + # execution + result = list(c.generator_cps_by_loadmanagement_prios([cp1, cp2, cp3])) + + # assert + assert len(result) == 3 + assert result[0] == (cp3, [cp3]) + assert result[1] == (cp1, [cp1, cp2]) + assert result[2] == (cp2, [cp2]) + + +def test_generator_cps_by_loadmanagement_prios_flat(cp1, cp2, cp3, monkeypatch): + # setup + mock_sort_cps = Mock(return_value=[[cp3], [cp1], [cp2]]) + monkeypatch.setattr(CounterAll, "sort_cps_by_loadmanagement_prios_nested", mock_sort_cps) + + c = CounterAll() + + # execution + result = list(c.generator_cps_by_loadmanagement_prios([cp1, cp2, cp3])) + + # assert + assert len(result) == 3 + assert result[0] == (cp3, [cp3]) + assert result[1] == (cp1, [cp1]) + assert result[2] == (cp2, [cp2]) + + +def test_generator_cps_by_loadmanagement_prios_one_group(cp1, cp2, cp3, monkeypatch): + # setup + mock_sort_cps = Mock(return_value=[[cp3, cp1, cp2]]) + monkeypatch.setattr(CounterAll, "sort_cps_by_loadmanagement_prios_nested", mock_sort_cps) + + c = CounterAll() + + # execution + result = list(c.generator_cps_by_loadmanagement_prios([cp1, cp2, cp3])) + + # assert + assert len(result) == 3 + assert result[0] == (cp3, [cp3, cp1, cp2]) + assert result[1] == (cp1, [cp1, cp2]) + assert result[2] == (cp2, [cp2]) From 58c12ca58482eadd08e04c3030ab5645aca632b8 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 29 Apr 2026 15:28:05 +0200 Subject: [PATCH 26/84] update openwb_local.conf --- data/config/mosquitto/local/openwb_local.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/config/mosquitto/local/openwb_local.conf b/data/config/mosquitto/local/openwb_local.conf index f540b3dcf1..8d37590972 100644 --- a/data/config/mosquitto/local/openwb_local.conf +++ b/data/config/mosquitto/local/openwb_local.conf @@ -1,4 +1,4 @@ -# openwb-version:19 +# openwb-version:20 listener 1886 localhost allow_anonymous true @@ -52,7 +52,7 @@ topic openWB/optional/# out 2 topic openWB/counter/config/# out 2 topic openWB/counter/set/# out 2 -topic openWB/counter/get/hierarchy out 2 +topic openWB/counter/get/# out 2 topic openWB/counter/+/module/# out 2 topic openWB/counter/+/config/# out 2 topic openWB/counter/+/get/# out 2 From 8ce496acb1912a22b3183c56324d27a85aad119a Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 29 May 2026 12:58:59 +0200 Subject: [PATCH 27/84] add loadmanagement prios to user management --- .../mosquitto/public/default-dynamic-security.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 422e796189..c9eceff753 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -1526,6 +1526,12 @@ "priority": 0, "allow": true }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/counter/get/loadmanagement_prios", + "priority": 0, + "allow": true + }, { "acltype": "publishClientSend", "topic": "openWB/set/counter/+/config/max_power_errorcase", @@ -1580,6 +1586,12 @@ "priority": 0, "allow": true }, + { + "acltype": "publishClientReceive", + "topic": "openWB/counter/get/loadmanagement_prios", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/system/device/+/component/+/config", From c339399d514b2f8b8e0f8ff602000d7388037617 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 2 Jun 2026 12:19:27 +0200 Subject: [PATCH 28/84] update config for prio list --- packages/control/ev/charge_template.py | 2 +- packages/helpermodules/update_config.py | 28 ++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index e02707f49b..ad521ec218 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -111,7 +111,7 @@ def chargemode_factory() -> Chargemode: class ChargeTemplateData: id: int = 0 name: str = "Lade-Profil" - prio: bool = False + prio: bool = False # OBSOLET seit 2.3.0 load_default: bool = False time_charging: TimeCharging = field(default_factory=time_charging_factory) chargemode: Chargemode = field(default_factory=chargemode_factory) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 458f3ae04e..c25ab384e5 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -11,6 +11,7 @@ from typing import List, Optional from paho.mqtt.client import Client as MqttClient, MQTTMessage +from control.chargemode import Chargemode from control.limiting_value import LoadmanagementLimit from control.chargepoint.chargepoint_template import get_chargepoint_template_default @@ -57,7 +58,7 @@ class UpdateConfig: - DATASTORE_VERSION = 140 + DATASTORE_VERSION = 141 valid_topic = [ "^openWB/bat/config/bat_control_activated$", @@ -3569,3 +3570,28 @@ def move_topic(new_topic: str, old_topic: str) -> None: f"{old_prefix}/retry_failed_phase_switches") self._append_datastore_version(140) + + def upgrade_datastore_141(self) -> None: + CHARGEMODES = ((Chargemode.SCHEDULED_CHARGING.value, True), + (Chargemode.SCHEDULED_CHARGING.value, False), + (Chargemode.INSTANT_CHARGING.value, True), + (Chargemode.INSTANT_CHARGING.value, False), + (Chargemode.ECO_CHARGING.value, True), + (Chargemode.PV_CHARGING.value, True), + (Chargemode.PV_CHARGING.value, False), + (Chargemode.STOP.value, True), + (Chargemode.STOP.value, False),) + + def upgrade(topic: str, payload) -> None: + if re.search("openWB/vehicle/[0-9]+/charge_template", topic) is not None: + charge_template_id = decode_payload(payload) + charge_template = decode_payload( + self.all_received_topics[f"openWB/vehicle/template/charge_template/{charge_template_id}"]) + if charge_template["chargemode"]["selected"] == chargemode and charge_template["prio"] == prio: + loadmanagement_prios.append({"type": "vehicle", "id": int(get_index(topic))}) + + loadmanagement_prios = [] + for chargemode, prio in CHARGEMODES: + self._loop_all_received_topics(upgrade) + self.__update_topic("openWB/counter/get/loadmanagement_prios", loadmanagement_prios) + self._append_datastore_version(141) From 5348b5720b145c277d2ad702d727be5d186670ca Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 8 Jul 2026 15:39:01 +0200 Subject: [PATCH 29/84] add pytest --- packages/helpermodules/update_config_test.py | 52 ++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index 18e036b162..8b45452ca1 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -1,4 +1,8 @@ +from typing import List from unittest.mock import Mock, patch, mock_open +from control.chargemode import Chargemode +from control.ev.charge_template import ChargeTemplate +import dataclass_utils from helpermodules import update_config import json from pathlib import Path @@ -233,3 +237,51 @@ def test_upgrade_datastore_125_is_idempotent_for_already_converted_values(mock_p assert uc.all_received_topics["openWB/optional/ep/grid_fee/provider"] == expected_grid_fee assert uc.all_received_topics["openWB/system/datastore_version"] == [123, 124, 125] assert mock_pub.pub.call_count == 1 # einmal publishen für Upgrade der Datastore-Version + + +@pytest.mark.parametrize("ev0_prio, ev0_chargemode, ev1_prio, ev1_chargemode, ev2_prio, ev2_chargemode, id_ordered", [ + pytest.param(False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.value, + False, Chargemode.INSTANT_CHARGING.value, [0, 1, 2], id="alle gleich"), + pytest.param(False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.value, + False, Chargemode.SCHEDULED_CHARGING.value, [2, 0, 1], id="Lademodi unterschiedlich"), + pytest.param(False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.True, + False, Chargemode.INSTANT_CHARGING.value, [1, 0, 2], id="Prioritäten unterschiedlich") +] +) +def test_upgrade_datastore_133_ev_chargemode_conversion(ev0_prio: bool, + ev0_chargemode: str, + ev1_prio: bool, + ev1_chargemode: str, + ev2_prio: bool, + ev2_chargemode: str, + id_ordered: List[int], + mock_pub: Mock): + # setup + ev0_charge_template = ChargeTemplate() + ev0_charge_template.data.chargemode.selected = ev0_chargemode + ev0_charge_template.data.prio = ev0_prio + ev1_charge_template = ChargeTemplate() + ev1_charge_template.data.chargemode.selected = ev1_chargemode + ev1_charge_template.data.prio = ev1_prio + ev2_charge_template = ChargeTemplate() + ev2_charge_template.data.chargemode.selected = ev2_chargemode + ev2_charge_template.data.prio = ev2_prio + + uc = UpdateConfig() + uc.all_received_topics = { + "openWB/vehicle/0/charge_template": dataclass_utils.asdict(ev0_charge_template), + "openWB/vehicle/1/charge_template": dataclass_utils.asdict(ev1_charge_template), + "openWB/vehicle/2/charge_template": dataclass_utils.asdict(ev2_charge_template), + "openWB/system/datastore_version": [131, 132], + } + + # execution + uc.upgrade_datastore_133() + + # evaluation + assert uc.all_received_topics["openWB/counter/get/loadmanagement_prios"] == [ + {"type": "vehicle", "id": id_ordered[0]}, + {"type": "vehicle", "id": id_ordered[1]}, + {"type": "vehicle", "id": id_ordered[2]}] + assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 133] + assert mock_pub.pub.call_count == 1 # einmal publishen für Upgrade der Datastore-Version From 2d5caadffb1c7657a38de59c7620b5d82c0068af Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Thu, 23 Jul 2026 15:21:38 +0200 Subject: [PATCH 30/84] pytest --- packages/helpermodules/update_config_test.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index 8b45452ca1..0559589062 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -244,11 +244,11 @@ def test_upgrade_datastore_125_is_idempotent_for_already_converted_values(mock_p False, Chargemode.INSTANT_CHARGING.value, [0, 1, 2], id="alle gleich"), pytest.param(False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.SCHEDULED_CHARGING.value, [2, 0, 1], id="Lademodi unterschiedlich"), - pytest.param(False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.True, + pytest.param(False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.value, [1, 0, 2], id="Prioritäten unterschiedlich") ] ) -def test_upgrade_datastore_133_ev_chargemode_conversion(ev0_prio: bool, +def test_upgrade_datastore_136_ev_chargemode_conversion(ev0_prio: bool, ev0_chargemode: str, ev1_prio: bool, ev1_chargemode: str, @@ -258,30 +258,36 @@ def test_upgrade_datastore_133_ev_chargemode_conversion(ev0_prio: bool, mock_pub: Mock): # setup ev0_charge_template = ChargeTemplate() + ev0_charge_template.data.id = 0 ev0_charge_template.data.chargemode.selected = ev0_chargemode ev0_charge_template.data.prio = ev0_prio ev1_charge_template = ChargeTemplate() + ev1_charge_template.data.id = 1 ev1_charge_template.data.chargemode.selected = ev1_chargemode ev1_charge_template.data.prio = ev1_prio ev2_charge_template = ChargeTemplate() + ev2_charge_template.data.id = 2 ev2_charge_template.data.chargemode.selected = ev2_chargemode ev2_charge_template.data.prio = ev2_prio uc = UpdateConfig() uc.all_received_topics = { - "openWB/vehicle/0/charge_template": dataclass_utils.asdict(ev0_charge_template), - "openWB/vehicle/1/charge_template": dataclass_utils.asdict(ev1_charge_template), - "openWB/vehicle/2/charge_template": dataclass_utils.asdict(ev2_charge_template), + "openWB/vehicle/0/charge_template": 0, + "openWB/vehicle/1/charge_template": 1, + "openWB/vehicle/2/charge_template": 2, + "openWB/vehicle/template/charge_template/0": dataclass_utils.asdict(ev0_charge_template.data), + "openWB/vehicle/template/charge_template/1": dataclass_utils.asdict(ev1_charge_template.data), + "openWB/vehicle/template/charge_template/2": dataclass_utils.asdict(ev2_charge_template.data), "openWB/system/datastore_version": [131, 132], } # execution - uc.upgrade_datastore_133() + uc.upgrade_datastore_136() # evaluation assert uc.all_received_topics["openWB/counter/get/loadmanagement_prios"] == [ {"type": "vehicle", "id": id_ordered[0]}, {"type": "vehicle", "id": id_ordered[1]}, {"type": "vehicle", "id": id_ordered[2]}] - assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 133] + assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 136] assert mock_pub.pub.call_count == 1 # einmal publishen für Upgrade der Datastore-Version From f7809473e773ec45f50b8eb0904b98ed3c73ddec Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Thu, 6 Aug 2026 15:14:15 +0200 Subject: [PATCH 31/84] fix merge --- packages/control/counter_all/hierarchy.py | 36 +++++++++++++++++++- packages/helpermodules/update_config_test.py | 10 +++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/control/counter_all/hierarchy.py b/packages/control/counter_all/hierarchy.py index 109e3b0397..07c6795fcb 100644 --- a/packages/control/counter_all/hierarchy.py +++ b/packages/control/counter_all/hierarchy.py @@ -1,6 +1,6 @@ import logging import re -from typing import Callable, Dict, List, Union +from typing import Callable, Dict, List, Optional, Union from control import data from control.counter_all.counter_all_data import HierarchyProtocol @@ -253,6 +253,40 @@ def _get_list_of_elements_per_level(self: HierarchyProtocol, elements_per_level = self._get_list_of_elements_per_level(elements_per_level, child, index+1) return elements_per_level + def get_hybrid_inverter_ids(self: HierarchyProtocol) -> List[int]: + inverter_ids = [] + + def collect_hybrid_inverter_ids(elements: List[Dict]): + for element in elements: + if element["type"] == ComponentType.INVERTER.value: + if any(child.get("type") == ComponentType.BAT.value for child in element.get("children", [])): + inverter_ids.append(element["id"]) + collect_hybrid_inverter_ids(element.get("children", [])) + + collect_hybrid_inverter_ids(self.data.get.hierarchy) + return inverter_ids + + def _get_bat_ids_by_hybrid_type(self: HierarchyProtocol, hybrid: bool) -> List[int]: + bat_ids = [] + + def collect_bat_ids(elements: List[Dict], parent_type: Optional[str] = None): + for element in elements: + element_type = element.get("type") + if element_type == ComponentType.BAT.value: + is_hybrid_bat = parent_type == ComponentType.INVERTER.value + if is_hybrid_bat == hybrid: + bat_ids.append(element["id"]) + collect_bat_ids(element.get("children", []), element_type) + + collect_bat_ids(self.data.get.hierarchy) + return bat_ids + + def get_hybrid_bat_ids(self: HierarchyProtocol) -> List[int]: + return self._get_bat_ids_by_hybrid_type(hybrid=True) + + def get_non_hybrid_bat_ids(self: HierarchyProtocol) -> List[int]: + return self._get_bat_ids_by_hybrid_type(hybrid=False) + def validate_hierarchy(self: HierarchyProtocol): try: self._delete_obsolete_entries() diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index 0559589062..19c3cd971f 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -244,11 +244,11 @@ def test_upgrade_datastore_125_is_idempotent_for_already_converted_values(mock_p False, Chargemode.INSTANT_CHARGING.value, [0, 1, 2], id="alle gleich"), pytest.param(False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.SCHEDULED_CHARGING.value, [2, 0, 1], id="Lademodi unterschiedlich"), - pytest.param(False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.value, + pytest.param(False, Chargemode.INSTANT_CHARGING.value, True, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.value, [1, 0, 2], id="Prioritäten unterschiedlich") ] ) -def test_upgrade_datastore_136_ev_chargemode_conversion(ev0_prio: bool, +def test_upgrade_datastore_138_ev_chargemode_conversion(ev0_prio: bool, ev0_chargemode: str, ev1_prio: bool, ev1_chargemode: str, @@ -282,12 +282,12 @@ def test_upgrade_datastore_136_ev_chargemode_conversion(ev0_prio: bool, } # execution - uc.upgrade_datastore_136() + uc.upgrade_datastore_138() # evaluation assert uc.all_received_topics["openWB/counter/get/loadmanagement_prios"] == [ {"type": "vehicle", "id": id_ordered[0]}, {"type": "vehicle", "id": id_ordered[1]}, {"type": "vehicle", "id": id_ordered[2]}] - assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 136] - assert mock_pub.pub.call_count == 1 # einmal publishen für Upgrade der Datastore-Version + assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 138] + assert mock_pub.pub.call_count == 2 # einmal publishen für Upgrade der Datastore-Version From f648d2858e82f7691117a08bd5e55f88bd07a0af Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 18 Aug 2026 11:25:20 +0200 Subject: [PATCH 32/84] fix rebase --- packages/helpermodules/update_config_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index 19c3cd971f..2371f460dc 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -248,7 +248,7 @@ def test_upgrade_datastore_125_is_idempotent_for_already_converted_values(mock_p False, Chargemode.INSTANT_CHARGING.value, [1, 0, 2], id="Prioritäten unterschiedlich") ] ) -def test_upgrade_datastore_138_ev_chargemode_conversion(ev0_prio: bool, +def test_upgrade_datastore_139_ev_chargemode_conversion(ev0_prio: bool, ev0_chargemode: str, ev1_prio: bool, ev1_chargemode: str, @@ -282,12 +282,12 @@ def test_upgrade_datastore_138_ev_chargemode_conversion(ev0_prio: bool, } # execution - uc.upgrade_datastore_138() + uc.upgrade_datastore_139() # evaluation assert uc.all_received_topics["openWB/counter/get/loadmanagement_prios"] == [ {"type": "vehicle", "id": id_ordered[0]}, {"type": "vehicle", "id": id_ordered[1]}, {"type": "vehicle", "id": id_ordered[2]}] - assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 138] + assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 139] assert mock_pub.pub.call_count == 2 # einmal publishen für Upgrade der Datastore-Version From eb217f2a126fb74424e399c71b216005f8bc3946 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:29:09 +0200 Subject: [PATCH 33/84] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/helpermodules/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 9baca647f8..f7d1d035f5 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -12,7 +12,7 @@ from pathlib import Path import paho.mqtt.client as mqtt -from control import bat, bridge, counter, pv +from control import bat, bridge, counter, data, pv from control.chargelog.process_chargelog import get_log_data from control.chargepoint import chargepoint from control.chargepoint.chargepoint_template import get_chargepoint_template_default From 83ab734a48a673e83d39e0bb2df38f5318168653 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 19 Aug 2026 11:39:59 +0200 Subject: [PATCH 34/84] review --- packages/helpermodules/update_config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index c25ab384e5..52dfa00f18 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -3577,6 +3577,7 @@ def upgrade_datastore_141(self) -> None: (Chargemode.INSTANT_CHARGING.value, True), (Chargemode.INSTANT_CHARGING.value, False), (Chargemode.ECO_CHARGING.value, True), + (Chargemode.ECO_CHARGING.value, False), (Chargemode.PV_CHARGING.value, True), (Chargemode.PV_CHARGING.value, False), (Chargemode.STOP.value, True), From 863f4802e0d5b77735e1c0be8ff6b3cd74b6d34a Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 19 Aug 2026 11:47:07 +0200 Subject: [PATCH 35/84] fix pytest after rebase --- packages/helpermodules/update_config_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index 2371f460dc..1012879bb6 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -248,7 +248,7 @@ def test_upgrade_datastore_125_is_idempotent_for_already_converted_values(mock_p False, Chargemode.INSTANT_CHARGING.value, [1, 0, 2], id="Prioritäten unterschiedlich") ] ) -def test_upgrade_datastore_139_ev_chargemode_conversion(ev0_prio: bool, +def test_upgrade_datastore_140_ev_chargemode_conversion(ev0_prio: bool, ev0_chargemode: str, ev1_prio: bool, ev1_chargemode: str, @@ -282,12 +282,12 @@ def test_upgrade_datastore_139_ev_chargemode_conversion(ev0_prio: bool, } # execution - uc.upgrade_datastore_139() + uc.upgrade_datastore_140() # evaluation assert uc.all_received_topics["openWB/counter/get/loadmanagement_prios"] == [ {"type": "vehicle", "id": id_ordered[0]}, {"type": "vehicle", "id": id_ordered[1]}, {"type": "vehicle", "id": id_ordered[2]}] - assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 139] + assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 140] assert mock_pub.pub.call_count == 2 # einmal publishen für Upgrade der Datastore-Version From 8fcef539d92cd5281bdeb958610c09b8954a8306 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 21 Aug 2026 09:55:35 +0200 Subject: [PATCH 36/84] update pytest after rebase --- packages/helpermodules/update_config_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index 1012879bb6..5ff0fabdb1 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -248,7 +248,7 @@ def test_upgrade_datastore_125_is_idempotent_for_already_converted_values(mock_p False, Chargemode.INSTANT_CHARGING.value, [1, 0, 2], id="Prioritäten unterschiedlich") ] ) -def test_upgrade_datastore_140_ev_chargemode_conversion(ev0_prio: bool, +def test_upgrade_datastore_141_ev_chargemode_conversion(ev0_prio: bool, ev0_chargemode: str, ev1_prio: bool, ev1_chargemode: str, @@ -282,12 +282,12 @@ def test_upgrade_datastore_140_ev_chargemode_conversion(ev0_prio: bool, } # execution - uc.upgrade_datastore_140() + uc.upgrade_datastore_141() # evaluation assert uc.all_received_topics["openWB/counter/get/loadmanagement_prios"] == [ {"type": "vehicle", "id": id_ordered[0]}, {"type": "vehicle", "id": id_ordered[1]}, {"type": "vehicle", "id": id_ordered[2]}] - assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 140] + assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 141] assert mock_pub.pub.call_count == 2 # einmal publishen für Upgrade der Datastore-Version From ce4465cb508940a6e330eb7c13b471077addce0b Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:22:26 +0200 Subject: [PATCH 37/84] add type for loadmanagement prio, improve update config (#3834) * draft * improve style * improve update config --- .../control/counter_all/counter_all_data.py | 6 ++--- .../counter_all/loadmanagement_prio.py | 23 +++++++++-------- .../counter_all/loadmanagement_prio_test.py | 12 +++++---- packages/helpermodules/command.py | 4 +-- packages/helpermodules/update_config.py | 7 +++++- packages/helpermodules/update_config_test.py | 25 ++++++++++++------- packages/modules/common/component_type.py | 1 + 7 files changed, 48 insertions(+), 30 deletions(-) diff --git a/packages/control/counter_all/counter_all_data.py b/packages/control/counter_all/counter_all_data.py index c137c5add5..432522e59b 100644 --- a/packages/control/counter_all/counter_all_data.py +++ b/packages/control/counter_all/counter_all_data.py @@ -99,9 +99,9 @@ def hierarchy_remove_item(self, id_to_find: int, keep_children: bool = True) -> class LoadmanagementPrioProtocol(Protocol): @property def data(self) -> CounterAllData: ... - def add_loadmanagement_prio_item(self, type: str, id: int) -> None: ... - def remove_loadmanagement_prio_item(self, id: int) -> None: ... - def _remove_loadmanagement_prio_item(self, id: int, entry: Dict) -> None: ... + def add_loadmanagement_prio_item(self, type: ComponentType, id: int) -> None: ... + def remove_loadmanagement_prio_item(self, type: ComponentType, id: int) -> None: ... + def _remove_loadmanagement_prio_item(self, type: ComponentType, id: int, entry: Dict) -> None: ... def sort_cps_by_loadmanagement_prios_nested(self, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: ... def generator_cps_by_loadmanagement_prios( diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py index 2ac9cbc403..9c68557c75 100644 --- a/packages/control/counter_all/loadmanagement_prio.py +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -4,27 +4,30 @@ from control.algorithm.filter_chargepoints import get_preferenced_chargepoint from control.chargepoint.chargepoint import Chargepoint from control.counter_all.counter_all_data import LoadmanagementPrioProtocol +from modules.common.component_type import ComponentType log = logging.getLogger(__name__) class LoadmanagementPrioMixin: - def add_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, type: str, id: int) -> None: - self.data.get.loadmanagement_prios.append({"type": type, "id": id}) + def add_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, type: ComponentType, id: int) -> None: + self.data.get.loadmanagement_prios.append({"type": type.value, "id": id}) - def remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, id: int) -> None: - if self._remove_loadmanagement_prio_item(id, self.data.get.loadmanagement_prios) is False: + def remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, type: ComponentType, id: int) -> None: + if self._remove_loadmanagement_prio_item(type, id, self.data.get.loadmanagement_prios) is False: raise IndexError(f"Element {id} konnte nicht in der Prioritätensteuerung gefunden werden.") - def _remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, id: int, entry: List[Dict]) -> bool: + def _remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, + type: ComponentType, + id: int, + entry: List[Dict]) -> bool: for item in entry: - if item["type"] == "vehicle": - if item["id"] == id: - entry.remove(item) - return True + if item["type"] == type.value and item["id"] == id: + entry.remove(item) + return True elif item["type"] == "group": - removed_item = self._remove_loadmanagement_prio_item(id, item["children"]) + removed_item = self._remove_loadmanagement_prio_item(type, id, item["children"]) if removed_item and len(item["children"]) == 0: entry.remove(item) if removed_item: diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index f6a7c01433..110e0bab2d 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -6,6 +6,7 @@ from control.chargepoint.chargepoint import Chargepoint from control.counter_all.counter_all import CounterAll +from modules.common.component_type import ComponentType @pytest.fixture @@ -32,9 +33,10 @@ def cp3(): @pytest.mark.parametrize( "loadmanagement_prios, id, type, expected_loadmanagement_prios", [ - pytest.param([], 2, "vehicle", [{"type": "vehicle", "id": 2}], id="emtpy list"), - pytest.param([{"type": "vehicle", "id": 3}], 2, "vehicle", [{"type": "vehicle", "id": 3}, - {"type": "vehicle", "id": 2}], id="flat list"), + pytest.param([], 2, ComponentType.VEHICLE, [{"type": "vehicle", "id": 2}], id="emtpy list"), + pytest.param([{"type": "vehicle", "id": 3}], 2, ComponentType.VEHICLE, + [{"type": "vehicle", "id": 3}, + {"type": "vehicle", "id": 2}], id="flat list"), pytest.param([ { "type": "group", @@ -45,7 +47,7 @@ def cp3(): ] }, {"type": "vehicle", "id": 2}, - ], 4, "vehicle", [ + ], 4, ComponentType.VEHICLE, [ { "type": "group", "label": "Wichtige Fahrzeuge", @@ -137,7 +139,7 @@ def test_remove_loadmanagement_prio_item(loadmanagement_prios: List[Dict], c.data.get.loadmanagement_prios = loadmanagement_prios # execution - c.remove_loadmanagement_prio_item(id) + c.remove_loadmanagement_prio_item(ComponentType.VEHICLE, id) # assert assert c.data.get.loadmanagement_prios == expected_loadmanagement_prios diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index f7d1d035f5..60ce85828b 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -773,7 +773,7 @@ def addVehicle(self, connection_id: str, payload: dict) -> None: # add ACL roles for vehicle access, if user management is active if SubData.system_data["system"].data["security"]["user_management_active"]: add_acl_role("vehicle--access", new_id) - data.data.counter_all_data.add_loadmanagement_prio_item("vehicle", new_id) + data.data.counter_all_data.add_loadmanagement_prio_item(ComponentType.VEHICLE, new_id) Pub().pub("openWB/set/counter/get/loadmanagement_prios", data.data.counter_all_data.data.get.loadmanagement_prios) pub_user_message(payload, connection_id, f'Neues EV mit ID \'{new_id}\' hinzugefügt.', MessageType.SUCCESS) @@ -791,7 +791,7 @@ def removeVehicle(self, connection_id: str, payload: dict) -> None: if SubData.system_data["system"].data["security"]["user_management_active"]: remove_acl_role("vehicle--access", payload["data"]["id"]) remove_acl_role("vehicle--write-access", payload["data"]["id"]) - data.data.counter_all_data.remove_loadmanagement_prio_item(payload["data"]["id"]) + data.data.counter_all_data.remove_loadmanagement_prio_item(ComponentType.VEHICLE, payload["data"]["id"]) Pub().pub("openWB/set/counter/get/loadmanagement_prios", data.data.counter_all_data.data.get.loadmanagement_prios) pub_user_message( diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 52dfa00f18..608b4cd729 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -3589,10 +3589,15 @@ def upgrade(topic: str, payload) -> None: charge_template = decode_payload( self.all_received_topics[f"openWB/vehicle/template/charge_template/{charge_template_id}"]) if charge_template["chargemode"]["selected"] == chargemode and charge_template["prio"] == prio: - loadmanagement_prios.append({"type": "vehicle", "id": int(get_index(topic))}) + grouped_vehicles.append({"type": "vehicle", "id": int(get_index(topic))}) loadmanagement_prios = [] for chargemode, prio in CHARGEMODES: + grouped_vehicles = [] self._loop_all_received_topics(upgrade) + if len(grouped_vehicles) == 1: + loadmanagement_prios.append(grouped_vehicles[0]) + elif len(grouped_vehicles) > 1: + loadmanagement_prios.append({"type": "group", "children": grouped_vehicles}) self.__update_topic("openWB/counter/get/loadmanagement_prios", loadmanagement_prios) self._append_datastore_version(141) diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index 5ff0fabdb1..30359a1cfd 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -239,13 +239,23 @@ def test_upgrade_datastore_125_is_idempotent_for_already_converted_values(mock_p assert mock_pub.pub.call_count == 1 # einmal publishen für Upgrade der Datastore-Version -@pytest.mark.parametrize("ev0_prio, ev0_chargemode, ev1_prio, ev1_chargemode, ev2_prio, ev2_chargemode, id_ordered", [ +@pytest.mark.parametrize("ev0_prio, ev0_chargemode, ev1_prio, ev1_chargemode, ev2_prio, ev2_chargemode, expected", [ pytest.param(False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.value, - False, Chargemode.INSTANT_CHARGING.value, [0, 1, 2], id="alle gleich"), + False, Chargemode.INSTANT_CHARGING.value, + [{"type": "group", "children": [{"type": "vehicle", "id": 0}, + {"type": "vehicle", "id": 1}, + {"type": "vehicle", "id": 2}]}], + id="alle gleich"), pytest.param(False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.value, - False, Chargemode.SCHEDULED_CHARGING.value, [2, 0, 1], id="Lademodi unterschiedlich"), + False, Chargemode.SCHEDULED_CHARGING.value, + [{"type": "vehicle", "id": 2}, + {"type": "group", "children": [{"type": "vehicle", "id": 0}, {"type": "vehicle", "id": 1}]}], + id="Lademodi unterschiedlich"), pytest.param(False, Chargemode.INSTANT_CHARGING.value, True, Chargemode.INSTANT_CHARGING.value, - False, Chargemode.INSTANT_CHARGING.value, [1, 0, 2], id="Prioritäten unterschiedlich") + False, Chargemode.INSTANT_CHARGING.value, + [{"type": "vehicle", "id": 1}, + {"type": "group", "children": [{"type": "vehicle", "id": 0}, {"type": "vehicle", "id": 2}]}], + id="Prioritäten unterschiedlich") ] ) def test_upgrade_datastore_141_ev_chargemode_conversion(ev0_prio: bool, @@ -254,7 +264,7 @@ def test_upgrade_datastore_141_ev_chargemode_conversion(ev0_prio: bool, ev1_chargemode: str, ev2_prio: bool, ev2_chargemode: str, - id_ordered: List[int], + expected: List[dict], mock_pub: Mock): # setup ev0_charge_template = ChargeTemplate() @@ -285,9 +295,6 @@ def test_upgrade_datastore_141_ev_chargemode_conversion(ev0_prio: bool, uc.upgrade_datastore_141() # evaluation - assert uc.all_received_topics["openWB/counter/get/loadmanagement_prios"] == [ - {"type": "vehicle", "id": id_ordered[0]}, - {"type": "vehicle", "id": id_ordered[1]}, - {"type": "vehicle", "id": id_ordered[2]}] + assert uc.all_received_topics["openWB/counter/get/loadmanagement_prios"] == expected assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 141] assert mock_pub.pub.call_count == 2 # einmal publishen für Upgrade der Datastore-Version diff --git a/packages/modules/common/component_type.py b/packages/modules/common/component_type.py index a759d3c533..69fd3694a5 100644 --- a/packages/modules/common/component_type.py +++ b/packages/modules/common/component_type.py @@ -11,6 +11,7 @@ class ComponentType(Enum): GRID_FEE = "grid_tariff" INVERTER = "inverter" IO = "io" + VEHICLE = "vehicle" def special_to_general_type_mapping(component_type: str) -> ComponentType: From 9d55bc0f423dbb6de103288f7e1a10583a97d8e3 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 16 Jun 2026 15:56:11 +0200 Subject: [PATCH 38/84] draft --- docs/samples/sample_consumer/__init__.py | 0 .../sample_consumer/__init__.py | 0 .../sample_consumer/sample_consumer/config.py | 30 +++ .../sample_consumer/consumer.py | 77 ++++++ docs/samples/sample_consumer/vendor.py | 14 + packages/control/consumer/consumer.py | 254 ++++++++++++++++++ packages/control/consumer/consumer_all.py | 53 ++++ packages/control/consumer/consumer_data.py | 155 +++++++++++ packages/control/consumer/usage.py | 8 + packages/control/load_protocol.py | 54 ++++ .../modules/common/configurable_consumer.py | 94 +++++++ packages/modules/common/consumer_setup.py | 23 ++ packages/modules/common/store/_consumer.py | 62 +++++ packages/modules/consumers/__init__.py | 0 packages/modules/consumers/askoma/__init__.py | 0 .../consumers/askoma/askoheat/__init__.py | 0 .../consumers/askoma/askoheat/config.py | 30 +++ .../consumers/askoma/askoheat/consumer.py | 48 ++++ packages/modules/consumers/askoma/vendor.py | 14 + packages/modules/consumers/avm/__init__.py | 0 .../modules/consumers/avm/avm/__init__.py | 0 packages/modules/consumers/avm/avm/config.py | 36 +++ .../modules/consumers/avm/avm/consumer.py | 134 +++++++++ packages/modules/consumers/avm/vendor.py | 14 + .../modules/consumers/generic/__init__.py | 0 .../consumers/generic/counter/__init__.py | 0 .../consumers/generic/counter/config.py | 24 ++ .../consumers/generic/counter/consumer.py | 11 + .../modules/consumers/generic/dac/__init__.py | 0 .../modules/consumers/generic/dac/config.py | 32 +++ .../modules/consumers/generic/dac/consumer.py | 54 ++++ .../modules/consumers/generic/dac/model.py | 8 + .../consumers/generic/http/__init__.py | 0 .../modules/consumers/generic/http/config.py | 45 ++++ .../consumers/generic/http/consumer.py | 90 +++++++ .../consumers/generic/json/__init__.py | 0 .../modules/consumers/generic/json/config.py | 45 ++++ .../consumers/generic/json/consumer.py | 104 +++++++ .../consumers/generic/mqtt/__init__.py | 0 .../modules/consumers/generic/mqtt/config.py | 27 ++ .../consumers/generic/mqtt/consumer.py | 59 ++++ packages/modules/consumers/generic/vendor.py | 14 + packages/modules/consumers/idm/__init__.py | 0 .../modules/consumers/idm/idm/__init__.py | 0 packages/modules/consumers/idm/idm/config.py | 32 +++ .../modules/consumers/idm/idm/consumer.py | 67 +++++ packages/modules/consumers/idm/vendor.py | 14 + .../modules/consumers/lambda_/__init__.py | 0 .../consumers/lambda_/lambda_/__init__.py | 0 .../consumers/lambda_/lambda_/config.py | 32 +++ .../consumers/lambda_/lambda_/consumer.py | 51 ++++ packages/modules/consumers/lambda_/vendor.py | 14 + packages/modules/consumers/my_pv/__init__.py | 0 .../consumers/my_pv/acthor/__init__.py | 0 .../modules/consumers/my_pv/acthor/config.py | 34 +++ .../consumers/my_pv/acthor/consumer.py | 76 ++++++ .../consumers/my_pv/elwa_e/__init__.py | 0 .../modules/consumers/my_pv/elwa_e/config.py | 30 +++ .../consumers/my_pv/elwa_e/consumer.py | 91 +++++++ packages/modules/consumers/my_pv/vendor.py | 14 + .../modules/consumers/mystrom/__init__.py | 0 .../consumers/mystrom/mystrom/__init__.py | 0 .../consumers/mystrom/mystrom/config.py | 25 ++ .../consumers/mystrom/mystrom/consumer.py | 58 ++++ packages/modules/consumers/mystrom/vendor.py | 14 + packages/modules/consumers/nibe/__init__.py | 0 .../consumers/nibe/nibe_s_series/__init__.py | 0 .../consumers/nibe/nibe_s_series/config.py | 27 ++ .../consumers/nibe/nibe_s_series/consumer.py | 43 +++ packages/modules/consumers/nibe/vendor.py | 14 + .../modules/consumers/ratiotherm/__init__.py | 0 .../ratiotherm/ratiotherm/__init__.py | 0 .../consumers/ratiotherm/ratiotherm/config.py | 29 ++ .../ratiotherm/ratiotherm/consumer.py | 36 +++ .../modules/consumers/ratiotherm/vendor.py | 14 + packages/modules/consumers/shelly/__init__.py | 0 .../consumers/shelly/shelly_em/__init__.py | 0 .../consumers/shelly/shelly_em/config.py | 30 +++ .../consumers/shelly/shelly_em/consumer.py | 35 +++ .../consumers/shelly/shelly_pm/__init__.py | 0 .../consumers/shelly/shelly_pm/config.py | 27 ++ .../consumers/shelly/shelly_pm/consumer.py | 33 +++ packages/modules/consumers/shelly/vendor.py | 14 + packages/modules/consumers/solvis/__init__.py | 0 .../consumers/solvis/solvis/__init__.py | 0 .../modules/consumers/solvis/solvis/config.py | 26 ++ .../consumers/solvis/solvis/consumer.py | 39 +++ packages/modules/consumers/solvis/vendor.py | 14 + .../modules/consumers/stiebel/__init__.py | 0 .../consumers/stiebel/stiebel/__init__.py | 0 .../consumers/stiebel/stiebel/config.py | 29 ++ .../consumers/stiebel/stiebel/consumer.py | 41 +++ packages/modules/consumers/stiebel/vendor.py | 14 + packages/modules/consumers/vendors.py | 5 + .../modules/consumers/viessmann/__init__.py | 0 .../modules/consumers/viessmann/vendor.py | 14 + .../viessmann/viessmann_heat_pump/__init__.py | 0 .../viessmann/viessmann_heat_pump/config.py | 26 ++ .../viessmann/viessmann_heat_pump/consumer.py | 33 +++ 99 files changed, 2708 insertions(+) create mode 100644 docs/samples/sample_consumer/__init__.py create mode 100644 docs/samples/sample_consumer/sample_consumer/__init__.py create mode 100644 docs/samples/sample_consumer/sample_consumer/config.py create mode 100644 docs/samples/sample_consumer/sample_consumer/consumer.py create mode 100644 docs/samples/sample_consumer/vendor.py create mode 100644 packages/control/consumer/consumer.py create mode 100644 packages/control/consumer/consumer_all.py create mode 100644 packages/control/consumer/consumer_data.py create mode 100644 packages/control/consumer/usage.py create mode 100644 packages/control/load_protocol.py create mode 100644 packages/modules/common/configurable_consumer.py create mode 100644 packages/modules/common/consumer_setup.py create mode 100644 packages/modules/common/store/_consumer.py create mode 100644 packages/modules/consumers/__init__.py create mode 100644 packages/modules/consumers/askoma/__init__.py create mode 100644 packages/modules/consumers/askoma/askoheat/__init__.py create mode 100644 packages/modules/consumers/askoma/askoheat/config.py create mode 100644 packages/modules/consumers/askoma/askoheat/consumer.py create mode 100644 packages/modules/consumers/askoma/vendor.py create mode 100644 packages/modules/consumers/avm/__init__.py create mode 100644 packages/modules/consumers/avm/avm/__init__.py create mode 100644 packages/modules/consumers/avm/avm/config.py create mode 100644 packages/modules/consumers/avm/avm/consumer.py create mode 100644 packages/modules/consumers/avm/vendor.py create mode 100644 packages/modules/consumers/generic/__init__.py create mode 100644 packages/modules/consumers/generic/counter/__init__.py create mode 100644 packages/modules/consumers/generic/counter/config.py create mode 100644 packages/modules/consumers/generic/counter/consumer.py create mode 100644 packages/modules/consumers/generic/dac/__init__.py create mode 100644 packages/modules/consumers/generic/dac/config.py create mode 100644 packages/modules/consumers/generic/dac/consumer.py create mode 100644 packages/modules/consumers/generic/dac/model.py create mode 100644 packages/modules/consumers/generic/http/__init__.py create mode 100644 packages/modules/consumers/generic/http/config.py create mode 100644 packages/modules/consumers/generic/http/consumer.py create mode 100644 packages/modules/consumers/generic/json/__init__.py create mode 100644 packages/modules/consumers/generic/json/config.py create mode 100644 packages/modules/consumers/generic/json/consumer.py create mode 100644 packages/modules/consumers/generic/mqtt/__init__.py create mode 100644 packages/modules/consumers/generic/mqtt/config.py create mode 100644 packages/modules/consumers/generic/mqtt/consumer.py create mode 100644 packages/modules/consumers/generic/vendor.py create mode 100644 packages/modules/consumers/idm/__init__.py create mode 100644 packages/modules/consumers/idm/idm/__init__.py create mode 100644 packages/modules/consumers/idm/idm/config.py create mode 100644 packages/modules/consumers/idm/idm/consumer.py create mode 100644 packages/modules/consumers/idm/vendor.py create mode 100644 packages/modules/consumers/lambda_/__init__.py create mode 100644 packages/modules/consumers/lambda_/lambda_/__init__.py create mode 100644 packages/modules/consumers/lambda_/lambda_/config.py create mode 100644 packages/modules/consumers/lambda_/lambda_/consumer.py create mode 100644 packages/modules/consumers/lambda_/vendor.py create mode 100644 packages/modules/consumers/my_pv/__init__.py create mode 100644 packages/modules/consumers/my_pv/acthor/__init__.py create mode 100644 packages/modules/consumers/my_pv/acthor/config.py create mode 100644 packages/modules/consumers/my_pv/acthor/consumer.py create mode 100644 packages/modules/consumers/my_pv/elwa_e/__init__.py create mode 100644 packages/modules/consumers/my_pv/elwa_e/config.py create mode 100644 packages/modules/consumers/my_pv/elwa_e/consumer.py create mode 100644 packages/modules/consumers/my_pv/vendor.py create mode 100644 packages/modules/consumers/mystrom/__init__.py create mode 100644 packages/modules/consumers/mystrom/mystrom/__init__.py create mode 100644 packages/modules/consumers/mystrom/mystrom/config.py create mode 100644 packages/modules/consumers/mystrom/mystrom/consumer.py create mode 100644 packages/modules/consumers/mystrom/vendor.py create mode 100644 packages/modules/consumers/nibe/__init__.py create mode 100644 packages/modules/consumers/nibe/nibe_s_series/__init__.py create mode 100644 packages/modules/consumers/nibe/nibe_s_series/config.py create mode 100644 packages/modules/consumers/nibe/nibe_s_series/consumer.py create mode 100644 packages/modules/consumers/nibe/vendor.py create mode 100644 packages/modules/consumers/ratiotherm/__init__.py create mode 100644 packages/modules/consumers/ratiotherm/ratiotherm/__init__.py create mode 100644 packages/modules/consumers/ratiotherm/ratiotherm/config.py create mode 100644 packages/modules/consumers/ratiotherm/ratiotherm/consumer.py create mode 100644 packages/modules/consumers/ratiotherm/vendor.py create mode 100644 packages/modules/consumers/shelly/__init__.py create mode 100644 packages/modules/consumers/shelly/shelly_em/__init__.py create mode 100644 packages/modules/consumers/shelly/shelly_em/config.py create mode 100644 packages/modules/consumers/shelly/shelly_em/consumer.py create mode 100644 packages/modules/consumers/shelly/shelly_pm/__init__.py create mode 100644 packages/modules/consumers/shelly/shelly_pm/config.py create mode 100644 packages/modules/consumers/shelly/shelly_pm/consumer.py create mode 100644 packages/modules/consumers/shelly/vendor.py create mode 100644 packages/modules/consumers/solvis/__init__.py create mode 100644 packages/modules/consumers/solvis/solvis/__init__.py create mode 100644 packages/modules/consumers/solvis/solvis/config.py create mode 100644 packages/modules/consumers/solvis/solvis/consumer.py create mode 100644 packages/modules/consumers/solvis/vendor.py create mode 100644 packages/modules/consumers/stiebel/__init__.py create mode 100644 packages/modules/consumers/stiebel/stiebel/__init__.py create mode 100644 packages/modules/consumers/stiebel/stiebel/config.py create mode 100644 packages/modules/consumers/stiebel/stiebel/consumer.py create mode 100644 packages/modules/consumers/stiebel/vendor.py create mode 100644 packages/modules/consumers/vendors.py create mode 100644 packages/modules/consumers/viessmann/__init__.py create mode 100644 packages/modules/consumers/viessmann/vendor.py create mode 100644 packages/modules/consumers/viessmann/viessmann_heat_pump/__init__.py create mode 100644 packages/modules/consumers/viessmann/viessmann_heat_pump/config.py create mode 100644 packages/modules/consumers/viessmann/viessmann_heat_pump/consumer.py diff --git a/docs/samples/sample_consumer/__init__.py b/docs/samples/sample_consumer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/samples/sample_consumer/sample_consumer/__init__.py b/docs/samples/sample_consumer/sample_consumer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/samples/sample_consumer/sample_consumer/config.py b/docs/samples/sample_consumer/sample_consumer/config.py new file mode 100644 index 0000000000..e39d7c6533 --- /dev/null +++ b/docs/samples/sample_consumer/sample_consumer/config.py @@ -0,0 +1,30 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class SampleConsumerConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class SampleConsumer(ConsumerSetup[SampleConsumerConfiguration]): + def __init__(self, + name: str = "Sample Consumer", + type: str = "sample_consumer", + id: int = 0, + configuration: SampleConsumerConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or SampleConsumerConfiguration(), usage=usage) diff --git a/docs/samples/sample_consumer/sample_consumer/consumer.py b/docs/samples/sample_consumer/sample_consumer/consumer.py new file mode 100644 index 0000000000..a5a9cbd4e1 --- /dev/null +++ b/docs/samples/sample_consumer/sample_consumer/consumer.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +from enum import IntEnum +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.askoma.askoheat.config import SampleConsumer + +log = logging.getLogger(__name__) + + +class Register(IntEnum): + POWER = 1000 + TEMP0 = 1001 + TEMP1 = 1030 + TEMP2 = 1031 + STATUS = 1003 + + +REG_MAPPING = ( + (Register.POWER, [ModbusDataType.INT_16]), + (Register.TEMP0, [ModbusDataType.INT_16]), + (Register.TEMP1, [ModbusDataType.INT_16]), + (Register.TEMP2, [ModbusDataType.INT_16]), + (Register.STATUS, ModbusDataType.INT_16), +) + + +def create_consumer(config: SampleConsumer): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + resp = client.read_holding_registers_bulk( + Register.POWER, 35, mapping=REG_MAPPING, unit=config.configuration.modbus_id) + imported, exported = sim_counter.sim_count(resp[Register.POWER]) + return ConsumerState( + power=resp[Register.POWER], + imported=imported, + exported=exported, + temperatures=[resp[Register.TEMP0]/10, resp[Register.TEMP1]/10, resp[Register.TEMP2]/10] + ) + + def set_limit(power_limit: float) -> None: + nonlocal client + client.write_registers(1000, power_limit, unit=config.configuration.modbus_id) + + # ODER + + def switch_on() -> None: + nonlocal client + client.write_registers(1000, 1, unit=config.configuration.modbus_id) + + def switch_off() -> None: + nonlocal client + client.write_registers(1000, 0, unit=config.configuration.modbus_id) + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_limit, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=SampleConsumer) diff --git a/docs/samples/sample_consumer/vendor.py b/docs/samples/sample_consumer/vendor.py new file mode 100644 index 0000000000..9741383ef5 --- /dev/null +++ b/docs/samples/sample_consumer/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Sample" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py new file mode 100644 index 0000000000..17e3c055b8 --- /dev/null +++ b/packages/control/consumer/consumer.py @@ -0,0 +1,254 @@ +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 +from control.load_protocol import Load +from helpermodules import timecheck +from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase +from modules.common.configurable_consumer import ConfigurableConsumer + +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): + def __init__(self, index: int): + self.num = index + self.data: ConsumerData = ConsumerData() + self.module: ConfigurableConsumer = None + self.chargemode_changed: bool = False + self.submode_changed: bool = False + + def set_state_and_log(self, message: str) -> None: + if message: + log.info(f"Verbraucher {self.num}: {message}") + if self.data.get.state_str is None: + self.data.get.state_str = message + elif message not in self.data.get.state_str: + self.data.get.state_str += f" {message}" + + def update(self): + if isinstance(self.data.usage, MeterOnlyConfig): + return + else: + if self.data.get.voltages is None: + self.data.get.voltages = [230 for i in range(0, self.data.config.connected_phases)] + if self.data.get.currents is None: + self.data.get.currents = [0]*3 + for i in range(0, self.data.config.connected_phases): + self.data.get.currents[i] = (self.data.get.power / + self.data.config.connected_phases / + self.data.get.voltages[i]) + self.data.get.phases_in_use = self.data.config.connected_phases + 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_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}") + + PRICE_LIMIT_EXCEEDED = "Preislimit für Verbraucher aktiv, aktueller Preis zu hoch." + PRICE_LIMIT_EXCEEDED_CONTINOUS_STILL_RUNNING = ("Preislimit für Verbraucher aktiv, aktueller Preis zu hoch. " + "Verbraucher läuft weiter, da der Verbraucher nicht abgeschaltet " + "werden darf.") + 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.usage.min_intervall: + log.debug("Intervall für neuen Schaltbefehl nicht abgelaufen.") + return (self.data.control_parameter.min_current, + self.data.control_parameter.required_current, + None, + self.data.control_parameter.chargemode, + self.data.control_parameter.submode) + + min_current = self.data.usage.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: + required_current, message, mode, submode = self.time_charging() + elif self.data.usage.chargemode == Chargemode.INSTANT_CHARGING: + required_current, message, mode, submode = self.instant_charging() + elif self.data.usage.chargemode == Chargemode.ECO_CHARGING: + required_current, message, mode, submode = self.wait_for_start_handler(self.eco_charging) + elif self.data.usage.chargemode == Chargemode.PV_CHARGING: + required_current, message, mode, submode = self.wait_for_start_handler(self.pv_charging) + elif self.data.usage.chargemode == Chargemode.STOP: + required_current = 0 + message = None + mode = Chargemode.STOP + submode = Chargemode.STOP + else: + raise ValueError(f"Ungültiger Lademodus {self.data.usage.chargemode} für Verbraucher {self.num}") + return min_current, required_current, message, mode, submode + + 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 + + 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.") + TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL = ( + "Betrieb mit Zeitladen nicht möglich, da eine aktive Batteriesteuerung einen Konflikt verursacht.") + TIME_CHARGING_NO_PLAN_ACTIVE = "Betrieb mit Zeitladen nicht möglich, da kein Plan aktiv ist." + TIME_CHARGING_NO_PLAN_CONFIGURED = "Betrieb mit Zeitladen nicht möglich, da kein Plan konfiguriert ist." + + def time_charging(self) -> Tuple[int, str, Optional[str], int]: + required_current = 0 + message = None + submode = Chargemode.STOP + if self.data.usage.time_charging.plans: + plan = timecheck.check_plans_timeframe(self.data.usage.time_charging.plans) + if plan is not None: + if plan.min_bat_soc is not None and data.data.bat_all_data.data.config.configured: + if data.data.bat_all_data.time_charging_min_bat_soc_allowed(): + if data.data.bat_all_data.data.get.soc < plan.min_bat_soc: + message = self.TIME_CHARGING_MIN_BAT_SOC_REACHED + else: + log.debug( + "Zeitladen: minimaler Speicher-SoC überschritten, Laden mit Zeitladen möglich.") + required_current = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + submode = Chargemode.TIME_CHARGING + else: + message = self.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL + else: + required_current = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + submode = Chargemode.TIME_CHARGING + else: + message = self.TIME_CHARGING_NO_PLAN_ACTIVE + else: + message = self.TIME_CHARGING_NO_PLAN_CONFIGURED + return required_current, message, None, submode + + def instant_charging(self) -> Tuple[int, str, Optional[str], int]: + required_current = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + message = None + mode = Chargemode.INSTANT_CHARGING + submode = Chargemode.INSTANT_CHARGING + return required_current, message, mode, submode + + CHARGING_PRICE_EXCEEDED = "Der aktuelle Strompreis liegt über dem maximalen Strompreis. " + CHARGING_PRICE_LOW = "Betrieb, da der aktuelle Strompreis unter dem maximalen Strompreis liegt." + + 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 = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + message = self.CHARGING_PRICE_LOW + submode = Chargemode.INSTANT_CHARGING + else: + required_current = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.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 = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + submode = Chargemode.PV_CHARGING + return required_current, message, mode, submode + + def pv_charging(self) -> Tuple[int, str, Optional[str], int]: + required_current = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + message = None + mode = Chargemode.PV_CHARGING + submode = Chargemode.PV_CHARGING + return required_current, message, mode, submode + + 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.usage.wait_for_start.last_test_timestamp + 60*60 + wait_for_start_state = self._wait_for_start_singal(timestamp_next_test) + if 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): + required_current = self.data.usage.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: + required_current = 0 + message = self.WAIT_FOR_START_SIGNAL.format( + timecheck.convert_timestamp_delta_to_time_string(timestamp_next_test, 0)) + 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: + if self.data.usage.wait_for_start.signal_received: + return WaitForStartStates.START_SIGNAL_RECEIVED + else: + if self.data.usage.wait_for_start.test_running: + if self.data.get.charge_state: + self.data.usage.wait_for_start.signal_received = True + self.data.usage.wait_for_start.test_running = False + return WaitForStartStates.START_SIGNAL_RECEIVED + elif timecheck.create_timestamp() < self.data.usage.wait_for_start.last_test_timestamp + 60: + return WaitForStartStates.START_TEST_RUN + else: + self.data.usage.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.usage.wait_for_start.last_test_timestamp = timecheck.create_timestamp() + return WaitForStartStates.TEST_RUNNIG + 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 + self.data.control_parameter.required_current = required_current + self.data.control_parameter.phases = phases + self.data.control_parameter.submode = submode + self.data.control_parameter.chargemode = mode + control_parameter = self.data.control_parameter + try: + for i in range(0, phases): + evu_phase = convert_single_evu_phase_to_cp_phase(self.data.config.phase_1, i) + control_parameter.required_currents[evu_phase] = required_current + except KeyError: + control_parameter.required_currents = [required_current]*3 + self.set_state_and_log("Bitte in den Verbraucher-Einstellungen die Einstellung 'Phase 1 des Ladekabels'" + + " angeben. Andernfalls wird der benötigte Strom auf allen 3 Phasen vorgehalten, " + + "was ggf eine unnötige Reduktion der Ladeleistung zur Folge hat.") + self.data.set.required_power = sum( + [c * v for c, v in zip(control_parameter.required_currents, self.data.get.voltages)]) + + def is_charging_stop_allowed(self) -> bool: + return self.data.usage.type != ConsumerUsage.CONTINUOUS + + 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) diff --git a/packages/control/consumer/consumer_all.py b/packages/control/consumer/consumer_all.py new file mode 100644 index 0000000000..f280d73100 --- /dev/null +++ b/packages/control/consumer/consumer_all.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass, field +import logging + +from control import data + + +log = logging.getLogger(__name__) + + +@dataclass +class AllGet: + daily_imported: float = field(default=0, metadata={"topic": "get/daily_imported"}) + daily_exported: float = field(default=0, metadata={"topic": "get/daily_exported"}) + power: float = field(default=0, metadata={"topic": "get/power"}) + imported: float = field(default=0, metadata={"topic": "get/imported"}) + exported: float = field(default=0, metadata={"topic": "get/exported"}) + + +def all_get_factory() -> AllGet: + return AllGet() + + +@dataclass +class AllConsumerData: + get: AllGet = field(default_factory=all_get_factory) + + +def all_Consumer_data_factory() -> AllConsumerData: + return AllConsumerData() + + +@dataclass +class AllConsumers: + data: AllConsumerData = field(default_factory=all_Consumer_data_factory) + + def get_consumer_sum(self): + imported, exported, power = 0, 0, 0 + try: + for consumer in data.data.consumer_data.values(): + try: + imported = imported + consumer.data.get.imported + exported = exported + consumer.data.get.exported + except Exception: + log.exception("Fehler in der allgemeinen Verbaucher-Klasse für Verbaucher "+consumer) + try: + power = power + consumer.data.get.power + except Exception: + log.exception("Fehler in der allgemeinen Verbaucher-Klasse für Verbaucher "+consumer) + self.data.get.power = power + self.data.get.imported = imported + self.data.get.exported = exported + except Exception: + log.exception("Fehler in der allgemeinen Verbaucher-Klasse") diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py new file mode 100644 index 0000000000..aa389e8a32 --- /dev/null +++ b/packages/control/consumer/consumer_data.py @@ -0,0 +1,155 @@ +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional, Union +from control.chargemode import Chargemode +from control.chargepoint.control_parameter import ControlParameter, control_parameter_factory +from control.consumer.usage import ConsumerUsage +from dataclass_utils.factories import empty_list_factory +from helpermodules.abstract_plans import (ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer, + TimeChargingPlanConsumer) +from helpermodules.constants import NO_ERROR +from modules.common.consumer_setup import ConsumerSetup + + +@dataclass +class EcoCharging: + price_limit: float = 0.07 + + +@dataclass +class ScheduledContinuousCharging: + plans: List[ContinuousScheduledPlanConsumer] = field(default_factory=empty_list_factory) + + +@dataclass +class ScheduledSuspendableCharging: + plans: List[SuspendableScheduledPlanConsumer] = field(default_factory=empty_list_factory) + + +@dataclass +class TimeCharging: + active: bool = False + plans: List[TimeChargingPlanConsumer] = field(default_factory=empty_list_factory) + + +@dataclass +class MeterOnlyConfig: + type: ConsumerUsage = ConsumerUsage.METER_ONLY + + +@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()) + type: ConsumerUsage = ConsumerUsage.SUSPENDABLE_TUNABLE + wait_for_start_active: bool = True + + +@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()) + type: ConsumerUsage = ConsumerUsage.SUSPENDABLE_ONOFF + wait_for_start_active: bool = True + + +@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()) + type: ConsumerUsage = ConsumerUsage.CONTINUOUS + wait_for_start_active: bool = True + + +GET_DEFAULTS_BY_USAGE: Dict[ConsumerUsage, Union[MeterOnlyConfig, + SuspendableTunableDeviceConfig, + SuspendableOnOffDeviceConfig, + ContinuousDeviceConfig]] = { + ConsumerUsage.METER_ONLY: MeterOnlyConfig(), + ConsumerUsage.SUSPENDABLE_TUNABLE: SuspendableTunableDeviceConfig(), + ConsumerUsage.SUSPENDABLE_ONOFF: SuspendableOnOffDeviceConfig(), + ConsumerUsage.CONTINUOUS: ContinuousDeviceConfig(), +} + +GET_CLASS_BY_USAGE: Dict[ConsumerUsage, Callable] = { + ConsumerUsage.METER_ONLY: MeterOnlyConfig, + ConsumerUsage.SUSPENDABLE_TUNABLE: SuspendableTunableDeviceConfig, + ConsumerUsage.SUSPENDABLE_ONOFF: SuspendableOnOffDeviceConfig, + ConsumerUsage.CONTINUOUS: ContinuousDeviceConfig, +} + +get_plan_class_for_usage: Dict[ConsumerUsage, Callable] = { + ConsumerUsage.SUSPENDABLE_TUNABLE: SuspendableScheduledPlanConsumer, + ConsumerUsage.SUSPENDABLE_ONOFF: SuspendableScheduledPlanConsumer, + ConsumerUsage.CONTINUOUS: ContinuousScheduledPlanConsumer, +} + + +@dataclass +class ConsumerConfig: + connected_phases: int = 1 + phase_1: int = 1 + max_power: float = 5000 + + +@dataclass +class Get: + charge_state: bool = False + currents: Optional[List[Optional[float]]] = None + daily_imported: float = field(default=0, metadata={"topic": "get/daily_imported"}) + error_timestamp: int = 0 + exported: float = 0 + fault_str: str = NO_ERROR + fault_state: int = 0 + imported: float = 0 + phases_in_use: int = 0 + power: float = 0 + powers: Optional[List[Optional[float]]] = None + set_power: Optional[float] = None + state: Optional[bool] = False + state_str: Optional[str] = field(default=None, metadata={"topic": "get/state_str"}) + voltages: Optional[List[Optional[float]]] = None + + +@dataclass +class Set: + current: float = 0 + loadmanagement_available: bool = False + phases_to_use: int = 0 + plug_time: Optional[float] = 0 + required_power: float = 0 + current_prev: float = 0 + target_current: float = 0 + 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"}) + + +@dataclass +class ConsumerData: + module: ConsumerSetup = None + config: ConsumerConfig = field(default_factory=lambda: ConsumerConfig()) + extra_meter: Optional[int] = None + usage: Union[MeterOnlyConfig, + SuspendableTunableDeviceConfig, + SuspendableOnOffDeviceConfig, + ContinuousDeviceConfig] = field(default_factory=lambda: MeterOnlyConfig()) + control_parameter: ControlParameter = field(default_factory=control_parameter_factory) + get: Get = field(default_factory=lambda: Get()) + set: Set = field(default_factory=lambda: Set()) diff --git a/packages/control/consumer/usage.py b/packages/control/consumer/usage.py new file mode 100644 index 0000000000..167215a9e7 --- /dev/null +++ b/packages/control/consumer/usage.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class ConsumerUsage(Enum): + METER_ONLY = "meter_only" + SUSPENDABLE_TUNABLE = "suspendable_tunable" + SUSPENDABLE_ONOFF = "suspendable_onoff" + CONTINUOUS = "continuous" diff --git a/packages/control/load_protocol.py b/packages/control/load_protocol.py new file mode 100644 index 0000000000..856e7072fb --- /dev/null +++ b/packages/control/load_protocol.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from typing import List, Optional, Protocol + +from control.chargepoint.control_parameter import ControlParameter + + +@dataclass +class LoadGet: + charge_state: bool + currents: List[float] + daily_imported: float + daily_exported: float + error_timestamp: int + exported: float + fault_str: str + fault_state: int + imported: float + phases_in_use: int + power: float + powers: List[float] + state_str: Optional[str] + voltages: List[float] + + +@dataclass +class LoadSet: + current: float + loadmanagement_available: bool + phases_to_use: int + plug_time: Optional[float] + required_power: float + current_prev: float + target_current: float + charge_state_prev: bool + + +@dataclass +class LoadData: + control_parameter: ControlParameter + get: LoadGet + set: LoadSet + + +class Load(Protocol): + num: int + chargemode_changed: bool + submode_changed: bool + data: LoadData + + def is_charging_stop_allowed(self) -> bool: + ... + + def set_state_and_log(self, message: str) -> None: + ... diff --git a/packages/modules/common/configurable_consumer.py b/packages/modules/common/configurable_consumer.py new file mode 100644 index 0000000000..76d8b61c81 --- /dev/null +++ b/packages/modules/common/configurable_consumer.py @@ -0,0 +1,94 @@ +import logging +from typing import Callable, Optional, TypeVar, Generic, Any + +from helpermodules import timecheck +from helpermodules.pub import Pub +from modules.common.abstract_device import AbstractCounter +from modules.common.component_context import SingleComponentUpdateContext +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.common.store._factory import get_component_value_store +from modules.common.utils.peak_filter import PeakFilter + +T_CONSUMER = TypeVar("T_CONSUMER") + + +log = logging.getLogger(__name__) + + +class ConfigurableConsumer(Generic[T_CONSUMER]): + def __init__(self, + consumer_config: T_CONSUMER, + initializer: Optional[Callable] = lambda: None, + error_handler: Optional[Callable] = lambda: None, + update: Optional[Callable[[], ConsumerState]] = lambda: None, + set_power_limit: Optional[Callable] = lambda: None, + switch_on: Optional[Callable] = lambda: None, + switch_off: Optional[Callable] = lambda: None,) -> None: + self.config = consumer_config + self.module_initializer = initializer + self.module_error_handler = error_handler + self.module_updater = update + self.module_set_power_limit = set_power_limit + self.module_switch_on = switch_on + self.module_switch_off = switch_off + + self.error_timestamp = None + self.sim_counter = SimCounterConsumer(self.config.id, ComponentType.CONSUMER) + self.store = get_component_value_store(ComponentType.CONSUMER.value, self.config.id) + self.fault_state = FaultState(ComponentInfo(self.config.id, self.config.name, ComponentType.CONSUMER.value)) + self.peak_filter = PeakFilter(ComponentType.CONSUMER, self.config.id, self.fault_state) + try: + self.module_initializer() + except Exception: + log.exception(f"Initialisierung von Gerät {self.config.name} fehlgeschlagen") + + def error_handler(self) -> None: + error_timestamp_topic = f"openWB/set/consumer/{self.config.id}/get/error_timestamp" + if self.error_timestamp is None: + self.error_timestamp = timecheck.create_timestamp() + Pub().pub(error_timestamp_topic, self.error_timestamp) + log.debug(f"Fehler bei Gerät {self.config.name} aufgetreten, " + f"Fehlerzeitstempel: {self.error_timestamp}") + if timecheck.check_timestamp(self.error_timestamp, 60) is False: + try: + self.module_error_handler() + except Exception: + log.exception(f"Fehlerbehandlung für Gerät {self.config.name} fehlgeschlagen") + else: + log.debug(f"Fehlerbehandlung für Gerät {self.config.name} wurde durchgeführt.") + + self.error_timestamp = None + Pub().pub(error_timestamp_topic, self.error_timestamp) + + def update(self): + with SingleComponentUpdateContext(self.fault_state): + if self.module_updater is not None: + consumer_state = self.module_updater() + imported, exported = self.peak_filter.check_values( + consumer_state.power, consumer_state.imported, consumer_state.exported) + if imported != consumer_state.imported or exported != consumer_state.exported: + consumer_state.imported = imported + consumer_state.exported = exported + self.store.set(consumer_state) + + def set_power_limit(self, power_limit: Any) -> None: + with SingleComponentUpdateContext(self.fault_state): + self.module_set_power_limit(power_limit) + + def switch_on(self) -> None: + with SingleComponentUpdateContext(self.fault_state): + self.module_switch_on() + + def switch_off(self) -> None: + with SingleComponentUpdateContext(self.fault_state): + self.module_switch_off() + + +def dependency_injection_devices_components(component: AbstractCounter): + component.sim_counter.component_type = ComponentType.CONSUMER.value + component.store = get_component_value_store(ComponentType.CONSUMER.value, component.component_config.id) + component.fault_state.component_info.type = ComponentType.CONSUMER.value + return component diff --git a/packages/modules/common/consumer_setup.py b/packages/modules/common/consumer_setup.py new file mode 100644 index 0000000000..153c906e27 --- /dev/null +++ b/packages/modules/common/consumer_setup.py @@ -0,0 +1,23 @@ +from typing import Generic, List, TypeVar + +from control.consumer.usage import ConsumerUsage + + +T = TypeVar("T") + + +class ConsumerSetup(Generic[T]): + def __init__(self, + name: str, + type: str, + id: int, + vendor: str, + configuration: T, + usage: List[ConsumerUsage]) -> None: + self.name = name + self.info = {"manufacturer": None, "model": None} + self.type = type + self.id = id + self.configuration = configuration + self.vendor = vendor + self.usage = usage diff --git a/packages/modules/common/store/_consumer.py b/packages/modules/common/store/_consumer.py new file mode 100644 index 0000000000..b2120d9f63 --- /dev/null +++ b/packages/modules/common/store/_consumer.py @@ -0,0 +1,62 @@ +from typing import Union +from control import data +from modules.common.component_state import ConsumerState, CounterState +from modules.common.store import ValueStore +from modules.common.store._api import LoggingValueStore +from modules.common.store._broker import pub_to_broker +from modules.common.utils.component_parser import get_component_obj_by_id + + +class ConsumerValueStoreBroker(ValueStore[ConsumerState]): + def __init__(self, component_num: int) -> None: + self.num = component_num + + def set(self, consumer_state: Union[ConsumerState, CounterState]): + self.state = consumer_state + + def update(self): + if self.state.currents is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/currents", self.state.currents, 2) + if self.state.powers is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/powers", self.state.powers, 2) + if self.state.power is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/power", self.state.power, 2) + if isinstance(self.state, ConsumerState): + if self.state.set_power is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/set_power", self.state.set_power, 2) + if self.state.state is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/state", self.state.state, 2) + if self.state.voltages is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/voltages", self.state.voltages, 2) + if self.state.imported is not None and self.state.exported is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/imported", self.state.imported, 2) + pub_to_broker(f"openWB/set/consumer/{self.num}/get/exported", self.state.exported, 2) + if self.state.temperatures is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/temperatures", self.state.temperatures) + + +class PurgeConsumerState: + def __init__(self, delegate: LoggingValueStore) -> None: + self.delegate = delegate + + def set(self, state: ConsumerState) -> None: + self.delegate.set(state) + + def update(self) -> None: + extra_meter_id = data.data.consumer_data[f"consumer{self.delegate.delegate.num}"].data.extra_meter + if extra_meter_id is not None: + component = get_component_obj_by_id(extra_meter_id) + component_state = component.store.delegate.state + self.set(ConsumerState( + power=component_state.power, + imported=component_state.imported, + exported=component_state.exported, + voltages=component_state.voltages, + currents=component_state.currents, + powers=component_state.powers, + )) + self.delegate.update() + + +def get_consumer_value_store(component_num: int) -> ValueStore[ConsumerState]: + return PurgeConsumerState(LoggingValueStore(ConsumerValueStoreBroker(component_num))) diff --git a/packages/modules/consumers/__init__.py b/packages/modules/consumers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/askoma/__init__.py b/packages/modules/consumers/askoma/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/askoma/askoheat/__init__.py b/packages/modules/consumers/askoma/askoheat/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/askoma/askoheat/config.py b/packages/modules/consumers/askoma/askoheat/config.py new file mode 100644 index 0000000000..5669b2b194 --- /dev/null +++ b/packages/modules/consumers/askoma/askoheat/config.py @@ -0,0 +1,30 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class AskoheatConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Askoheat(ConsumerSetup[AskoheatConfiguration]): + def __init__(self, + name: str = "Askoheat", + type: str = "askoheat", + id: int = 0, + configuration: AskoheatConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or AskoheatConfiguration(), usage=usage) diff --git a/packages/modules/consumers/askoma/askoheat/consumer.py b/packages/modules/consumers/askoma/askoheat/consumer.py new file mode 100644 index 0000000000..dc906c8a5a --- /dev/null +++ b/packages/modules/consumers/askoma/askoheat/consumer.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.askoma.askoheat.config import Askoheat + +log = logging.getLogger(__name__) + + +def create_consumer(config: Askoheat): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + power = client.read_input_registers(110, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported, + temperatures=[client.read_input_registers(638, ModbusDataType.INT_16, unit=config.configuration.modbus_id)] + ) + + def set_limit(power_limit: float) -> None: + nonlocal client + client.write_registers(201, power_limit, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Askoheat) diff --git a/packages/modules/consumers/askoma/vendor.py b/packages/modules/consumers/askoma/vendor.py new file mode 100644 index 0000000000..b1861e388d --- /dev/null +++ b/packages/modules/consumers/askoma/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "ASKOMA" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/avm/__init__.py b/packages/modules/consumers/avm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/avm/avm/__init__.py b/packages/modules/consumers/avm/avm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/avm/avm/config.py b/packages/modules/consumers/avm/avm/config.py new file mode 100644 index 0000000000..c8d5407d83 --- /dev/null +++ b/packages/modules/consumers/avm/avm/config.py @@ -0,0 +1,36 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class AvmConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + session_id: Optional[str] = None, + session_mtime: Optional[str] = None, + name: Optional[str] = None) -> None: + self.ip_address = ip_address + self.username = username + self.password = password + self.session_id = session_id # don't show in UI + self.session_mtime = session_mtime # don't show in UI + self.name = name + + +@auto_str +class Avm(ConsumerSetup[AvmConfiguration]): + def __init__(self, + name: str = "AVM Fritz!Box", + type: str = "avm", + id: int = 0, + configuration: AvmConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or AvmConfiguration(), usage=usage) diff --git a/packages/modules/consumers/avm/avm/consumer.py b/packages/modules/consumers/avm/avm/consumer.py new file mode 100644 index 0000000000..18c2d57823 --- /dev/null +++ b/packages/modules/consumers/avm/avm/consumer.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +import hashlib +import logging +import time +import xml.etree.ElementTree as ET + +from dataclass_utils._dataclass_asdict import asdict +from helpermodules.pub import Pub +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.consumers.avm.avm.config import Avm + +log = logging.getLogger(__name__) + +INVALID_SESSIONID = "0000000000000000" + + +def create_consumer(config: Avm): + ain = None # Actuator Identification Number + + def update() -> ConsumerState: + nonlocal ain + ensure_valid_session_id() + response = req.get_http_session().get( + f"http://{config.configuration.ip_address}/webservices/homeautoswitch.lua?sid=" + f"{config.configuration.session_id}&switchcmd=getdevicelistinfos") + deviceListElementTree = ET.fromstring(response.text.strip()) + + for device in deviceListElementTree: + name = device.find("name").text + if name == config.configuration.name: + presentText = device.find("present").text + if presentText != '1': + continue + + ain = device.attrib["identifier"] + powermeterBlock = device.find("powermeter") + if powermeterBlock is not None: + # AVM returns mW, convert to W here + power = float(powermeterBlock.find("power").text)/1000 + # AVM returns mV, convert to V here + voltageInfo = powermeterBlock.find("voltage") + if voltageInfo is not None: + voltages = [float(voltageInfo.text)/1000, 0, 0] + # AVM returns Wh + imported = float(powermeterBlock.find("energy").text) + temperatureBlock = device.find("temperature") + if temperatureBlock is not None: + # AVM returns tenths of degrees Celsius + temperature = float(temperatureBlock.find("celsius").text)/10.0 + switchBlock = device.find("switch") + if switchBlock is not None: + state = (int(switchBlock.find("state").text) == 1) + + return ConsumerState( + power=power, + imported=imported, + temperatures=[temperature], + state=state, + voltages=voltages + ) + + def ensure_valid_session_id(): + if check_valid_session_id() is False: + config.configuration.session_id = get_session_id() + config.configuration.session_mtime = time.time() + Pub().pub(f"openWB/set/system/device/{config.id}/config", asdict(config)) + + def check_valid_session_id() -> bool: + return (config.configuration.session_id is None or + config.configuration.session_mtime is None or + time.time() - config.configuration.session_mtime > 300) + + def get_session_id(): + # checking existing sessionID + data = { + 'content-type': 'application/text' + } + response = req.get_http_session().post( + f"http://{config.configuration.ip_address}/login_sid.lua", data=data, timeout=5) + challengeResponse = ET.fromstring(response.content) + session_id = challengeResponse.find('SID').text + if session_id != INVALID_SESSIONID: + return + blockTimeXML = challengeResponse.find('BlockTime') + if blockTimeXML is not None and int(blockTimeXML.text) > 0: + raise Exception("Durch Anmeldefehler in der Vergangenheit ist der Zugang zur FRITZ!Box " + f"noch für {blockTimeXML.text} Sekunden gesperrt.") + + # last sessionID was invalid, performing new challenge-response authentication + challenge = challengeResponse.find('Challenge').text + m = hashlib.md5() + m.update((f"{challenge}-{config.configuration.password}").encode('utf-16le')) + hashedPassword = m.hexdigest() + + data = { + 'username': config.configuration.username, + 'response': challenge + "-" + hashedPassword + } + try: + response = req.get_http_session().post( + f"http://{config.configuration.ip_address}/login_sid.lua", data=data, timeout=5) + session_info = ET.fromstring(response.content) + session_id = session_info.find('SID').text + except Exception: + session_id = None + raise Exception("Anmeldung fehlgeschlagen, bitte Benutzername und Passwort überprüfen. Anmeldung für " + f"die nächsten {session_info.find('BlockTime').text} Sekunden durch FRITZ!Box-Webinterface " + "gesperrt.") + return session_id + + def switch_on() -> None: + nonlocal ain + ensure_valid_session_id() + req.get_http_session().get( + f"http://{config.configuration.ip_address}/webservices/homeautoswitch.lua?sid=" + f"{config.configuration.session_id}&switchcmd=setswitchon&ain={ain}") + + def switch_off() -> None: + nonlocal ain + ensure_valid_session_id() + req.get_http_session().get( + f"http://{config.configuration.ip_address}/webservices/homeautoswitch.lua?sid=" + f"{config.configuration.session_id}&switchcmd=setswitchoff&ain={ain}") + + return ConfigurableConsumer(consumer_config=config, + update=update, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Avm) diff --git a/packages/modules/consumers/avm/vendor.py b/packages/modules/consumers/avm/vendor.py new file mode 100644 index 0000000000..de4025faae --- /dev/null +++ b/packages/modules/consumers/avm/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "AVM Fritz!Box" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/generic/__init__.py b/packages/modules/consumers/generic/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/generic/counter/__init__.py b/packages/modules/consumers/generic/counter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/generic/counter/config.py b/packages/modules/consumers/generic/counter/config.py new file mode 100644 index 0000000000..aa183310de --- /dev/null +++ b/packages/modules/consumers/generic/counter/config.py @@ -0,0 +1,24 @@ +from typing import List + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class CounterConfiguration: + def __init__(self): + pass + + +@auto_str +class Counter(ConsumerSetup[CounterConfiguration]): + def __init__(self, + name: str = "openWB-kompatibler Zähler", + type: str = "counter", + id: int = 0, + configuration: CounterConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or CounterConfiguration(), usage=usage) diff --git a/packages/modules/consumers/generic/counter/consumer.py b/packages/modules/consumers/generic/counter/consumer.py new file mode 100644 index 0000000000..88abfc4f51 --- /dev/null +++ b/packages/modules/consumers/generic/counter/consumer.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.consumers.generic.counter.config import Counter + + +def create_consumer(config: Counter): + return ConfigurableConsumer(consumer_config=config) + + +device_descriptor = DeviceDescriptor(configuration_factory=Counter) diff --git a/packages/modules/consumers/generic/dac/__init__.py b/packages/modules/consumers/generic/dac/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/generic/dac/config.py b/packages/modules/consumers/generic/dac/config.py new file mode 100644 index 0000000000..7ab286282a --- /dev/null +++ b/packages/modules/consumers/generic/dac/config.py @@ -0,0 +1,32 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from modules.consumers.generic.dac.model import Model +from ..vendor import vendor_descriptor + + +@auto_str +class DacConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1, + model: Model = Model.N4Dac02): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + self.model = model + + +@auto_str +class Dac(ConsumerSetup[DacConfiguration]): + def __init__(self, + name: str = "Digital-Analog-Konverter (DAC) 0.01V bis 10.0V", + type: str = "dac", + id: int = 0, + configuration: DacConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or DacConfiguration(), usage=usage) diff --git a/packages/modules/consumers/generic/dac/consumer.py b/packages/modules/consumers/generic/dac/consumer.py new file mode 100644 index 0000000000..88f34476c6 --- /dev/null +++ b/packages/modules/consumers/generic/dac/consumer.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.generic.dac.config import Dac +from modules.consumers.generic.dac.model import Model + +log = logging.getLogger(__name__) + + +def create_consumer(config: Dac): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def set_limit(power_limit: float) -> None: + nonlocal client + if config.configuration.model == Model.N4Dac02 or config.configuration.model == Model.M120T: + # pwoer_limit -= power + pass + + # power_limit = max(power_limit, 0) + # if config.configuration.model == Model.N4Dac02: + # client.write_register(1, power_limit * 1000 / max_power, ModbusDataType.INT_16, + # unit=config.configuration.modbus_id) + # elif config.configuration.model == Model.DA02: + # client.write_register(0x01f4, power_limit * 4000 / max_power, + # ModbusDataType.INT_16, unit=config.configuration.modbus_id) + # elif config.configuration.model == Model.M120T: + # # ausgabe nicht kleiner 0,9V sonst Leistungsregelung der WP aus + # power_limit = max(power_limit * 4095 / max_power, 370) + # client.write_register(0x01f4, power_limit, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + # elif config.configuration.model == Model.AA02B: + # power_limit = max((power_limit * (4095-820) / max_power)+820, 820) + # # ausgabe nicht kleiner 4ma sonst Leistungsregelung der WP aus + # client.write_register(0x01f4, power_limit, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Dac) diff --git a/packages/modules/consumers/generic/dac/model.py b/packages/modules/consumers/generic/dac/model.py new file mode 100644 index 0000000000..364c712555 --- /dev/null +++ b/packages/modules/consumers/generic/dac/model.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class Model(Enum): + N4Dac02 = "N4Dac02" + DA02 = "DA02" + M120T = "M120T" + AA02B = "AA02B" diff --git a/packages/modules/consumers/generic/http/__init__.py b/packages/modules/consumers/generic/http/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/generic/http/config.py b/packages/modules/consumers/generic/http/config.py new file mode 100644 index 0000000000..921c3b4e1e --- /dev/null +++ b/packages/modules/consumers/generic/http/config.py @@ -0,0 +1,45 @@ +from typing import List + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class HttpConfiguration: + def __init__(self, + url: str = None, + current_l1_path: str = None, + current_l2_path: str = None, + current_l3_path: str = None, + power_path: str = None, + temperatures_path: str = None, + imported_path: str = None, + exported_path: str = None, + switch_on_path: str = None, + switch_off_path: str = None, + set_power_limit_path: str = None): + self.url = url + self.current_l1_path = current_l1_path + self.current_l2_path = current_l2_path + self.current_l3_path = current_l3_path + self.power_path = power_path + self.temperatures_path = temperatures_path + self.imported_path = imported_path + self.exported_path = exported_path + self.switch_on_path = switch_on_path + self.switch_off_path = switch_off_path + self.set_power_limit_path = set_power_limit_path + + +@auto_str +class Http(ConsumerSetup[HttpConfiguration]): + def __init__(self, + name: str = "HTTP", + type: str = "http", + id: int = 0, + configuration: HttpConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or HttpConfiguration(), usage=usage) diff --git a/packages/modules/consumers/generic/http/consumer.py b/packages/modules/consumers/generic/http/consumer.py new file mode 100644 index 0000000000..a7815ef6e3 --- /dev/null +++ b/packages/modules/consumers/generic/http/consumer.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +import logging + +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.generic.http.config import Http +from modules.devices.generic.http.api import (create_post_function, create_request_function, + create_request_function_array) +log = logging.getLogger(__name__) + + +def create_consumer(config: Http): + session = None + sim_counter = None + get_power = None + get_imported = None + get_exported = None + get_currents = None + get_temperatures = None + post_set_power_limit = None + post_switch_on = None + post_switch_off = None + + def initializer(): + nonlocal session, sim_counter + nonlocal get_power, get_imported, get_exported, get_currents, get_temperatures + nonlocal post_set_power_limit, post_switch_on, post_switch_off + if not config.configuration.url.startswith('https://'): + raise ValueError("Only HTTPS URLs allowed for security") + session = req.get_http_session() + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + get_power = create_request_function(config.configuration.url, config.configuration.power_path) + get_imported = create_request_function(config.configuration.url, config.configuration.imported_path) + get_exported = create_request_function(config.configuration.url, config.configuration.exported_path) + get_currents = create_request_function_array(config.configuration.url, [ + config.configuration.current_l1_path, + config.configuration.current_l2_path, + config.configuration.current_l3_path, + ]) + get_temperatures = create_request_function(config.configuration.url, config.configuration.temperatures_path) + post_set_power_limit = create_post_function(config.configuration.url, config.configuration.set_power_limit_path) + post_switch_on = create_post_function(config.configuration.url, config.configuration.switch_on_path) + post_switch_off = create_post_function(config.configuration.url, config.configuration.switch_off_path) + + def update() -> None: + nonlocal session, sim_counter, get_power, get_imported, get_exported, get_currents, get_temperatures + power = get_power(session) + exported = get_exported(session) + imported = get_imported(session) + currents = get_currents(session) + temperatures = get_temperatures(session) + if imported is None or exported is None: + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + currents=currents, + imported=imported, + exported=exported, + temperatures=temperatures if isinstance(temperatures, list) else [temperatures], + ) + + def switch_on(): + nonlocal session, post_switch_on + # Authorization? + post_switch_on(session, params={"state": True}) + + def switch_off(): + nonlocal session, post_switch_off + # Authorization? + post_switch_off(session, params={"state": False}) + + def set_power_limit(power_limit: int): + nonlocal session, post_set_power_limit + # Authorization? + post_set_power_limit(session, params={"power_limit": power_limit}) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + set_power_limit=set_power_limit, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Http) diff --git a/packages/modules/consumers/generic/json/__init__.py b/packages/modules/consumers/generic/json/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/generic/json/config.py b/packages/modules/consumers/generic/json/config.py new file mode 100644 index 0000000000..2e5f18ab5c --- /dev/null +++ b/packages/modules/consumers/generic/json/config.py @@ -0,0 +1,45 @@ +from typing import List + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class JsonConfiguration: + def __init__(self, + url: str = None, + jq_current_l1: str = None, + jq_current_l2: str = None, + jq_current_l3: str = None, + jq_power: str = None, + jq_temperatures: str = None, + jq_imported: str = None, + jq_exported: str = None, + jq_switch_on: str = None, + jq_switch_off: str = None, + jq_set_power_limit: str = None): + self.url = url + self.jq_current_l1 = jq_current_l1 + self.jq_current_l2 = jq_current_l2 + self.jq_current_l3 = jq_current_l3 + self.jq_power = jq_power + self.jq_temperatures = jq_temperatures + self.jq_imported = jq_imported + self.jq_exported = jq_exported + self.jq_switch_on = jq_switch_on + self.jq_switch_off = jq_switch_off + self.jq_set_power_limit = jq_set_power_limit + + +@auto_str +class Json(ConsumerSetup[JsonConfiguration]): + def __init__(self, + name: str = "JSON", + type: str = "json", + id: int = 0, + configuration: JsonConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or JsonConfiguration(), usage=usage) diff --git a/packages/modules/consumers/generic/json/consumer.py b/packages/modules/consumers/generic/json/consumer.py new file mode 100644 index 0000000000..93cfe5cc50 --- /dev/null +++ b/packages/modules/consumers/generic/json/consumer.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +import logging +from typing import Callable, Optional +import jq + +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.generic.json.config import Json + +log = logging.getLogger(__name__) + + +def create_consumer(config: Json): + session = None + sim_counter = None + jq_power = None + jq_imported = None + jq_exported = None + jq_currents = None + jq_temperatures = None + jq_set_power_limit = None + jq_switch_on = None + jq_switch_off = None + + def _compile_jq_filters() -> None: + nonlocal jq_power, jq_imported, jq_exported, jq_currents, jq_temperatures + jq_power = jq.compile(config.configuration.jq_power) + jq_currents = [jq.compile(c) for c in config.configuration.jq_currents] if all( + config.configuration.jq_currents) else None + jq_imported = jq.compile(config.configuration.jq_imported) if config.configuration.jq_imported else None + jq_exported = jq.compile(config.configuration.jq_exported) if config.configuration.jq_exported else None + jq_temperatures = jq.compile( + config.configuration.jq_temperatures) if config.configuration.jq_temperatures else None + + def create_post_function(path: Optional[str]) -> Callable[[dict], None]: + nonlocal session + if path is None: + return lambda _: None + else: + def post_function(params: dict): + session.post(config.configuration.url + path, json=params, timeout=5) + return post_function + + def initializer(): + nonlocal session, sim_counter + nonlocal jq_set_power_limit, jq_switch_on, jq_switch_off + if not config.configuration.url.startswith('https://'): + raise ValueError("Only HTTPS URLs allowed for security") + session = req.get_http_session() + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + _compile_jq_filters() + jq_set_power_limit = create_post_function(config.configuration.jq_set_power_limit) + jq_switch_on = create_post_function(config.configuration.jq_switch_on) + jq_switch_off = create_post_function(config.configuration.jq_switch_off) + + def update() -> None: + nonlocal session, sim_counter, jq_power, jq_imported, jq_exported, jq_currents, jq_temperatures + response = req.get_http_session().get(config.configuration.url, timeout=5).json() + power = float(jq_power.input(response).first()) + temperatures = float(jq_temperatures.input(response).first()) if jq_temperatures is not None else None + currents = ([float(j.input(response).first()) for j in jq_currents] if jq_currents is not None else None) + if jq_imported is None or jq_exported is None: + imported, exported = sim_counter.sim_count(power) + else: + imported = float(jq_imported.input(response).first()) + exported = float(jq_exported.input(response).first()) + + return ConsumerState( + power=power, + currents=currents, + imported=imported, + exported=exported, + temperatures=temperatures if isinstance(temperatures, list) else [temperatures], + ) + + def switch_on(): + nonlocal session, jq_switch_on + # Authorization? + jq_switch_on(session, params={"state": True}) + + def switch_off(): + nonlocal session, jq_switch_off + # Authorization? + jq_switch_off(session, params={"state": False}) + + def set_power_limit(power_limit: int): + nonlocal session, jq_set_power_limit + # Authorization? + jq_set_power_limit(session, params={"power_limit": power_limit}) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + set_power_limit=set_power_limit, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Json) diff --git a/packages/modules/consumers/generic/mqtt/__init__.py b/packages/modules/consumers/generic/mqtt/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/generic/mqtt/config.py b/packages/modules/consumers/generic/mqtt/config.py new file mode 100644 index 0000000000..7a4bebee6d --- /dev/null +++ b/packages/modules/consumers/generic/mqtt/config.py @@ -0,0 +1,27 @@ +from typing import List + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class MqttConfiguration: + def __init__(self): + pass + + +@auto_str +class Mqtt(ConsumerSetup[MqttConfiguration]): + def __init__(self, + name: str = "Mqtt", + type: str = "mqtt", + id: int = 0, + configuration: MqttConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY, + ConsumerUsage.CONTINUOUS, + ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.SUSPENDABLE_TUNABLE]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or MqttConfiguration(), usage=usage) diff --git a/packages/modules/consumers/generic/mqtt/consumer.py b/packages/modules/consumers/generic/mqtt/consumer.py new file mode 100644 index 0000000000..7d1eb44d73 --- /dev/null +++ b/packages/modules/consumers/generic/mqtt/consumer.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +import logging + +from helpermodules.broker import BrokerClient +from helpermodules.pub import Pub +from helpermodules.utils._get_default import get_default +from helpermodules.utils.topic_parser import decode_payload +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.consumers.generic.mqtt.config import Mqtt + +log = logging.getLogger(__name__) + + +def create_consumer(config: Mqtt): + def update() -> None: + def parse_received_topics(value: str): + return received_topics.get(f"{topic_prefix}{value}", get_default(ConsumerState, value)) + + def on_connect(client, userdata, flags, rc): + client.subscribe(f"openWB/mqtt/consumer/{config.id}/get/#") + + def on_message(client, userdata, message): + received_topics.update({message.topic: decode_payload(message.payload)}) + + received_topics = {} + BrokerClient(f"subscribeMqttConsumer{config.id}", + on_connect, on_message).start_finite_loop() + + log.debug(f"Empfange MQTT Daten für Verbraucher {config.id}: {received_topics}") + topic_prefix = f"openWB/mqtt/consumer/{config.id}/get/" + try: + return ConsumerState( + power=received_topics[f"{topic_prefix}power"], + imported=received_topics[f"{topic_prefix}imported"], + powers=parse_received_topics("powers"), + voltages=parse_received_topics("voltages"), + currents=received_topics[f"{topic_prefix}currents"], + ) + except KeyError: + raise KeyError("Es wurden nicht alle notwendigen Daten empfangen.") + + def set_power_limit(power_limit: float) -> None: + Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/power", power_limit) + + def switch_on() -> None: + Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/switch", True) + + def switch_off() -> None: + Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/switch", False) + return ConfigurableConsumer(consumer_config=config, + update=update, + set_power_limit=set_power_limit, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Mqtt) diff --git a/packages/modules/consumers/generic/vendor.py b/packages/modules/consumers/generic/vendor.py new file mode 100644 index 0000000000..04d4a0c59f --- /dev/null +++ b/packages/modules/consumers/generic/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Universell" + self.group = VendorGroup.GENERIC.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/idm/__init__.py b/packages/modules/consumers/idm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/idm/idm/__init__.py b/packages/modules/consumers/idm/idm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/idm/idm/config.py b/packages/modules/consumers/idm/idm/config.py new file mode 100644 index 0000000000..2d10083600 --- /dev/null +++ b/packages/modules/consumers/idm/idm/config.py @@ -0,0 +1,32 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class IdmConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1, + version: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + self.version = version + + +@auto_str +class Idm(ConsumerSetup[IdmConfiguration]): + def __init__(self, + name: str = "Wärmepumpe der Firma IDM mit Navigatorregelung 1.7/2.0", + type: str = "idm", + id: int = 0, + configuration: IdmConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or IdmConfiguration(), usage=usage) diff --git a/packages/modules/consumers/idm/idm/consumer.py b/packages/modules/consumers/idm/idm/consumer.py new file mode 100644 index 0000000000..0ac9fbf973 --- /dev/null +++ b/packages/modules/consumers/idm/idm/consumer.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +from pymodbus.constants import Endian +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.idm.idm.config import Idm + +log = logging.getLogger(__name__) + + +def create_consumer(config: Idm): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + if config.configuration.version == 1: + power = client.read_holding_registers(4122, ModbusDataType.FLOAT_32, + unit=config.configuration.modbus_id) + else: + power = client.read_input_registers(4122, ModbusDataType.FLOAT_32, + unit=config.configuration.modbus_id) + power *= 100 + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + def set_limit(power_limit: float) -> None: + # braucht man Reg 78? der Wärmepumpe kann es doch egal sein, wie viel PV-Leistung vom Dach kommt, + # vlt soll die anderweitig genutzt werden + # + # + # muss man unterscheiden, ob Bezug als negative Zahl oder null übertragen werden soll. Die Wärmepumpe kann + # den Bezug ja nicht kompensieren. Bei 0W oder 500W Bezug soll das Verhalten gleich sein (Wärmepumpe nur + # nach Bedarf einschalten, Eigensteuerung) + nonlocal client + # if config.configuration.send_import: + # power_limit = min(power_limit, MAX_VALUE_UINT32) + # power_limit = max(power_limit, 0) + # else: + # power_limit = min(power_limit, MAX_VALUE_INT16) + # power_limit = max(power_limit, MIN_VALUE_INT16) + + client.write_register(74, max(power_limit, 0), wordorder=Endian.Little, unit=config.configuration.modbus_id) + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Idm) diff --git a/packages/modules/consumers/idm/vendor.py b/packages/modules/consumers/idm/vendor.py new file mode 100644 index 0000000000..e2d6437048 --- /dev/null +++ b/packages/modules/consumers/idm/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "IDM" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/lambda_/__init__.py b/packages/modules/consumers/lambda_/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/lambda_/lambda_/__init__.py b/packages/modules/consumers/lambda_/lambda_/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/lambda_/lambda_/config.py b/packages/modules/consumers/lambda_/lambda_/config.py new file mode 100644 index 0000000000..476639d569 --- /dev/null +++ b/packages/modules/consumers/lambda_/lambda_/config.py @@ -0,0 +1,32 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class LambdaConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1, + sign: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + self.sign = sign + + +@auto_str +class Lambda(ConsumerSetup[LambdaConfiguration]): + def __init__(self, + name: str = "Lambda Wärmepumpe", + type: str = "lambda", + id: int = 0, + configuration: LambdaConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or LambdaConfiguration(), usage=usage) diff --git a/packages/modules/consumers/lambda_/lambda_/consumer.py b/packages/modules/consumers/lambda_/lambda_/consumer.py new file mode 100644 index 0000000000..0b3da7745f --- /dev/null +++ b/packages/modules/consumers/lambda_/lambda_/consumer.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +from pymodbus.constants import Endian +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.lambda_.lambda_.config import Lambda + +log = logging.getLogger(__name__) + + +def create_consumer(config: Lambda): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + power = client.read_holding_registers(103, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + def set_limit(power_limit: float) -> None: + nonlocal client + client.write_register(102, + max(power_limit * config.configuration.sign, 0), + wordorder=Endian.Little, + unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Lambda) diff --git a/packages/modules/consumers/lambda_/vendor.py b/packages/modules/consumers/lambda_/vendor.py new file mode 100644 index 0000000000..4cef2bafab --- /dev/null +++ b/packages/modules/consumers/lambda_/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Lambda" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/my_pv/__init__.py b/packages/modules/consumers/my_pv/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/my_pv/acthor/__init__.py b/packages/modules/consumers/my_pv/acthor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/my_pv/acthor/config.py b/packages/modules/consumers/my_pv/acthor/config.py new file mode 100644 index 0000000000..2429eccc4c --- /dev/null +++ b/packages/modules/consumers/my_pv/acthor/config.py @@ -0,0 +1,34 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class ActhorConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1, + model: Optional[str] = "9s45", + max_power: Optional[int] = 1000): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + self.model = model + self.max_power = max_power + + +@auto_str +class Acthor(ConsumerSetup[ActhorConfiguration]): + def __init__(self, + name: str = "my-PV Acthor oder Elwa2 Heizstab", + type: str = "acthor", + id: int = 0, + configuration: ActhorConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ActhorConfiguration(), usage=usage) diff --git a/packages/modules/consumers/my_pv/acthor/consumer.py b/packages/modules/consumers/my_pv/acthor/consumer.py new file mode 100644 index 0000000000..455f5b0f0b --- /dev/null +++ b/packages/modules/consumers/my_pv/acthor/consumer.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +from enum import IntEnum +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.my_pv.acthor.config import Acthor + +log = logging.getLogger(__name__) + + +class Register(IntEnum): + POWER = 1000 + TEMP0 = 1001 + TEMP1 = 1030 + TEMP2 = 1031 + STATUS = 1003 + + +FACTORS = {"9s45": 45000, + "9s27": 27000, + "9s18": 18000, + "9s": 9000, + "M3": 6000, + "E2M1": 3500, + "E2M3": 6500, + "M1": 3000} +REG_MAPPING = ( + (Register.POWER, [ModbusDataType.INT_16]), + (Register.TEMP0, [ModbusDataType.INT_16]), + (Register.TEMP1, [ModbusDataType.INT_16]), + (Register.TEMP2, [ModbusDataType.INT_16]), + (Register.STATUS, ModbusDataType.INT_16), +) + + +def create_consumer(config: Acthor): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + resp = client.read_holding_registers_bulk( + Register.POWER, 35, mapping=REG_MAPPING, unit=config.configuration.modbus_id) + power = resp[Register.POWER] + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported, + temperatures=[resp[Register.TEMP0]/10, resp[Register.TEMP1]/10, resp[Register.TEMP2]/10] + ) + + def set_limit(power_limit: float) -> None: + nonlocal client + power_limit = power_limit * FACTORS.get(config.configuration.model, 9000)/config.configuration.max_power + client.write_registers(1000, power_limit, unit=config.configuration.modbus_id) + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Acthor) diff --git a/packages/modules/consumers/my_pv/elwa_e/__init__.py b/packages/modules/consumers/my_pv/elwa_e/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/my_pv/elwa_e/config.py b/packages/modules/consumers/my_pv/elwa_e/config.py new file mode 100644 index 0000000000..c5f1e7e1fc --- /dev/null +++ b/packages/modules/consumers/my_pv/elwa_e/config.py @@ -0,0 +1,30 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class ElwaConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Elwa(ConsumerSetup[ElwaConfiguration]): + def __init__(self, + name: str = "my-PV Elwa-E Heizstab", + type: str = "elwa_e", + id: int = 0, + configuration: ElwaConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ElwaConfiguration(), usage=usage) diff --git a/packages/modules/consumers/my_pv/elwa_e/consumer.py b/packages/modules/consumers/my_pv/elwa_e/consumer.py new file mode 100644 index 0000000000..ad28612b4d --- /dev/null +++ b/packages/modules/consumers/my_pv/elwa_e/consumer.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +from enum import IntEnum +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.my_pv.elwa_e.config import Elwa + +log = logging.getLogger(__name__) + + +class Register(IntEnum): + POWER = 1000 + TEMP0 = 1001 + STATUS = 1003 + + +REG_MAPPING = ( + (Register.POWER, [ModbusDataType.INT_16]), + (Register.TEMP0, [ModbusDataType.INT_16]), + (Register.STATUS, ModbusDataType.INT_16), +) + +STATUS = { + 2: "Heat", + 3: "Standby", + 4: "Boost heat", + 5: "Heat finished", + 9: "Setup", + 201: "Error Overtemp Fuse blown", + 202: "Error Overtemp measured", + 203: "Error Overtemp Electronics", + 204: "Error Hardware Fault", + 205: "Error Temp Sensor" +} + + +def create_consumer(config: Elwa): + client = None + fuse = 1 + power = 0 + sim_counter = None + status = None + + def initializer(): + nonlocal client, fuse, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + fuse = client.read_input_registers(1014, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter, status + resp = client.read_holding_registers_bulk( + Register.POWER, 4, mapping=REG_MAPPING, unit=config.configuration.modbus_id) + power = resp[Register.POWER] + status = resp[Register.STATUS] + if status > 200: + raise Exception(f"Elwa-E meldet einen Fehler-Status: {STATUS.get(status, 'Unknown status')}") + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported, + temperatures=[resp[Register.TEMP0]/10] + ) + + def set_limit(power_limit: float) -> None: + nonlocal client, fuse, power, status + if status == 4: + log.debug("Elwa-E im Boost-Heat Modus, keine Leistungsvorgabe möglich") + return + if power_limit < power: + power_limit = power + (power - power_limit) * fuse + power_limit = min(power_limit, 4000) + power_limit = min(power_limit, 0) + + client.write_registers(1000, power_limit, unit=config.configuration.modbus_id) + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Elwa) diff --git a/packages/modules/consumers/my_pv/vendor.py b/packages/modules/consumers/my_pv/vendor.py new file mode 100644 index 0000000000..8cae7ed9b8 --- /dev/null +++ b/packages/modules/consumers/my_pv/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "my-PV" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/mystrom/__init__.py b/packages/modules/consumers/mystrom/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/mystrom/mystrom/__init__.py b/packages/modules/consumers/mystrom/mystrom/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/mystrom/mystrom/config.py b/packages/modules/consumers/mystrom/mystrom/config.py new file mode 100644 index 0000000000..642b7d8b55 --- /dev/null +++ b/packages/modules/consumers/mystrom/mystrom/config.py @@ -0,0 +1,25 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class MyStromConfiguration: + def __init__(self, ip_address: Optional[str] = None): + self.ip_address = ip_address + + +@auto_str +class MyStrom(ConsumerSetup[MyStromConfiguration]): + def __init__(self, + name: str = "MyStrom", + type: str = "mystrom", + id: int = 0, + configuration: MyStromConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or MyStromConfiguration(), usage=usage) diff --git a/packages/modules/consumers/mystrom/mystrom/consumer.py b/packages/modules/consumers/mystrom/mystrom/consumer.py new file mode 100644 index 0000000000..11844537f8 --- /dev/null +++ b/packages/modules/consumers/mystrom/mystrom/consumer.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import logging +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.mystrom.mystrom.config import MyStrom + +log = logging.getLogger(__name__) + + +def create_consumer(config: MyStrom): + session = None + sim_counter = None + + def initializer(): + nonlocal session, sim_counter + session = req.get_http_session() + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal session, sim_counter + resp = session.get(f"http://{config.configuration.ip_address}/report", timeout=3).json() + power = resp["power"] + relais = resp["relay"] + temp = float(resp["temperature"]) + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported, + temperatures=[temp], + state=relais + ) + + def switch_on() -> None: + nonlocal session + session.get(f"http://{config.configuration.ip_address}/relay?state=1", timeout=3) + + def switch_off() -> None: + nonlocal session + session.get(f"http://{config.configuration.ip_address}/relay?state=0", timeout=3) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + switch_on=switch_on, + switch_off=switch_off, + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=MyStrom) diff --git a/packages/modules/consumers/mystrom/vendor.py b/packages/modules/consumers/mystrom/vendor.py new file mode 100644 index 0000000000..9e36f5507c --- /dev/null +++ b/packages/modules/consumers/mystrom/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "MyStrom" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/nibe/__init__.py b/packages/modules/consumers/nibe/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/nibe/nibe_s_series/__init__.py b/packages/modules/consumers/nibe/nibe_s_series/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/nibe/nibe_s_series/config.py b/packages/modules/consumers/nibe/nibe_s_series/config.py new file mode 100644 index 0000000000..dc1da37b8f --- /dev/null +++ b/packages/modules/consumers/nibe/nibe_s_series/config.py @@ -0,0 +1,27 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class NibeConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.modbus_id = modbus_id + + +@auto_str +class Nibe(ConsumerSetup[NibeConfiguration]): + def __init__(self, + name: str = "Nibe S-Series Wärmepumpe", + type: str = "nibe_s_series", + id: int = 0, + configuration: NibeConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or NibeConfiguration(), usage=usage) diff --git a/packages/modules/consumers/nibe/nibe_s_series/consumer.py b/packages/modules/consumers/nibe/nibe_s_series/consumer.py new file mode 100644 index 0000000000..4958de5d59 --- /dev/null +++ b/packages/modules/consumers/nibe/nibe_s_series/consumer.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.nibe.nibe_s_series.config import Nibe + +log = logging.getLogger(__name__) + + +def create_consumer(config: Nibe): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, 502) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + power = client.read_input_registers(2166, ModbusDataType.UINT_32, + unit=config.configuration.modbus_id) # / 10 lt Doku? + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update) + + +device_descriptor = DeviceDescriptor(configuration_factory=Nibe) diff --git a/packages/modules/consumers/nibe/vendor.py b/packages/modules/consumers/nibe/vendor.py new file mode 100644 index 0000000000..48ccad8553 --- /dev/null +++ b/packages/modules/consumers/nibe/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Nibe" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/ratiotherm/__init__.py b/packages/modules/consumers/ratiotherm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ratiotherm/ratiotherm/__init__.py b/packages/modules/consumers/ratiotherm/ratiotherm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ratiotherm/ratiotherm/config.py b/packages/modules/consumers/ratiotherm/ratiotherm/config.py new file mode 100644 index 0000000000..6167b5d300 --- /dev/null +++ b/packages/modules/consumers/ratiotherm/ratiotherm/config.py @@ -0,0 +1,29 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class RatiothermConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Ratiotherm(ConsumerSetup[RatiothermConfiguration]): + def __init__(self, + name: str = "Ratiotherm Wärmepumpe", + type: str = "ratiotherm", + id: int = 0, + configuration: RatiothermConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or RatiothermConfiguration(), usage=usage) diff --git a/packages/modules/consumers/ratiotherm/ratiotherm/consumer.py b/packages/modules/consumers/ratiotherm/ratiotherm/consumer.py new file mode 100644 index 0000000000..8b6d0e10a9 --- /dev/null +++ b/packages/modules/consumers/ratiotherm/ratiotherm/consumer.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.ratiotherm.ratiotherm.config import Ratiotherm + +log = logging.getLogger(__name__) + + +def create_consumer(config: Ratiotherm): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def set_limit(power_limit: float) -> None: + nonlocal client + # Absturz bei negativen Zahlen + client.write_register(100, max(power_limit, 0), ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Ratiotherm) diff --git a/packages/modules/consumers/ratiotherm/vendor.py b/packages/modules/consumers/ratiotherm/vendor.py new file mode 100644 index 0000000000..7e6f5af780 --- /dev/null +++ b/packages/modules/consumers/ratiotherm/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Ratiotherm" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/shelly/__init__.py b/packages/modules/consumers/shelly/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/shelly/shelly_em/__init__.py b/packages/modules/consumers/shelly/shelly_em/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/shelly/shelly_em/config.py b/packages/modules/consumers/shelly/shelly_em/config.py new file mode 100644 index 0000000000..877991a84a --- /dev/null +++ b/packages/modules/consumers/shelly/shelly_em/config.py @@ -0,0 +1,30 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class ShellyEMConfiguration: + def __init__(self, ip_address: Optional[str] = None, username: Optional[str] = None, + password: Optional[str] = None, channel: int = 0, phase: int = 1, factor: float = 1.0) -> None: + self.ip_address = ip_address + self.username = username + self.password = password + self.channel = channel # Channel 0: alle Meter summiert + self.phase = phase + self.factor = factor + + +@auto_str +class ShellyEM(ConsumerSetup[ShellyEMConfiguration]): + def __init__(self, + name: str = "Shelly EM (Messen)", + type: str = "shelly_em", + id: int = 0, + configuration: ShellyEMConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ShellyEMConfiguration(), usage=usage) diff --git a/packages/modules/consumers/shelly/shelly_em/consumer.py b/packages/modules/consumers/shelly/shelly_em/consumer.py new file mode 100644 index 0000000000..54bcc9a080 --- /dev/null +++ b/packages/modules/consumers/shelly/shelly_em/consumer.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.devices.shelly.shelly.device import create_device +from modules.consumers.shelly.shelly_em.config import ShellyEM +from modules.devices.shelly.shelly.config import (ShellyConfiguration as ShellyDeviceConfiguration, + Shelly as ShellyDevice, + ShellyCounterSetup as ShellyDeviceCounterSetup) + + +def create_consumer(config: ShellyEM): + device = None + + def initializer(): + nonlocal device + device = create_device(device_config=ShellyDevice( + configuration=ShellyDeviceConfiguration( + ip_address=config.configuration.ip_address, + factor=config.configuration.factor, + phase=config.configuration.phase),), + id=config.id) + device.create_counter_component(component_config=ShellyDeviceCounterSetup(type="consumer_counter")) + + def error_handler() -> None: + initializer() + + def update() -> None: + device.update() + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update,) + + +device_descriptor = DeviceDescriptor(configuration_factory=ShellyEM) diff --git a/packages/modules/consumers/shelly/shelly_pm/__init__.py b/packages/modules/consumers/shelly/shelly_pm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/shelly/shelly_pm/config.py b/packages/modules/consumers/shelly/shelly_pm/config.py new file mode 100644 index 0000000000..4e9398e004 --- /dev/null +++ b/packages/modules/consumers/shelly/shelly_pm/config.py @@ -0,0 +1,27 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class ShellyPMConfiguration: + def __init__(self, ip_address: Optional[str] = None, port: Optional[int] = 502, modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class ShellyPM(ConsumerSetup[ShellyPMConfiguration]): + def __init__(self, + name: str = "Shelly PM (Messen & Schalten)", + type: str = "shelly_pm", + id: int = 0, + configuration: ShellyPMConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY, + ConsumerUsage.SUSPENDABLE_ONOFF]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ShellyPMConfiguration(), usage=usage) diff --git a/packages/modules/consumers/shelly/shelly_pm/consumer.py b/packages/modules/consumers/shelly/shelly_pm/consumer.py new file mode 100644 index 0000000000..adb68ea3bb --- /dev/null +++ b/packages/modules/consumers/shelly/shelly_pm/consumer.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusTcpClient_ +from modules.consumers.shelly.shelly_pm.config import ShellyPM + + +def create_consumer(config: ShellyPM): + client = None + + def initializer(): + nonlocal client + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + + def error_handler() -> None: + initializer() + + def switch_on() -> None: + nonlocal client + client.write_coil(16, True, unit=config.configuration.modbus_id) + + def switch_off() -> None: + nonlocal client + client.write_coil(16, False, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + switch_on=switch_on, + switch_off=switch_off,) + + +device_descriptor = DeviceDescriptor(configuration_factory=ShellyPM) diff --git a/packages/modules/consumers/shelly/vendor.py b/packages/modules/consumers/shelly/vendor.py new file mode 100644 index 0000000000..f4c9e8c5b2 --- /dev/null +++ b/packages/modules/consumers/shelly/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Shelly" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/solvis/__init__.py b/packages/modules/consumers/solvis/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/solvis/solvis/__init__.py b/packages/modules/consumers/solvis/solvis/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/solvis/solvis/config.py b/packages/modules/consumers/solvis/solvis/config.py new file mode 100644 index 0000000000..e51fd7156e --- /dev/null +++ b/packages/modules/consumers/solvis/solvis/config.py @@ -0,0 +1,26 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class SolvisConfiguration: + def __init__(self, ip_address: Optional[str] = None, port: Optional[int] = 502, modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class SolvisHeatPump(ConsumerSetup[SolvisConfiguration]): + def __init__(self, + name: str = "Solvis Wärmepumpe", + type: str = "solvis", + id: int = 0, + configuration: SolvisConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or SolvisConfiguration(), usage=usage) diff --git a/packages/modules/consumers/solvis/solvis/consumer.py b/packages/modules/consumers/solvis/solvis/consumer.py new file mode 100644 index 0000000000..d6244a7b8f --- /dev/null +++ b/packages/modules/consumers/solvis/solvis/consumer.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.solvis.solvis.config import SolvisHeatPump + + +def create_consumer(config: SolvisHeatPump): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + power = client.read_input_registers(33545, ModbusDataType.INT_16, unit=config.configuration.modbus_id) * 100 + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update) + + +device_descriptor = DeviceDescriptor(configuration_factory=SolvisHeatPump) diff --git a/packages/modules/consumers/solvis/vendor.py b/packages/modules/consumers/solvis/vendor.py new file mode 100644 index 0000000000..2adee7c9c1 --- /dev/null +++ b/packages/modules/consumers/solvis/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Solvis" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/stiebel/__init__.py b/packages/modules/consumers/stiebel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/stiebel/stiebel/__init__.py b/packages/modules/consumers/stiebel/stiebel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/stiebel/stiebel/config.py b/packages/modules/consumers/stiebel/stiebel/config.py new file mode 100644 index 0000000000..17e629b03b --- /dev/null +++ b/packages/modules/consumers/stiebel/stiebel/config.py @@ -0,0 +1,29 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class StiebelConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Stiebel(ConsumerSetup[StiebelConfiguration]): + def __init__(self, + name: str = "Stiebel", + type: str = "stiebel", + id: int = 0, + configuration: StiebelConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_ONOFF]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or StiebelConfiguration(), usage=usage) diff --git a/packages/modules/consumers/stiebel/stiebel/consumer.py b/packages/modules/consumers/stiebel/stiebel/consumer.py new file mode 100644 index 0000000000..c3d6d1a0d0 --- /dev/null +++ b/packages/modules/consumers/stiebel/stiebel/consumer.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.stiebel.stiebel.config import Stiebel + +log = logging.getLogger(__name__) + + +def create_consumer(config: Stiebel): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def switch_on() -> None: + nonlocal client + client.write_register(4001, 1, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def switch_off() -> None: + nonlocal client + client.write_register(4001, 0, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + switch_on=switch_on, + switch_off=switch_off, + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=Stiebel) diff --git a/packages/modules/consumers/stiebel/vendor.py b/packages/modules/consumers/stiebel/vendor.py new file mode 100644 index 0000000000..4e20a18158 --- /dev/null +++ b/packages/modules/consumers/stiebel/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Stiebel" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/vendors.py b/packages/modules/consumers/vendors.py new file mode 100644 index 0000000000..9743d0c896 --- /dev/null +++ b/packages/modules/consumers/vendors.py @@ -0,0 +1,5 @@ +from enum import Enum + + +class VendorGroup(Enum): + VENDORS = "vendors" diff --git a/packages/modules/consumers/viessmann/__init__.py b/packages/modules/consumers/viessmann/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/viessmann/vendor.py b/packages/modules/consumers/viessmann/vendor.py new file mode 100644 index 0000000000..31a0aa7195 --- /dev/null +++ b/packages/modules/consumers/viessmann/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Viessmann" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/viessmann/viessmann_heat_pump/__init__.py b/packages/modules/consumers/viessmann/viessmann_heat_pump/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/viessmann/viessmann_heat_pump/config.py b/packages/modules/consumers/viessmann/viessmann_heat_pump/config.py new file mode 100644 index 0000000000..ddc8be0fd0 --- /dev/null +++ b/packages/modules/consumers/viessmann/viessmann_heat_pump/config.py @@ -0,0 +1,26 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class ViessmannConfiguration: + def __init__(self, ip_address: Optional[str] = None, port: Optional[int] = 502, modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class ViessmannHeatPump(ConsumerSetup[ViessmannConfiguration]): + def __init__(self, + name: str = "Viessmann Wärmepumpe", + type: str = "viessmann_heat_pump", + id: int = 0, + configuration: ViessmannConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_ONOFF]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ViessmannConfiguration(), usage=usage) diff --git a/packages/modules/consumers/viessmann/viessmann_heat_pump/consumer.py b/packages/modules/consumers/viessmann/viessmann_heat_pump/consumer.py new file mode 100644 index 0000000000..e09b65ca5d --- /dev/null +++ b/packages/modules/consumers/viessmann/viessmann_heat_pump/consumer.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusTcpClient_ +from modules.consumers.viessmann.viessmann_heat_pump.config import ViessmannHeatPump + + +def create_consumer(config: ViessmannHeatPump): + client = None + + def initializer(): + nonlocal client + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + + def error_handler() -> None: + initializer() + + def switch_on() -> None: + nonlocal client + client.write_single_coil(16, True, unit=config.configuration.modbus_id) + + def switch_off() -> None: + nonlocal client + client.write_single_coil(16, False, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + switch_on=switch_on, + switch_off=switch_off,) + + +device_descriptor = DeviceDescriptor(configuration_factory=ViessmannHeatPump) From f525a119cceb3b9d798c0df8cdbc6cbc016fadc4 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 16 Jun 2026 15:57:04 +0200 Subject: [PATCH 39/84] draft --- data/config/mosquitto/local/openwb_local.conf | 8 +- .../public/default-dynamic-security.json | 98 ++++++++ .../mosquitto/public/role-templates.json | 62 +++++ .../control/algorithm/additional_current.py | 64 +++--- packages/control/algorithm/bidi_charging.py | 55 ++--- packages/control/algorithm/chargemodes.py | 44 ++-- packages/control/algorithm/common.py | 132 ++++++----- packages/control/algorithm/common_test.py | 2 +- .../control/algorithm/filter_chargepoints.py | 199 ++++++++-------- .../algorithm/filter_chargepoints_test.py | 204 ++++++----------- .../integration_test/instant_charging_test.py | 73 +++--- .../integration_test/pv_charging_test.py | 5 +- packages/control/algorithm/min_current.py | 58 ++--- packages/control/algorithm/no_current.py | 10 +- .../control/algorithm/surplus_controlled.py | 214 ++++++++++-------- .../algorithm/surplus_controlled_test.py | 22 +- packages/control/bat_all.py | 11 +- packages/control/bat_all_test.py | 11 +- packages/control/chargepoint/chargepoint.py | 9 +- .../control/chargepoint/control_parameter.py | 1 - packages/control/counter.py | 190 +++++++++------- .../control/counter_all/counter_all_data.py | 4 +- packages/control/counter_all/hierarchy.py | 16 +- .../control/counter_all/hierarchy_test.py | 14 +- .../counter_all/loadmanagement_prio.py | 24 +- .../counter_all/loadmanagement_prio_test.py | 18 +- packages/control/counter_test.py | 6 +- packages/control/data.py | 37 +++ packages/control/general.py | 12 + packages/control/loadmanagement.py | 41 ++-- packages/control/prepare.py | 3 + packages/control/process.py | 41 +++- packages/helpermodules/abstract_plans.py | 20 ++ packages/helpermodules/auto_str.py | 7 + .../helpermodules/changed_values_handler.py | 3 + packages/helpermodules/command.py | 124 +++++++++- packages/helpermodules/setdata.py | 116 +++++++--- packages/helpermodules/subdata.py | 53 +++++ packages/helpermodules/timecheck.py | 4 +- packages/helpermodules/update_config.py | 31 +++ packages/modules/common/component_state.py | 33 +++ packages/modules/common/component_type.py | 5 + .../modules/common/simcount/_simcounter.py | 14 ++ packages/modules/common/store/_factory.py | 3 + packages/modules/common/utils/peak_filter.py | 3 + packages/modules/configuration.py | 99 +++++++- packages/modules/loadvars.py | 15 ++ 47 files changed, 1452 insertions(+), 766 deletions(-) diff --git a/data/config/mosquitto/local/openwb_local.conf b/data/config/mosquitto/local/openwb_local.conf index 8d37590972..ab42e95798 100644 --- a/data/config/mosquitto/local/openwb_local.conf +++ b/data/config/mosquitto/local/openwb_local.conf @@ -1,4 +1,4 @@ -# openwb-version:20 +# openwb-version:21 listener 1886 localhost allow_anonymous true @@ -28,6 +28,12 @@ topic openWB/chargepoint/+/get/# out 2 topic openWB/chargepoint/+/config/# out 2 topic openWB/chargepoint/template/# out 2 +topic openWB/consumer/+/module out 2 +topic openWB/consumer/+/config out 2 +topic openWB/consumer/+/extra_meter/# out 2 +topic openWB/consumer/+/usage out 2 +topic openWB/consumer/+/get/# out 2 + topic openWB/internal_chargepoint/# out 2 topic openWB/io/# out 2 diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index c9eceff753..f6668cecac 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -1097,6 +1097,24 @@ "priority": 0, "allow": true }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/general/consumer/config/switch_on_delay", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/general/consumer/config/switch_off_delay", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/general/consumer/config/switch_off_threshold", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/system/security/access/SurplusChargeConfiguration", @@ -1156,6 +1174,24 @@ "topic": "openWB/general/chargemode_config/surplus/vehicle/switch_off_delay", "priority": 0, "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/consumer/config/switch_on_delay", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/consumer/config/switch_off_delay", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/consumer/config/switch_off_threshold", + "priority": 0, + "allow": true } ] }, @@ -1733,6 +1769,55 @@ } ] }, + { + "rolename": "consumer-configuration-access", + "textname": "Zugang zu den Verbrauchereinstellungen", + "textdescription": "Erlaubt den Zugang zu den Verbrauchereinstellungen.", + "acls": [ + { + "acltype": "publishClientSend", + "topic": "openWB/consumer/+/config", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/consumer/+/module", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/consumer/+/usage", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer/+/config", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer/+/module", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer/+/usage", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/system/configurable/consumers", + "priority": 0, + "allow": true + } + ] + }, { "rolename": "vehicle-configuration-access", "textname": "Zugang zu den Fahrzeugeinstellungen", @@ -2837,6 +2922,19 @@ } ] }, + { + "rolename": "consumer-sum-access", + "textname": "Leserechte für Verbrauchersummendaten", + "textdescription": "Erlaubt das Empfangen von Daten für die Summe aller Verbraucher.", + "acls": [ + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer/get/+", + "priority": 0, + "allow": true + } + ] + }, { "rolename": "tenant-energy-configuration-access", "textname": "Leserechte für Mieterstromdaten", diff --git a/data/config/mosquitto/public/role-templates.json b/data/config/mosquitto/public/role-templates.json index 06fe366212..f09a94e480 100644 --- a/data/config/mosquitto/public/role-templates.json +++ b/data/config/mosquitto/public/role-templates.json @@ -72,6 +72,68 @@ "allow": true } ] + }, + { + "rolename": "consumer--access", + "textname": "Leserechte für Verbraucher ", + "textdescription": "Erlaubt das Empfangen von Daten für Verbraucher .", + "acls": [ + { + "acltype": "publishClientSend", + "topic": "openWB/set/consumer//set/charge_template", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/consumer//set/manual_lock", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/consumer//config/ev", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/consumer//get/rfid", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer//#", + "priority": 0, + "allow": true + } + ] + }, + { + "rolename": "consumer--write-access", + "textname": "Schreibrechte für MQTT-Verbraucher ", + "textdescription": "Erlaubt das Senden von Daten für Verbraucher .", + "acls": [ + { + "acltype": "publishClientSend", + "topic": "openWB/set/mqtt/consumer//get/#", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/set/mqtt/consumer//get/#", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/mqtt/consumer//#", + "priority": 0, + "allow": true + } + ] }, { "rolename": "vehicle--access", diff --git a/packages/control/algorithm/additional_current.py b/packages/control/algorithm/additional_current.py index 87893fa10e..cecaebf573 100644 --- a/packages/control/algorithm/additional_current.py +++ b/packages/control/algorithm/additional_current.py @@ -1,13 +1,12 @@ import logging -from control import data from control.algorithm import common from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT from control.limiting_value import LoadmanagementLimit +from control.load_protocol import Load from control.loadmanagement import Loadmanagement -from control.chargepoint.chargepoint import Chargepoint -from control.algorithm.filter_chargepoints import (get_chargepoints_by_mode_and_counter, - get_preferenced_chargepoint_charging) +from control.algorithm.filter_chargepoints import (filtered_loads_to_str, get_grouped_loads_by_mode_and_counter, + get_preferenced_load_charging) log = logging.getLogger(__name__) @@ -19,44 +18,45 @@ def __init__(self) -> None: def set_additional_current(self) -> None: common.reset_current_by_chargemode(CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT) - for mode_tuple, counter in common.mode_and_counter_generator(CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT): - preferenced_chargepoints, preferenced_cps_without_set_current = get_preferenced_chargepoint_charging( - get_chargepoints_by_mode_and_counter(mode_tuple, f"counter{counter.num}")) - if preferenced_chargepoints: - common.update_raw_data(preferenced_chargepoints) - log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") - for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios( - preferenced_chargepoints): - missing_currents, counts = common.get_missing_currents_left(group) - available_currents, limit = Loadmanagement().get_available_currents(missing_currents, counter, cp) - log.debug(f"cp {cp.num} available currents {available_currents} missing currents " + for counter in common.counter_generator(): + preferenced_loads_groups, preferenced_loads_without_set_current = get_preferenced_load_charging( + get_grouped_loads_by_mode_and_counter(CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT, + f"counter{counter.num}")) + for preferenced_loads in preferenced_loads_groups: + common.update_raw_data(preferenced_loads) + log.info(f"Zähler {counter.num}, Verbraucher {filtered_loads_to_str(preferenced_loads)}") + while len(preferenced_loads): + load = preferenced_loads[0] + missing_currents, counts = common.get_missing_currents_left(preferenced_loads) + available_currents, limit = Loadmanagement().get_available_currents(missing_currents, counter, load) + log.debug(f"load {load.num} available currents {available_currents} missing currents " f"{missing_currents} limit {limit.message}") - if limit.limiting_value is not None: - cp.data.control_parameter.limit = limit - available_for_cp = common.available_current_for_cp(cp, counts, available_currents, missing_currents) + load.data.control_parameter.limit = limit + available_for_cp = common.available_current_for_load( + load, counts, available_currents, missing_currents) current = common.get_current_to_set( - cp.data.set.current, available_for_cp, cp.data.set.target_current) - self._set_loadmangement_message(current, limit, cp) + load.data.set.current, available_for_cp, load.data.set.target_current) + self._set_loadmangement_message(current, limit, load) common.set_current_counterdiff( - cp.data.control_parameter.min_current, + load.data.control_parameter.min_current, current, - cp) - if preferenced_cps_without_set_current: - for cp in preferenced_cps_without_set_current: - cp.data.set.current = cp.data.set.target_current + load) + preferenced_loads.pop(0) + for load in preferenced_loads_without_set_current: + load.data.set.current = load.data.set.target_current # tested def _set_loadmangement_message(self, current: float, limit: LoadmanagementLimit, - chargepoint: Chargepoint) -> None: + load: Load) -> None: # Strom muss an diesem Zähler geändert werden log.debug( - f"current {current} target {chargepoint.data.set.target_current} set current {chargepoint.data.set.current}" - f" required currents {chargepoint.data.control_parameter.required_currents}") - if (current != max(chargepoint.data.set.target_current, chargepoint.data.set.current or 0) and + f"current {current} target {load.data.set.target_current} set current {load.data.set.current}" + f" required currents {load.data.control_parameter.required_currents}") + if (current != max(load.data.set.target_current, load.data.set.current or 0) and # Strom erreicht nicht die vorgegebene Stromstärke round(current, 2) != round(max( - chargepoint.data.control_parameter.required_currents), 2)): - chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" - f"{limit.message}") + load.data.control_parameter.required_currents), 2)): + load.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" + f"{limit.message}") diff --git a/packages/control/algorithm/bidi_charging.py b/packages/control/algorithm/bidi_charging.py index 91e4b090fd..40aea05879 100644 --- a/packages/control/algorithm/bidi_charging.py +++ b/packages/control/algorithm/bidi_charging.py @@ -1,7 +1,8 @@ import logging from control import data from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE -from control.algorithm.filter_chargepoints import get_chargepoints_with_required_current_by_chargemode +from control.algorithm.filter_chargepoints import filtered_loads_to_str, get_loads_by_chargemodes +from control.chargepoint.chargepoint import Chargepoint from helpermodules.phase_handling import voltages_mean log = logging.getLogger(__name__) @@ -15,30 +16,30 @@ def set_bidi(self): grid_counter = data.data.counter_all_data.get_evu_counter() log.debug(f"Nullpunktanpassung {grid_counter.data.set.surplus_power_left}W") zero_point_adjustment = grid_counter - for mode_tuple in CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE: - preferenced_cps = get_chargepoints_with_required_current_by_chargemode(mode_tuple) - if preferenced_cps: - log.info( - f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {grid_counter.num}") - for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(preferenced_cps): - zero_point_adjustment = grid_counter.data.set.surplus_power_left / len(group) - log.debug(f"Nullpunktanpassung für LP{cp.num}: verbleibende Leistung {zero_point_adjustment}W") - missing_currents = [zero_point_adjustment / cp.data.get.phases_in_use / - 230 for i in range(0, cp.data.get.phases_in_use)] - missing_currents += [0] * (3 - len(missing_currents)) - if zero_point_adjustment > 0: - if cp.data.set.charging_ev_data.charge_template.bidi_charging_allowed( - cp.data.control_parameter.current_plan, cp.data.set.charging_ev_data.data.get.soc): - for index in range(0, 3): - missing_currents[index] = min(cp.data.control_parameter.required_current, - missing_currents[index]) - else: - log.info(f"LP{cp.num}: Nur bidirektional entladen erlaubt, da SoC-Limit erreicht.") - missing_currents = [0, 0, 0] - else: + preferenced_cps = get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE) + if preferenced_cps: + log.info(f"Verbraucher {filtered_loads_to_str(preferenced_cps)}") + while len(preferenced_cps): + cp: Chargepoint = preferenced_cps[0] + zero_point_adjustment = grid_counter.data.set.surplus_power_left / len(preferenced_cps) + log.debug(f"Nullpunktanpassung für LP{cp.num}: verbleibende Leistung {zero_point_adjustment}W") + missing_currents = [zero_point_adjustment / cp.data.get.phases_in_use / + 230 for i in range(0, cp.data.get.phases_in_use)] + missing_currents += [0] * (3 - len(missing_currents)) + if zero_point_adjustment > 0: + if cp.data.set.charging_ev_data.charge_template.bidi_charging_allowed( + cp.data.control_parameter.current_plan, cp.data.set.charging_ev_data.data.get.soc): for index in range(0, 3): - missing_currents[index] = cp.check_min_max_current(missing_currents[index], - cp.data.get.phases_in_use) - grid_counter.update_surplus_values_left(missing_currents, voltages_mean(cp.data.get.voltages)) - cp.data.set.current = missing_currents[0] - log.info(f"LP{cp.num}: Stromstärke {missing_currents}A") + missing_currents[index] = min(cp.data.control_parameter.required_current, + missing_currents[index]) + else: + log.info(f"LP{cp.num}: Nur bidirektional entladen erlaubt, da SoC-Limit erreicht.") + missing_currents = [0, 0, 0] + else: + for index in range(0, 3): + missing_currents[index] = cp.check_min_max_current(missing_currents[index], + cp.data.get.phases_in_use) + grid_counter.update_surplus_values_left(missing_currents, voltages_mean(cp.data.get.voltages)) + cp.data.set.current = missing_currents[0] + log.info(f"LP{cp.num}: Stromstärke {missing_currents}A") + preferenced_cps.pop(0) diff --git a/packages/control/algorithm/chargemodes.py b/packages/control/algorithm/chargemodes.py index 0ebfa07cf3..5dbbd23673 100644 --- a/packages/control/algorithm/chargemodes.py +++ b/packages/control/algorithm/chargemodes.py @@ -2,32 +2,22 @@ # Lademodi in absteigender Priorität # Tupel-Inhalt:(eingestellter Modus, tatsächlich genutzter Modus, Priorität) -CHARGEMODES = ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, True), - (Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - (None, Chargemode.TIME_CHARGING, True), - (None, Chargemode.TIME_CHARGING, False), - (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING, True), - (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING, False), - (Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING, True), - (Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING, False), - (Chargemode.PV_CHARGING, Chargemode.INSTANT_CHARGING, True), - (Chargemode.PV_CHARGING, Chargemode.INSTANT_CHARGING, False), - (Chargemode.SCHEDULED_CHARGING, Chargemode.PV_CHARGING, True), - (Chargemode.SCHEDULED_CHARGING, Chargemode.PV_CHARGING, False), - (Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING, True), - (Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING, False), - (Chargemode.PV_CHARGING, Chargemode.PV_CHARGING, True), - (Chargemode.PV_CHARGING, Chargemode.PV_CHARGING, False), +CHARGEMODES = ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING), + (None, Chargemode.TIME_CHARGING), + (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING), + (Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING), + (Chargemode.PV_CHARGING, Chargemode.INSTANT_CHARGING), + (Chargemode.SCHEDULED_CHARGING, Chargemode.PV_CHARGING), + (Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING), + (Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), # niedrigere Priorität soll nachrangig geladen, aber zuerst entladen werden - (Chargemode.SCHEDULED_CHARGING, Chargemode.BIDI_CHARGING, False), - (Chargemode.SCHEDULED_CHARGING, Chargemode.BIDI_CHARGING, True), - (None, Chargemode.STOP, True), - (None, Chargemode.STOP, False)) + (Chargemode.SCHEDULED_CHARGING, Chargemode.BIDI_CHARGING), + (None, Chargemode.STOP),) -CONSIDERED_CHARGE_MODES_SURPLUS = CHARGEMODES[0:2] + CHARGEMODES[6:16] -CONSIDERED_CHARGE_MODES_PV_ONLY = CHARGEMODES[10:16] -CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT = CHARGEMODES[0:10] -CONSIDERED_CHARGE_MODES_MIN_CURRENT = CHARGEMODES[0:-4] -CONSIDERED_CHARGE_MODES_NO_CURRENT = CHARGEMODES[18:20] -CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE = CHARGEMODES[16:18] -CONSIDERED_CHARGE_MODES_CHARGING = CHARGEMODES[0:16] +CONSIDERED_CHARGE_MODES_SURPLUS = (CHARGEMODES[0], *CHARGEMODES[3:8]) +CONSIDERED_CHARGE_MODES_PV_ONLY = CHARGEMODES[5:8] +CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT = CHARGEMODES[0:5] +CONSIDERED_CHARGE_MODES_MIN_CURRENT = CHARGEMODES[0:-2] +CONSIDERED_CHARGE_MODES_NO_CURRENT = (CHARGEMODES[9],) +CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE = (CHARGEMODES[8],) +CONSIDERED_CHARGE_MODES_CHARGING = CHARGEMODES[0:8] diff --git a/packages/control/algorithm/common.py b/packages/control/algorithm/common.py index b6a78f451e..3a91068201 100644 --- a/packages/control/algorithm/common.py +++ b/packages/control/algorithm/common.py @@ -2,10 +2,11 @@ from typing import Iterable, List, Optional, Tuple from control import data -from control.algorithm.filter_chargepoints import get_chargepoints_with_required_current_by_chargemode +from control.algorithm.filter_chargepoints import get_loads_by_chargemodes from control.algorithm.utils import get_medium_charging_current from control.chargepoint.chargepoint import Chargepoint from control.counter import Counter +from control.load_protocol import Load from helpermodules.phase_handling import voltages_mean from helpermodules.timecheck import check_timestamp from modules.common.component_type import ComponentType @@ -19,40 +20,40 @@ def reset_current(): - for cp in data.data.cp_data.values(): + for load in list(data.data.cp_data.values()) + list(data.data.consumer_data.values()): try: - cp.data.set.current = None - cp.data.set.target_current = 0 + load.data.set.current = None + load.data.set.target_current = 0 except Exception: - log.exception(f"Fehler im Algorithmus-Modul für Ladepunkt{cp.num}") + log.exception(f"Fehler im Algorithmus-Modul für " + f"{'Ladepunkt' if isinstance(load, Chargepoint) else 'Verbraucher'}{load.num}") -def reset_current_by_chargemode(modes: List[Tuple[Optional[str], str, bool]]) -> None: - for cp in get_chargepoints_with_required_current_by_chargemode(modes): - cp.data.set.current = None +def reset_current_by_chargemode(chargemodes: Tuple[Tuple[Optional[str], str]]) -> None: + for load in get_loads_by_chargemodes(chargemodes): + load.data.set.current = None -def mode_and_counter_generator(chargemodes: List) -> Iterable[Tuple[Tuple[Optional[str], str, bool], Counter]]: - for mode_tuple in chargemodes: - levels = data.data.counter_all_data.get_list_of_elements_per_level() - for level in reversed(levels): - for element in level: - if element["type"] == ComponentType.COUNTER.value: - counter = data.data.counter_data[f"counter{element['id']}"] - yield mode_tuple, counter +def counter_generator() -> Iterable[Counter]: + levels = data.data.counter_all_data.get_list_of_elements_per_level() + for level in reversed(levels): + for element in level: + if element["type"] == ComponentType.COUNTER.value: + counter = data.data.counter_data[f"counter{element['id']}"] + yield counter # tested -def get_min_current(chargepoint: Chargepoint) -> Tuple[List[float], List[int]]: +def get_min_current(load: Load) -> Tuple[List[float], List[int]]: min_currents = [0.0]*3 counts = [0]*3 - required_currents = chargepoint.data.control_parameter.required_currents + required_currents = load.data.control_parameter.required_currents for i in range(3): if required_currents[i] != 0: counts[i] += 1 - min_currents[i] = chargepoint.data.control_parameter.min_current + min_currents[i] = load.data.control_parameter.min_current else: min_currents[i] = 0 return min_currents, counts @@ -62,30 +63,34 @@ def get_min_current(chargepoint: Chargepoint) -> Tuple[List[float], List[int]]: def set_current_counterdiff(diff_current: float, current: float, - chargepoint: Chargepoint, + load: Load, surplus: bool = False) -> None: - required_currents = chargepoint.data.control_parameter.required_currents - considered_current = consider_less_charging_chargepoint_in_loadmanagement( - chargepoint, current) + required_currents = load.data.control_parameter.required_currents + if isinstance(load, Chargepoint): + considered_current = consider_less_charging_chargepoint_in_loadmanagement( + load, current) + else: + considered_current = current # gar nicht ladende Autos? diff = max(considered_current - diff_current, 0) diffs = [diff if required_currents[i] != 0 else 0 for i in range(3)] if max(diffs) > 0: - counters = data.data.counter_all_data.get_counters_to_check(chargepoint.num) + counters = data.data.counter_all_data.get_counters_to_check(load.num) for counter in counters: if surplus: data.data.counter_data[counter].update_surplus_values_left( diffs, - voltages_mean(chargepoint.data.get.voltages)) + voltages_mean(load.data.get.voltages)) else: data.data.counter_data[counter].update_values_left( diffs, - voltages_mean(chargepoint.data.get.voltages)) - data.data.io_actions.dimming_set_import_power_left({"type": "cp", "id": chargepoint.num}, sum(diffs)*230) + voltages_mean(load.data.get.voltages)) + data.data.io_actions.dimming_set_import_power_left({"type": "cp" if isinstance( + load, Chargepoint) else "consumer", "id": load.num}, sum(diffs)*230) data.data.io_actions.set_limit_loadmanager(sum(diffs)*230) - chargepoint.data.set.current = current - log.info(f"LP{chargepoint.num}: Stromstärke {current}A") + load.data.set.current = current + log.info(f"{'LP' if isinstance(load, Chargepoint) else 'Verbraucher'} {load.num}: Stromstärke {current}A") # tested @@ -105,58 +110,62 @@ def get_current_to_set(set_current: float, diff: float, prev_current: float) -> # tested -def available_current_for_cp(chargepoint: Chargepoint, - counts: List[int], - available_currents: List[float], - missing_currents: List[float]) -> float: - control_parameter = chargepoint.data.control_parameter +def available_current_for_load(load: Load, + counts: List[int], + available_currents: List[float], + missing_currents: List[float]) -> float: + control_parameter = load.data.control_parameter available_current = float("inf") - missing_current_cp = control_parameter.required_current - chargepoint.data.set.target_current + missing_current_cp = control_parameter.required_current - load.data.set.target_current + for i in range(0, 3): - if (control_parameter.required_currents[i] != 0 and - missing_currents[i] != available_currents[i]): - available_current = min(min(missing_current_cp, available_currents[i]/counts[i]), available_current) + shared_with = counts[i] + if (control_parameter.required_currents[i] != 0 and missing_currents[i] != available_currents[i]): + available_current = min(min(missing_current_cp, available_currents[i]/shared_with), available_current) + if available_current == float("inf"): available_current = missing_current_cp return available_current -def update_raw_data(preferenced_chargepoints: List[Chargepoint], +def update_raw_data(preferenced_loads: List[Load], diff_to_zero: bool = False, surplus: bool = False) -> None: """alle CP, die schon einen Soll-Strom haben, wieder herausrechnen, da dieser neu gesetzt wird und die neue Differenz bei den Zählern eingetragen wird.""" - for chargepoint in preferenced_chargepoints: - required_currents = chargepoint.data.control_parameter.required_currents - max_target_set_current = max(chargepoint.data.set.target_current, chargepoint.data.set.current or 0) - max_target_set_current = consider_less_charging_chargepoint_in_loadmanagement( - chargepoint, max_target_set_current) + for load in preferenced_loads: + required_currents = load.data.control_parameter.required_currents + max_target_set_current = max(load.data.set.target_current, load.data.set.current or 0) + if isinstance(load, Chargepoint): + max_target_set_current = consider_less_charging_chargepoint_in_loadmanagement( + load, max_target_set_current) if diff_to_zero is False: - if chargepoint.data.control_parameter.min_current < max_target_set_current: + if load.data.control_parameter.min_current < max_target_set_current: if surplus: - diffs = [chargepoint.data.set.target_current - + diffs = [load.data.set.target_current - max_target_set_current if required_currents[i] != 0 else 0 for i in range(3)] else: - diffs = [chargepoint.data.control_parameter.min_current - + diffs = [load.data.control_parameter.min_current - max_target_set_current if required_currents[i] != 0 else 0 for i in range(3)] else: continue else: - if chargepoint.data.control_parameter.min_current <= max_target_set_current: - diffs = [-chargepoint.data.control_parameter.min_current if required_currents[i] + if load.data.control_parameter.min_current <= max_target_set_current: + diffs = [-load.data.control_parameter.min_current if required_currents[i] != 0 else 0 for i in range(3)] else: continue - counters = data.data.counter_all_data.get_counters_to_check(chargepoint.num) + counters = data.data.counter_all_data.get_counters_to_check(load.num) for counter in counters: if surplus: data.data.counter_data[counter].update_surplus_values_left( diffs, - voltages_mean(chargepoint.data.get.voltages)) + voltages_mean(load.data.get.voltages)) else: - data.data.counter_data[counter].update_values_left(diffs, voltages_mean(chargepoint.data.get.voltages)) - data.data.io_actions.dimming_set_import_power_left({"type": "cp", "id": chargepoint.num}, sum(diffs)*230) + data.data.counter_data[counter].update_values_left(diffs, voltages_mean(load.data.get.voltages)) + data.data.io_actions.dimming_set_import_power_left({"type": "cp" if isinstance( + load, Chargepoint) else "consumer", "id": load.num}, sum(diffs)*230) data.data.io_actions.set_limit_loadmanager(sum(diffs)*230) @@ -175,18 +184,18 @@ def consider_less_charging_chargepoint_in_loadmanagement(cp: Chargepoint, set_cu # tested -def get_missing_currents_left(preferenced_chargepoints: List[Chargepoint]) -> Tuple[List[float], List[int]]: +def get_missing_currents_left(preferenced_loads: List[Load]) -> Tuple[List[float], List[int]]: missing_currents = [0.0]*3 counts = [0]*3 - for chargepoint in preferenced_chargepoints: - required_currents = chargepoint.data.control_parameter.required_currents + for load in preferenced_loads: + required_currents = load.data.control_parameter.required_currents for i in range(0, 3): if required_currents[i] != 0: counts[i] += 1 try: - missing_currents[i] += required_currents[i] - chargepoint.data.control_parameter.min_current + missing_currents[i] += required_currents[i] - load.data.control_parameter.min_current except KeyError: - missing_currents[i] += max(required_currents) - chargepoint.data.control_parameter.min_current + missing_currents[i] += max(required_currents) - load.data.control_parameter.min_current else: missing_currents[i] += 0 return missing_currents, counts @@ -196,8 +205,9 @@ def reset_current_to_target_current(): """target_current enthält die gesetzte Stromstärke der vorherigen Stufe. Notwendig, um zB bei der Mindeststromstärke erkennen zu können, ob diese ein vom LM begrenzter Strom aus Stufe 2 oder der Mindeststrom aus Stufe 1 ist.""" - for cp in data.data.cp_data.values(): + for load in (list(data.data.cp_data.values()) + list(data.data.consumer_data.values())): try: - cp.data.set.target_current = cp.data.set.current + load.data.set.target_current = load.data.set.current except Exception: - log.exception(f"Fehler im Algorithmus-Modul für Ladepunkt{cp.num}") + log.exception(f"Fehler im Algorithmus-Modul für " + f"{'Ladepunkt' if isinstance(load, Chargepoint) else 'Verbraucher'}{load.num}") diff --git a/packages/control/algorithm/common_test.py b/packages/control/algorithm/common_test.py index df53dbb7c2..347cd87e1f 100644 --- a/packages/control/algorithm/common_test.py +++ b/packages/control/algorithm/common_test.py @@ -123,7 +123,7 @@ def test_available_currents_for_cp(counts: List[int], cp.data.set.target_current = 10 # evaluation - current = common.available_current_for_cp(cp, counts, available_currents, missing_currents) + current = common.available_current_for_load(cp, counts, available_currents, missing_currents) # assertion assert current == expected_current diff --git a/packages/control/algorithm/filter_chargepoints.py b/packages/control/algorithm/filter_chargepoints.py index 041bd337a6..1659aca5bf 100644 --- a/packages/control/algorithm/filter_chargepoints.py +++ b/packages/control/algorithm/filter_chargepoints.py @@ -1,117 +1,100 @@ # tested import logging -from typing import List, Optional, Tuple, Union +import re +from typing import List, Optional, Tuple from control import data +from control.chargemode import Chargemode from control.chargepoint.chargepoint import Chargepoint +from control.load_protocol import Load log = logging.getLogger(__name__) -def get_chargepoints_by_mode_and_counter(mode_tuple: Tuple[Optional[str], str, bool], - counter: str) -> List[Chargepoint]: - cps_to_counter = data.data.counter_all_data.get_chargepoints_of_counter(counter) - cps_to_counter_ids = [int(cp[2:]) for cp in cps_to_counter] - cps_by_mode = get_chargepoints_with_required_current_by_chargemode(mode_tuple) - return list(filter(lambda cp: cp.num in cps_to_counter_ids, cps_by_mode)) - -# tested - - -def get_chargepoints_by_chargemode( - modes: Union[Tuple[Optional[str], str, bool], - Tuple[Tuple[Optional[str], str, bool]]]) -> List[Chargepoint]: - modes = modes if isinstance(modes[0], Tuple) else (modes,) - valid_chargepoints: List[Chargepoint] = [] - - for mode_tuple in modes: - mode = mode_tuple[0] - submode = mode_tuple[1] - prio = mode_tuple[2] - - for cp in data.data.cp_data.values(): - if ((cp.data.control_parameter.prio == prio) and - (cp.data.control_parameter.chargemode == mode or mode is None) and - (cp.data.control_parameter.submode == submode) and - cp not in valid_chargepoints): - valid_chargepoints.append(cp) - return valid_chargepoints - - -def get_chargepoints_with_required_current_by_chargemode( - modes: Union[Tuple[Optional[str], str, bool], - Tuple[Tuple[Optional[str], str, bool]]]) -> List[Chargepoint]: - cps_by_mode = get_chargepoints_by_chargemode(modes) - return list(filter(lambda cp: cp.data.control_parameter.required_current != 0, cps_by_mode)) - - -def get_preferenced_chargepoint_charging( - chargepoints: List[Chargepoint]) -> Tuple[List[Chargepoint], List[Chargepoint]]: - preferenced_chargepoints = get_preferenced_chargepoint(chargepoints) - preferenced_chargepoints_with_set_current = [] - preferenced_chargepoints_without_set_current = [] - for cp in preferenced_chargepoints: - if cp.data.set.target_current == 0: - log.info( - f"LP {cp.num}: Keine Zuteilung des Mindeststroms, daher keine weitere Berücksichtigung") - preferenced_chargepoints_without_set_current.append(cp) - elif cp.data.get.charge_state is False: - log.info( - f"LP {cp.num}: Lädt nicht, daher keine weitere Berücksichtigung") - preferenced_chargepoints_without_set_current.append(cp) - else: - preferenced_chargepoints_with_set_current.append(cp) - return preferenced_chargepoints_with_set_current, preferenced_chargepoints_without_set_current - - -# tested - - -def get_preferenced_chargepoint(valid_chargepoints: List[Chargepoint]) -> List: - """ermittelt die Ladepunkte in der Reihenfolge, in der sie geladen/gestoppt werden sollen. Die Bedingungen - sind: - geringste Mindeststromstärke, niedrigster SoC, frühester Ansteck-Zeitpunkt(Einschalten)/Lademenge(Abschalten), - niedrigste Ladepunktnummer. - """ - preferenced_chargepoints = [] - chargepoints = dict.fromkeys(valid_chargepoints) - try: - # Bedingungen in der Reihenfolge, in der sie geprüft werden. - condition_types = ("min_current", "soc", "imported_since_plugged", "plug_in", "num") - # Bedingung, die geprüft wird (entspricht Index von condition_types) - condition = 0 - if chargepoints: - while len(chargepoints) > 0: - # entsprechend der Bedingung die Values im Dictionary füllen - if condition_types[condition] == "min_current": - chargepoints.update( - (cp, cp.data.control_parameter.required_current) - for cp in chargepoints.keys()) - elif condition_types[condition] == "soc": - chargepoints.update( - (cp, cp.data.set.charging_ev_data.data.get.soc or 0) for cp in chargepoints.keys()) - elif condition_types[condition] == "plug_in": - chargepoints.update((cp, cp.data.set.plug_time) - for cp in chargepoints.keys()) - elif condition_types[condition] == "imported_since_plugged": - chargepoints.update((cp, cp.data.set.log.imported_since_plugged) for cp in chargepoints.keys()) - else: - chargepoints.update((cp, cp.num) for cp in chargepoints.keys()) - - # kleinsten Value im Dictionary ermitteln - extreme_value = min(chargepoints.values()) - # dazugehörige Keys ermitteln - extreme_cp = [ - key for key in chargepoints if chargepoints[key] == extreme_value] - if len(extreme_cp) > 1: - # Wenn es mehrere LP gibt, die den gleichen Minimalwert haben, nächste Bedingung prüfen. - condition += 1 - else: - preferenced_chargepoints.append(extreme_cp[0]) - chargepoints.pop(extreme_cp[0]) - if preferenced_chargepoints: - log.debug(f"Geordnete Ladepunkte {[cp.num for cp in preferenced_chargepoints]}") - return preferenced_chargepoints - except Exception: - log.exception("Fehler im Algorithmus-Modul") - return preferenced_chargepoints +def get_grouped_loads_by_mode_and_counter(chargemodes: Tuple[Tuple[Optional[str], str]], + counter: str) -> List[List[Load]]: + loads_to_counter = data.data.counter_all_data.get_loads_of_counter(counter) + # nur die Zahl aus dem String "cp1" und "consumer2" extrahieren + loads_to_counter_ids = [int(re.search(r'\d+', load).group()) for load in loads_to_counter] + + def _is_valid_for_chargemode(entity: Load, + chargemode: Tuple[Optional[str], str], + valid: List[Load]) -> bool: + """Helper function to validate entity against chargemode conditions.""" + return (entity.data.control_parameter.required_current != 0 and + (entity.data.control_parameter.chargemode == chargemode[0] or chargemode[0] is None) and + entity.data.control_parameter.submode == chargemode[1] and + entity not in valid and + entity.num in loads_to_counter_ids) + + return _group_loads_by_chargemode(chargemodes, _is_valid_for_chargemode)[1] + + +def _group_loads_by_chargemode(chargemodes: Tuple[Tuple[Optional[str], str]], + filter_func) -> Tuple[List[Load], List[List[Load]]]: + grouped_loads: List[List[Load]] = [] + flat_loads: List[Load] = [] + for chargemode in chargemodes: + for item in data.data.counter_all_data.data.get.loadmanagement_prios: + if item["type"] == "group": + sub_valid_chargemode: List[Load] = [] + for group_item in item["children"]: + if group_item["type"] == "vehicle": + for cp in data.data.cp_data.values(): + if group_item["id"] == cp.data.config.ev: + if filter_func(cp, chargemode, flat_loads): + sub_valid_chargemode.append(cp) + flat_loads.append(cp) + elif group_item["type"] == "consumer": + consumer = data.data.consumer_data[f"{group_item['type']}{group_item['id']}"] + if filter_func(consumer, chargemode, flat_loads): + sub_valid_chargemode.append(consumer) + flat_loads.append(consumer) + grouped_loads.append(sub_valid_chargemode) + if item["type"] == "vehicle": + for cp in data.data.cp_data.values(): + if item["id"] == cp.data.config.ev: + if filter_func(cp, chargemode, flat_loads): + grouped_loads.append([cp]) + flat_loads.append(cp) + elif item["type"] == "consumer": + consumer = data.data.consumer_data[f"{item['type']}{item['id']}"] + if filter_func(consumer, chargemode, flat_loads): + grouped_loads.append([consumer]) + flat_loads.append(consumer) + return flat_loads, grouped_loads + + +def get_loads_by_chargemodes(chargemodes: Tuple[Tuple[Optional[Chargemode], Chargemode]]) -> List[Load]: + def _is_valid_for_chargemode(entity, chargemode, valid): + """Helper function to validate entity against chargemode conditions.""" + return (entity.data.control_parameter.required_current != 0 and + (entity.data.control_parameter.chargemode == chargemode[0] or chargemode[0] is None) and + entity.data.control_parameter.submode == chargemode[1] and + entity not in valid) + + return _group_loads_by_chargemode(chargemodes, _is_valid_for_chargemode)[0] + + +def get_preferenced_load_charging( + grouped_loads: List[List[Load]]) -> Tuple[List[List[Load]], List[Load]]: + preferenced_loads_without_set_current: List[Load] = [] + for group in grouped_loads: + valid_group: List[Load] = [] + for load in group: + if load.data.set.target_current == 0: + log.info(f"{'LP' if isinstance(load, Chargepoint) else 'Verbraucher'} {load.num}: " + f"Keine Zuteilung des Mindeststroms, daher keine weitere Berücksichtigung") + preferenced_loads_without_set_current.append(load) + elif load.data.get.charge_state is False: + log.info(f"{'LP' if isinstance(load, Chargepoint) else 'Verbraucher'} {load.num}: " + f"Lädt nicht, daher keine weitere Berücksichtigung") + preferenced_loads_without_set_current.append(load) + else: + valid_group.append(load) + group[:] = valid_group + return grouped_loads, preferenced_loads_without_set_current + + +def filtered_loads_to_str(loads: List[Load]) -> str: + return ", ".join([f"{'LP' if isinstance(load, Chargepoint) else 'Verbraucher'}{load.num}" for load in loads]) diff --git a/packages/control/algorithm/filter_chargepoints_test.py b/packages/control/algorithm/filter_chargepoints_test.py index 68078b266b..aabc3cd627 100644 --- a/packages/control/algorithm/filter_chargepoints_test.py +++ b/packages/control/algorithm/filter_chargepoints_test.py @@ -1,5 +1,4 @@ -from dataclasses import dataclass -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, Union from unittest.mock import Mock import pytest @@ -7,29 +6,15 @@ from control import data from control.algorithm import filter_chargepoints from control.chargemode import Chargemode -from control.chargepoint.chargepoint import Chargepoint, ChargepointData -from control.chargepoint.chargepoint_data import Log, Set -from control.chargepoint.control_parameter import ControlParameter +from control.chargepoint.chargepoint import Chargepoint +from control.consumer.consumer import Consumer from control.counter_all.counter_all import CounterAll -from control.ev.ev import Ev, EvData, Get - - -@dataclass -class PreferencedParams: - name: str - expected_sort: List - plug_time_1: str = "10/31/2022, 07:00:00" - imported_since_plugged_1: float = 100 - soc_1: float = 50 - required_current_1: float = 6 - plug_time_2: str = "10/31/2022, 07:00:00" - imported_since_plugged_2: float = 100 - soc_2: float = 50 - required_current_2: float = 6 - plug_time_3: str = "10/31/2022, 07:00:00" - imported_since_plugged_3: float = 100 - soc_3: float = 50 - required_current_3: float = 6 +from control.load_protocol import Load + + +@pytest.fixture(autouse=True) +def mock_data() -> None: + data.data_init(Mock()) @pytest.fixture(autouse=True) @@ -43,123 +28,80 @@ def mock_cp2() -> Chargepoint: @pytest.fixture(autouse=True) -def mock_cp3() -> Chargepoint: - return Chargepoint(3, None) - - -preferenced_cases = [ - PreferencedParams("sort by num", [mock_cp1, mock_cp2, mock_cp3]), - PreferencedParams("sort by imported_since_plugged", [ - mock_cp2, mock_cp3, mock_cp1], imported_since_plugged_2=20, imported_since_plugged_3=60), - PreferencedParams("sort by plug_time", [mock_cp2, mock_cp1, mock_cp3], - plug_time_2="10/31/2022, 06:00:00", plug_time_3="10/31/2022, 12:00:00"), - PreferencedParams("sort by soc", [mock_cp2, mock_cp1, mock_cp3], soc_2=20, soc_3=60), - PreferencedParams("sort by required_current", [mock_cp1, mock_cp2, - mock_cp3], required_current_2=7, required_current_3=8), - PreferencedParams("sort by required_current and soc", [ - mock_cp2, mock_cp1, mock_cp3], required_current_2=7, soc_2=40), -] - - -@pytest.mark.parametrize("params", preferenced_cases, ids=[c.name for c in preferenced_cases]) -def test_get_preferenced_chargepoint(params: PreferencedParams): - # setup - def mock_cp(cp: Chargepoint, num: int): - ev = Ev(0) - ev.data = EvData(get=Get(soc=getattr(params, f"soc_{num}"))) - cp.data = ChargepointData(set=Set(plug_time=getattr(params, f"plug_time_{num}"), log=Log( - imported_since_plugged=getattr(params, f"imported_since_plugged_{num}")), charging_ev_data=ev), - control_parameter=ControlParameter( - required_current=getattr(params, f"required_current_{num}"))) - cp.num = num - return cp - - cp1 = mock_cp(mock_cp1, 1) - cp2 = mock_cp(mock_cp2, 2) - cp3 = mock_cp(mock_cp3, 3) - # execution - preferenced_chargepoints = filter_chargepoints.get_preferenced_chargepoint([cp1, cp2, cp3]) +def mock_consumer3() -> Consumer: + return Consumer(3) - # evaluation - assert preferenced_chargepoints == params.expected_sort - - -preferenced_cases = [ - PreferencedParams("sort by num", [mock_cp1, mock_cp2, mock_cp3]), - - PreferencedParams("sort by soc", [mock_cp2, mock_cp1, mock_cp3], soc_2=20, soc_3=60), - PreferencedParams("sort by required_current", [mock_cp1, mock_cp2, - mock_cp3], required_current_2=7, required_current_3=8), - PreferencedParams("sort by required_current and soc", [ - mock_cp2, mock_cp1, mock_cp3], required_current_2=7, soc_2=40), -] +# flache prio liste +# prio liste mit gruppe und einzel lp und verbraucher +# ein lademodus +# zwei lademodi +# ein fahrzeug für mwhrere ladepunkte +# required current 0 für ein fahrzeug @pytest.mark.parametrize( - "set_mode_tuple, required_current_1, mode_tuple_1, mode_tuple_2, expected_valid_chargepoints", + "required_current_1, ev_1, chargemode_filter, loadmanagement_prios, expected_cp_indices", [ - pytest.param((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - 6, (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, False), - (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, False), - [mock_cp1, mock_cp2], id="fits mode"), - pytest.param((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - 0, (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, False), - (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, False), - [mock_cp2], id="cp1 should not charge"), - pytest.param((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - 6, (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, False), - (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, True), - [mock_cp1], id="cp2 is prioritized") + pytest.param(6, 1, ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING),), + [{"type": "vehicle", "id": 1}, + {"type": "vehicle", "id": 2}, + {"type": "consumer", "id": 3}], + [1, 3], id="flat prio list"), + pytest.param(6, 1, ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING),), + [{"type": "vehicle", "id": 1}, + { + "type": "group", + "label": "Gruppe 1", + "children": [ + {"type": "vehicle", "id": 2}, + {"type": "consumer", "id": 3}]}], + [1, 3], id="grouped prio list"), + pytest.param(6, 2, ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING), + (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING)), + [{"type": "vehicle", "id": 1}, + {"type": "consumer", "id": 3}, + {"type": "vehicle", "id": 2}], + [3, 1, 2], id="ev 2 for cp 1 and cp 2"), + pytest.param(0, 1, ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING), + (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING)), + [{"type": "vehicle", "id": 1}, + {"type": "consumer", "id": 3}, + {"type": "vehicle", "id": 2}], + [3, 2], id="required current 0 for cp 1"), ]) -def test_get_chargepoints_by_mode(set_mode_tuple: Tuple[Optional[str], str, bool], - required_current_1: int, - mode_tuple_1: Tuple[str, str, bool], - mode_tuple_2: Tuple[str, str, bool], - expected_valid_chargepoints): +def test_get_loadmanagement_prios( + required_current_1: int, + ev_1: int, + chargemode_filter: Tuple[Tuple[Optional[Chargemode], Chargemode]], + loadmanagement_prios: List[Dict[str, Union[str, int, List[Dict[str, Union[str, int]]]]]], + expected_cp_indices: List[int], + mock_cp1: Chargepoint, + mock_cp2: Chargepoint, + mock_consumer3: Consumer): # setup - def setup_cp(cp: Chargepoint, required_current: float, mode_tuple: Tuple[str, str, bool]) -> Chargepoint: - cp.data.control_parameter.required_current = required_current - cp.data.control_parameter.prio = mode_tuple[2] - cp.data.control_parameter.chargemode = mode_tuple[0] - cp.data.control_parameter.submode = mode_tuple[1] - return cp - data.data.cp_data = {"cp1": setup_cp(mock_cp1, required_current_1, mode_tuple_1), - "cp2": setup_cp(mock_cp2, 6, mode_tuple_2)} + data.data.cp_data = {"cp1": mock_cp1, "cp2": mock_cp2} + data.data.cp_data["cp1"].data.config.ev = ev_1 + data.data.cp_data["cp1"].data.control_parameter.required_current = required_current_1 + data.data.cp_data["cp1"].data.control_parameter.chargemode = Chargemode.SCHEDULED_CHARGING + data.data.cp_data["cp1"].data.control_parameter.submode = Chargemode.INSTANT_CHARGING + + data.data.cp_data["cp2"].data.config.ev = 2 + data.data.cp_data["cp2"].data.control_parameter.required_current = 6 + data.data.cp_data["cp2"].data.control_parameter.chargemode = Chargemode.INSTANT_CHARGING + data.data.cp_data["cp2"].data.control_parameter.submode = Chargemode.INSTANT_CHARGING + + data.data.consumer_data = {"consumer3": mock_consumer3} + data.data.consumer_data["consumer3"].data.control_parameter.required_current = 1 + data.data.consumer_data["consumer3"].data.control_parameter.chargemode = Chargemode.SCHEDULED_CHARGING + data.data.consumer_data["consumer3"].data.control_parameter.submode = Chargemode.INSTANT_CHARGING - # evaluation - valid_chargepoints = filter_chargepoints.get_chargepoints_with_required_current_by_chargemode(set_mode_tuple) - - # assertion - assert valid_chargepoints == expected_valid_chargepoints - - -@pytest.mark.parametrize( - "chargepoints_of_counter, chargepoints_by_mode, expected_chargepoints", - [ - pytest.param(["cp1", "cp2"], [mock_cp1, mock_cp2], [mock_cp1, mock_cp2], id="match all"), - pytest.param(["cp1", "cp2"], [mock_cp1], [mock_cp1], id="match by mode"), - pytest.param(["cp2"], [mock_cp1, mock_cp2], [mock_cp2], id="match by counter"), - pytest.param(["cp1"], [mock_cp2], [], id="match none") - ]) -def test_get_chargepoints_by_mode_and_counter(chargepoints_of_counter: List[str], - chargepoints_by_mode: List[Chargepoint], - expected_chargepoints: List[Chargepoint], - monkeypatch): - # setup - get_chargepoints_of_counter_mock = Mock(return_value=chargepoints_of_counter) - monkeypatch.setattr(CounterAll, "get_chargepoints_of_counter", get_chargepoints_of_counter_mock) - get_chargepoints_by_chargemode_mock = Mock(return_value=chargepoints_by_mode) - monkeypatch.setattr(filter_chargepoints, "get_chargepoints_by_chargemode", get_chargepoints_by_chargemode_mock) data.data.counter_all_data = CounterAll() + data.data.counter_all_data.data.get.loadmanagement_prios = loadmanagement_prios # evaluation - valid_chargepoints = filter_chargepoints.get_chargepoints_by_mode_and_counter(Mock(), "counter6") + valid_loads = filter_chargepoints.get_loads_by_chargemodes(chargemode_filter) # assertion - assert valid_chargepoints == expected_chargepoints + load_mapping: Dict[int, Load] = {1: mock_cp1, 2: mock_cp2, 3: mock_consumer3} + expected_valid_loads = [load_mapping[i] for i in expected_cp_indices] + assert valid_loads == expected_valid_loads diff --git a/packages/control/algorithm/integration_test/instant_charging_test.py b/packages/control/algorithm/integration_test/instant_charging_test.py index afc9b23551..53214f7bb3 100644 --- a/packages/control/algorithm/integration_test/instant_charging_test.py +++ b/packages/control/algorithm/integration_test/instant_charging_test.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import List +from typing import Dict, List from unittest.mock import Mock import pytest @@ -7,8 +7,9 @@ from control.chargemode import Chargemode from control import data, loadmanagement from control.algorithm.algorithm import Algorithm +from control.ev.ev import Ev from control.limiting_value import LimitingValue -from dataclass_utils.factories import currents_list_factory +from dataclass_utils.factories import currents_list_factory, empty_list_factory @pytest.fixture() @@ -43,6 +44,10 @@ def all_cp_instant_charging_3p(): control_parameter.chargemode = Chargemode.INSTANT_CHARGING data.data.cp_data[f"cp{i}"].data.get.charge_state = True data.data.cp_data[f"cp{i}"].data.get.currents = [16]*3 + data.data.cp_data[f"cp{i}"].data.set.charging_ev_data = Ev(i) + data.data.cp_data[f"cp{i}"].data.config.ev = i + data.data.counter_all_data.data.get.loadmanagement_prios = [ + {"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 4}, {"type": "vehicle", "id": 5}] @dataclass @@ -58,13 +63,16 @@ def assert_counter_set(params: ParamsExpectedCounterSet): assert data.data.counter_data["counter6"].data.set.raw_currents_left == params.expected_raw_currents_left_counter6 -def test_start_instant_charging(all_cp_instant_charging_1p, all_cp_not_charging, monkeypatch): +def test_start_instant_charging(all_cp_instant_charging_1p, all_cp_not_charging, monkeypatch: pytest.MonkeyPatch): # alle 3 im Sofortladen, keine Ladung aktiv -> dürfen nur Mindeststrom zugeteilt kriegen # setup data.data.counter_data["counter0"].data.set.raw_power_left = 21310 data.data.counter_data["counter0"].data.set.raw_currents_left = [32, 30, 31] data.data.counter_data["counter6"].data.set.raw_currents_left = [16, 12, 14] + mock_get_component_name_by_id = Mock(return_value="Garage") + monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mock_get_component_name_by_id) + # execution Algorithm().calc_current() @@ -111,6 +119,7 @@ class ParamsLimit(ParamsExpectedSetCurrent, ParamsExpectedCounterSet): expected_raw_power_left=0, expected_raw_currents_left_counter0=[21.53846153846154, 25.23076923076923, 25.23076923076923], expected_raw_currents_left_counter6=[16, 9.23076923076923, 9.23076923076923]), + # limit by unbalanced load ] @@ -137,19 +146,23 @@ def test_instant_charging_limit(params: ParamsLimit, all_cp_instant_charging_1p, @dataclass class ParamsControlParameter(ParamsExpectedSetCurrent, ParamsExpectedCounterSet): name: str = "" - prio_cp3: bool = False + loadmanagement_prios: List[Dict] = field(default_factory=empty_list_factory) submode_cp3: Chargemode = Chargemode.INSTANT_CHARGING - prio_cp4: bool = False submode_cp4: Chargemode = Chargemode.INSTANT_CHARGING - prio_cp5: bool = False submode_cp5: Chargemode = Chargemode.INSTANT_CHARGING cases_control_parameter = [ ParamsControlParameter(name="lift prio cp3", - prio_cp3=True, - prio_cp4=False, - prio_cp5=False, + loadmanagement_prios=[{"type": "vehicle", "id": 3}, { + "type": "group", + "label": "Fahrzeuge", + "children": [ + + {"type": "vehicle", "id": 4}, + {"type": "vehicle", "id": 5} + ] + }], expected_current_cp3=16, expected_current_cp4=8, expected_current_cp5=8, @@ -157,44 +170,44 @@ class ParamsControlParameter(ParamsExpectedSetCurrent, ParamsExpectedCounterSet) expected_raw_currents_left_counter0=[0]*3, expected_raw_currents_left_counter6=[0]*3), ParamsControlParameter(name="drop prio cp4", - prio_cp3=True, - prio_cp4=False, - prio_cp5=True, + loadmanagement_prios=[{ + "type": "group", + "label": "Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 5} + ] + }, {"type": "vehicle", "id": 4}], expected_current_cp3=15, expected_current_cp4=6, expected_current_cp5=10, expected_raw_power_left=690, - expected_raw_currents_left_counter0=[1]*3, + expected_raw_currents_left_counter0=[1.0]*3, expected_raw_currents_left_counter6=[0]*3), - ParamsControlParameter(name="lift submode cp4", + ParamsControlParameter(name="change submode cp4", + loadmanagement_prios=[{"type": "vehicle", "id": 3}, { + "type": "vehicle", "id": 4}, {"type": "vehicle", "id": 5}], submode_cp4=Chargemode.TIME_CHARGING, - expected_current_cp3=13, + expected_current_cp3=16, expected_current_cp4=10, expected_current_cp5=6, - expected_raw_power_left=2070, - expected_raw_currents_left_counter0=[3]*3, + expected_raw_power_left=0, + expected_raw_currents_left_counter0=[0]*3, expected_raw_currents_left_counter6=[0]*3), - # ParamsControlParameter(name="drop submode cp4", - # niedrigster instant modus erreicht - # submode_cp4=Chargemode.PV_CHARGING, - # expected_current_cp3=16, - # expected_current_cp4=6, - # expected_current_cp5=10, - # expected_raw_power_left=0, - # expected_raw_currents_left_counter0=[0]*3, - # expected_raw_currents_left_counter6=[0]*3) ] @pytest.mark.parametrize("params", cases_control_parameter, ids=[c.name for c in cases_control_parameter]) -def test_control_parameter_instant_charging(params: ParamsControlParameter, all_cp_instant_charging_3p, monkeypatch): +def test_control_parameter_instant_charging(params: ParamsControlParameter, + all_cp_instant_charging_3p, + monkeypatch: pytest.MonkeyPatch): # setup - data.data.cp_data["cp3"].data.control_parameter.prio = params.prio_cp3 data.data.cp_data["cp3"].data.control_parameter.submode = params.submode_cp3 - data.data.cp_data["cp4"].data.control_parameter.prio = params.prio_cp4 + data.data.cp_data["cp3"].data.config.ev = 3 data.data.cp_data["cp4"].data.control_parameter.submode = params.submode_cp4 - data.data.cp_data["cp5"].data.control_parameter.prio = params.prio_cp5 + data.data.cp_data["cp4"].data.config.ev = 4 data.data.cp_data["cp5"].data.control_parameter.submode = params.submode_cp5 + data.data.cp_data["cp5"].data.config.ev = 5 + data.data.counter_all_data.data.get.loadmanagement_prios = params.loadmanagement_prios data.data.counter_data["counter0"].data.set.raw_power_left = 22080 data.data.counter_data["counter0"].data.set.raw_currents_left = [32]*3 data.data.counter_data["counter6"].data.set.raw_currents_left = [16]*3 diff --git a/packages/control/algorithm/integration_test/pv_charging_test.py b/packages/control/algorithm/integration_test/pv_charging_test.py index 8ee3aa0e7d..1f14fa610c 100644 --- a/packages/control/algorithm/integration_test/pv_charging_test.py +++ b/packages/control/algorithm/integration_test/pv_charging_test.py @@ -289,7 +289,7 @@ def test_phase_switch(all_cp_pv_charging_3p, all_cp_charging_3p, monkeypatch): assert_counter_set(cases_phase_switch[0]) -def test_phase_switch_1p_3p(all_cp_pv_charging_1p, monkeypatch): +def test_phase_switch_1p_3p(all_cp_pv_charging_1p, monkeypatch: pytest.MonkeyPatch): # setup data.data.counter_data["counter0"].data.get.power = -3000 data.data.counter_data["counter0"].data.set.raw_power_left = cases_phase_switch[1].raw_power_left @@ -306,6 +306,9 @@ def test_phase_switch_1p_3p(all_cp_pv_charging_1p, monkeypatch): for i in range(3, 6): data.data.cp_data[f"cp{i}"].data.control_parameter.template_phases = 0 + mock_get_component_name_by_id = Mock(return_value="Garage") + monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mock_get_component_name_by_id) + # execution Algorithm().calc_current() diff --git a/packages/control/algorithm/min_current.py b/packages/control/algorithm/min_current.py index 634404d7db..822d21056d 100644 --- a/packages/control/algorithm/min_current.py +++ b/packages/control/algorithm/min_current.py @@ -3,9 +3,10 @@ from control import data from control.algorithm import common from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_MIN_CURRENT, CONSIDERED_CHARGE_MODES_PV_ONLY +from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_state import ChargepointState from control.loadmanagement import Loadmanagement -from control.algorithm.filter_chargepoints import get_chargepoints_by_mode_and_counter +from control.algorithm.filter_chargepoints import filtered_loads_to_str, get_grouped_loads_by_mode_and_counter log = logging.getLogger(__name__) @@ -16,39 +17,42 @@ def __init__(self) -> None: pass def set_min_current(self) -> None: - for mode_tuple, counter in common.mode_and_counter_generator(CONSIDERED_CHARGE_MODES_MIN_CURRENT): - preferenced_chargepoints = get_chargepoints_by_mode_and_counter(mode_tuple, f"counter{counter.num}") - if preferenced_chargepoints: - log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") - common.update_raw_data(preferenced_chargepoints, diff_to_zero=True) - for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios( - preferenced_chargepoints): - missing_currents, counts = common.get_min_current(cp) + for counter in common.counter_generator(): + preferenced_loads_groups = get_grouped_loads_by_mode_and_counter(CONSIDERED_CHARGE_MODES_MIN_CURRENT, + f"counter{counter.num}") + for preferenced_loads in preferenced_loads_groups: + log.info(f"Zähler {counter.num}, Verbraucher {filtered_loads_to_str(preferenced_loads)}") + common.update_raw_data(preferenced_loads, diff_to_zero=True) + while len(preferenced_loads): + load = preferenced_loads[0] + missing_currents, counts = common.get_min_current(load) if max(missing_currents) > 0: available_currents, limit = Loadmanagement().get_available_currents( - missing_currents, counter, cp) + missing_currents, counter, load) if limit.limiting_value is not None: - cp.data.control_parameter.limit = limit - available_for_cp = common.available_current_for_cp( - cp, counts, available_currents, missing_currents) + load.data.control_parameter.limit = limit + available_for_load = common.available_current_for_load( + load, counts, available_currents, missing_currents) current = common.get_current_to_set( - cp.data.set.current, available_for_cp, cp.data.set.target_current) - if current < cp.data.control_parameter.min_current: - common.set_current_counterdiff(-(cp.data.set.current or 0), 0, cp) - if limit: - cp.set_state_and_log( + load.data.set.current, available_for_load, load.data.set.target_current) + if isinstance(load, Chargepoint) and current < load.data.control_parameter.min_current: + common.set_current_counterdiff(-(load.data.set.current or 0), 0, load) + if limit.limiting_value is not None: + load.set_state_and_log( f"Ladung kann nicht gestartet werden{limit.message}") else: common.set_current_counterdiff( - cp.data.set.target_current, - cp.data.control_parameter.min_current, - cp) + load.data.set.target_current, + load.data.control_parameter.min_current, + load) else: - if mode_tuple in CONSIDERED_CHARGE_MODES_PV_ONLY: + if (load.data.control_parameter.chargemode, + load.data.control_parameter.submode) in CONSIDERED_CHARGE_MODES_PV_ONLY: try: - if (cp.data.control_parameter.state == ChargepointState.NO_CHARGING_ALLOWED or - cp.data.control_parameter.state == ChargepointState.SWITCH_ON_DELAY): - data.data.counter_all_data.get_evu_counter().switch_on_threshold_reached(cp) + if (load.data.control_parameter.state == ChargepointState.NO_CHARGING_ALLOWED or + load.data.control_parameter.state == ChargepointState.SWITCH_ON_DELAY): + data.data.counter_all_data.get_evu_counter().switch_on_threshold_reached(load) except Exception: - log.exception(f"Fehler in der PV-gesteuerten Ladung bei {cp.num}") - cp.data.set.current = 0 + log.exception(f"Fehler in der PV-gesteuerten Ladung bei {load.num}") + load.data.set.current = 0 + preferenced_loads.pop(0) diff --git a/packages/control/algorithm/no_current.py b/packages/control/algorithm/no_current.py index 7306837565..8d0cf49a45 100644 --- a/packages/control/algorithm/no_current.py +++ b/packages/control/algorithm/no_current.py @@ -2,8 +2,8 @@ from control import data from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_NO_CURRENT -from control.algorithm.filter_chargepoints import get_chargepoints_by_chargemode from control.chargepoint.chargepoint_state import ChargepointState +from control.algorithm.filter_chargepoints import get_loads_by_chargemodes log = logging.getLogger(__name__) @@ -13,12 +13,12 @@ def __init__(self) -> None: pass def set_no_current(self) -> None: - chargepoints = get_chargepoints_by_chargemode(CONSIDERED_CHARGE_MODES_NO_CURRENT) + chargepoints = get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_NO_CURRENT) for cp in chargepoints: cp.data.set.current = 0 cp.data.control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED def set_none_current(self) -> None: - for cp in data.data.cp_data.values(): - if cp.data.set.current is None: - cp.data.set.current = 0 + for load in list(data.data.cp_data.values()) + list(data.data.consumer_data.values()): + if load.data.set.current is None: + load.data.set.current = 0 diff --git a/packages/control/algorithm/surplus_controlled.py b/packages/control/algorithm/surplus_controlled.py index b7e14096c1..5902beb15b 100644 --- a/packages/control/algorithm/surplus_controlled.py +++ b/packages/control/algorithm/surplus_controlled.py @@ -1,19 +1,21 @@ import logging -from typing import List, Optional, Tuple, Final +from typing import List, Tuple, Final, Union from control import data from control.algorithm import common from control.algorithm.chargemodes import (CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE, CONSIDERED_CHARGE_MODES_PV_ONLY, CONSIDERED_CHARGE_MODES_SURPLUS) -from control.algorithm.filter_chargepoints import (get_chargepoints_with_required_current_by_chargemode, - get_chargepoints_by_mode_and_counter, - get_preferenced_chargepoint_charging) +from control.algorithm.filter_chargepoints import (filtered_loads_to_str, get_loads_by_chargemodes, + get_grouped_loads_by_mode_and_counter, + get_preferenced_load_charging) from control.algorithm.utils import get_medium_charging_current from control.chargepoint.charging_type import ChargingType from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_state import ChargepointState, CHARGING_STATES +from control.consumer.consumer import Consumer from control.counter import ControlRangeState, Counter from control.limiting_value import LoadmanagementLimit +from control.load_protocol import Load from control.loadmanagement import LimitingValue, Loadmanagement from helpermodules.phase_handling import voltages_mean @@ -30,71 +32,90 @@ def __init__(self) -> None: def set_surplus_current(self) -> None: common.reset_current_by_chargemode(CONSIDERED_CHARGE_MODES_SURPLUS) - for mode_tuple, counter in common.mode_and_counter_generator(CONSIDERED_CHARGE_MODES_SURPLUS): - preferenced_chargepoints, preferenced_cps_without_set_current = get_preferenced_chargepoint_charging( - get_chargepoints_by_mode_and_counter(mode_tuple, f"counter{counter.num}")) - self._set(preferenced_chargepoints, mode_tuple, counter) - if preferenced_cps_without_set_current: - for cp in preferenced_cps_without_set_current: + for counter in common.counter_generator(): + preferenced_loads_groups, preferenced_loads_without_set_current = get_preferenced_load_charging( + get_grouped_loads_by_mode_and_counter(CONSIDERED_CHARGE_MODES_SURPLUS, f"counter{counter.num}")) + if data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit: + feed_in_yield = data.data.general_data.data.chargemode_config.feed_in_yield + else: + feed_in_yield = 0 + self._set(preferenced_loads_groups, feed_in_yield, counter) + if preferenced_loads_without_set_current: + for cp in preferenced_loads_without_set_current: cp.data.set.current = cp.data.set.target_current - for cp in get_chargepoints_with_required_current_by_chargemode(CONSIDERED_CHARGE_MODES_SURPLUS): - if cp.data.control_parameter.state in CHARGING_STATES: - self._fix_deviating_evse_current(cp) + for load in get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_SURPLUS): + if isinstance(load, Chargepoint) and load.data.control_parameter.state in CHARGING_STATES: + self._fix_deviating_evse_current(load) def _set(self, - chargepoints: List[Chargepoint], - mode_tuple: Tuple[Optional[str], str, bool], + preferenced_loads_groups: List[List[Load]], + feed_in_yield: int, counter: Counter) -> None: - log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") - common.update_raw_data(chargepoints, surplus=True) - for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(chargepoints): - missing_currents, counts = common.get_missing_currents_left(group) - available_currents, limit = Loadmanagement().get_available_currents_surplus( - missing_currents, - voltages_mean(cp.data.get.voltages), - counter, - cp - ) - # im PV-Laden wird der Strom immer durch die Leistung begrenzt - if limit.limiting_value is not None and limit.limiting_value != LimitingValue.POWER: - cp.data.control_parameter.limit = limit - available_for_cp = common.available_current_for_cp(cp, counts, available_currents, missing_currents) - if counter.get_control_range_state() == ControlRangeState.MIDDLE: - surplus = data.data.general_data.data.chargemode_config.surplus - dif_to_old_current = available_for_cp + cp.data.set.target_current - cp.data.set.current_prev - # Wenn die Differenz zwischen altem und neuem Soll-Strom größer als der Regelbereich ist, trotzdem - # nachregeln, auch wenn der Regelbereich eingehalten wird. Sonst würde zB nicht berücksichtigt werden, - # wenn noch ein Fahrzeug dazu kommt. - if ((surplus.control_range[1] - surplus.control_range[0]) / - (sum(counter.data.get.voltages) / len(counter.data.get.voltages)) < abs(dif_to_old_current)): - current = available_for_cp + for loads in preferenced_loads_groups: + log.info(f"Zähler {counter.num}, Verbraucher {filtered_loads_to_str(loads)}") + common.update_raw_data(loads, surplus=True) + while len(loads): + load = loads[0] + missing_currents, counts = common.get_missing_currents_left(loads) + available_currents, limit = Loadmanagement().get_available_currents_surplus( + missing_currents, + voltages_mean(load.data.get.voltages), + counter, + load + ) + # im PV-Laden wird der Strom immer durch die Leistung begrenzt + if limit.limiting_value is not None and limit.limiting_value != LimitingValue.POWER: + load.data.control_parameter.limit = limit + available_for_cp = common.available_current_for_load(load, counts, available_currents, missing_currents) + if counter.get_control_range_state() == ControlRangeState.MIDDLE: + surplus_config = data.data.general_data.data.chargemode_config.surplus + dif_to_old_current = available_for_cp + load.data.set.target_current - load.data.set.current_prev + # Wenn die Differenz zwischen altem und neuem Soll-Strom größer als der Regelbereich ist, trotzdem + # nachregeln, auch wenn der Regelbereich eingehalten wird. Sonst würde zB nicht berücksichtigt + # werden,wenn noch ein Fahrzeug dazu kommt. + if ((surplus_config.control_range[1] - surplus_config.control_range[0]) / + (sum(counter.data.get.voltages) / + len(counter.data.get.voltages)) < abs(dif_to_old_current)): + current = available_for_cp + else: + # Nicht mehr freigeben, wie das Lastmanagement vorgibt + current = min(load.data.set.current_prev - load.data.set.target_current, available_for_cp) else: - # Nicht mehr freigeben, wie das Lastmanagement vorgibt - current = min(cp.data.set.current_prev - cp.data.set.target_current, available_for_cp) - else: - current = available_for_cp + current = available_for_cp - current = common.get_current_to_set(cp.data.set.current, current, cp.data.set.target_current) - self._set_loadmangement_message(current, limit, cp) - limited_current = limit_adjust_current(cp, current) - common.set_current_counterdiff( - cp.data.control_parameter.min_current, - limited_current, - cp, - surplus=True) + current = common.get_current_to_set(load.data.set.current, current, load.data.set.target_current) + self._set_loadmangement_message(current, limit, load) + limited_current = limit_adjust_current(load, current) + common.set_current_counterdiff( + load.data.control_parameter.min_current, + limited_current, + load, + surplus=True) + loads.pop(0) def _set_loadmangement_message(self, current: float, limit: LoadmanagementLimit, - chargepoint: Chargepoint) -> None: + load: Load) -> None: # Strom muss an diesem Zähler geändert werden - if (current != chargepoint.data.set.current and + if (current != load.data.set.current and # Strom erreicht nicht die vorgegebene Stromstärke - current != max(chargepoint.data.control_parameter.required_currents) and + current != max(load.data.control_parameter.required_currents) and # im PV-Laden wird der Strom immer durch die Leistung begrenzt limit.limiting_value != LimitingValue.POWER): - chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" - f"{limit.message}") + load.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" + f"{limit.message}") + + # tested + def filter_by_feed_in_limit(self, loads: List[Load]) -> Tuple[List[Union[Chargepoint, Load]], + List[Union[Chargepoint, Load]]]: + cp_with_feed_in = list(filter(lambda cp: isinstance(cp, Chargepoint) + and data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit is True, + loads)) + cp_without_feed_in = list(filter(lambda cp: isinstance(cp, Consumer) + or data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit is False, + loads)) + return cp_with_feed_in, cp_without_feed_in def _fix_deviating_evse_current(self, chargepoint: Chargepoint) -> float: """Wenn Autos nicht die volle Ladeleistung nutzen, wird unnötig eingespeist. Dann kann um den noch nicht @@ -115,18 +136,18 @@ def _fix_deviating_evse_current(self, chargepoint: Chargepoint) -> float: def check_submode_pv_charging(self) -> None: evu_counter = data.data.counter_all_data.get_evu_counter() - for cp in get_chargepoints_with_required_current_by_chargemode(CONSIDERED_CHARGE_MODES_PV_ONLY): + for load in get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_PV_ONLY): try: def phase_switch_necessary() -> bool: - return (cp.cp_state_hw_support_phase_switch() and - cp.data.get.phases_in_use != 1 and - cp.data.control_parameter.template_phases == 0) - control_parameter = cp.data.control_parameter - if cp.chargemode_changed or cp.submode_changed: + return isinstance(load, Chargepoint) and (load.cp_state_hw_support_phase_switch() and + load.data.get.phases_in_use != 1 and + load.data.control_parameter.template_phases == 0) + control_parameter = load.data.control_parameter + if load.chargemode_changed or load.submode_changed: if (control_parameter.state in CHARGING_STATES): - if cp.data.set.charging_ev_data.ev_template.data.prevent_charge_stop is False: - threshold = evu_counter.calc_switch_off_threshold(cp) - if evu_counter.calc_raw_surplus() - cp.data.set.required_power < threshold: + if load.is_charging_stop_allowed(): + threshold = evu_counter.calc_switch_off_threshold(load) + if evu_counter.calc_raw_surplus() - load.data.set.required_power < threshold: control_parameter.required_currents = [0]*3 control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED else: @@ -135,68 +156,75 @@ def phase_switch_necessary() -> bool: if ((control_parameter.state == ChargepointState.CHARGING_ALLOWED or control_parameter.state == ChargepointState.SWITCH_OFF_DELAY) and phase_switch_necessary() is False): - evu_counter.switch_off_check_threshold(cp) + evu_counter.switch_off_check_threshold(load) if control_parameter.state == ChargepointState.SWITCH_OFF_DELAY: - evu_counter.switch_off_check_timer(cp) + evu_counter.switch_off_check_timer(load) if control_parameter.state == ChargepointState.SWITCH_ON_DELAY: # Wenn charge_state False und set_current > 0, will Auto nicht laden - evu_counter.switch_on_timer_expired(cp) + evu_counter.switch_on_timer_expired(load) if control_parameter.state not in CHARGING_STATES: control_parameter.required_currents = [0]*3 except Exception: - log.exception(f"Fehler in der PV-gesteuerten Ladung bei {cp.num}") + log.exception(f"Fehler in der PV-gesteuerten Ladung bei {load.num}") def set_required_current_to_max(self) -> None: - for cp in get_chargepoints_with_required_current_by_chargemode(CONSIDERED_CHARGE_MODES_SURPLUS + - CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE): + for load in get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_SURPLUS + + CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE): try: - charging_ev_data = cp.data.set.charging_ev_data - required_currents = cp.data.control_parameter.required_currents - control_parameter = cp.data.control_parameter + control_parameter = load.data.control_parameter + required_currents = control_parameter.required_currents + if isinstance(load, Chargepoint): + charging_ev_data = load.data.set.charging_ev_data - if control_parameter.phases == 1: - max_current = charging_ev_data.ev_template.data.max_current_single_phase - else: - max_current = charging_ev_data.ev_template.data.max_current_multi_phases - - if cp.template.data.charging_type == ChargingType.AC.value: if control_parameter.phases == 1: max_current = charging_ev_data.ev_template.data.max_current_single_phase else: max_current = charging_ev_data.ev_template.data.max_current_multi_phases - else: - max_current = charging_ev_data.ev_template.data.dc_max_current + + if load.template.data.charging_type == ChargingType.AC.value: + if control_parameter.phases == 1: + max_current = charging_ev_data.ev_template.data.max_current_single_phase + else: + max_current = charging_ev_data.ev_template.data.max_current_multi_phases + else: + max_current = charging_ev_data.ev_template.data.dc_max_current + elif isinstance(load, Consumer): + max_current = load.data.config.max_power / \ + (sum(load.data.get.voltages) / len(load.data.get.voltages)) / load.data.config.connected_phases control_parameter.required_currents = [ max_current if required_currents[i] != 0 else 0 for i in range(3)] control_parameter.required_current = max_current except Exception: - log.exception(f"Fehler in der PV-gesteuerten Ladung bei {cp.num}") + log.exception(f"Fehler in der PV-gesteuerten Ladung bei {load.num}") # tested -def limit_adjust_current(chargepoint: Chargepoint, new_current: float) -> float: - if chargepoint.template.data.charging_type == ChargingType.AC.value: +def limit_adjust_current(load: Load, new_current: float) -> float: + if isinstance(load, Consumer) or load.template.data.charging_type == ChargingType.AC.value: MAX_CURRENT = 5 else: MAX_CURRENT = 30 msg = None - nominal_difference = chargepoint.data.set.charging_ev_data.ev_template.data.nominal_difference - if chargepoint.chargemode_changed or chargepoint.data.get.charge_state is False: + if isinstance(load, Chargepoint): + nominal_difference = load.data.set.charging_ev_data.ev_template.data.nominal_difference + else: + nominal_difference = 0 + if load.chargemode_changed or load.data.get.charge_state is False: return new_current else: # Um max. +/- 5A pro Zyklus regeln if (-MAX_CURRENT-nominal_difference - < new_current - get_medium_charging_current(chargepoint.data.get.currents) + < new_current - get_medium_charging_current(load.data.get.currents) < MAX_CURRENT+nominal_difference): current = new_current else: - if new_current < get_medium_charging_current(chargepoint.data.get.currents): - current = get_medium_charging_current(chargepoint.data.get.currents) - MAX_CURRENT + if new_current < get_medium_charging_current(load.data.get.currents): + current = get_medium_charging_current(load.data.get.currents) - MAX_CURRENT else: - current = get_medium_charging_current(chargepoint.data.get.currents) + MAX_CURRENT - chargepoint.set_state_and_log(msg) + current = get_medium_charging_current(load.data.get.currents) + MAX_CURRENT + load.set_state_and_log(msg) return max(current, - chargepoint.data.control_parameter.min_current, - chargepoint.data.set.target_current) + load.data.control_parameter.min_current, + load.data.set.target_current) diff --git a/packages/control/algorithm/surplus_controlled_test.py b/packages/control/algorithm/surplus_controlled_test.py index 5e2d26fa03..52c72c1a4d 100644 --- a/packages/control/algorithm/surplus_controlled_test.py +++ b/packages/control/algorithm/surplus_controlled_test.py @@ -4,7 +4,7 @@ from control import data from control.algorithm import surplus_controlled -from control.algorithm.filter_chargepoints import get_chargepoints_with_required_current_by_chargemode +from control.algorithm.filter_chargepoints import get_loads_by_chargemodes from control.algorithm.surplus_controlled import (CONSIDERED_CHARGE_MODES_PV_ONLY, SurplusControlled, limit_adjust_current) from control.chargemode import Chargemode @@ -12,6 +12,7 @@ from control.chargepoint.chargepoint_data import Get, Set from control.chargepoint.chargepoint_template import CpTemplate from control.chargepoint.control_parameter import ControlParameter +from control.counter_all.counter_all import CounterAll from control.ev.ev import Ev @@ -35,6 +36,12 @@ def mock_cp3() -> Chargepoint: return Chargepoint(3, None) +@pytest.fixture(autouse=True) +def mock_data() -> None: + data.data_init(Mock()) + data.data.counter_all_data = CounterAll() + + @pytest.mark.parametrize("new_current, expected_current", [ pytest.param(7, 10), @@ -63,7 +70,8 @@ def test_limit_adjust_current(new_current: float, expected_current: float, monke def test_set_required_current_to_max(phases: int, required_currents: List[float], expected_currents: List[int], - monkeypatch): + monkeypatch, + mock_cp1: Chargepoint): # setup ev = Ev(0) mock_cp1.data = ChargepointData(set=Set(charging_ev_data=ev), @@ -71,7 +79,7 @@ def test_set_required_current_to_max(phases: int, required_currents=required_currents)) mock_cp1.template = CpTemplate() mock_get_chargepoints_surplus_controlled = Mock(return_value=[mock_cp1]) - monkeypatch.setattr(surplus_controlled, "get_chargepoints_with_required_current_by_chargemode", + monkeypatch.setattr(surplus_controlled, "get_loads_by_chargemodes", mock_get_chargepoints_surplus_controlled) # execution @@ -130,7 +138,8 @@ def test_get_chargepoints_submode_pv_charging(submode_1: Chargemode, # setup def setup_cp(cp: Chargepoint, submode: str) -> Chargepoint: cp.data = ChargepointData() - cp.data.set.charging_ev_data = Ev(0) + cp.data.set.charging_ev_data = Ev(cp.num) + cp.data.config.ev = cp.num cp.data.control_parameter.chargemode = Chargemode.PV_CHARGING cp.data.control_parameter.submode = submode cp.data.control_parameter.required_current = 6 @@ -138,9 +147,12 @@ def setup_cp(cp: Chargepoint, submode: str) -> Chargepoint: data.data.cp_data = {"cp1": setup_cp(mock_cp1, submode_1), "cp2": setup_cp(mock_cp2, submode_2)} expected_chargepoints = [data.data.cp_data[key] for key in expected_cp_keys] + data.data.counter_all_data = CounterAll() + data.data.counter_all_data.data.get.loadmanagement_prios = [{"type": "vehicle", "id": 1}, + {"type": "vehicle", "id": 2}] # evaluation - chargepoints = get_chargepoints_with_required_current_by_chargemode(CONSIDERED_CHARGE_MODES_PV_ONLY) + chargepoints = get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_PV_ONLY) # assertion assert chargepoints == expected_chargepoints diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index cd846d5f89..c1939c2187 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -23,8 +23,6 @@ from typing import List, Optional, Tuple from control import data -from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_CHARGING -from control.algorithm.filter_chargepoints import get_chargepoints_with_required_current_by_chargemode from helpermodules.constants import NO_ERROR from modules.common.abstract_device import AbstractDevice from modules.common.component_context import SingleComponentUpdateContext @@ -490,11 +488,8 @@ def set_power_limit_controllable_state(self): data.data.bat_data[f"bat{bat.component_config.id}"].data.get.power_limit_controllable = False def get_charge_mode_vehicle_charge(self): - chargepoint_by_chargemodes = get_chargepoints_with_required_current_by_chargemode( - CONSIDERED_CHARGE_MODES_CHARGING) # Fahrzeuge laden - vehicle_charging = (len(chargepoint_by_chargemodes) > 0 and - data.data.cp_all_data.data.get.power > 100) + vehicle_charging = data.data.cp_all_data.data.get.power > 100 # Speicher entlädt oder Speicher lädt bei gewollter PV-Ladung bat_power_valid = (self.data.get.power <= 0 or (self.data.get.power > 0 and @@ -517,9 +512,7 @@ def get_charge_mode_vehicle_charge(self): control_range_low = data.data.general_data.data.chargemode_config.surplus.control_range[0] control_range_high = data.data.general_data.data.chargemode_config.surplus.control_range[1] control_range_center = control_range_high - (control_range_high - control_range_low) / 2 - if len(chargepoint_by_chargemodes) == 0: - log.debug("Speicher-Leistung nicht begrenzen, da keine Ladepunkte in einem aktiven Lademodus sind.") - elif data.data.cp_all_data.data.get.power <= 100: + if data.data.cp_all_data.data.get.power <= 100: log.debug("Speicher-Leistung nicht begrenzen, da kein Ladepunkt lädt.") elif self.data.get.power > 0: log.debug("Speicher-Leistung nicht begrenzen, da kein Speicher entladen wird.") diff --git a/packages/control/bat_all_test.py b/packages/control/bat_all_test.py index cd83b18d59..4f0a6f7cbe 100644 --- a/packages/control/bat_all_test.py +++ b/packages/control/bat_all_test.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import List, Optional from unittest.mock import MagicMock, Mock import pytest @@ -237,7 +237,6 @@ class BatControlParams: power_limit_mode: str = BatPowerLimitMode.MODE_NO_DISCHARGE.value power_limit_condition: str = BatPowerLimitCondition.VEHICLE_CHARGING.value bat_manual_mode: str = ManualMode.MANUAL_DISABLE.value - cps: List[Chargepoint] = field(default_factory=default_chargepoint_factory) power_limit_controllable: bool = True bat_power: float = -10 bat_soc: float = 50.0 @@ -279,8 +278,6 @@ class BatControlParams: power_limit_condition=BatPowerLimitCondition.MANUAL.value, bat_manual_mode=ManualMode.MANUAL_CHARGE.value), # Wenn Fahrzeuge Laden - BatControlParams("Fahrzeuge laden, Begrenzung immer, keine LP im Sofortladen", None, cps=[], - power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE.value), BatControlParams("Fahrzeuge laden, Begrenzung immer, Speicher lädt", None, bat_power=100, power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE.value), BatControlParams("Fahrzeuge laden, Begrenzung immer,Einspeisung", None, evu_power=-110, @@ -321,9 +318,6 @@ def test_active_bat_control(params: BatControlParams, data_, monkeypatch): data.data.counter_data["counter0"].data.get.power = params.evu_power data.data.bat_all_data = b_all - get_chargepoints_with_required_current_by_chargemode_mock = Mock(return_value=params.cps) - monkeypatch.setattr(bat_all, "get_chargepoints_with_required_current_by_chargemode", - get_chargepoints_with_required_current_by_chargemode_mock) get_evu_counter_mock = Mock(return_value=data.data.counter_data["counter0"]) monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", get_evu_counter_mock) get_bat_components_by_controllability_mock = Mock(return_value=([MqttBat(MqttBatSetup(id=2), device_id=0)], [])) @@ -403,9 +397,6 @@ def test_control_price_limit(params: BatControlParams, data_, monkeypatch): data.data.counter_data["counter0"].data.get.power = params.evu_power data.data.bat_all_data = b_all - get_chargepoints_with_required_current_by_chargemode_mock = Mock(return_value=params.cps) - monkeypatch.setattr(bat_all, "get_chargepoints_with_required_current_by_chargemode", - get_chargepoints_with_required_current_by_chargemode_mock) get_evu_counter_mock = Mock(return_value=data.data.counter_data["counter0"]) monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", get_evu_counter_mock) get_bat_components_by_controllability_mock = Mock(return_value=([MqttBat(MqttBatSetup(id=2), device_id=0)], [])) diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index 81b5434e7e..dd53a0d1c4 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -18,6 +18,7 @@ from control import phase_switch from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState from control.limiting_value import loadmanagement_limit_factory +from control.load_protocol import Load from control.text import BidiState from dataclass_utils import asdict from helpermodules.constants import DEFAULT_COLORS @@ -51,7 +52,7 @@ def get_chargepoint_get_default() -> Dict: log = logging.getLogger(__name__) -class Chargepoint(ChargepointRfidMixin): +class Chargepoint(ChargepointRfidMixin, Load): """ geht alle Ladepunkte durch, prüft, ob geladen werden darf und ruft die Funktion des angesteckten Autos auf. """ MAX_FAILED_PHASE_SWITCHES = 2 @@ -231,7 +232,6 @@ def set_control_parameter(self, submode: Chargemode): self.data.control_parameter.chargemode = Chargemode.TIME_CHARGING else: self.data.control_parameter.chargemode = self.data.set.charge_template.data.chargemode.selected - self.data.control_parameter.prio = self.data.set.charge_template.data.prio if self.template.data.charging_type == ChargingType.AC.value: self.data.control_parameter.min_current = self.data.set.charging_ev_data.ev_template.data.min_current else: @@ -695,7 +695,6 @@ def update(self, ev_list: Dict[str, Ev]) -> None: f"{self.data.set.charge_template.data.chargemode.selected}, Submodus: " f"{self.data.control_parameter.submode}, Phasen: " f"{self.data.control_parameter.phases}" - f", Priorität: {self.data.control_parameter.prio}" f", mittlerer Ist-Strom: {get_medium_charging_current(self.data.get.currents)}") except Exception: log.exception("Fehler im Prepare-Modul für Ladepunkt "+str(self.num)) @@ -814,7 +813,6 @@ def _pub_connected_vehicle(self, vehicle: Ev): charge_template=self.data.set.charge_template.data.id, ev_template=vehicle.ev_template.data.id, chargemode=self.data.set.charge_template.data.chargemode.selected, - priority=self.data.set.charge_template.data.prio, current_plan=current_plan, average_consumption=vehicle.ev_template.data.average_consump, time_charging_in_use=True if (self.data.control_parameter.submode == @@ -850,3 +848,6 @@ def failed_phase_switches_reached(self) -> bool: return True else: return False + + def is_charging_stop_allowed(self) -> bool: + return self.data.set.charging_ev_data.ev_template.data.prevent_charge_stop is False diff --git a/packages/control/chargepoint/control_parameter.py b/packages/control/chargepoint/control_parameter.py index dd0b1681e0..d36c1b7c82 100644 --- a/packages/control/chargepoint/control_parameter.py +++ b/packages/control/chargepoint/control_parameter.py @@ -17,7 +17,6 @@ class ControlParameter: "topic": "control_parameter/limit"}) min_current: int = field(default=6, metadata={"topic": "control_parameter/min_current"}) phases: int = field(default=0, metadata={"topic": "control_parameter/phases"}) - prio: bool = field(default=False, metadata={"topic": "control_parameter/prio"}) required_current: float = field(default=0, metadata={"topic": "control_parameter/required_current"}) required_currents: List[float] = field(default_factory=currents_list_factory) state: ChargepointState = field(default=ChargepointState.NO_CHARGING_ALLOWED, diff --git a/packages/control/counter.py b/packages/control/counter.py index ab121626ac..36c7444a36 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -1,5 +1,18 @@ """Zähler-Logik """ +from modules.common.utils.component_parser import get_component_name_by_id +from modules.common.fault_state import FaultStateLevel +from helpermodules.phase_handling import convert_cp_currents_to_evu_currents +from helpermodules.constants import NO_ERROR +from helpermodules import timecheck +from dataclass_utils.factories import currents_list_factory, voltages_list_factory +from control.load_protocol import Load +from control.consumer.consumer import Consumer +from control.chargepoint.chargepoint_state import ChargepointState +from control.chargepoint.chargepoint import Chargepoint +from control.chargemode import Chargemode +from control.algorithm.utils import get_medium_charging_current +from control.algorithm.filter_chargepoints import get_loads_by_chargemodes from dataclasses import dataclass, field from enum import Enum import logging @@ -8,17 +21,6 @@ from control import data from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE -from control.algorithm.filter_chargepoints import get_chargepoints_with_required_current_by_chargemode -from control.algorithm.utils import get_medium_charging_current -from control.chargemode import Chargemode -from control.chargepoint.chargepoint import Chargepoint -from control.chargepoint.chargepoint_state import ChargepointState -from dataclass_utils.factories import currents_list_factory, voltages_list_factory -from helpermodules import timecheck -from helpermodules.constants import NO_ERROR -from helpermodules.phase_handling import convert_cp_currents_to_evu_currents -from modules.common.fault_state import FaultStateLevel -from modules.common.utils.component_parser import get_component_name_by_id log = logging.getLogger(__name__) @@ -119,7 +121,7 @@ def setup_counter(self): # tested def _get_loadmanagement_state(self) -> None: # Wenn der Zähler keine Werte liefert, darf nicht geladen werden. - connected_cps = data.data.counter_all_data.get_chargepoints_of_counter(f'counter{self.num}') + connected_cps = data.data.counter_all_data.get_loads_of_counter(f'counter{self.num}') if self.data.get.fault_state == FaultStateLevel.ERROR: if self.data.set.error_timer is None: self.data.set.error_timer = timecheck.create_timestamp() @@ -145,15 +147,18 @@ def _get_loadmanagement_state(self) -> None: def _set_current_left(self, loadmanagement_available: bool) -> None: if loadmanagement_available: currents_raw = self.data.get.currents - cp_keys = data.data.counter_all_data.get_chargepoints_of_counter(f"counter{self.num}") - for cp_key in cp_keys: - chargepoint = data.data.cp_data[cp_key] + load_keys = data.data.counter_all_data.get_loads_of_counter(f"counter{self.num}") + for load_key in load_keys: + if "cp" in load_key: + load = data.data.cp_data[load_key] + else: + load = data.data.consumer_data[load_key] try: element_current = convert_cp_currents_to_evu_currents( - chargepoint.data.config.phase_1, - chargepoint.data.get.currents) + load.data.config.phase_1, + load.data.get.currents) except KeyError: - element_current = [get_medium_charging_current(chargepoint.data.get.currents)]*3 + element_current = [get_medium_charging_current(load.data.get.currents)]*3 currents_raw = list(map(operator.sub, currents_raw, element_current)) currents_raw = list(map(operator.sub, self.data.config.max_currents, currents_raw)) if min(currents_raw) < 0: @@ -219,7 +224,7 @@ def calc_surplus(self): surplus = evu_counter.data.get.power - bat_surplus - disengageable_smarthome_power return surplus - def calc_raw_surplus(self): + def calc_raw_surplus(self) -> float: # reservierte Leistung wird nicht berücksichtigt, weil diese noch verwendet werden kann, bis die EV # eingeschaltet werden. Es darf bloß nicht für zu viele zB die Einschaltverzögerung gestartet werden. evu_counter = data.data.counter_all_data.get_evu_counter() @@ -243,7 +248,7 @@ def get_control_range_state(self) -> ControlRangeState: else: return ControlRangeState.MIDDLE - def _control_range_offset(self): + def _control_range_offset(self) -> float: if data.data.bat_all_data.data.set.regulate_up: # Regelmodus ignorieren, denn mit Regelmodus Bezug kann keine Einspeisung für den Speicher erzeugt werden. return 0 @@ -266,9 +271,9 @@ def get_usable_surplus(self) -> float: SWITCH_ON_EXPIRED = "Einschaltschwelle für die Dauer der Wartezeit überschritten." SWITCH_ON_MAX_PHASES = "Der Überschuss ist ausreichend, um direkt mit {} Phasen zu laden." - def calc_switch_on_power(self, chargepoint: Chargepoint) -> Tuple[float, float]: + def calc_switch_on_power(self, load: Load) -> Tuple[float, float]: surplus = self.calc_raw_surplus() - self.data.set.reserved_surplus - control_parameter = chargepoint.data.control_parameter + control_parameter = load.data.control_parameter surplus_config = data.data.general_data.data.chargemode_config.surplus if surplus_config.feed_in_limit: @@ -277,14 +282,28 @@ def calc_switch_on_power(self, chargepoint: Chargepoint) -> Tuple[float, float]: threshold = surplus_config.vehicle.switch_on_threshold*control_parameter.phases return surplus, threshold - def switch_on_threshold_reached(self, chargepoint: Chargepoint) -> None: + def get_pv_config_py_load(self, load: Load) -> Tuple[float, float, bool]: + if isinstance(load, Chargepoint): + switch_on_delay = data.data.general_data.data.chargemode_config.pv_charging.switch_on_delay + switch_on_threshold = data.data.general_data.data.chargemode_config.pv_charging.switch_on_threshold + feed_in_limit = data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit + else: + switch_on_delay = data.data.general_data.data.consumer_config.switch_on_delay + switch_on_threshold = (load.data.control_parameter.required_current * + 230 * + load.data.control_parameter.phases) + feed_in_limit = False + return switch_on_delay, switch_on_threshold, feed_in_limit + + def switch_on_threshold_reached(self, load: Load) -> None: try: message = None - control_parameter = chargepoint.data.control_parameter + control_parameter = load.data.control_parameter surplus_config = data.data.general_data.data.chargemode_config.surplus timestamp_switch_on_off = control_parameter.timestamp_switch_on_off + switch_on_delay, switch_on_threshold, feed_in_limit = self.get_pv_config_py_load(load) - surplus, threshold = self.calc_switch_on_power(chargepoint) + surplus, threshold = self.calc_switch_on_power(load) power_to_reserve = surplus_config.vehicle.switch_on_threshold*control_parameter.phases if control_parameter.state == ChargepointState.SWITCH_ON_DELAY: # Wurde die Einschaltschwelle erreicht? Reservierte Leistung aus all_surplus herausrechnen, @@ -293,7 +312,7 @@ def switch_on_threshold_reached(self, chargepoint: Chargepoint) -> None: # Einschaltschwelle wurde unterschritten, Timer zurücksetzen timestamp_switch_on_off = None self.data.set.reserved_surplus -= power_to_reserve - message = self.SWITCH_ON_FALLEN_BELOW.format(surplus_config.vehicle.switch_on_threshold) + message = self.SWITCH_ON_FALLEN_BELOW.format(switch_on_threshold) control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED else: # Timer starten @@ -302,21 +321,21 @@ def switch_on_threshold_reached(self, chargepoint: Chargepoint) -> None: timestamp_switch_on_off = timecheck.create_timestamp() self.data.set.reserved_surplus += power_to_reserve message = self.SWITCH_ON_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( - timestamp_switch_on_off, surplus_config.vehicle.switch_on_delay)) + timestamp_switch_on_off, switch_on_delay)) if surplus_config.feed_in_limit: message += " Die Einspeisegrenze wird berücksichtigt." control_parameter.state = ChargepointState.SWITCH_ON_DELAY else: # Einschaltschwelle nicht erreicht - message = self.SWITCH_ON_NOT_EXCEEDED.format(surplus_config.vehicle.switch_on_threshold) + message = self.SWITCH_ON_NOT_EXCEEDED.format(switch_on_threshold) if timestamp_switch_on_off != control_parameter.timestamp_switch_on_off: control_parameter.timestamp_switch_on_off = timestamp_switch_on_off - chargepoint.set_state_and_log(message) + load.set_state_and_log(message) except Exception: log.exception("Fehler im allgemeinen PV-Modul") - def switch_on_timer_expired(self, chargepoint: Chargepoint) -> None: + def switch_on_timer_expired(self, load: Load) -> None: """ prüft, ob die Einschaltschwelle erreicht wurde, reserviert Leistung und gibt diese frei bzw. stoppt die Freigabe wenn die Ausschaltschwelle und -verzögerung erreicht wurde. @@ -327,32 +346,36 @@ def switch_on_timer_expired(self, chargepoint: Chargepoint) -> None: """ try: msg = None - surplus_config = data.data.general_data.data.chargemode_config.surplus - control_parameter = chargepoint.data.control_parameter - charging_ev_data = chargepoint.data.set.charging_ev_data + switch_on_delay, switch_on_threshold, _ = self.get_pv_config_py_load(load) + control_parameter = load.data.control_parameter # Timer ist noch nicht abgelaufen if timecheck.check_timestamp(control_parameter.timestamp_switch_on_off, - surplus_config.vehicle.switch_on_delay): + switch_on_delay): msg = self.SWITCH_ON_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( - control_parameter.timestamp_switch_on_off, surplus_config.vehicle.switch_on_delay)) + control_parameter.timestamp_switch_on_off, switch_on_delay)) # Timer abgelaufen else: control_parameter.timestamp_switch_on_off = None - self.data.set.reserved_surplus -= ( - surplus_config.vehicle.switch_on_threshold*control_parameter.phases - ) - msg = self.SWITCH_ON_EXPIRED.format(surplus_config.vehicle.switch_on_threshold) + self.data.set.reserved_surplus -= switch_on_threshold*control_parameter.phases + msg = self.SWITCH_ON_EXPIRED.format(switch_on_threshold) control_parameter.state = ChargepointState.WAIT_FOR_USING_PHASES - ev_template = charging_ev_data.ev_template - max_phases_power = ev_template.data.min_current * ev_template.data.max_phases * 230 - if (control_parameter.submode == Chargemode.PV_CHARGING and - chargepoint.data.set.charge_template.data.chargemode.pv_charging.phases_to_use == 0 and - chargepoint.hw_supports_phase_switch() and - self.get_usable_surplus() > max_phases_power): - control_parameter.phases = ev_template.data.max_phases - msg += self.SWITCH_ON_MAX_PHASES.format(ev_template.data.max_phases) - chargepoint.set_state_and_log(msg) + if isinstance(load, Chargepoint): + charging_ev_data = load.data.set.charging_ev_data + # bei ausreichend Überschuss direkt mit max. Phasen laden + if data.data.general_data.data.chargemode_config.surplus.feed_in_limit: + feed_in_yield = data.data.general_data.data.chargemode_config.surplus.feed_in_yield + else: + feed_in_yield = 0 + ev_template = charging_ev_data.ev_template + max_phases_power = ev_template.data.min_current * ev_template.data.max_phases * 230 + if (control_parameter.submode == Chargemode.PV_CHARGING and + load.data.set.charge_template.data.chargemode.pv_charging.phases_to_use == 0 and + load.hw_supports_phase_switch() and + self.get_usable_surplus(feed_in_yield) > max_phases_power): + control_parameter.phases = ev_template.data.max_phases + msg += self.SWITCH_ON_MAX_PHASES.format(ev_template.data.max_phases) + load.set_state_and_log(msg) except Exception: log.exception("Fehler im allgemeinen PV-Modul") @@ -364,30 +387,37 @@ def switch_on_timer_expired(self, chargepoint: Chargepoint) -> None: SWITCH_OFF_NOT_CHARGING = ("Da das EV nicht lädt und die Abschaltschwelle erreicht wird, " "wird die Ladefreigabe sofort entzogen.") - def switch_off_check_timer(self, chargepoint: Chargepoint) -> None: + def _get_switch_off_delay_by_load(self, load: Load) -> float: + if isinstance(load, Chargepoint): + return data.data.general_data.data.chargemode_config.pv_charging.switch_off_delay + else: + return data.data.general_data.data.consumer_config.switch_off_delay + + def switch_off_check_timer(self, load: Load) -> None: try: msg = None surplus_config = data.data.general_data.data.chargemode_config.surplus - control_parameter = chargepoint.data.control_parameter + control_parameter = load.data.control_parameter + switch_off_delay = self._get_switch_off_delay_by_load(load) if control_parameter.timestamp_switch_on_off is not None: if not timecheck.check_timestamp( control_parameter.timestamp_switch_on_off, - surplus_config.vehicle.switch_off_delay): + switch_off_delay): control_parameter.timestamp_switch_on_off = None - self.data.set.released_surplus -= chargepoint.data.set.required_power + self.data.set.released_surplus -= load.data.set.required_power msg = self.SWITCH_OFF_STOP control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED else: msg = self.SWITCH_OFF_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( - control_parameter.timestamp_switch_on_off, surplus_config.vehicle.switch_off_delay)) - chargepoint.set_state_and_log(msg) + control_parameter.timestamp_switch_on_off, switch_off_delay)) + load.set_state_and_log(msg) except Exception: log.exception("Fehler im allgemeinen PV-Modul") - def calc_switch_off_threshold(self, chargepoint: Chargepoint) -> float: + def calc_switch_off_threshold(self, load: Load) -> float: surplus_config = data.data.general_data.data.chargemode_config.surplus - control_parameter = chargepoint.data.control_parameter + control_parameter = load.data.control_parameter if surplus_config.feed_in_limit: # Der EVU-Überschuss muss ggf um die Einspeisegrenze bereinigt werden. # Wnn die Leistung nicht Einspeisegrenze + Einschaltschwelle erreicht, darf die Ladung nicht pulsieren. @@ -395,47 +425,50 @@ def calc_switch_off_threshold(self, chargepoint: Chargepoint) -> float: threshold = (-surplus_config.feed_in_yield + surplus_config.vehicle.switch_on_threshold*control_parameter.phases) else: - threshold = surplus_config.vehicle.switch_off_threshold + threshold = data.data.general_data.data.consumer_config.switch_off_threshold return threshold - def calc_switch_off(self, chargepoint: Chargepoint) -> Tuple[float, float]: + def calc_switch_off(self, load: Load) -> Tuple[float, float]: switch_off_power = self.calc_surplus() - self.data.set.released_surplus - threshold = self.calc_switch_off_threshold(chargepoint) - log.debug(f'LP{chargepoint.num} Switch-Off-Threshold prüfen: {switch_off_power}W, Schwelle: {threshold}W, ' + threshold = self.calc_switch_off_threshold(load) + log.debug(f'LP{load.num} Switch-Off-Threshold prüfen: {switch_off_power}W, Schwelle: {threshold}W, ' f'freigegebener Überschuss {self.data.set.released_surplus}W') return switch_off_power, threshold - def switch_off_check_threshold(self, chargepoint: Chargepoint) -> bool: + def switch_off_check_threshold(self, load: Load) -> bool: """ prüft, ob die Abschaltschwelle erreicht wurde und startet die Abschaltverzögerung. Ist die Abschaltverzögerung bereits aktiv, wird geprüft, ob die Abschaltschwelle wieder unterschritten wurde, sodass die Verzögerung wieder gestoppt wird. """ charge = True msg = None - charging_ev_data = chargepoint.data.set.charging_ev_data - control_parameter = chargepoint.data.control_parameter + if isinstance(load, Chargepoint): + nominal_difference = load.data.set.charging_ev_data.ev_template.data.nominal_difference + else: + nominal_difference = 0 + switch_off_delay = self._get_switch_off_delay_by_load(load) + control_parameter = load.data.control_parameter timestamp_switch_on_off = control_parameter.timestamp_switch_on_off - power_in_use, threshold = self.calc_switch_off(chargepoint) + power_in_use, threshold = self.calc_switch_off(load) if control_parameter.state == ChargepointState.SWITCH_OFF_DELAY: # Wenn automatische Phasenumschaltung aktiv, die Umschaltung abwarten, bevor die Abschaltschwelle # greift. if control_parameter.state == ChargepointState.PHASE_SWITCH_DELAY: timestamp_switch_on_off = None - self.data.set.released_surplus -= chargepoint.data.set.required_power + self.data.set.released_surplus -= load.data.set.required_power log.info("Abschaltverzögerung gestoppt, da die Verzögerung für die Phasenumschaltung aktiv ist. " + "Diese wird abgewartet, bevor die Abschaltverzögerung gestartet wird.") # Wurde die Abschaltschwelle erreicht? # Eigene Leistung aus der freigegebenen Leistung herausrechnen. - if power_in_use + chargepoint.data.set.required_power < threshold: + if power_in_use + load.data.set.required_power < threshold: timestamp_switch_on_off = None - self.data.set.released_surplus -= chargepoint.data.set.required_power + self.data.set.released_surplus -= load.data.set.required_power msg = self.SWITCH_OFF_EXCEEDED control_parameter.state = ChargepointState.CHARGING_ALLOWED else: # Wurde die Abschaltschwelle ggf. durch die Verzögerung anderer LP erreicht? - min_current = (chargepoint.data.control_parameter.min_current - + charging_ev_data.ev_template.data.nominal_difference) + min_current = (load.data.control_parameter.min_current + nominal_difference) switch_off_condition = (power_in_use > threshold or # Wenn der Speicher hochregeln soll, muss auch abgeschaltet werden. (self.calc_raw_surplus() <= 0 and @@ -443,25 +476,25 @@ def switch_off_check_threshold(self, chargepoint: Chargepoint) -> bool: # Einen nach dem anderen abschalten, bis Ladeleistung des Speichers erreicht ist # und wieder eingespeist wird. self.data.set.reserved_surplus == 0)) - if switch_off_condition and get_medium_charging_current(chargepoint.data.get.currents) <= min_current: - if not charging_ev_data.ev_template.data.prevent_charge_stop: + if (switch_off_condition and (isinstance(load, Consumer) or + get_medium_charging_current(load.data.get.currents) <= min_current)): + if load.is_charging_stop_allowed(): # EV, die ohnehin nicht laden, wird direkt die Ladefreigabe entzogen. # Würde man required_power vom released_evu_surplus subtrahieren, würden keine anderen EVs # abgeschaltet werden und nach der Abschaltverzögerung des nicht ladenden EVs wäre die # Abschaltschwelle immer noch überschritten. Würde man die tatsächliche Leistung von # released_evu_surplus subtrahieren, würde released_evu_surplus nach Ablauf der Verzögerung # nicht 0 sein, wenn sich die Ladeleistung zwischendurch verändert hat. - if chargepoint.data.get.charge_state is False: + if load.data.get.charge_state is False: charge = False msg = self.SWITCH_OFF_NOT_CHARGING control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED else: timestamp_switch_on_off = timecheck.create_timestamp() # merken, dass ein LP verzögert wird, damit nicht zu viele LP verzögert werden. - self.data.set.released_surplus += chargepoint.data.set.required_power + self.data.set.released_surplus += load.data.set.required_power msg = self.SWITCH_OFF_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( - timestamp_switch_on_off, - data.data.general_data.data.chargemode_config.surplus.vehicle.switch_off_delay)) + timestamp_switch_on_off, switch_off_delay)) control_parameter.state = ChargepointState.SWITCH_OFF_DELAY # Die Abschaltschwelle wird immer noch überschritten und es sollten weitere LP abgeschaltet # werden. @@ -469,7 +502,7 @@ def switch_off_check_threshold(self, chargepoint: Chargepoint) -> bool: msg = self.SWITCH_OFF_NO_STOP if timestamp_switch_on_off != control_parameter.timestamp_switch_on_off: control_parameter.timestamp_switch_on_off = timestamp_switch_on_off - chargepoint.set_state_and_log(msg) + load.set_state_and_log(msg) return charge def reset_switch_on_off(self, chargepoint: Chargepoint): @@ -501,7 +534,7 @@ def reset_pv_data(self): log.exception("Fehler im allgemeinen PV-Modul") -def limit_raw_power_left_to_surplus(surplus) -> None: +def limit_raw_power_left_to_surplus(surplus: float) -> None: for counter in data.data.counter_data.values(): # Zwischenzähler werden nur nach Strömen begrenzt, daher kann hier die Leistung vom EVU-Zähler gesetzt werden counter.data.set.surplus_power_left = surplus @@ -516,8 +549,7 @@ def set_raw_surplus_power_left() -> None: """ grid_counter = data.data.counter_all_data.get_evu_counter() bidi_power = 0 - chargepoint_by_chargemodes = get_chargepoints_with_required_current_by_chargemode( - CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE) + chargepoint_by_chargemodes = get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE) for cp in chargepoint_by_chargemodes: bidi_power += cp.data.get.power grid_counter.data.set.surplus_power_left = grid_counter.data.get.power * -1 + bidi_power diff --git a/packages/control/counter_all/counter_all_data.py b/packages/control/counter_all/counter_all_data.py index 432522e59b..5b24eb7572 100644 --- a/packages/control/counter_all/counter_all_data.py +++ b/packages/control/counter_all/counter_all_data.py @@ -62,7 +62,7 @@ class HierarchyProtocol(Protocol): @property def childless(self) -> List: ... @property - def connected_chargepoints(self) -> List: ... + def connected_loads(self) -> List: ... @property def connected_counters(self) -> List: ... @property @@ -101,7 +101,7 @@ class LoadmanagementPrioProtocol(Protocol): def data(self) -> CounterAllData: ... def add_loadmanagement_prio_item(self, type: ComponentType, id: int) -> None: ... def remove_loadmanagement_prio_item(self, type: ComponentType, id: int) -> None: ... - def _remove_loadmanagement_prio_item(self, type: ComponentType, id: int, entry: Dict) -> None: ... + def _remove_loadmanagement_prio_item(self, type: ComponentType, id: int, entry: List[Dict]) -> bool: ... def sort_cps_by_loadmanagement_prios_nested(self, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: ... def generator_cps_by_loadmanagement_prios( diff --git a/packages/control/counter_all/hierarchy.py b/packages/control/counter_all/hierarchy.py index 07c6795fcb..a6e1e19fe5 100644 --- a/packages/control/counter_all/hierarchy.py +++ b/packages/control/counter_all/hierarchy.py @@ -26,10 +26,10 @@ def _get_all_elements_without_children_recursive(self: HierarchyProtocol, child: except Exception: log.exception("Fehler in der allgemeinen Zähler-Klasse") - def get_chargepoints_of_counter(self: HierarchyProtocol, counter: str) -> List[str]: + def get_loads_of_counter(self: HierarchyProtocol, counter: str) -> List[str]: """ gibt eine Liste der Ladepunkte, die in den folgenden Zweigen des Zählers sind, zurück. """ - self.connected_chargepoints = [] + self.connected_loads = [] if counter == self.get_evu_counter_str(): counter_object = self.data.get.hierarchy[0] else: @@ -38,23 +38,25 @@ def get_chargepoints_of_counter(self: HierarchyProtocol, counter: str) -> List[s int(counter[7:]), self._get_entry_of_element) try: - self._get_all_cp_connected_to_counter(counter_object) + self._get_all_loads_connected_to_counter(counter_object) except KeyError: # Kein Ladepunkt unter dem Zähler pass - return self.connected_chargepoints + return self.connected_loads - def _get_all_cp_connected_to_counter(self: HierarchyProtocol, child: Dict) -> None: + def _get_all_loads_connected_to_counter(self: HierarchyProtocol, child: Dict) -> None: """ Rekursive Funktion, die alle Ladepunkte ermittelt, die an den angegebenen Zähler angeschlossen sind. """ # Alle Objekte der Ebene durchgehen for child in child["children"]: try: if child["type"] == ComponentType.CHARGEPOINT.value: - self.connected_chargepoints.append(f"cp{child['id']}") + self.connected_loads.append(f"cp{child['id']}") + elif child["type"] == ComponentType.CONSUMER.value: + self.connected_loads.append(f"consumer{child['id']}") # Wenn das Objekt noch Kinder hat, diese ebenfalls untersuchen. elif len(child["children"]) != 0: - self._get_all_cp_connected_to_counter(child) + self._get_all_loads_connected_to_counter(child) except Exception: log.exception("Fehler in der allgemeinen Zähler-Klasse") diff --git a/packages/control/counter_all/hierarchy_test.py b/packages/control/counter_all/hierarchy_test.py index 811d9becc6..6cf5a1456d 100644 --- a/packages/control/counter_all/hierarchy_test.py +++ b/packages/control/counter_all/hierarchy_test.py @@ -141,18 +141,18 @@ def test_delete_discard_children(params: ParamsItem): assert params.counter_all.data.get.hierarchy == params.expected_hierarchy -cases_get_chargepoints_of_counter = [ - ParamsItem("get_chargepoints_of_counter", hierarchy_cp(), "counter2", expected_return=["cp3", "cp5", "cp6"]), - ParamsItem("get_chargepoints_of_counter", hierarchy_two_level(), "counter0", expected_return=["cp2"]) +cases_get_loads_of_counter = [ + ParamsItem("get_loads_of_counter", hierarchy_cp(), "counter2", expected_return=["cp3", "cp5", "cp6"]), + ParamsItem("get_loads_of_counter", hierarchy_two_level(), "counter0", expected_return=["cp2"]) ] @pytest.mark.parametrize("params", - cases_get_chargepoints_of_counter, - ids=[c.name for c in cases_get_chargepoints_of_counter]) -def test_get_chargepoints_of_counter(params: ParamsItem): + cases_get_loads_of_counter, + ids=[c.name for c in cases_get_loads_of_counter]) +def test_get_loads_of_counter(params: ParamsItem): # execution - actual = params.counter_all.get_chargepoints_of_counter(params.id) + actual = params.counter_all.get_loads_of_counter(params.id) # evaluation assert actual == params.expected_return diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py index 9c68557c75..0ee2175d1c 100644 --- a/packages/control/counter_all/loadmanagement_prio.py +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -1,7 +1,6 @@ import logging from typing import Dict, Generator, List, Tuple -from control.algorithm.filter_chargepoints import get_preferenced_chargepoint from control.chargepoint.chargepoint import Chargepoint from control.counter_all.counter_all_data import LoadmanagementPrioProtocol from modules.common.component_type import ComponentType @@ -57,8 +56,7 @@ def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, if cp.data.config.ev == entry["id"]: grouped_cps.append(cp) if len(grouped_cps) > 0: - sorted_grouped_cps = get_preferenced_chargepoint(grouped_cps) - sorted_cps.append(sorted_grouped_cps) + sorted_cps.append(grouped_cps) elif entry["type"] == "group": sorted_grouped_cps = [] for group_entry in entry["children"]: @@ -66,25 +64,7 @@ def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, for cp in filtered_cps: if cp.data.config.ev == group_entry["id"]: grouped_cps.append(cp) - sorted_grouped_cps.extend(get_preferenced_chargepoint(grouped_cps)) + sorted_grouped_cps.extend(grouped_cps) if len(sorted_grouped_cps) > 0: sorted_cps.append(sorted_grouped_cps) return sorted_cps - - # def sort_cps_by_loadmanagement_prios_flat(self: LoadmanagementPrioProtocol, - # filtered_cps: List[Chargepoint]) -> List[Chargepoint]: - # sorted_cps = [] - # for entry in self.data.get.loadmanagement_prios: - # if entry["type"] == "vehicle": - # for cp in filtered_cps: - # if cp.data.config.ev == entry["id"]: - # sorted_cps.append(cp) - # elif entry["type"] == "group": - # for group_entry in entry["children"]: - # for cp in filtered_cps: - # if cp.data.config.ev == group_entry["id"]: - # sorted_cps.append(cp) - # if len(sorted_cps) != len(filtered_cps): - # raise ValueError( - # "Fahrzeuge der Prioritätensteuerung konnten nicht korrekt den Ladepunkten zugeordnet werden.") - # return sorted_cps diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index 110e0bab2d..14ac59a8de 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -61,7 +61,10 @@ def cp3(): ], id="nested list"), ] ) -def test_add_item(loadmanagement_prios: List[Dict], id: int, type: str, expected_loadmanagement_prios: List[Dict]): +def test_add_item(loadmanagement_prios: List[Dict], + id: int, + type: ComponentType, + expected_loadmanagement_prios: List[Dict]): # setup c = CounterAll() c.data.get.loadmanagement_prios = loadmanagement_prios @@ -74,10 +77,10 @@ def test_add_item(loadmanagement_prios: List[Dict], id: int, type: str, expected @pytest.mark.parametrize( - "loadmanagement_prios, id, type, expected_loadmanagement_prios", + "loadmanagement_prios, id, expected_loadmanagement_prios", [ pytest.param([{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 2}], - 2, "vehicle", [{"type": "vehicle", "id": 3}], id="flat list"), + 2, [{"type": "vehicle", "id": 3}], id="flat list"), pytest.param([ { "type": "group", @@ -88,7 +91,7 @@ def test_add_item(loadmanagement_prios: List[Dict], id: int, type: str, expected ] }, {"type": "vehicle", "id": 2}, - ], 2, "vehicle", [ + ], 2, [ { "type": "group", "label": "Wichtige Fahrzeuge", @@ -108,7 +111,7 @@ def test_add_item(loadmanagement_prios: List[Dict], id: int, type: str, expected ] }, {"type": "vehicle", "id": 2}, - ], 0, "vehicle", [ + ], 0, [ { "type": "group", "label": "Wichtige Fahrzeuge", @@ -127,12 +130,11 @@ def test_add_item(loadmanagement_prios: List[Dict], id: int, type: str, expected ] }, {"type": "vehicle", "id": 2}, - ], 0, "vehicle", [{"type": "vehicle", "id": 2}], id="nested list, empty group"), + ], 0, [{"type": "vehicle", "id": 2}], id="nested list, empty group"), ] ) def test_remove_loadmanagement_prio_item(loadmanagement_prios: List[Dict], id: int, - type: str, expected_loadmanagement_prios: List[Dict]): # setup c = CounterAll() @@ -160,7 +162,7 @@ def test_sort_cps_by_loadmanagement_prios_nested_same_vehicle(cp1, cp2, cp3): # assert - eine Gruppe mit allen CPs (sortiert nach required_current) assert len(result) == 1 - assert result[0] == [cp3, cp2, cp1] # direkte Objektvergleiche! + assert result[0] == [cp1, cp2, cp3] # direkte Objektvergleiche! def test_sort_cps_by_loadmanagement_prios_nested_different_vehicles(cp1, cp2, cp3): diff --git a/packages/control/counter_test.py b/packages/control/counter_test.py index 06509b89a5..e689c675b2 100644 --- a/packages/control/counter_test.py +++ b/packages/control/counter_test.py @@ -29,7 +29,7 @@ def test_set_loadmanagement_state(fault_state: FaultStateLevel, data_): # setup connected_cps_mock = Mock(return_value=["cp3", "cp4"]) - monkeypatch.setattr(data.data.counter_all_data, "get_chargepoints_of_counter", connected_cps_mock) + monkeypatch.setattr(data.data.counter_all_data, "get_loads_of_counter", connected_cps_mock) id_mock = Mock(return_value=0) monkeypatch.setattr(data.data.counter_all_data, "get_id_evu_counter", id_mock) name_mock = Mock(return_value="Test") @@ -79,8 +79,8 @@ def test_set_current_left(loadmanagement_available: bool, monkeypatch, data_): # setup - get_chargepoints_of_counter_mock = Mock(return_value=["cp3", "cp4", "cp5"]) - monkeypatch.setattr(data.data.counter_all_data, "get_chargepoints_of_counter", get_chargepoints_of_counter_mock) + get_loads_of_counter_mock = Mock(return_value=["cp3", "cp4", "cp5"]) + monkeypatch.setattr(data.data.counter_all_data, "get_loads_of_counter", get_loads_of_counter_mock) counter = Counter(0) counter.data.config.max_currents = max_currents counter.data.config.max_total_power = sum(max_currents)*230 diff --git a/packages/control/data.py b/packages/control/data.py index 5d73d59ec1..4cf11a9c27 100644 --- a/packages/control/data.py +++ b/packages/control/data.py @@ -12,6 +12,8 @@ from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_all import AllChargepoints from control.chargepoint.chargepoint_template import CpTemplate +from control.consumer.consumer import Consumer +from control.consumer.consumer_all import AllConsumers from control.pv import Pv from control.pv_all import PvAll @@ -33,6 +35,8 @@ bat_data_lock = Lock() bat_all_data_lock = Lock() graph_data_lock = Lock() +consumer_data_lock = Lock() +consumer_all_data_lock = Lock() counter_data_lock = Lock() counter_all_data_lock = Lock() cp_data_lock = Lock() @@ -69,6 +73,8 @@ def __init__(self, event_module_update_completed: Event): self.event_module_update_completed = event_module_update_completed self._bat_data: Dict[str, Bat] = {} self._bat_all_data = BatAll() + self._consumer_data: Dict[str, Consumer] = {} + self._consumer_all_data: AllConsumers = {} self._counter_data: Dict[str, Counter] = {} self._counter_all_data = CounterAll() self._cp_data: Dict[str, Chargepoint] = {} @@ -121,6 +127,24 @@ def graph_data(self) -> Graph: def graph_data(self, value): self._graph_data = value + @property + def consumer_data(self) -> Dict[str, Consumer]: + return self._consumer_data + + @consumer_data.setter + @locked(consumer_data_lock) + def consumer_data(self, value): + self._consumer_data = value + + @property + def consumer_all_data(self) -> AllConsumers: + return self._consumer_all_data + + @consumer_all_data.setter + @locked(consumer_all_data_lock) + def consumer_all_data(self, value): + self._consumer_all_data = value + @property def counter_data(self) -> Dict[str, Counter]: return self._counter_data @@ -271,6 +295,7 @@ def print_all(self): log.info(f"general_data\n{self._general_data.data}") log.info(f"general_data-display\n{self._general_data.data.extern_display_mode}") log.info(f"graph_data\n{self._graph_data.data}") + self._print_dictionaries(self._consumer_data) self._print_io_actions(self._io_actions) self._print_dictionaries(self._io_states) log.info(f"optional_data\n{self._optional_data.data}") @@ -445,6 +470,7 @@ def copy_data(self) -> None: self.__copy_system_data() self.__copy_module_data() self.graph_data = copy.deepcopy(SubData.graph_data) + self.__copy_consumer_data() except Exception: log.exception("Fehler im Prepare-Modul") @@ -464,6 +490,17 @@ def __copy_ev_data(self) -> None: except Exception: log.exception("Fehler im Prepare-Modul für EV "+str(vehicle)) + def __copy_consumer_data(self) -> None: + try: + self.consumer_data.clear() + for consumer in SubData.consumer_data: + self.consumer_data[consumer] = Consumer(SubData.consumer_data[consumer].num) + self.consumer_data[consumer].data = copy.deepcopy(SubData.consumer_data[consumer].data) + self.consumer_data[consumer].module = SubData.consumer_data[consumer].module + self.consumer_all_data = copy.deepcopy(SubData.consumer_all_data) + except Exception: + log.exception("Fehler im Prepare-Modul beim Kopieren der Verbraucherdaten") + class ModuleDataReceivedContext: """ Moduldaten erst kopieren, wenn alle Daten vom Broker empfangen wurden.""" diff --git a/packages/control/general.py b/packages/control/general.py index a76b2a0a75..d27a8c219e 100644 --- a/packages/control/general.py +++ b/packages/control/general.py @@ -89,9 +89,21 @@ def prices_factory() -> Prices: return Prices() +@dataclass +class ConsumerConfig: + switch_on_delay: int = field(default=60, metadata={"topic": "switch_on_delay"}) + switch_off_delay: int = field(default=60, metadata={"topic": "switch_off_delay"}) + switch_off_threshold: int = field(default=0, metadata={"topic": "power_threshold"}) + + +def consumer_config_factory() -> ConsumerConfig: + return ConsumerConfig() + + @dataclass class GeneralData: chargemode_config: ChargemodeConfig = field(default_factory=chargemode_config_factory) + consumer_config: ConsumerConfig = field(default_factory=consumer_config_factory) control_interval: int = field(default=10, metadata={"topic": "control_interval"}) extern_display_mode: str = field(default="primary", metadata={ "topic": "extern_display_mode"}) diff --git a/packages/control/loadmanagement.py b/packages/control/loadmanagement.py index 0f7205269b..764e3332f0 100644 --- a/packages/control/loadmanagement.py +++ b/packages/control/loadmanagement.py @@ -6,6 +6,7 @@ from control.chargepoint.chargepoint import Chargepoint from control.counter import Counter from control.limiting_value import LimitingValue, LoadmanagementLimit +from control.load_protocol import Load from helpermodules.phase_handling import voltages_mean from modules.common.utils.component_parser import get_component_name_by_id @@ -17,18 +18,19 @@ class Loadmanagement: def get_available_currents(self, missing_currents: List[float], counter: Counter, - cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: + load: Load, + feed_in: int = 0) -> Tuple[List[float], LoadmanagementLimit]: raw_currents_left = counter.data.set.raw_currents_left try: - available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, cp) + available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, load) - available_currents, new_limit = self._limit_by_dimming(available_currents, cp) + available_currents, new_limit = self._limit_by_dimming(available_currents, load) limit = new_limit if new_limit.limiting_value is not None else limit - available_currents, new_limit = self._limit_loadmanager(available_currents, cp) + available_currents, new_limit = self._limit_loadmanager(available_currents, load) limit = new_limit if new_limit.limiting_value is not None else limit - available_currents, new_limit = self._limit_by_ripple_control_receiver(available_currents, cp) + available_currents, new_limit = self._limit_by_ripple_control_receiver(available_currents, load) limit = new_limit if new_limit.limiting_value is not None else limit except ValueError as e: available_currents = [0]*3 @@ -37,8 +39,10 @@ def get_available_currents(self, available_currents, new_limit = self._limit_by_current(counter, available_currents, raw_currents_left) limit = new_limit if new_limit.limiting_value is not None else limit - available_currents, new_limit = self._limit_by_power( - counter, available_currents, voltages_mean(cp.data.get.voltages), counter.data.set.raw_power_left) + available_currents, new_limit = self._limit_by_power(counter, + available_currents, + voltages_mean(load.data.get.voltages), + counter.data.set.raw_power_left) limit = new_limit if new_limit.limiting_value is not None else limit if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str(): @@ -50,13 +54,14 @@ def get_available_currents(self, def get_available_currents_surplus(self, missing_currents: List[float], - cp_voltage: float, + load_voltage: float, counter: Counter, - cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: + load: Load, + feed_in: int = 0) -> Tuple[List[float], LoadmanagementLimit]: raw_currents_left = counter.data.set.raw_currents_left - available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, cp) + available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, load) - available_currents, new_limit = self._limit_by_ripple_control_receiver(available_currents, cp) + available_currents, new_limit = self._limit_by_ripple_control_receiver(available_currents, load) limit = new_limit if new_limit.limiting_value is not None else limit available_currents, new_limit = self._limit_by_current(counter, available_currents, raw_currents_left) @@ -65,7 +70,7 @@ def get_available_currents_surplus(self, surplus_config = data.data.general_data.data.chargemode_config.surplus feed_in_yield = surplus_config.feed_in_yield if surplus_config.feed_in_limit else None available_currents, new_limit = self._limit_by_power( - counter, available_currents, cp_voltage, counter.data.set.surplus_power_left, feed_in_yield) + counter, available_currents, load_voltage, counter.data.set.surplus_power_left, feed_in_yield) limit = new_limit if new_limit.limiting_value is not None else limit if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str(): @@ -144,13 +149,14 @@ def _limit_by_current(self, def _limit_by_dimming_via_direct_control(self, missing_currents: List[float], - cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: - value, limit = data.data.io_actions.dimming_via_direct_control({"type": "cp", "id": cp.num}) + load: Load) -> Tuple[List[float], LoadmanagementLimit]: + value, limit = data.data.io_actions.dimming_via_direct_control( + {"type": "cp" if isinstance(load, Chargepoint) else "consumer", "id": load.num}) if value is not None: phases = 3-missing_currents.count(0) current_per_phase = 4200 / 230 / phases available_currents = [current_per_phase - - cp.data.set.target_current if c > 0 else 0 for c in missing_currents] + load.data.set.target_current if c > 0 else 0 for c in missing_currents] log.debug(f"Dimmung per Direkt-Steuerung: {available_currents}A") else: available_currents = missing_currents @@ -158,8 +164,9 @@ def _limit_by_dimming_via_direct_control(self, def _limit_by_dimming(self, available_currents: List[float], - cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: - dimming_power_left, limit = data.data.io_actions.dimming_get_import_power_left({"type": "cp", "id": cp.num}) + load: Load) -> Tuple[List[float], LoadmanagementLimit]: + dimming_power_left, limit = data.data.io_actions.dimming_get_import_power_left( + {"type": "cp" if isinstance(load, Chargepoint) else "consumer", "id": load.num}) if dimming_power_left is not None: if sum(available_currents)*230 > dimming_power_left: phases = 3-available_currents.count(0) diff --git a/packages/control/prepare.py b/packages/control/prepare.py index e10429952e..4f559f8e87 100644 --- a/packages/control/prepare.py +++ b/packages/control/prepare.py @@ -29,10 +29,13 @@ def setup_algorithm(self) -> None: for element in level: if element["type"] == ComponentType.COUNTER.value: data.data.counter_data[f"counter{element['id']}"].setup_counter() + for consumer in data.data.consumer_data.values(): + consumer.update() for cp in data.data.cp_data.values(): cp.update(data.data.ev_data) # Nach cp update, da für die Speicher-Sperre der Lademodus bekannt sein muss. data.data.bat_all_data.setup_bat() + data.data.consumer_all_data.get_consumer_sum() data.data.cp_all_data.get_cp_sum() data.data.cp_all_data.no_charge() data.data.counter_all_data.set_home_consumption() diff --git a/packages/control/process.py b/packages/control/process.py index 9fa439b828..193b626d4c 100644 --- a/packages/control/process.py +++ b/packages/control/process.py @@ -9,6 +9,9 @@ from control.chargepoint import chargepoint from control import data from control.chargepoint.chargepoint_state import ChargepointState +from control.consumer.consumer import Consumer +from control.consumer.usage import ConsumerUsage +from helpermodules.phase_handling import voltages_mean from helpermodules.pub import Pub from helpermodules.utils._thread_handler import joined_thread_handler from modules.common.abstract_io import AbstractIoDevice @@ -42,7 +45,7 @@ def process_algorithm_results(self) -> None: if control_parameter.state == ChargepointState.NO_CHARGING_ALLOWED and cp.data.set.current != 0: control_parameter.state = ChargepointState.WAIT_FOR_USING_PHASES cp.set_timestamp_charge_start() - self._update_state(cp) + self._update_state_cp(cp) else: control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED cp.data.set.current = 0 @@ -71,6 +74,23 @@ def process_algorithm_results(self) -> None: args=(bat_component, data.data.bat_data[f"bat{bat_component.component_config.id}"].data.set.power_limit), name=f"set power limit {bat_component.component_config.id}")) + for consumer in data.data.consumer_data.values(): + try: + control_parameter = consumer.data.control_parameter + if (control_parameter.state != ChargepointState.NO_CHARGING_ALLOWED or + consumer.data.set.current != 0): + control_parameter.state = ChargepointState.CHARGING_ALLOWED + else: + control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED + self._update_state_consumer(consumer) + if consumer.data.get.state_str is None: + if consumer.data.get.charge_state: + consumer.data.get.state_str = "Verbraucher läuft." + else: + consumer.data.get.state_str = "Verbraucher wird gestartet... " + modules_threads.append(self._start_consumer(consumer)) + except Exception: + log.exception("Fehler im Process-Modul für Verbaucher "+str(consumer)) for action in data.data.io_actions.actions.values(): if isinstance(action, DimmingDirectControl): for d in action.config.configuration.devices: @@ -109,7 +129,7 @@ def process_algorithm_results(self) -> None: except Exception: log.exception("Fehler im Process-Modul") - def _update_state(self, chargepoint: chargepoint.Chargepoint) -> None: + def _update_state_cp(self, chargepoint: chargepoint.Chargepoint) -> None: """aktualisiert den Zustand des Ladepunkts. """ charging_ev = chargepoint.data.set.charging_ev_data @@ -149,3 +169,20 @@ def _start_charging(self, chargepoint: chargepoint.Chargepoint) -> Thread: return Thread(target=chargepoint.chargepoint_module.set_current, args=(chargepoint.data.set.current,), name=f"set current cp{chargepoint.chargepoint_module.config.id}") + + def _update_state_consumer(self, consumer: Consumer) -> None: + consumer.data.set.current = round(consumer.data.set.current, 2) + consumer.data.set.power = consumer.data.set.current * \ + voltages_mean(consumer.data.get.voltages) * consumer.data.config.connected_phases + log.info(f"Verbraucher{consumer.num}: set current {consumer.data.set.current} A, " + f"state {ChargepointState(consumer.data.control_parameter.state).name}") + + def _start_consumer(self, consumer: Consumer) -> Thread: + if consumer.data.usage.type in (ConsumerUsage.CONTINUOUS, + ConsumerUsage.SUSPENDABLE_ONOFF): + return Thread( + target=consumer.module.switch_on if consumer.data.set.current > 0 else consumer.module.switch_off, + name=f"set current consumer{consumer.num}") + return Thread(target=consumer.module.set_power_limit, + args=(consumer.data.set.power,), + name=f"set current consumer{consumer.num}") diff --git a/packages/helpermodules/abstract_plans.py b/packages/helpermodules/abstract_plans.py index 20b3ce6b98..8e2c2b7dd1 100644 --- a/packages/helpermodules/abstract_plans.py +++ b/packages/helpermodules/abstract_plans.py @@ -106,3 +106,23 @@ class TimeChargingPlan(TimeframePlan): class AutolockPlan(TimeframePlan): id: Optional[int] = None name: str = "neuer Plan für Sperren nach Uhrzeit" + + +@dataclass +class TimeChargingPlanConsumer(TimeframePlan): + id: Optional[int] = None + min_bat_soc: Optional[int] = None + name: str = "neuer Verbraucher-Plan" + + +@dataclass +class SuspendableScheduledPlanConsumer(TimeframePlan): + id: Optional[int] = None + name: str = "neuer Verbraucher-Plan" + duration: int = 60 + + +@dataclass +class ContinuousScheduledPlanConsumer(TimeframePlan): + id: Optional[int] = None + name: str = "neuer Verbraucher-Plan" diff --git a/packages/helpermodules/auto_str.py b/packages/helpermodules/auto_str.py index 93fcc047c1..4f21210816 100644 --- a/packages/helpermodules/auto_str.py +++ b/packages/helpermodules/auto_str.py @@ -10,5 +10,12 @@ def __str__(self) -> str: ', '.join('%s=%s' % item for item in vars(self).items()) ) + def __repr__(self) -> str: + return '%s(%s)' % ( + type(self).__name__, + ', '.join('%s=%s' % item for item in vars(self).items()) + ) + cls.__str__ = __str__ + cls.__repr__ = __repr__ return cls diff --git a/packages/helpermodules/changed_values_handler.py b/packages/helpermodules/changed_values_handler.py index 5692cf5762..23cc6409a7 100644 --- a/packages/helpermodules/changed_values_handler.py +++ b/packages/helpermodules/changed_values_handler.py @@ -94,6 +94,9 @@ def pub_changed_values(self): for key, value in data.data.counter_data.items(): self._update_value(f"openWB/set/counter/{value.num}/", self.prev_data.counter_data[key].data, value.data) + for key, value in data.data.consumer_data.items(): + self._update_value(f"openWB/set/consumer/{value.num}/", + self.prev_data.consumer_data[key].data, value.data) for key, value in data.data.pv_data.items(): self._update_value(f"openWB/set/pv/{value.num}/", self.prev_data.pv_data[key].data, value.data) # chargepoint, ev template, autolock, time and scheduled charging plans mutable_by_algorithm immer false diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 60ce85828b..9da35d146e 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -16,10 +16,12 @@ from control.chargelog.process_chargelog import get_log_data 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, Set, get_plan_class_for_usage +from control.consumer.usage import ConsumerUsage from control.counter_all import counter_all -from control.ev import ev 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 dataclass_utils import asdict from helpermodules import pub from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan @@ -58,7 +60,8 @@ class Command: "nested payload": [("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)], + ("charge_template_time_charging_plan", "openWB/vehicle/template/charge_template/[0-9]+$", -1), + ("usage_plan", "openWB/consumer/[0-9]+/usage$", -1)], "topic": [("mqtt_bridge", "openWB/system/mqtt/bridge", -1), ("vehicle", "openWB/vehicle/[0-9]+/name$", 0)], @@ -85,7 +88,8 @@ def _get_max_ids(self) -> None: "autolock_plan": lambda p: p.get("autolock", {}).get("plans", []), "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", []) + "charge_template_time_charging_plan": lambda p: p.get("time_charging", {}).get("plans", []), + "usage_plan": lambda p: p.get("plans", []) } try: received_topics = ProcessBrokerBranch("").get_max_id() @@ -95,7 +99,7 @@ def _get_max_ids(self) -> None: for topic, payload in received_topics.items(): try: if max_id_type == "nested payload": - if re.search(topic_str, topic) is not None: + if re.search(topic_str, topic) is not None and payload is not None: extractor = plan_extractors.get(id_topic) for plan in extractor(payload): try: @@ -773,9 +777,9 @@ def addVehicle(self, connection_id: str, payload: dict) -> None: # add ACL roles for vehicle access, if user management is active if SubData.system_data["system"].data["security"]["user_management_active"]: add_acl_role("vehicle--access", new_id) - data.data.counter_all_data.add_loadmanagement_prio_item(ComponentType.VEHICLE, new_id) + SubData.counter_all_data.add_loadmanagement_prio_item(ComponentType.VEHICLE, new_id) Pub().pub("openWB/set/counter/get/loadmanagement_prios", - data.data.counter_all_data.data.get.loadmanagement_prios) + SubData.counter_all_data.data.get.loadmanagement_prios) pub_user_message(payload, connection_id, f'Neues EV mit ID \'{new_id}\' hinzugefügt.', MessageType.SUCCESS) def removeVehicle(self, connection_id: str, payload: dict) -> None: @@ -791,9 +795,9 @@ def removeVehicle(self, connection_id: str, payload: dict) -> None: if SubData.system_data["system"].data["security"]["user_management_active"]: remove_acl_role("vehicle--access", payload["data"]["id"]) remove_acl_role("vehicle--write-access", payload["data"]["id"]) - data.data.counter_all_data.remove_loadmanagement_prio_item(ComponentType.VEHICLE, payload["data"]["id"]) + SubData.counter_all_data.remove_loadmanagement_prio_item(payload["data"]["id"]) Pub().pub("openWB/set/counter/get/loadmanagement_prios", - data.data.counter_all_data.data.get.loadmanagement_prios) + SubData.counter_all_data.data.get.loadmanagement_prios) pub_user_message( payload, connection_id, f'EV mit ID \'{payload["data"]["id"]}\' gelöscht.', MessageType.SUCCESS) @@ -1152,6 +1156,110 @@ def resetUserPassword(self, connection_id: str, payload: dict) -> None: "Der Token ist ungültig oder abgelaufen.", MessageType.ERROR) + def addConsumer(self, connection_id: str, payload: dict) -> None: + """ sendet das Topic, zu dem ein neues Device erstellt werden soll. + """ + new_id = self.max_id_hierarchy + 1 + dev = importlib.import_module(f'.consumers.{payload["data"]["vendor"]}' + f'.{payload["data"]["type"]}.consumer', + "modules") + consumer_default = dataclass_utils.asdict(dev.device_descriptor.configuration_factory()) + consumer_default["id"] = new_id + Pub().pub(f'openWB/set/consumer/{new_id}/module', consumer_default) + Pub().pub(f"openWB/set/consumer/{new_id}/config", dataclass_utils.asdict(ConsumerConfig())) + Pub().pub(f"openWB/set/consumer/{new_id}/extra_meter", None) + Pub().pub(f"openWB/set/consumer/{new_id}/set", dataclass_utils.asdict(Set())) + Pub().pub(f"openWB/set/consumer/{new_id}/usage", None) + self.max_id_hierarchy = new_id + Pub().pub("openWB/set/command/max_id/hierarchy", new_id) + try: + evu_counter = SubData.counter_all_data.get_id_evu_counter() + SubData.counter_all_data.hierarchy_add_item_below( + new_id, ComponentType.CONSUMER, evu_counter) + Pub().pub("openWB/set/counter/get/hierarchy", SubData.counter_all_data.data.get.hierarchy) + except (TypeError, IndexError): + pub_user_message(payload, connection_id, + "Bitte zuerst einen EVU-Zähler konfigurieren oder in den Steuerungsmodus 'secondary' " + "umschalten.", + MessageType.ERROR) + try: + SubData.counter_all_data.add_loadmanagement_prio_item(ComponentType.CONSUMER, new_id) + Pub().pub("openWB/set/counter/get/loadmanagement_prios", + SubData.counter_all_data.data.get.loadmanagement_prios) + except (TypeError, IndexError) as e: + pub_user_message(payload, connection_id, + str(e), + MessageType.ERROR) + pub_user_message( + payload, connection_id, + f'Neues Gerät vom Typ \'{payload["data"]["type"]}\' mit ID \'{new_id}\' hinzugefügt.', + MessageType.SUCCESS) + + def removeConsumer(self, connection_id: str, payload: dict) -> None: + """ löscht ein Ladepunkt. + """ + if self.max_id_hierarchy < payload["data"]["consumer_id"]: + pub_user_message( + payload, connection_id, + f'Die ID \'{payload["data"]["consumer_id"]}\' ist größer als die maximal vergebene ' + f'ID \'{self.max_id_hierarchy}\'.', MessageType.ERROR) + ProcessBrokerBranch(f'consumer/{payload["data"]["consumer_id"]}/').remove_topics() + SubData.counter_all_data.hierarchy_remove_item(payload["data"]["consumer_id"]) + Pub().pub("openWB/set/counter/get/hierarchy", SubData.counter_all_data.data.get.hierarchy) + SubData.counter_all_data.remove_loadmanagement_prio_item(payload["data"]["consumer_id"]) + Pub().pub("openWB/set/counter/get/loadmanagement_prios", SubData.counter_all_data.data.get.loadmanagement_prios) + pub_user_message(payload, connection_id, + f'Ladepunkt mit ID \'{payload["data"]["consumer_id"]}\' gelöscht.', MessageType.SUCCESS) + + def selectUsage(self, connection_id: str, payload: dict) -> None: + """ weist einem Verbraucher eine Nutzung zu. + """ + usage = ConsumerUsage(payload["data"]["usage"]) + usage_config = GET_DEFAULTS_BY_USAGE[usage] + Pub().pub(f'openWB/set/consumer/{payload["data"]["consumer_id"]}/usage', dataclass_utils.asdict(usage_config)) + pub_user_message( + payload, connection_id, + f'Dem Verbraucher mit ID \'{payload["data"]["consumer_id"]}\' wurde die Nutzung ' + f'\'{payload["data"]["usage"]}\' zugewiesen.', + MessageType.SUCCESS) + + def addUsagePlan(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) + 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.', + MessageType.SUCCESS) + + def removeUsagePlan(self, connection_id: str, payload: dict) -> None: + """ löscht einen Zielladen-Plan. + """ + if self.max_id_usage_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) + usage = SubData.consumer_data[f'consumer{payload["data"]["consumer_id"]}'].data.usage + for plan in usage.plans: + if plan.id == payload["data"]["plan"]: + usage.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.', + MessageType.SUCCESS) + class ErrorHandlingContext: def __init__(self, payload: dict, connection_id: str): diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index d75f26ea53..bd4f1b3b99 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -104,6 +104,8 @@ def on_message(self, client: mqtt.Client, userdata, msg: mqtt.MQTTMessage): self.process_internal_chargepoint_topic(msg) elif "openWB/set/LegacySmartHome/" in msg.topic: self.process_legacy_smart_home_topic(msg) + elif "openWB/set/consumer/" in msg.topic: + self.process_consumer_topic(msg) def _validate_value(self, msg: mqtt.MQTTMessage, data_type, ranges=[], collection=None, pub_json=False, retain: bool = True): @@ -511,34 +513,8 @@ def process_chargepoint_topic(self, msg: mqtt.MQTTMessage): msg, int, [(0, float("inf"))], pub_json=True) elif "get" in msg.topic: self.process_chargepoint_get_topics(msg) - elif "/control_parameter/required_current" in msg.topic: - if hardware_configuration.get_hardware_configuration_setting("dc_charging"): - self._validate_value(msg, float, [(0, 0), (6, 32), (0, 450)]) - else: - self._validate_value(msg, float, [(6, 32), (0, 0)]) - elif ("/control_parameter/phases" in msg.topic or - "/control_parameter/template_phases" in msg.topic): - self._validate_value(msg, int, [(0, 3)]) - elif "/control_parameter/failed_phase_switches" in msg.topic: - self._validate_value(msg, int, [(0, 4)]) - elif ("/control_parameter/submode" in msg.topic or - "/control_parameter/chargemode" in msg.topic): - self._validate_value(msg, str) - elif "/control_parameter/limit" in msg.topic: - self._validate_value(msg, "json") - elif "/control_parameter/prio" in msg.topic: - self._validate_value(msg, bool) - elif "/control_parameter/current_plan" in msg.topic: - self._validate_value(msg, int) - elif ("/control_parameter/min_current" in msg.topic or - "/control_parameter/timestamp_switch_on_off" in msg.topic or - "/control_parameter/timestamp_charge_start" in msg.topic or - "/control_parameter/timestamp_chargemode_changed" in msg.topic or - "/control_parameter/timestamp_last_phase_switch" in msg.topic or - "/control_parameter/timestamp_phase_switch_buffer_start" in msg.topic): - self._validate_value(msg, float, [(0, float("inf"))]) - elif "/control_parameter/state" in msg.topic: - self._validate_value(msg, int, [(0, 7)]) + elif "/control_parameter/" in msg.topic: + self.process_control_parameter_topics(msg) elif "/disable_after_unplug" in msg.topic: self._validate_value(msg, bool, pub_json=True) else: @@ -548,6 +524,39 @@ def process_chargepoint_topic(self, msg: mqtt.MQTTMessage): except Exception: log.exception(f"Fehler im setdata-Modul: Topic {msg.topic}, Value: {msg.payload}") + def process_control_parameter_topics(self, msg): + if "/control_parameter/required_current" in msg.topic: + if "chargepoint" in msg.topic: + if hardware_configuration.get_hardware_configuration_setting("dc_charging"): + self._validate_value(msg, float, [(0, 0), (6, 32), (0, 450)]) + else: + self._validate_value(msg, float, [(6, 32), (0, 0)]) + elif "consumer" in msg.topic: + self._validate_value(msg, float, [(0, float("inf"))]) + elif ("/control_parameter/phases" in msg.topic or + "/control_parameter/template_phases" in msg.topic): + self._validate_value(msg, int, [(0, 3)]) + elif "/control_parameter/failed_phase_switches" in msg.topic: + self._validate_value(msg, int, [(0, 4)]) + elif ("/control_parameter/submode" in msg.topic or + "/control_parameter/chargemode" in msg.topic): + self._validate_value(msg, str) + elif "/control_parameter/limit" in msg.topic: + self._validate_value(msg, "json") + elif "/control_parameter/prio" in msg.topic: + self._validate_value(msg, bool) + elif "/control_parameter/current_plan" in msg.topic: + self._validate_value(msg, int) + elif ("/control_parameter/min_current" in msg.topic or + "/control_parameter/timestamp_switch_on_off" in msg.topic or + "/control_parameter/timestamp_charge_start" in msg.topic or + "/control_parameter/timestamp_chargemode_changed" in msg.topic or + "/control_parameter/timestamp_last_phase_switch" in msg.topic or + "/control_parameter/timestamp_phase_switch_buffer_start" in msg.topic): + self._validate_value(msg, float, [(0, float("inf"))]) + elif "/control_parameter/state" in msg.topic: + self._validate_value(msg, int, [(0, 7)]) + def process_chargepoint_get_topics(self, msg): if ("/get/voltages" in msg.topic): self._validate_value(msg, float, [(0, 500)], collection=list) @@ -770,9 +779,12 @@ def process_general_topic(self, msg: mqtt.MQTTMessage): elif ("openWB/set/general/chargemode_config/surplus/feed_in_yield" in msg.topic or "openWB/set/general/chargemode_config/surplus/vehicle/switch_on_threshold" in msg.topic or "openWB/set/general/chargemode_config/surplus/vehicle/switch_on_delay" in msg.topic or - "openWB/set/general/chargemode_config/surplus/vehicle/switch_off_delay" in msg.topic): + "openWB/set/general/chargemode_config/surplus/vehicle/switch_off_delay" in msg.topic + "openWB/set/general/consumer/config/switch_on_delay" in msg.topic or + "openWB/set/general/consumer/config/switch_off_delay" in msg.topic): self._validate_value(msg, int, [(0, float("inf"))]) - elif "openWB/set/general/chargemode_config/surplus/vehicle/switch_off_threshold" in msg.topic: + elif ("openWB/set/general/chargemode_config/surplus/vehicle/switch_off_threshold" in msg.topic or + "openWB/set/general/consumer/config/switch_off_threshold" in msg.topic): self._validate_value(msg, float) elif "openWB/set/general/chargemode_config/surplus/vehicle/phase_switch_delay" in msg.topic: self._validate_value(msg, int, [(5, 180)]) @@ -845,6 +857,8 @@ def process_io_topic(self, msg: mqtt.MQTTMessage): def process_mqtt_topic(self, msg: mqtt.MQTTMessage): if "openWB/set/mqtt/chargepoint/" in msg.topic: self.process_chargepoint_get_topics(msg) + elif "openWB/set/mqtt/consumer/" in msg.topic: + self.process_consumer_topic(msg) elif "openWB/set/mqtt/counter/" in msg.topic: self.process_counter_topic(msg) elif "openWB/set/mqtt/bat/" in msg.topic: @@ -1217,6 +1231,48 @@ def process_legacy_smart_home_topic(self, msg): except Exception: log.exception(f"Fehler im setdata-Modul: Topic {msg.topic}, Value: {msg.payload}") + def process_consumer_topic(self, msg): + try: + if ("openWB/set/consumer/get/imported" in msg.topic or + "openWB/set/consumer/get/exported" in msg.topic or + "openWB/set/consumer/get/power" in msg.topic or + "openWB/set/consumer/get/daily_imported" in msg.topic or + "openWB/set/consumer/get/daily_exported" in msg.topic): + self._validate_value(msg, float) + elif (re.search("openWB/set/consumer/[0-9]+/module$", msg.topic) is not None or + re.search("openWB/set/consumer/[0-9]+/config$", msg.topic) is not None or + re.search("openWB/set/consumer/[0-9]+/usage$", msg.topic) is not None): + self._validate_value(msg, "json") + elif re.search("openWB/set/consumer/[0-9]+/extra_meter$", msg.topic) is not None: + self._validate_value(msg, int) + 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]+/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 + re.search("consumer/[0-9]+/get/voltages$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/powers$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/temperatures$", msg.topic) is not None): + self._validate_value(msg, float, collection=list) + elif (re.search("consumer/[0-9]+/get/imported$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/daily_imported$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/timestamp_last_current_set$", msg.topic) is not None): + self._validate_value(msg, float, [(0, float("inf"))]) + 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): + 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]+/control_parameter/", msg.topic) is not None: + self.process_control_parameter_topics(msg) + else: + self.__unknown_topic(msg) + except Exception: + log.exception(f"Fehler im setdata-Modul: Topic {msg.topic}, Value: {msg.payload}") + def _get_ramdisk_path(self) -> Path: return Path(__file__).resolve().parents[2]/"ramdisk" diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 50a145a424..84b4b2bc83 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -17,6 +17,10 @@ from control.chargepoint.chargepoint_state_update import ChargepointStateUpdate from control.chargepoint.chargepoint_template import CpTemplate, CpTemplateData from control.counter_all import counter_all +from control.consumer.consumer import Consumer +from control.consumer.consumer_all import AllConsumers +from control.consumer.consumer_data import GET_CLASS_BY_USAGE +from control.consumer.usage import ConsumerUsage from control.ev.charge_template import ChargeTemplate, ChargeTemplateData from control.ev import ev from control.ev.ev_template import EvTemplate, EvTemplateData @@ -76,6 +80,8 @@ class SubData: optional_data = optional.Optional() system_data = {"system": system.System()} graph_data = graph.Graph() + consumer_data: Dict[str, Consumer] = {} + consumer_all_data = AllConsumers() def __init__(self, event_ev_template: Event, @@ -141,6 +147,7 @@ def on_connect(self, client: mqtt.Client, userdata, flags: dict, rc: int): ("openWB/internal_io/#", 2), ("openWB/optional/#", 2), ("openWB/counter/#", 2), + ("openWB/consumer/#", 2), ("openWB/command/command_completed", 2), ("openWB/internal_chargepoint/#", 2), # MQTT Bridge Topics vor "openWB/system/+" abonnieren, damit sie auch vor @@ -201,6 +208,8 @@ def on_message(self, client: mqtt.Client, userdata, msg: mqtt.MQTTMessage): self.process_legacy_smarthome_topic(client, self.counter_all_data, msg) elif "openWB/command/command_completed" == msg.topic: self.event_command_completed.set() + elif "openWB/consumer/" in msg.topic: + self.process_consumer_topic(client, self.consumer_data, msg) else: log.warning("unknown subdata-topic: "+str(msg.topic)) @@ -1224,3 +1233,47 @@ def process_legacy_smarthome_topic(self, client: mqtt.Client, var: counter_all.C var.data.set.smarthome_power_excluded_from_home_consumption = decode_payload(msg.payload) except Exception: log.exception("Fehler im subdata-Modul") + + def process_consumer_topic(self, client: mqtt.Client, var: Dict[str, Consumer], msg: mqtt.MQTTMessage): + try: + index = get_index(msg.topic) + if re.search("openWB/consumer/[0-9]+/module", msg.topic) is not None: + if decode_payload(msg.payload) == "": + if "consumer"+index in var: + var.pop("consumer"+index) + else: + log.error("Es konnte kein Verbraucher mit der ID " + + str(index)+" gefunden werden.") + else: + if f"consumer{index}" not in var: + var[f"consumer{index}"] = Consumer(int(index)) + consumer_config = decode_payload(msg.payload) + con = importlib.import_module( + f".consumers.{consumer_config['vendor']}.{consumer_config['type']}.consumer", + "modules") + config = dataclass_from_dict(con.device_descriptor.configuration_factory, consumer_config) + var["consumer"+index].module = con.create_consumer(config) + var["consumer"+index].data.module = config + elif re.search("openWB/consumer/[0-9]+/config", msg.topic) is not None: + 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]+/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: + payload = decode_payload(msg.payload) + if payload is not None: + var[f"consumer{index}"].data.usage = dataclass_from_dict( + GET_CLASS_BY_USAGE[ConsumerUsage(payload["type"])], + payload) + elif re.search("openWB/consumer/[0-9]+/control_parameter/", msg.topic) is not None: + if re.search("openWB/consumer/[0-9]+/control_parameter/limit", msg.topic) is not None: + payload = decode_payload(msg.payload) + var[f"consumer{index}"].data.control_parameter.limit = dataclass_from_dict( + LoadmanagementLimit, payload) + else: + self.set_json_payload_class(var[f"consumer{index}"].data.control_parameter, msg) + elif re.search("/consumer/get/", msg.topic) is not None: + self.set_json_payload_class(self.consumer_all_data.data.get, msg) + except Exception: + log.exception("Fehler im subdata-Modul") diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index a65ff63018..e0ac8bc70e 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 +from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan, TimeChargingPlanConsumer log = logging.getLogger(__name__) @@ -44,7 +44,7 @@ def is_now_in_locking_time(now: datetime.datetime, return False -T = TypeVar("T", AutolockPlan, TimeChargingPlan) +T = TypeVar("T", AutolockPlan, TimeChargingPlan, TimeChargingPlanConsumer) def check_plans_timeframe(plans: List[T]) -> Optional[T]: diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 608b4cd729..2d1bd9c1aa 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -185,6 +185,24 @@ class UpdateConfig: "^openWB/command/[A-Za-z0-9_]+/error$", "^openWB/command/todo$", + "^openWB/consumer/get/power$", + "^openWB/consumer/get/exported$", + "^openWB/consumer/get/imported$", + "^openWB/consumer/get/daily_exported$", + "^openWB/consumer/get/daily_imported$", + "^openWB/consumer/[0-9]+/module$", + "^openWB/consumer/[0-9]+/config$", + "^openWB/consumer/[0-9]+/extra_meter$", + "^openWB/consumer/[0-9]+/usage$", + "^openWB/consumer/[0-9]+/get/currents$", + "^openWB/consumer/[0-9]+/get/fault_state$", + "^openWB/consumer/[0-9]+/get/fault_str$", + "^openWB/consumer/[0-9]+/get/powers$", + "^openWB/consumer/[0-9]+/get/power$", + "^openWB/consumer/[0-9]+/get/state$", + "^openWB/consumer/[0-9]+/get/state_str$", + "^openWB/consumer/[0-9]+/get/voltages$", + "^openWB/counter/config/consider_less_charging$", "^openWB/counter/config/home_consumption_source_id$", "^openWB/counter/get/hierarchy$", @@ -246,6 +264,9 @@ class UpdateConfig: "^openWB/general/chargemode_config/bat/power_reserve$", "^openWB/general/chargemode_config/bat/power_reserve_active$", "^openWB/general/chargemode_config/surplus/vehicle/retry_failed_phase_switches$", + "^openWB/general/consumer/config/switch_on_delay$", + "^openWB/general/consumer/config/switch_off_delay$", + "^openWB/general/consumer/config/switch_off_threshold$", # obsolet, Daten hieraus müssen nach prices/ überführt werden "^openWB/general/price_kwh$", "^openWB/general/prices/bat$", @@ -318,6 +339,12 @@ class UpdateConfig: "^openWB/mqtt/chargepoint/[0-9]+/get/voltages$", "^openWB/mqtt/chargepoint/[0-9]+/get/power_factors$", "^openWB/mqtt/chargepoint/[0-9]+/get/rfid$", + "^openWB/mqtt/consumer/[0-9]+/get/currents$", + "^openWB/mqtt/consumer/[0-9]+/get/imported$", + "^openWB/mqtt/consumer/[0-9]+/get/exported$", + "^openWB/mqtt/consumer/[0-9]+/get/power$", + "^openWB/mqtt/consumer/[0-9]+/get/powers$", + "^openWB/mqtt/consumer/[0-9]+/get/voltages$", "^openWB/mqtt/counter/[0-9]+/get/currents$", "^openWB/mqtt/counter/[0-9]+/get/imported$", "^openWB/mqtt/counter/[0-9]+/get/exported$", @@ -503,6 +530,7 @@ class UpdateConfig: "^openWB/system/backup_password$", "^openWB/system/configurable/chargepoints$", "^openWB/system/configurable/chargepoints_internal$", + "^openWB/system/configurable/consumers$", "^openWB/system/configurable/devices_components$", "^openWB/system/configurable/flexible_tariffs$", "^openWB/system/configurable/grid_fees$", @@ -625,6 +653,9 @@ class UpdateConfig: ChargemodeConfigSurplusVehicle().retry_failed_phase_switches), ("openWB/general/chargemode_config/unbalanced_load", False), ("openWB/general/chargemode_config/unbalanced_load_limit", 18), + ("openWB/general/consumer/config/switch_on_delay", 60), + ("openWB/general/consumer/config/switch_off_delay", 60), + ("openWB/general/consumer/config/switch_off_threshold", 0), ("openWB/general/control_interval", 10), ("openWB/general/extern", False), ("openWB/general/extern_display_mode", "primary"), diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index be27fda208..4ac03f1eb1 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -266,3 +266,36 @@ def __init__(self, plug_state: bool, charge_state: bool, set_current: int, max_c self.charge_state = charge_state self.set_current = set_current self.max_current = max_current + + +@auto_str +class ConsumerState: + def __init__( + self, + imported: Optional[float] = None, + exported: Optional[float] = None, + power: Optional[float] = None, + voltages: Optional[List[Optional[float]]] = None, + currents: Optional[List[Optional[float]]] = None, + powers: Optional[List[Optional[float]]] = None, + set_power: Optional[float] = None, + state: Optional[bool] = False, + temperatures: Optional[List[Optional[float]]] = None, + ): + """Args: + imported: total imported energy in Wh + exported: total exported energy in Wh + power: actual power in W + voltages: actual voltages for 3 phases in V + currents: actual currents for 3 phases in A + powers: actual powers for 3 phases in W + power_factors: actual power factors for 3 phases + frequency: actual grid frequency in Hz + """ + self.currents, self.powers, self.voltages = _calculate_powers_and_currents(currents, powers, voltages) + self.imported = imported + self.exported = exported + self.power = power + self.set_power = set_power + self.state = state + self.temperatures = temperatures diff --git a/packages/modules/common/component_type.py b/packages/modules/common/component_type.py index 69fd3694a5..8ae99c397b 100644 --- a/packages/modules/common/component_type.py +++ b/packages/modules/common/component_type.py @@ -6,6 +6,7 @@ class ComponentType(Enum): BACKUP_CLOUD = "backup_cloud" BAT = "bat" CHARGEPOINT = "cp" + CONSUMER = "consumer" COUNTER = "counter" FLEXIBLE_TARIFF = "dynamic_tariff" GRID_FEE = "grid_tariff" @@ -17,6 +18,8 @@ class ComponentType(Enum): def special_to_general_type_mapping(component_type: str) -> ComponentType: if "bat" in component_type: return ComponentType.BAT + elif "consumer" in component_type: + return ComponentType.CONSUMER elif "counter" in component_type: return ComponentType.COUNTER elif "inverter" in component_type: @@ -30,6 +33,8 @@ def special_to_general_type_mapping(component_type: str) -> ComponentType: def type_to_topic_mapping(component_type: str) -> str: if "bat" in component_type: return "bat" + elif "consumer" in component_type: + return "consumer" elif "counter" in component_type: return "counter" elif "inverter" in component_type: diff --git a/packages/modules/common/simcount/_simcounter.py b/packages/modules/common/simcount/_simcounter.py index 09b74e5866..6606ef5658 100644 --- a/packages/modules/common/simcount/_simcounter.py +++ b/packages/modules/common/simcount/_simcounter.py @@ -19,6 +19,20 @@ def sim_count(self, power: float, dc_power: Optional[float] = None) -> Tuple[flo return self.data.imported, self.data.exported +class SimCounterConsumer: + def __init__(self, consumer_id: int, component_type: ComponentType): + self.topic = f"openWB/set/consumer/{consumer_id}/module/" + self.component_type = special_to_general_type_mapping( + component_type.value if isinstance(component_type, ComponentType) else component_type) + self.data: Optional[SimCounterState] = None + + def sim_count(self, power: float, dc_power: Optional[float] = None) -> Tuple[float, float]: + if self.component_type != ComponentType.INVERTER and dc_power is not None and dc_power == 0: + power = 0 + self.data = sim_count(power, self.topic, self.data, self.component_type) + return self.data.imported, self.data.exported + + class SimCounterChargepoint: def __init__(self, chargepoint_id: int): self.topic = f"openWB/set/chargepoint/{chargepoint_id}/get/" diff --git a/packages/modules/common/store/_factory.py b/packages/modules/common/store/_factory.py index 8b137aecab..20fba6f41b 100644 --- a/packages/modules/common/store/_factory.py +++ b/packages/modules/common/store/_factory.py @@ -2,6 +2,7 @@ from modules.common.component_type import ComponentType, special_to_general_type_mapping from modules.common.simcount._simcounter import SimCounter from modules.common.store._battery import get_bat_value_store +from modules.common.store._consumer import get_consumer_value_store from modules.common.store._counter import get_counter_value_store from modules.common.store._inverter import get_inverter_value_store @@ -14,6 +15,8 @@ def get_component_value_store(component_type_str: str, component_type_str.value if isinstance(component_type_str, ComponentType) else component_type_str) if ComponentType.BAT == component_type: return get_bat_value_store(component_num) + elif ComponentType.CONSUMER == component_type: + return get_consumer_value_store(component_num) elif ComponentType.COUNTER == component_type: return get_counter_value_store(component_num, add_child_values, simcounter) elif ComponentType.INVERTER == component_type: diff --git a/packages/modules/common/utils/peak_filter.py b/packages/modules/common/utils/peak_filter.py index fb7b07aa43..0ef0cc1af3 100644 --- a/packages/modules/common/utils/peak_filter.py +++ b/packages/modules/common/utils/peak_filter.py @@ -32,6 +32,9 @@ def check_values( elif self.component_type == ComponentType.BAT: bat = data.data.bat_data[f"bat{self.component_id}"] max_power = bat.data.config.max_power + elif self.component_type == ComponentType.CONSUMER: + consumer = data.data.consumer_data[f"consumer{self.component_id}"] + max_power = consumer.data.config.max_power else: raise ValueError(f"Unsupported component type {self.component_type!r} in PeakFilter") # prüfe Leistung und importierte/exportierte Energie auf Plausibilität diff --git a/packages/modules/configuration.py b/packages/modules/configuration.py index 0af7ccf629..7122f1d2d6 100644 --- a/packages/modules/configuration.py +++ b/packages/modules/configuration.py @@ -23,6 +23,7 @@ def pub_configurable(): _pub_configurable_io_devices() _pub_configurable_io_actions() _pub_configurable_monitoring() + _pub_configurable_consumers() def _pub_configurable_backup_clouds() -> None: @@ -189,6 +190,11 @@ def _pub_configurable_soc_modules() -> None: def _pub_configurable_devices_components() -> None: + Pub().pub("openWB/set/system/configurable/devices_components", + _get_configurable_devices_components(["*bat*", "*counter*", "*inverter*"])) + + +def _get_configurable_devices_components(component_pattern: List[str]) -> None: def update_nested_dict(dictionary: Dict, update: Dict) -> Dict: for key, value in update.items(): if isinstance(value, dict): @@ -218,7 +224,7 @@ def get_vendor_group_name(group: str) -> str: vendor_path = path.parts[-2] vendor_info = importlib.import_module( f".devices.{vendor_path}.vendor", "modules").vendor_descriptor.configuration_factory() - vendor_groups = update_nested_dict(vendor_groups, { + entry = { vendor_info.group: { "group_name": get_vendor_group_name(vendor_info.group), "vendors": { @@ -228,7 +234,9 @@ def get_vendor_group_name(group: str) -> str: } } } - }) + } + if len(entry[vendor_info.group]["vendors"][vendor_path]["devices"]) > 0: + vendor_groups = update_nested_dict(vendor_groups, entry) except Exception: log.exception(f"Fehler im configuration-Modul: vendors: {path}") return vendor_groups @@ -244,19 +252,21 @@ def get_vendor_devices(vendor: str) -> Dict: device_path = path.parts[-2] dev_defaults = importlib.import_module( f".devices.{vendor}.{device_path}.device", "modules").device_descriptor.configuration_factory() - devices.update({ + entry = { dev_defaults.type: { "device_name": dev_defaults.name, "components": get_device_components(vendor, dev_defaults.type) } - }) + } + if len(entry[dev_defaults.type]["components"]) > 0: + devices.update(entry) except Exception: log.exception(f"Fehler im configuration-Modul: devices: {path}") return devices def get_device_components(vendor: str, device: str) -> Dict: components = {} - for pattern in ["*bat*", "*counter*", "*inverter*"]: + for pattern in component_pattern: path_list = Path(_get_packages_path()/"modules"/"devices"/vendor/device).glob(f'**/{pattern}.py') for path in path_list: try: @@ -276,7 +286,7 @@ def get_device_components(vendor: str, device: str) -> Dict: return components try: - Pub().pub("openWB/set/system/configurable/devices_components", get_vendor_groups()) + return get_vendor_groups() except Exception: log.exception("Fehler im configuration-Modul") @@ -403,5 +413,82 @@ def _pub_configurable_monitoring() -> None: log.exception("Fehler im configuration-Modul") +def _pub_configurable_consumers() -> None: + try: + path_list = Path(_get_packages_path()/"modules"/"consumer").glob('**/consumer.py') + + def update_nested_dict(dictionary: Dict, update: Dict) -> Dict: + for key, value in update.items(): + if isinstance(value, dict): + dictionary[key] = update_nested_dict(dictionary.get(key, {}), value) + else: + dictionary[key] = value + return dictionary + + def get_vendor_consumers(vendor: str) -> Dict: + consumers = {} + path_list = Path(_get_packages_path()/"modules"/"consumers"/vendor).glob('**/consumer.py') + for path in path_list: + try: + if path.name.endswith("_test.py"): + # Tests überspringen + continue + device_path = path.parts[-2] + dev_defaults = importlib.import_module( + f".consumers.{vendor}.{device_path}.consumer", + "modules").device_descriptor.configuration_factory() + usage_defaults = [] + for usage_type in dev_defaults.usage: + usage_defaults.append(usage_type.value) + dev_defaults.usage = usage_defaults + consumers.update({ + dev_defaults.type: { + "consumer_name": dev_defaults.name, + "defaults": dataclass_utils.asdict(dev_defaults) + } + }) + except Exception: + log.exception(f"Fehler im configuration-Modul: devices: {path}") + return consumers + + def get_vendor_group_name(group: str) -> str: + # ToDo: find a better way to lookup the group names in "devices/vendors.py" + if group == "generic": + return "herstellerunabhängig" + if group == "openwb": + return "openWB" + if group == "vendors": + return "andere Hersteller" + return group + + vendor_groups = {} + path_list = Path(_get_packages_path()/"modules"/"consumers").glob('**/vendor.py') + for path in path_list: + try: + if path.name.endswith("_test.py"): + # Tests überspringen + continue + vendor_path = path.parts[-2] + vendor_info = importlib.import_module( + f".consumers.{vendor_path}.vendor", "modules").vendor_descriptor.configuration_factory() + vendor_groups = update_nested_dict(vendor_groups, { + vendor_info.group: { + "group_name": get_vendor_group_name(vendor_info.group), + "vendors": { + vendor_path: { + "vendor_name": vendor_info.vendor, + "consumers": get_vendor_consumers(vendor_path) + } + } + } + }) + except Exception: + log.exception(f"Fehler im configuration-Modul: vendors: {path}") + + Pub().pub("openWB/set/system/configurable/consumers", vendor_groups) + except Exception: + log.exception("Fehler im configuration-Modul") + + def _get_packages_path() -> Path: return Path(__file__).resolve().parents[2]/"packages" diff --git a/packages/modules/loadvars.py b/packages/modules/loadvars.py index d88f9197da..81e9e6b187 100644 --- a/packages/modules/loadvars.py +++ b/packages/modules/loadvars.py @@ -62,6 +62,13 @@ def _set_values(self) -> List[str]: args=(), name=f"set values cp{cp.chargepoint_module.config.id}")) except Exception: log.exception(f"Fehler im loadvars-Modul bei Element {cp.num}") + for consumer in data.data.consumer_data.values(): + try: + modules_threads.append(Thread(target=consumer.module.update, + args=(), + name=f"set values consumer{consumer.data.module.id}")) + except Exception: + log.exception(f"Fehler im loadvars-Modul bei Element {consumer.num}") return joined_thread_handler(modules_threads, data.data.general_data.data.control_interval/3) def _set_timeout_fault_for_timed_out_device_components(self, not_finished_threads: List[str]) -> None: @@ -104,6 +111,14 @@ def _update_values_of_level_buttom_top(self, elements, not_finished_threads: Lis target=update_values, args=(chargepoint.chargepoint_module,), name=f"update values cp{chargepoint.chargepoint_module.config.id}")) + elif element["type"] == ComponentType.CONSUMER.value: + consumer = data.data.consumer_data[f'{type_to_topic_mapping(element["type"])}{element["id"]}'] + thread_name = f"set values consumer{consumer.data.module.id}" + if thread_name not in not_finished_threads: + modules_threads.append(Thread( + target=update_values, + args=(consumer.module,), + name=f"update values consumer{consumer.data.module.id}")) else: component = get_finished_component_obj_by_id(element["id"], not_finished_threads) if component is None: From b0dafc51b96fb0c277d2f3d2be71df00ccb227d6 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 17 Jun 2026 14:41:11 +0200 Subject: [PATCH 40/84] fixes --- packages/control/consumer/consumer.py | 46 +++++++++---------- .../counter_all/loadmanagement_prio.py | 2 +- .../counter_all/loadmanagement_prio_test.py | 4 +- packages/helpermodules/command.py | 3 +- packages/helpermodules/setdata.py | 7 ++- packages/helpermodules/update_config.py | 2 + .../modules/consumers/generic/mqtt/config.py | 2 +- 7 files changed, 37 insertions(+), 29 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 17e3c055b8..540ceb30d8 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -100,6 +100,13 @@ def get_parameter(self) -> Tuple[int, int, Optional[str], Chargemode, Chargemode raise ValueError(f"Ungültiger Lademodus {self.data.usage.chargemode} für Verbraucher {self.num}") return min_current, required_current, message, mode, submode + 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 + else: + return required_current + def scheduled_charging(self) -> Tuple[int, str, Optional[str], int]: required_current = 0 message = None @@ -128,14 +135,12 @@ 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 = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) submode = Chargemode.TIME_CHARGING else: message = self.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL else: - required_current = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) submode = Chargemode.TIME_CHARGING else: message = self.TIME_CHARGING_NO_PLAN_ACTIVE @@ -144,8 +149,7 @@ 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 = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) message = None mode = Chargemode.INSTANT_CHARGING submode = Chargemode.INSTANT_CHARGING @@ -158,26 +162,22 @@ 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 = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) message = self.CHARGING_PRICE_LOW submode = Chargemode.INSTANT_CHARGING else: - required_current = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.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 = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) submode = Chargemode.PV_CHARGING return required_current, message, mode, submode def pv_charging(self) -> Tuple[int, str, Optional[str], int]: - required_current = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) message = None mode = Chargemode.PV_CHARGING submode = Chargemode.PV_CHARGING @@ -185,8 +185,8 @@ 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.usage.wait_for_start.last_test_timestamp + 60*60 + 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) if wait_for_start_state == WaitForStartStates.START_SIGNAL_RECEIVED: required_current, message, mode, submode = func() @@ -207,22 +207,22 @@ def wait_for_start_handler( return required_current, message, mode, submode def _wait_for_start_singal(self, timestamp_next_test: float) -> WaitForStartStates: - if self.data.usage.wait_for_start.signal_received: + if self.data.set.wait_for_start_signal_received: return WaitForStartStates.START_SIGNAL_RECEIVED else: - if self.data.usage.wait_for_start.test_running: + if self.data.set.wait_for_start_test_running: if self.data.get.charge_state: - self.data.usage.wait_for_start.signal_received = True - self.data.usage.wait_for_start.test_running = False + 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.usage.wait_for_start.last_test_timestamp + 60: + elif timecheck.create_timestamp() < self.data.set.wait_for_start_last_test_timestamp + 60: return WaitForStartStates.START_TEST_RUN else: - self.data.usage.wait_for_start.test_running = False + 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.usage.wait_for_start.last_test_timestamp = timecheck.create_timestamp() + self.data.set.wait_for_start_last_test_timestamp = timecheck.create_timestamp() return WaitForStartStates.TEST_RUNNIG else: return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py index 0ee2175d1c..80b94dfc1b 100644 --- a/packages/control/counter_all/loadmanagement_prio.py +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -50,7 +50,7 @@ def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: sorted_cps = [] for entry in self.data.get.loadmanagement_prios: - if entry["type"] == "vehicle": + if entry["type"] == "vehicle" or entry["type"] == "consumer": grouped_cps = [] for cp in filtered_cps: if cp.data.config.ev == entry["id"]: diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index 14ac59a8de..f5904c6c0a 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -80,7 +80,9 @@ def test_add_item(loadmanagement_prios: List[Dict], "loadmanagement_prios, id, expected_loadmanagement_prios", [ pytest.param([{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 2}], - 2, [{"type": "vehicle", "id": 3}], id="flat list"), + 2, [{"type": "vehicle", "id": 3}], id="flat list, remove vehicle"), + pytest.param([{"type": "vehicle", "id": 3}, {"type": "consumer", "id": 2}], + 2, [{"type": "vehicle", "id": 3}], id="flat list, remove consumer"), pytest.param([ { "type": "group", diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 9da35d146e..a613c34b95 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -1168,7 +1168,6 @@ def addConsumer(self, connection_id: str, payload: dict) -> None: Pub().pub(f'openWB/set/consumer/{new_id}/module', consumer_default) Pub().pub(f"openWB/set/consumer/{new_id}/config", dataclass_utils.asdict(ConsumerConfig())) Pub().pub(f"openWB/set/consumer/{new_id}/extra_meter", None) - Pub().pub(f"openWB/set/consumer/{new_id}/set", dataclass_utils.asdict(Set())) Pub().pub(f"openWB/set/consumer/{new_id}/usage", None) self.max_id_hierarchy = new_id Pub().pub("openWB/set/command/max_id/hierarchy", new_id) @@ -1209,7 +1208,7 @@ def removeConsumer(self, connection_id: str, payload: dict) -> None: SubData.counter_all_data.remove_loadmanagement_prio_item(payload["data"]["consumer_id"]) Pub().pub("openWB/set/counter/get/loadmanagement_prios", SubData.counter_all_data.data.get.loadmanagement_prios) pub_user_message(payload, connection_id, - f'Ladepunkt mit ID \'{payload["data"]["consumer_id"]}\' gelöscht.', MessageType.SUCCESS) + f'Verbraucher mit ID \'{payload["data"]["consumer_id"]}\' gelöscht.', MessageType.SUCCESS) def selectUsage(self, connection_id: str, payload: dict) -> None: """ weist einem Verbraucher eine Nutzung zu. diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index bd4f1b3b99..f258f2c2f7 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -858,7 +858,12 @@ def process_mqtt_topic(self, msg: mqtt.MQTTMessage): if "openWB/set/mqtt/chargepoint/" in msg.topic: self.process_chargepoint_get_topics(msg) elif "openWB/set/mqtt/consumer/" in msg.topic: - self.process_consumer_topic(msg) + if re.search("openWB/set/mqtt/consumer/[0-9]+/set/switch", msg.topic) is not None: + self._validate_value(msg, bool) + elif re.search("openWB/set/mqtt/consumer/[0-9]+/set/power", msg.topic) is not None: + self._validate_value(msg, float) + else: + self.process_consumer_topic(msg) elif "openWB/set/mqtt/counter/" in msg.topic: self.process_counter_topic(msg) elif "openWB/set/mqtt/bat/" in msg.topic: diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 2d1bd9c1aa..b4f6ba419e 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -345,6 +345,8 @@ class UpdateConfig: "^openWB/mqtt/consumer/[0-9]+/get/power$", "^openWB/mqtt/consumer/[0-9]+/get/powers$", "^openWB/mqtt/consumer/[0-9]+/get/voltages$", + "^openWB/mqtt/consumer/[0-9]+/set/power$", + "^openWB/mqtt/consumer/[0-9]+/set/switch$", "^openWB/mqtt/counter/[0-9]+/get/currents$", "^openWB/mqtt/counter/[0-9]+/get/imported$", "^openWB/mqtt/counter/[0-9]+/get/exported$", diff --git a/packages/modules/consumers/generic/mqtt/config.py b/packages/modules/consumers/generic/mqtt/config.py index 7a4bebee6d..3c5e816e51 100644 --- a/packages/modules/consumers/generic/mqtt/config.py +++ b/packages/modules/consumers/generic/mqtt/config.py @@ -15,7 +15,7 @@ def __init__(self): @auto_str class Mqtt(ConsumerSetup[MqttConfiguration]): def __init__(self, - name: str = "Mqtt", + name: str = "MQTT", type: str = "mqtt", id: int = 0, configuration: MqttConfiguration = None, From 7802aa0c3ab510d611cac7bc7a8c4ce0fc5c20dc Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Thu, 18 Jun 2026 08:38:24 +0200 Subject: [PATCH 41/84] fixes --- packages/control/algorithm/filter_chargepoints.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/control/algorithm/filter_chargepoints.py b/packages/control/algorithm/filter_chargepoints.py index 1659aca5bf..944af89944 100644 --- a/packages/control/algorithm/filter_chargepoints.py +++ b/packages/control/algorithm/filter_chargepoints.py @@ -6,6 +6,8 @@ from control import data from control.chargemode import Chargemode from control.chargepoint.chargepoint import Chargepoint +from control.consumer.consumer import Consumer +from control.consumer.usage import ConsumerUsage from control.load_protocol import Load log = logging.getLogger(__name__) @@ -90,6 +92,11 @@ def get_preferenced_load_charging( log.info(f"{'LP' if isinstance(load, Chargepoint) else 'Verbraucher'} {load.num}: " f"Lädt nicht, daher keine weitere Berücksichtigung") preferenced_loads_without_set_current.append(load) + elif (isinstance(load, Consumer) and + load.data.usage.type in [ConsumerUsage.CONTINUOUS, ConsumerUsage.SUSPENDABLE_ONOFF]): + log.info(f"Verbraucher {load.num}: Verbrauchsart {load.data.usage.type} führt zu keiner weiteren " + "Berücksichtigung") + preferenced_loads_without_set_current.append(load) else: valid_group.append(load) group[:] = valid_group From e5d76aba4b6edf6948de91219057bb8c3b43994a Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:48:27 +0200 Subject: [PATCH 42/84] =?UTF-8?q?Anlauferkennung,=20Betriebsmodus=20zur?= =?UTF-8?q?=C3=BCcksetzen,=20Befehle=20f=C3=BCr=20Pl=C3=A4ne=20(#3588)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * draft * Anlauferkennung * Betriebsmodus zurücksetzen * ammend * Anlauferkennung * draft * draft scheduled charging * add scheduled und time plan commands for adding and removing * flake8 * update config * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * review * fix pytest --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/control/consumer/consumer.py | 331 ++++++++++++++---- packages/control/consumer/consumer_data.py | 53 ++- packages/control/consumer/consumer_test.py | 121 +++++++ packages/helpermodules/abstract_plans.py | 6 +- packages/helpermodules/command.py | 95 +++-- .../missing_role_topics_test.py | 2 + packages/helpermodules/setdata.py | 7 +- packages/helpermodules/subdata.py | 2 + packages/helpermodules/timecheck.py | 7 +- packages/helpermodules/update_config.py | 7 + packages/main.py | 4 + 11 files changed, 527 insertions(+), 108 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 540ceb30d8..03499cc25f 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -1,27 +1,21 @@ -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 +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 +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__) -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 + def __init__(self, index: int): self.num = index self.data: ConsumerData = ConsumerData() @@ -37,7 +31,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: @@ -53,9 +51,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}") @@ -67,12 +65,9 @@ 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.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 +75,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,16 +98,183 @@ 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 - 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 + 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 + + 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: + self.data.config.control_parameter.current_plan = plan_data.id + else: + self.data.config.control_parameter.current_plan = None + return self.scheduled_charging_calc_current( + plan_data, + 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.") @@ -135,12 +297,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 +313,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,70 +327,94 @@ 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: - 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 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[float, str, Optional[Chargemode], Chargemode]: 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) - if wait_for_start_state == WaitForStartStates.START_SIGNAL_RECEIVED: + 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): - required_current = self.data.usage.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: - required_current = 0 - message = self.WAIT_FOR_START_SIGNAL.format( - timecheck.convert_timestamp_delta_to_time_string(timestamp_next_test, 0)) - 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_singal(self, timestamp_next_test: float) -> 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 + def reset_wait_for_start(self): + 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: + 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 @@ -251,4 +440,12 @@ 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 == 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") + 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 aa389e8a32..fb765d43a8 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,31 @@ 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" + TIME = "time" + + +@dataclass +class ResetChargemode: + mode: ResetModes = ResetModes.NEVER + time: Optional[int] = None + chargemode: Chargemode = Chargemode.INSTANT_CHARGING + + +@dataclass +class Log: + on_time: float = 0 + + @dataclass class MeterOnlyConfig: type: ConsumerUsage = ConsumerUsage.METER_ONLY @@ -39,37 +65,34 @@ 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()) type: ConsumerUsage = ConsumerUsage.SUSPENDABLE_TUNABLE + reset_chargemode: ResetChargemode = field(default_factory=lambda: ResetChargemode()) wait_for_start_active: bool = True @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()) type: ConsumerUsage = ConsumerUsage.SUSPENDABLE_ONOFF + reset_chargemode: ResetChargemode = field(default_factory=lambda: ResetChargemode()) wait_for_start_active: bool = True @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()) 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, @@ -89,7 +112,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, @@ -101,6 +124,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,21 +149,19 @@ 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 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_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 new file mode 100644 index 0000000000..6e6228f585 --- /dev/null +++ b/packages/control/consumer/consumer_test.py @@ -0,0 +1,121 @@ +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 +from control.consumer.consumer_data import ContinuousDeviceConfig, WaitForStartStates + + +@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 + load.data.config.max_power = 2300 + load.data.config.connected_phases = 1 + load.data.get.voltages = [230] + return load + + +@pytest.mark.parametrize( + ("wait_for_start_active", + "state", "charge_state", + "func_result", + "expected_result", + "expected_state", + "func_calls"), + [ + 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_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, +): + # 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 + charging_func = Mock(return_value=func_result) + + # execution + result = consumer.wait_for_start_handler(charging_func) + + # 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/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 a613c34b95..3f7edab061 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -16,7 +16,7 @@ from control.chargelog.process_chargelog import get_log_data 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, Set, 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 @@ -24,7 +24,7 @@ from control.consumer.consumer_data import ConsumerConfig from dataclass_utils import asdict 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 @@ -61,7 +61,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)], @@ -89,7 +90,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() @@ -1222,41 +1224,94 @@ 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) + return + 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) + return + 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/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', diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index f258f2c2f7..1f284027b9 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -1253,6 +1253,8 @@ 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]+/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,10 +1269,13 @@ 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) + 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 84b4b2bc83..a235e07790 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -1258,6 +1258,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: diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index e0ac8bc70e..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, ScheduledChargingPlan, TimeChargingPlan, TimeChargingPlanConsumer +from helpermodules.abstract_plans import (AutolockPlan, ContinuousScheduledPlanConsumer, ScheduledChargingPlan, + SuspendableScheduledPlanConsumer, TimeChargingPlan, TimeChargingPlanConsumer) log = logging.getLogger(__name__) @@ -113,7 +114,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. diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index b4f6ba419e..51339ed16f 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -174,6 +174,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$", @@ -202,6 +204,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$", 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 9be69f4a8812c2149ff2875a985b285be1464de0 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:00:24 +0200 Subject: [PATCH 43/84] on time, fixes (#3612) * on_time * eco default price, typo --- packages/control/consumer/consumer.py | 335 +++++++++++---------- packages/control/consumer/consumer_data.py | 5 +- packages/helpermodules/setdata.py | 1 + packages/helpermodules/timecheck.py | 3 +- packages/helpermodules/update_config.py | 1 + packages/main.py | 3 +- 6 files changed, 184 insertions(+), 164 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 03499cc25f..a7819344f6 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -1,5 +1,6 @@ +import datetime import logging -from typing import Callable, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple, Union from control import data from control.algorithm.utils import get_medium_charging_current from control.chargemode import Chargemode @@ -7,6 +8,7 @@ from control.consumer.consumer_data import ConsumerData, ConsumerUsage, MeterOnlyConfig, ResetModes, WaitForStartStates from control.load_protocol import Load from helpermodules import timecheck +from helpermodules.abstract_plans import ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase, voltages_mean from modules.common.configurable_consumer import ConfigurableConsumer @@ -54,10 +56,23 @@ def update(self): 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.process_on_time() log.debug( f"Verbraucher {self.num}: Sollstrom {required_current}, min. Ist-Strom {max(self.data.get.currents)}," f" Modus {mode}, Submodus {submode}, {message}") + def process_on_time(self): + if self.data.control_parameter.timestamp_charge_start is None: + if max(self.data.get.currents) > self.data.config.min_current: + self.data.control_parameter.timestamp_charge_start = timecheck.create_timestamp() + elif max(self.data.get.currents) < self.data.config.min_current: + self.reset_timestamp_start() + else: + self.data.set.on_time = timecheck.create_timestamp() - self.data.control_parameter.timestamp_charge_start + + def reset_timestamp_start(self): + self.data.control_parameter.timestamp_charge_start = None + PRICE_LIMIT_EXCEEDED = "Preislimit für Verbraucher aktiv, aktueller Preis zu hoch." PRICE_LIMIT_EXCEEDED_CONTINOUS_STILL_RUNNING = ("Preislimit für Verbraucher aktiv, aktueller Preis zu hoch. " "Verbraucher läuft weiter, da der Verbraucher nicht abgeschaltet " @@ -67,7 +82,7 @@ def update(self): "Verbraucher nicht abgeschaltet werden darf.") 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: + if timecheck.create_timestamp() < self.data.set.timestamp_last_current_set + self.data.config.min_interval: log.debug("Intervall für neuen Schaltbefehl nicht abgelaufen.") return (self.data.control_parameter.min_current, self.data.control_parameter.required_current, @@ -111,170 +126,169 @@ def _convert_power_to_current(self, power: float) -> float: BUFFER_START_EARLIER = 600 # 10 Min vor dem geplanten Start kann begonnen werden 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: - self.data.config.control_parameter.current_plan = plan_data.id + self.data.control_parameter.current_plan = plan_data.id else: - self.data.config.control_parameter.current_plan = None + self.data.control_parameter.current_plan = None return self.scheduled_charging_calc_current( plan_data, 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 + def _find_recent_plan(self, + plans: List[Union[ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer]]): + plans_diff_end_date: List[Dict[int, float]] = [] + 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.num)) + 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.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.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 data.data.optional_data.data.electricity_pricing.configured: + 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.") @@ -400,6 +414,11 @@ def wait_for_start_handler( def reset_wait_for_start(self): self.data.set.wait_for_start_state = WaitForStartStates.WAIT_FOR_DEVICE_START + def midnight_handler(self): + self.reset_chargemode_at_midnight() + self.reset_wait_for_start() + self.reset_timestamp_start() + 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: diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index fb765d43a8..3ec679609f 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -13,7 +13,7 @@ @dataclass class EcoCharging: - price_limit: float = 0.07 + price_limit: float = 0.0002 @dataclass @@ -125,7 +125,7 @@ class ConsumerConfig: phase_1: int = 1 max_power: float = 5000 min_current: float = 0.5 - min_intervall: int = 60 + min_interval: int = 60 @dataclass @@ -158,6 +158,7 @@ class Set: target_current: float = 0 charge_state_prev: bool = False log: Log = field(default_factory=lambda: Log()) + on_time: float = field(default=0, metadata={"topic": "set/on_time"}) 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/setdata.py b/packages/helpermodules/setdata.py index 1f284027b9..beb83f4ebb 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -1253,6 +1253,7 @@ 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/on_time$", 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]+/get/set_power$", msg.topic) is not None): diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index ccc37b5606..1e8a3d7088 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -117,7 +117,7 @@ def is_timeframe_valid(now: datetime.datetime, begin: datetime.datetime, end: da def check_end_time(plan: Union[ContinuousScheduledPlanConsumer, ScheduledChargingPlan, SuspendableScheduledPlanConsumer], - buffer: Optional[float]) -> Optional[float]: + buffer: Optional[float]) -> float: """ gibt die verbleibende Zeit in Sekunden zurück. Return @@ -127,7 +127,6 @@ def check_end_time(plan: Union[ContinuousScheduledPlanConsumer, """ now = datetime.datetime.today() end = datetime.datetime.strptime(plan.time, '%H:%M') - remaining_time = None if plan.frequency.selected == "once": endDate = datetime.datetime.strptime(plan.frequency.once, "%Y-%m-%d") end = end.replace(endDate.year, endDate.month, endDate.day) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 51339ed16f..32fa4e0af9 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -205,6 +205,7 @@ class UpdateConfig: "^openWB/consumer/[0-9]+/get/state_str$", "^openWB/consumer/[0-9]+/get/voltages$", "^openWB/consumer/[0-9]+/set/current$", + "^openWB/consumer/[0-9]+/set/on_time$", "^openWB/consumer/[0-9]+/set/phases_to_use$", "^openWB/consumer/[0-9]+/set/plug_time$", "^openWB/consumer/[0-9]+/set/timestamp_last_current_set$", diff --git a/packages/main.py b/packages/main.py index 1e9570706d..9243b0bbcc 100755 --- a/packages/main.py +++ b/packages/main.py @@ -236,8 +236,7 @@ def handler_midnight(self): 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() + consumer.midnight_handler() except Exception: log.exception("Fehler im Main-Modul") From ea0a0243aefd91f40b4a035f8d8576928bde4444 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:15:35 +0200 Subject: [PATCH 44/84] on_time accessible via 1883 (#3626) * on_time accessible via 1883 * fix --- data/config/mosquitto/local/openwb_local.conf | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/data/config/mosquitto/local/openwb_local.conf b/data/config/mosquitto/local/openwb_local.conf index ab42e95798..37a3930ec5 100644 --- a/data/config/mosquitto/local/openwb_local.conf +++ b/data/config/mosquitto/local/openwb_local.conf @@ -1,4 +1,4 @@ -# openwb-version:21 +# openwb-version:22 listener 1886 localhost allow_anonymous true @@ -30,9 +30,10 @@ topic openWB/chargepoint/template/# out 2 topic openWB/consumer/+/module out 2 topic openWB/consumer/+/config out 2 -topic openWB/consumer/+/extra_meter/# out 2 +topic openWB/consumer/+/extra_meter out 2 topic openWB/consumer/+/usage out 2 topic openWB/consumer/+/get/# out 2 +topic openWB/consumer/+/set/# out 2 topic openWB/internal_chargepoint/# out 2 From 41fc4342a1635c05d21025e6aa12c7bc9e5f9442 Mon Sep 17 00:00:00 2001 From: BrettS <168732306+Brett-S-OWB@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:34:00 +0200 Subject: [PATCH 45/84] Add Consumer tab and cards to koala web theme (#3630) * add consumer tab/cards * apply new design to consumer cards * add consumer time on to consumer card * fix consumer card CSS * remove redundant placeholders --- .../source/src/components/ConsumerCard.vue | 178 +++++++++++++ .../src/components/ConsumerInformation.vue | 44 +++ .../source/src/components/ConsumerMessage.vue | 43 +++ .../src/components/ConsumerModeButtons.vue | 72 +++++ .../src/components/ConsumerSettings.vue | 148 +++++++++++ .../source/src/css/quasar.variables.scss | 3 + .../koala/source/src/pages/IndexPage.vue | 24 +- .../source/src/stores/mqtt-store-model.ts | 22 ++ .../koala/source/src/stores/mqtt-store.ts | 251 ++++++++++++++++++ 9 files changed, 777 insertions(+), 8 deletions(-) create mode 100644 packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue create mode 100644 packages/modules/web_themes/koala/source/src/components/ConsumerInformation.vue create mode 100644 packages/modules/web_themes/koala/source/src/components/ConsumerMessage.vue create mode 100644 packages/modules/web_themes/koala/source/src/components/ConsumerModeButtons.vue create mode 100644 packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue diff --git a/packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue b/packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue new file mode 100644 index 0000000000..5d0b9e50d7 --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/packages/modules/web_themes/koala/source/src/components/ConsumerInformation.vue b/packages/modules/web_themes/koala/source/src/components/ConsumerInformation.vue new file mode 100644 index 0000000000..951e4e31d6 --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerInformation.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/packages/modules/web_themes/koala/source/src/components/ConsumerMessage.vue b/packages/modules/web_themes/koala/source/src/components/ConsumerMessage.vue new file mode 100644 index 0000000000..38974ef37d --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerMessage.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/modules/web_themes/koala/source/src/components/ConsumerModeButtons.vue b/packages/modules/web_themes/koala/source/src/components/ConsumerModeButtons.vue new file mode 100644 index 0000000000..d36bca3106 --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerModeButtons.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue b/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue new file mode 100644 index 0000000000..b7907c11fd --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/packages/modules/web_themes/koala/source/src/css/quasar.variables.scss b/packages/modules/web_themes/koala/source/src/css/quasar.variables.scss index 79c8afe197..b53b6b4af4 100644 --- a/packages/modules/web_themes/koala/source/src/css/quasar.variables.scss +++ b/packages/modules/web_themes/koala/source/src/css/quasar.variables.scss @@ -61,6 +61,7 @@ $chart-shadow: $secondary; // energy flow chart: shadow behind the pills and the icon circles $flow-chart-shadow: #000000; $flow-chart-shadow-opacity: 0.12; +$consumer: #7D42F5; // Light theme (default) :root { --q-primary: #{$primary}; @@ -104,6 +105,7 @@ $flow-chart-shadow-opacity: 0.12; --q-diagram-icon: #{$diagram-icon}; --q-table-hover: #{$table-hover}; --q-tab-icon: #{$grey-7}; + --q-consumer: #{$consumer}; // Main background background-color: var(--q-background-1); @@ -305,6 +307,7 @@ $dark-table-hover: #ffffff12; --q-diagram-icon: #{$white}; --q-table-hover: #{$dark-table-hover}; --q-tab-icon: #{$grey-7}; + --q-consumer: #{$consumer}; // Main background background-color: var(--q-background-1); diff --git a/packages/modules/web_themes/koala/source/src/pages/IndexPage.vue b/packages/modules/web_themes/koala/source/src/pages/IndexPage.vue index f41073f613..640d812f61 100644 --- a/packages/modules/web_themes/koala/source/src/pages/IndexPage.vue +++ b/packages/modules/web_themes/koala/source/src/pages/IndexPage.vue @@ -17,9 +17,9 @@ - + + + @@ -39,10 +39,14 @@ > - - + + + + @@ -55,7 +59,7 @@ import ChartCarousel from 'src/components/ChartCarousel.vue'; import ChargePointInformation from 'src/components/ChargePointInformation.vue'; import BatteryInformation from 'src/components/BatteryInformation.vue'; import VehicleInformation from 'src/components/VehicleInformation.vue'; -// import SmartHomeInformation from 'src/components/SmartHomeInformation.vue'; +import ConsumerInformation from 'src/components/ConsumerInformation.vue'; defineOptions({ name: 'IndexPage', @@ -67,6 +71,10 @@ const mqttStore = useMqttStore(); const accessBatteryAllowed = computed(() => { return mqttStore.batteryConfigured && mqttStore.batteryIds.length > 0; }); + +const consumersAvailable = computed(() => { + return mqttStore.consumerIds.length > 0; +}); diff --git a/packages/modules/web_themes/koala/source/src/components/charts/historyChart/HistoryChartLegendCategory.vue b/packages/modules/web_themes/koala/source/src/components/charts/historyChart/HistoryChartLegendCategory.vue index aeec8de4ee..3876526674 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/historyChart/HistoryChartLegendCategory.vue +++ b/packages/modules/web_themes/koala/source/src/components/charts/historyChart/HistoryChartLegendCategory.vue @@ -12,14 +12,14 @@ @@ -60,11 +60,12 @@ diff --git a/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue b/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue index ac06f121b3..2271ecf21a 100644 --- a/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue @@ -17,18 +17,19 @@ - -
Betriebsmodus umstellen
- - - + +
Betriebsmodus umstellen
+ + +
- -
- + +
From 3526efe9609ce6ce4b95735ed4441599ed4c181e Mon Sep 17 00:00:00 2001 From: Brett-S-OWB Date: Fri, 31 Jul 2026 14:57:05 +0200 Subject: [PATCH 61/84] update message - data order in consumer card --- .../koala/source/src/components/ConsumerCard.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue b/packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue index 1fec04913f..5ec53f7e33 100644 --- a/packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue @@ -28,6 +28,10 @@ + + + + @@ -46,10 +50,6 @@ - - - -