diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 2a2cc2513f..def4aafc6c 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -7,7 +7,7 @@ from control.algorithm.utils import get_medium_charging_current from control.chargemode import Chargemode from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState -from control.consumer.consumer_data import ConsumerData, ConsumerUsage, ResetModes, WaitForStartStates +from control.consumer.consumer_data import ConsumerData, ConsumerUsage, WaitForStartStates from control.load_protocol import Load from control.text import format_next_time_charging_start from helpermodules import timecheck @@ -465,26 +465,23 @@ 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_on_time() - 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 (self.data.usage.reset_chargemode.time < timecheck.create_timestamp() < - self.data.usage.reset_chargemode.time + data.data.general_data.data.control_interval): - 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 + if self.data.usage.reset_chargemode.active is False: + return + + control_interval = data.data.general_data.data.control_interval + reset_window_start = -control_interval + remaining_time = timecheck.check_end_time_current_occurrence(self.data.usage.reset_chargemode) + is_within_reset_window = reset_window_start < remaining_time <= 0 + + if (is_within_reset_window and + 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, required_current: float, diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index 42d5c75029..f830321cba 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -5,7 +5,7 @@ from control.chargepoint.control_parameter import ControlParameter, control_parameter_factory from control.consumer.usage import ConsumerUsage from dataclass_utils.factories import empty_list_factory, voltages_list_factory -from helpermodules.abstract_plans import ScheduledPlanConsumer, TimeChargingPlanConsumer +from helpermodules.abstract_plans import ScheduledPlanConsumer, TimeChargingPlanConsumer, TimepointPlan from helpermodules.constants import NO_ERROR from modules.common.consumer_setup import ConsumerSetup @@ -33,16 +33,9 @@ class WaitForStartStates(Enum): 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 +class ResetChargemode(TimepointPlan): + active: bool = False chargemode: Chargemode = Chargemode.INSTANT_CHARGING diff --git a/packages/control/consumer/consumer_test.py b/packages/control/consumer/consumer_test.py index 11cfbf6d36..b64e6b6631 100644 --- a/packages/control/consumer/consumer_test.py +++ b/packages/control/consumer/consumer_test.py @@ -497,3 +497,71 @@ def test_scheduled_charging_calc_current_electricity_tariff( # evaluation assert ret == expected + + +@pytest.mark.parametrize( + "is_active,current_mode,target_mode,remaining_time,expected_mode,expected_check_end_time_calls", + [ + pytest.param( + False, + Chargemode.STOP, + Chargemode.PV_CHARGING, + 0, + Chargemode.STOP, + 0, + id="inactive-noop", + ), + pytest.param( + True, + Chargemode.STOP, + Chargemode.PV_CHARGING, + 5, + Chargemode.STOP, + 1, + id="active-not-due-noop", + ), + pytest.param( + True, + Chargemode.STOP, + Chargemode.PV_CHARGING, + 0, + Chargemode.PV_CHARGING, + 1, + id="active-due-switch", + ), + pytest.param( + True, + Chargemode.PV_CHARGING, + Chargemode.PV_CHARGING, + 0, + Chargemode.PV_CHARGING, + 1, + id="active-due-already-target", + ), + ], +) +def test_reset_chargemode_at_time( + consumer: Consumer, + monkeypatch: pytest.MonkeyPatch, + is_active: bool, + current_mode: Chargemode, + target_mode: Chargemode, + remaining_time: int, + expected_mode: Chargemode, + expected_check_end_time_calls: int, +): + # setup + consumer.data.usage.chargemode = current_mode + consumer.data.usage.reset_chargemode.active = is_active + consumer.data.usage.reset_chargemode.chargemode = target_mode + check_end_time_mock = Mock(return_value=remaining_time) + monkeypatch.setattr(timecheck, "check_end_time_current_occurrence", check_end_time_mock) + + # execution + consumer.reset_chargemode_at_time() + + # evaluation + assert consumer.data.usage.chargemode == expected_mode + assert check_end_time_mock.call_count == expected_check_end_time_calls + if expected_check_end_time_calls: + check_end_time_mock.assert_called_once_with(consumer.data.usage.reset_chargemode) diff --git a/packages/helpermodules/abstract_plans.py b/packages/helpermodules/abstract_plans.py index 65bec56e84..669e7d8078 100644 --- a/packages/helpermodules/abstract_plans.py +++ b/packages/helpermodules/abstract_plans.py @@ -76,19 +76,23 @@ class TimeframePlan(PlanBase): @dataclass -class ScheduledChargingPlan(PlanBase): +class TimepointPlan(PlanBase): + time: str = "07:00" + frequency: FrequencyDate = field(default_factory=frequency_date_factory) + + +@dataclass +class ScheduledChargingPlan(TimepointPlan): bidi_charging_enabled: bool = False bidi_power: int = 10000 current: int = 14 dc_current: float = 145 et_active: bool = False - frequency: FrequencyDate = field(default_factory=frequency_date_factory) id: Optional[int] = None name: str = "neuer Zielladen-Plan" limit: ScheduledLimit = field(default_factory=scheduled_limit_factory) phases_to_use: int = 0 phases_to_use_pv: int = 0 - time: str = "07:00" # ToDo: aktuelle Zeit verwenden @dataclass diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index e4720c4d7f..eeee139646 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -10,7 +10,7 @@ from dateutil.relativedelta import relativedelta from helpermodules.abstract_plans import (AutolockPlan, ScheduledChargingPlan, ScheduledPlanConsumer, - TimeChargingPlan, TimeChargingPlanConsumer) + TimeChargingPlan, TimeChargingPlanConsumer, TimepointPlan) log = logging.getLogger(__name__) @@ -201,8 +201,8 @@ def _get_next_start_for_timeframe_plan(plan: T, now: datetime.datetime) -> Optio return None -def check_end_time(plan: Union[ScheduledPlanConsumer, ScheduledChargingPlan, TimeChargingPlanConsumer], - buffer: Optional[float]) -> float: +def check_end_time(plan: Union[ScheduledPlanConsumer, ScheduledChargingPlan, TimeChargingPlanConsumer, TimepointPlan], + buffer: float = 0) -> float: """ gibt die verbleibende Zeit in Sekunden zurück. Return @@ -238,6 +238,33 @@ def check_end_time(plan: Union[ScheduledPlanConsumer, ScheduledChargingPlan, Tim return remaining_time.total_seconds() +def check_end_time_current_occurrence(plan: TimepointPlan) -> float: + now = datetime.datetime.today() + end = datetime.datetime.strptime(plan.time, '%H:%M') + + if plan.frequency.selected == "once": + end_date = datetime.datetime.strptime(plan.frequency.once, "%Y-%m-%d") + end = end.replace(end_date.year, end_date.month, end_date.day) + elif plan.frequency.selected == "daily": + end = end.replace(now.year, now.month, now.day) + elif plan.frequency.selected == "weekly": + if not any(plan.frequency.weekly): + raise ValueError("Es muss mindestens ein Tag ausgewählt werden.") + end = end.replace(now.year, now.month, now.day) + today_weekday = now.weekday() + if not plan.frequency.weekly[today_weekday]: + days_to_previous_selected = next( + day_offset + for day_offset in range(1, 7) + if plan.frequency.weekly[(today_weekday - day_offset) % 7] + ) + end = end - datetime.timedelta(days=days_to_previous_selected) + else: + raise TypeError(f'Unbekannte Häufigkeit {plan.frequency.selected}') + + return (end - now).total_seconds() + + def _get_next_charging_day(weekly: List[bool], weekday: int) -> int: count = 0 for i in range(weekday, len(weekly)): diff --git a/packages/helpermodules/timecheck_test.py b/packages/helpermodules/timecheck_test.py index 143d010aac..3aa646e21d 100644 --- a/packages/helpermodules/timecheck_test.py +++ b/packages/helpermodules/timecheck_test.py @@ -49,6 +49,40 @@ def test_check_end_time(time: str, assert remaining_time == expected_remaining_time +@pytest.mark.parametrize( + "time, selected, date,expected_remaining_time", + [ + pytest.param("9:00", "once", "2022-05-16", 1148, id="once-future-not-due"), + pytest.param("12:00", "daily", [], 11948, id="daily-future-not-due"), + pytest.param("8:05", "daily", [], -2152, id="daily-past-due"), + pytest.param("9:00", "weekly", [True, False, False, False, False, False, False], 1148, + id="weekly-selected-today-future"), + pytest.param("8:05", "weekly", [True, False, False, False, False, False, False], -2152, + id="weekly-selected-today-past-due"), + pytest.param("9:00", "weekly", [False, False, False, False, False, False, True], -85252, + id="weekly-not-selected-today-use-previous-selected"), + ], +) +def test_check_end_time_current_occurrence( + time: str, + selected: str, + date: Union[List[bool], str, List], + expected_remaining_time: float, +): + # setup + plan = Mock(spec=ScheduledChargingPlan, time=time, frequency=Mock(spec=FrequencyDate, selected=selected)) + if selected == "weekly": + setattr(plan.frequency, "weekly", date) + elif selected == "once": + setattr(plan.frequency, "once", date) + + # execution + remaining_time = timecheck.check_end_time_current_occurrence(plan) + + # evaluation + assert remaining_time == expected_remaining_time + + @pytest.mark.parametrize("weekday, weekly, expected_days", [pytest.param(0, [True, True, False, False, False, False, False], 0), pytest.param(1, [True, False, False, False, False, False, False], 6), diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 5777eb80e8..df9bd4824a 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -14,6 +14,7 @@ from paho.mqtt.client import Client as MqttClient, MQTTMessage from control.chargemode import Chargemode +from control.consumer.consumer_data import ResetChargemode from control.consumer.usage import NOT_CONTROLLED from control.limiting_value import LoadmanagementLimit @@ -22,7 +23,7 @@ from helpermodules import hardware_configuration from helpermodules import pub from helpermodules.broker import BrokerClient -from helpermodules.abstract_plans import Limit +from helpermodules.abstract_plans import FrequencyDate, Limit from helpermodules.constants import DEFAULT_COLORS, NO_ERROR from helpermodules.hardware_configuration import ( get_hardware_configuration_setting, @@ -62,7 +63,7 @@ class UpdateConfig: - DATASTORE_VERSION = 152 + DATASTORE_VERSION = 153 FILE_OPERATION_VERSION = 0 @@ -4070,3 +4071,35 @@ def upgrade_datastore_152(self): else: remove_acl_role("consumer--write-access", consumer_id) self._append_datastore_version(152) + + def upgrade_datastore_153(self) -> None: + def upgrade(topic: str, payload) -> Optional[dict]: + if re.search(r"^openWB/consumer/[0-9]+/usage$", topic) is not None: + usage = decode_payload(payload) + reset_chargemode = usage["reset_chargemode"] + if reset_chargemode.get("mode") is not None: + legacy_target_chargemode = reset_chargemode.get("chargemode", Chargemode.INSTANT_CHARGING) + # altes Schema + if reset_chargemode["mode"] == "never": + reset_chargemode = ResetChargemode(active=False, chargemode=legacy_target_chargemode) + elif reset_chargemode["mode"] == "midnight": + reset_chargemode = ResetChargemode(active=True, time="00:00", + frequency=FrequencyDate(selected="daily"), + chargemode=legacy_target_chargemode) + elif reset_chargemode["mode"] == "time": + timestamp = reset_chargemode["time"] + if timestamp is not None: + date = datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d") + time = datetime.datetime.fromtimestamp(timestamp).strftime("%H:%M") + else: + date = datetime.datetime.today().strftime("%Y-%m-%d") + time = "07:00" + reset_chargemode = ResetChargemode( + active=True, time=time, frequency=FrequencyDate(selected="once", once=date), + chargemode=legacy_target_chargemode) + + usage["reset_chargemode"] = reset_chargemode + return {topic: asdict(usage)} + return None + self._loop_all_received_topics(upgrade) + self._append_datastore_version(153) diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index 1938dcc7ea..e05490039b 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -1,4 +1,5 @@ -from typing import List +import datetime +from typing import List, Optional from unittest.mock import Mock, patch, mock_open from control.chargemode import Chargemode from control.ev.charge_template import ChargeTemplate @@ -487,16 +488,65 @@ def test_upgrade_datastore_151_removes_linked_extra_meter_counters_from_hierarch uc.upgrade_datastore_151() - assert uc.all_received_topics["openWB/counter/get/hierarchy"] == [{ - "id": 0, - "type": "counter", - "children": [ - {"id": 2, "type": "consumer", "children": []}, - {"id": 6, "type": "counter", "children": []}, - ], - }] - assert uc.all_received_topics["openWB/system/datastore_version"] == list(range(152)) - assert mock_pub.pub.call_count == 2 + +@pytest.mark.parametrize( + "legacy_reset, expected_active, expected_time, expected_selected, expected_once, expected_chargemode", + [ + pytest.param( + {"mode": "never", "time": None, "chargemode": "pv_charging"}, + False, + None, + None, + None, + "pv_charging", + id="legacy-never", + ), + pytest.param( + {"mode": "midnight", "time": None, "chargemode": "eco_charging"}, + True, + "00:00", + "daily", + None, + "eco_charging", + id="legacy-midnight", + ), + pytest.param( + {"mode": "time", "time": 1735687800, "chargemode": "stop"}, + True, + datetime.datetime.fromtimestamp(1735687800).strftime("%H:%M"), + "once", + datetime.datetime.fromtimestamp(1735687800).strftime("%Y-%m-%d"), + "stop", + id="legacy-time-with-timestamp", + ), + ], +) +def test_upgrade_datastore_153_migrates_legacy_modes( + legacy_reset: dict, + expected_active: bool, + expected_time: Optional[str], + expected_selected: Optional[str], + expected_once: Optional[str], + expected_chargemode: str, +): + uc = UpdateConfig() + uc.all_received_topics = { + "openWB/consumer/1/usage": {"reset_chargemode": legacy_reset}, + "openWB/system/datastore_version": [152], + } + + uc.upgrade_datastore_153() + + result = uc.all_received_topics["openWB/consumer/1/usage"]["reset_chargemode"] + assert result["active"] is expected_active + if expected_time is not None: + assert result["time"] == expected_time + if expected_selected is not None: + assert result["frequency"]["selected"] == expected_selected + if expected_once is not None: + assert result["frequency"]["once"] == expected_once + assert result["chargemode"] == expected_chargemode + assert uc.all_received_topics["openWB/system/datastore_version"] == [152, 153] @pytest.mark.parametrize("file_operation_version, finished, expected_calls", [ 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 b8d9773054..75ab0e89aa 100644 --- a/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue @@ -18,38 +18,59 @@ -
Betriebsmodus umstellen
- - +
Betriebsmodus umstellen
+ -
+ + +