diff --git a/packages/helpermodules/changed_values_handler.py b/packages/helpermodules/changed_values_handler.py new file mode 100644 index 0000000000..e60c976da1 --- /dev/null +++ b/packages/helpermodules/changed_values_handler.py @@ -0,0 +1,112 @@ +from dataclasses import asdict, fields, is_dataclass +from enum import Enum +import logging +import threading +from typing import Dict, List, Tuple + +from control.data import Data +from helpermodules.pub import Pub + + +log = logging.getLogger(__name__) + + +# In den Metadaten wird unter dem Key der Topic-Suffix ab "openWB/ev/2/" angegeben. Der Topic-Prefix ("openWB/ev/2/") +# wird automatisch ermittelt. +# Der Key mutable_by_algorithm ist True, wenn die Werte im Algorithmus geändert werden können. Nur diese Werte werden +# automatisiert gepublished. Werte, die an anderer Stelle gepublished werden, wie zB Zählerstände, werden mit False +# gekennzeichnet. Um eine Dokumentation der Topics zu erhalten, sollten die Metadaten dennoch ausgefüllt werden. +# Metadaten werden nur für Felder erzeugt, die gepublished werden sollen, dh bei ganzen Klassen für das Feld der +# jeweiligen Klasse. Wenn Werte aus einer instanziierten Klasse gepublished werden sollen, erhält die übergeordnete +# Klasse keine Metadaten (siehe Beispiel unten). +# Damit die geänderten Werte automatisiert gepublished werden können, muss jede Klasse eine bestimmte Form haben: +# +# @dataclass +# class SampleClass: +# parameter1: bool = False +# parameter2: int = 5 + + +# def sample_class() -> SampleClass: +# return SampleClass() + + +# @dataclass +# class SampleNested: +# parameter1: bool = field(default=False, metadata={"topic": "get/nested1", "mutable_by_algorithm": True}) +# parameter2: int = field(default=0, metadata={"topic": "get/nested2", "mutable_by_algorithm": True}) + + +# def sample_nested() -> SampleNested: +# return SampleNested() + + +# @dataclass +# class SampleData: +# # Wenn eine ganze Klasse als Dictionary gepublished werden soll, wie zB bei Konfigurationen, werden Metadaten für +# diese Klasse eingetragen. Die Felder der Konfigurationsklasse bekommen keine Metadaten, da diese nicht einzeln +# gepublished werden. +# sample_field_class: SampleClass = field( +# default_factory=sample_class, metadata={"topic": "get/field_class", "mutable_by_algorithm": True}) +# sample_field_int: int = field(default=0, metadata={"topic": "get/field_int", "mutable_by_algorithm": True}) +# sample_field_immutable: float = field( +# default=0, metadata={"topic": "get/field_immutable", "mutable_by_algorithm": False}) +# sample_field_list: List = field(default_factory=currents_list_factory, metadata={ +# "topic": "get/field_list", "mutable_by_algorithm": True}) +# # Bei verschachtelten Klassen, wo der zu publishende Wert auf einer tieferen Ebene liegt, werden nur für den zu +# publishenden Wert Metadaten erzeugt. +# sample_field_nested: SampleNested = field(default_factory=sample_nested) + + +# class Sample: +# def __init__(self) -> None: +# self.data = SampleData() + + +class ChangedValuesHandler: + def __init__(self, event_module_update_completed: threading.Event) -> None: + self.prev_data: Data = Data(event_module_update_completed) + + def store_inital_values(self): + # speichern der Daten zum Zyklus-Beginn, um später die geänderten Werte zu ermitteln + self.prev_data.copy_data() + + def pub_changed_values(self): + # publishen der geänderten Werte + pass + + def _update_value(self, topic_prefix, data_inst_previous, data_inst): + for f in fields(data_inst): + try: + changed = False + value = getattr(data_inst, f.name) + if isinstance(value, Enum): + value = value.value + previous_value = getattr(data_inst_previous, f.name) + if isinstance(previous_value, Enum): + previous_value = previous_value.value + if hasattr(f, "metadata"): + if f.metadata.get("mutable_by_algorithm"): + if isinstance(value, (str, int, float, Dict, List, Tuple)): + if previous_value != value: + changed = True + elif isinstance(value, (bool, type(None))): + if previous_value is not value: + changed = True + else: + dict_prev = dict(asdict(previous_value)) + dict_current = dict(asdict(value)) + if dict_prev != dict_current: + changed = True + value = asdict(value) + previous_value = asdict(previous_value) + + if changed: + topic = f"{topic_prefix}{f.metadata['topic']}" + Pub().pub(topic, value) + log.debug(f"Topic {topic}, Payload {value}, vorherige Payload: {previous_value}") + continue + if is_dataclass(value): + self._update_value(topic_prefix, previous_value, value) + except Exception as e: + log.exception(e) diff --git a/packages/helpermodules/changed_values_handler_test.py b/packages/helpermodules/changed_values_handler_test.py new file mode 100644 index 0000000000..d42524dbe4 --- /dev/null +++ b/packages/helpermodules/changed_values_handler_test.py @@ -0,0 +1,112 @@ +from dataclasses import asdict, dataclass, field +from typing import Dict, List, Optional, Tuple +from unittest.mock import Mock + +import pytest + +from control.chargepoint.chargepoint_state import ChargepointState +from dataclass_utils.factories import currents_list_factory +from helpermodules.changed_values_handler import ChangedValuesHandler + +NONE_TYPE = type(None) + + +@dataclass +class SampleClass: + parameter1: bool = False + parameter2: int = 5 + + +def sample_class() -> SampleClass: + return SampleClass() + + +@dataclass +class SampleNested: + parameter1: bool = field(default=False, metadata={"topic": "get/nested1", "mutable_by_algorithm": True}) + parameter2: int = field(default=0, metadata={"topic": "get/nested2", "mutable_by_algorithm": True}) + + +def sample_nested() -> SampleNested: + return SampleNested() + + +def sample_dict_factory() -> Dict: + return {"key": "value"} + + +def sample_tuple_factory() -> Tuple: + return (1, 2, 3) + + +@dataclass +class SampleData: + sample_field_bool: bool = field(default=False, metadata={"topic": "get/field_bool", "mutable_by_algorithm": True}) + sample_field_class: SampleClass = field( + default_factory=sample_class, metadata={"topic": "get/field_class", "mutable_by_algorithm": True}) + sample_field_dict: Dict = field(default_factory=sample_dict_factory, metadata={ + "topic": "get/field_dict", "mutable_by_algorithm": True}) + sample_field_enum: ChargepointState = field(default=ChargepointState.CHARGING_ALLOWED, metadata={ + "topic": "get/field_enum", "mutable_by_algorithm": True}) + sample_field_float: float = field(default=0, metadata={"topic": "get/field_float", "mutable_by_algorithm": True}) + sample_field_int: int = field(default=0, metadata={"topic": "get/field_int", "mutable_by_algorithm": True}) + sample_field_immutable: float = field( + default=0, metadata={"topic": "get/field_immutable", "mutable_by_algorithm": False}) + sample_field_list: List = field(default_factory=currents_list_factory, metadata={ + "topic": "get/field_list", "mutable_by_algorithm": True}) + sample_field_nested: SampleNested = field(default_factory=sample_nested) + sample_field_none: Optional[str] = field( + default="Hi", metadata={"topic": "get/field_none", "mutable_by_algorithm": True}) + sample_field_str: str = field(default="Hi", metadata={"topic": "get/field_str", "mutable_by_algorithm": True}) + sample_field_tuple: Tuple = field(default_factory=sample_tuple_factory, metadata={ + "topic": "get/field_tuple", "mutable_by_algorithm": True}) + + +@dataclass +class Params: + name: str + sample_data: SampleData + expected_pub_call: Tuple = () + expected_calls: int = 1 + + +cases = [ + Params(name="change bool", sample_data=SampleData(sample_field_bool=True), + expected_pub_call=("openWB/get/field_bool", True)), + Params(name="change class", sample_data=SampleData(sample_field_class=SampleClass(parameter1=True)), + expected_pub_call=("openWB/get/field_class", asdict(SampleClass(parameter1=True)))), + Params(name="change dict", sample_data=SampleData(sample_field_dict={"key": "another_value"}), + expected_pub_call=("openWB/get/field_dict", {"key": "another_value"})), + Params(name="change enum", sample_data=SampleData(sample_field_enum=ChargepointState.NO_CHARGING_ALLOWED), + expected_pub_call=("openWB/get/field_enum", ChargepointState.NO_CHARGING_ALLOWED.value)), + Params(name="change float", sample_data=SampleData(sample_field_float=2.5), + expected_pub_call=("openWB/get/field_float", 2.5)), + Params(name="change int", sample_data=SampleData(sample_field_int=2), + expected_pub_call=("openWB/get/field_int", 2)), + Params(name="immutable", sample_data=SampleData(sample_field_immutable=2), expected_calls=0), + Params(name="change list", sample_data=SampleData(sample_field_list=[ + 10, 0, 0]), expected_pub_call=("openWB/get/field_list", [10, 0, 0])), + Params(name="change nested", sample_data=SampleData(sample_field_nested=SampleNested( + parameter1=True)), expected_pub_call=("openWB/get/nested1", True)), + Params(name="change none", sample_data=SampleData(sample_field_none=None), + expected_pub_call=("openWB/get/field_none", None)), + Params(name="change str", sample_data=SampleData(sample_field_str="Hello"), + expected_pub_call=("openWB/get/field_str", "Hello")), + Params(name="change tuple", sample_data=SampleData(sample_field_tuple=(1, 2, 4)), + expected_pub_call=("openWB/get/field_tuple", (1, 2, 4))), + +] + + +@pytest.mark.parametrize("params", cases, ids=[c.name for c in cases]) +def test_update_value(params: Params, mock_pub: Mock): + # setup + handler = ChangedValuesHandler(Mock()) + + # execution + handler._update_value("openWB/", SampleData(), params.sample_data) + + # evaluation + assert len(mock_pub.method_calls) == params.expected_calls + if params.expected_calls > 0: + assert mock_pub.method_calls[0].args == params.expected_pub_call diff --git a/packages/main.py b/packages/main.py index 1c643256c1..c4a170eb3e 100755 --- a/packages/main.py +++ b/packages/main.py @@ -9,9 +9,10 @@ import threading import traceback from threading import Thread + +from helpermodules.changed_values_handler import ChangedValuesHandler from helpermodules.measurement_logging.update_daily_yields import update_daily_yields from helpermodules.measurement_logging.write_log import save_log - from modules import loadvars from modules import configuration from helpermodules import timecheck, update_config @@ -50,6 +51,7 @@ def handler_with_control_interval(): data.data.copy_data() loadvars_.get_values() data.data.copy_data() + changed_values_handler.store_inital_values() self.heartbeat = True if data.data.system_data["system"].data["perform_update"]: data.data.system_data["system"].perform_update() @@ -61,6 +63,7 @@ def handler_with_control_interval(): control.calc_current() proc.process_algorithm_results() data.data.graph_data.pub_graph_data() + changed_values_handler.pub_changed_values() self.interval_counter = 1 else: self.interval_counter = self.interval_counter + 1 @@ -170,6 +173,7 @@ def schedule_jobs(): general_internal_chargepoint_handler = GeneralInternalChargepointHandler() rfid0 = RfidReader("event0") rfid1 = RfidReader("event1") + changed_values_handler = ChangedValuesHandler(loadvars_.event_module_update_completed) event_ev_template = threading.Event() event_ev_template.set() event_charge_template = threading.Event()