From 3a27491e35dd85418eae33dc562eb1e197c20dcf Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 23 Oct 2026 16:10:29 +0200 Subject: [PATCH 1/3] =?UTF-8?q?RFID=20f=C3=BCr=20Verbraucher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/control/chargepoint/rfid.py | 2 +- packages/control/consumer/consumer.py | 7 +- packages/control/consumer/consumer_data.py | 30 +- packages/control/consumer/rfid.py | 61 +++++ packages/control/consumer/rfid_test.py | 257 ++++++++++++++++++ packages/helpermodules/setdata.py | 10 +- packages/helpermodules/update_config.py | 8 + packages/modules/common/component_state.py | 4 + .../modules/common/configurable_consumer.py | 8 +- packages/modules/common/store/_consumer.py | 4 + .../modules/common/store/_consumer_test.py | 18 ++ .../consumers/generic/mqtt/consumer.py | 30 +- .../consumers/generic/mqtt/consumer_test.py | 38 +++ 13 files changed, 459 insertions(+), 18 deletions(-) create mode 100644 packages/control/consumer/rfid.py create mode 100644 packages/control/consumer/rfid_test.py create mode 100644 packages/modules/common/store/_consumer_test.py create mode 100644 packages/modules/consumers/generic/mqtt/consumer_test.py diff --git a/packages/control/chargepoint/rfid.py b/packages/control/chargepoint/rfid.py index 6d0fc9a5fd..bee5647ee4 100644 --- a/packages/control/chargepoint/rfid.py +++ b/packages/control/chargepoint/rfid.py @@ -71,7 +71,7 @@ def _validate_rfid(self) -> None: else: msg = "Nach Ladestart wird kein anderer ID-Tag akzeptiert." else: - msg = f"Der ID-Tag {rfid} ist an diesem Ladepunkt nicht gültig." + msg = f"Der ID-Tag {rfid} ist hier keinem Ladepunkt-Profil oder Fahrzeug zugeordnet." else: msg = "Identifikation von Fahrzeugen ist nicht aktiviert." self.data.get.rfid = None diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index baff6e5e61..1675f10991 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -4,6 +4,7 @@ from control import data from control.consumer.usage import NOT_CONTROLLED, ON_OFF +from control.consumer.rfid import ConsumerRfidMixin from control.algorithm.utils import get_medium_charging_current from control.chargemode import Chargemode from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState @@ -18,7 +19,7 @@ log = logging.getLogger(__name__) -class Consumer(Load): +class Consumer(ConsumerRfidMixin, Load): PAUSE_BETWEEN_WAIT_FOR_START_TEST_RUNS = 3600 STANDBY_THRESHOLD = 0.15 # 35 Watt pro Phase @@ -59,6 +60,7 @@ def update(self): self.data.set.phases_to_use = self.data.config.connected_phases self.data.get.charge_state = max(self.data.get.currents) > self.STANDBY_THRESHOLD self.process_on_time() + self.update_rfid() if self.data.usage.type in NOT_CONTROLLED: return @@ -121,6 +123,9 @@ def is_switch_interval_elapsed(self): "Verbraucher nicht abgeschaltet werden darf.") def get_parameter(self) -> Tuple[float, Optional[str], Optional[Chargemode], Chargemode]: + if self.rfid_required() and self.data.set.rfid is None: + return (0, "Verbraucher gesperrt. Bitte per RFID freischalten.", + self.data.usage.chargemode, Chargemode.STOP) if self.data.set.switch_interval_elapsed is False: log.debug("Intervall für neuen Schaltbefehl nicht abgelaufen.") return (max(self.data.get.currents), diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index 9bd5493a8c..6cecb7733a 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from enum import Enum -from typing import List, Optional +from typing import List, Optional, Protocol from control.chargemode import Chargemode from control.chargepoint.control_parameter import ControlParameter, control_parameter_factory from control.consumer.usage import ConsumerUsage @@ -33,6 +33,11 @@ class WaitForStartStates(Enum): START_SIGNAL_RECEIVED = "start_signal_received" +class RfidReset(Enum): + AFTER_TIME = "after_time" + AFTER_POWER_END = "after_power_end" + + @dataclass class ResetChargemode(TimepointPlan): active: bool = False @@ -58,10 +63,16 @@ class ConsumerConfig: min_power: float = 115 min_interval: int = 60 is_home_consumption_consumer: str = "auto_home_consumption" + rfid_enabled: bool = False + valid_tags: List[str] = field(default_factory=empty_list_factory) + rfid_reset: RfidReset = RfidReset.AFTER_TIME + rfid_timeout: int = 300 @dataclass class Get: + rfid: Optional[str] = field(default=None, metadata={"topic": "get/rfid"}) + rfid_timestamp: Optional[float] = field(default=None, metadata={"topic": "get/rfid_timestamp"}) charge_state: bool = field(default=False, metadata={"topic": "get/charge_state"}) currents: List[float] = field(default_factory=currents_list_factory) daily_imported: float = field(default=0, metadata={"topic": "get/daily_imported"}) @@ -82,6 +93,10 @@ class Get: @dataclass class Set: + rfid: Optional[str] = field(default=None, metadata={"topic": "set/rfid"}) + rfid_timestamp: Optional[float] = field(default=None, metadata={"topic": "set/rfid_timestamp"}) + rfid_power_end: Optional[float] = field(default=None, metadata={"topic": "set/rfid_power_end"}) + rfid_power_seen: bool = field(default=False, metadata={"topic": "set/rfid_power_seen"}) current: float = field(default=0, metadata={"topic": "set/current"}) 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"}) @@ -109,3 +124,16 @@ class ConsumerData: config: ConsumerConfig = field(default_factory=lambda: ConsumerConfig()) extra_meter: Optional[int] = None usage: Usage = field(default_factory=lambda: Usage(), metadata={"topic": "usage"}) + + +class ConsumerRfidProtocol(Protocol): + @property + def data(self) -> ConsumerData: ... + @property + def module(self) -> ConsumerSetup: ... + + def set_state_and_log(self, message: Optional[str]) -> None: ... + def rfid_required(self) -> bool: ... + def update_rfid(self) -> None: ... + def clear_rfid(self) -> None: ... + def reset_rfid(self) -> None: ... diff --git a/packages/control/consumer/rfid.py b/packages/control/consumer/rfid.py new file mode 100644 index 0000000000..dafe50de8c --- /dev/null +++ b/packages/control/consumer/rfid.py @@ -0,0 +1,61 @@ +from fnmatch import fnmatch + +from control import data +from control.consumer.consumer_data import ConsumerRfidProtocol, RfidReset +from control.consumer.usage import NOT_CONTROLLED +from helpermodules import timecheck + + +def rfid_supported(module) -> bool: + return getattr(module, "type", None) == "mqtt" + + +class ConsumerRfidMixin: + def rfid_required(self: ConsumerRfidProtocol) -> bool: + return (self.data.usage.type not in NOT_CONTROLLED and self.data.extra_meter is None and + rfid_supported(self.data.module) and self.data.config.rfid_enabled and + data.data.optional_data.data.rfid.active) + + def update_rfid(self: ConsumerRfidProtocol) -> None: + state = self.data.set + tag = self.data.get.rfid + if tag is not None: + if self.rfid_required(): + if any(fnmatch(tag.lower(), pattern.lower()) for pattern in self.data.config.valid_tags): + state.rfid = tag + state.rfid_timestamp = (self.data.get.rfid_timestamp if self.data.get.rfid_timestamp is not None + else timecheck.create_timestamp()) + state.rfid_power_seen = False + state.rfid_power_end = None + else: + self.set_state_and_log(f"Der ID-Tag {tag} ist diesem Verbraucher nicht zugeordnet.") + self.clear_rfid() + if not self.rfid_required(): + self.reset_rfid() + return + if state.rfid is None: + return + now = timecheck.create_timestamp() + if self.data.config.rfid_reset == RfidReset.AFTER_POWER_END: + if self.data.get.charge_state: + state.rfid_power_seen = True + state.rfid_power_end = None + elif state.rfid_power_seen and state.rfid_power_end is None: + state.rfid_power_end = now + timestamp = state.rfid_power_end + else: + timestamp = state.rfid_timestamp + if timestamp is not None and now - timestamp >= self.data.config.rfid_timeout: + self.reset_rfid() + + def clear_rfid(self: ConsumerRfidProtocol) -> None: + """Verarbeiteten Eingangstag löschen.""" + self.data.get.rfid = None + self.data.get.rfid_timestamp = None + self.module.clear_rfid() + + def reset_rfid(self: ConsumerRfidProtocol) -> None: + self.data.set.rfid = None + self.data.set.rfid_timestamp = None + self.data.set.rfid_power_seen = False + self.data.set.rfid_power_end = None diff --git a/packages/control/consumer/rfid_test.py b/packages/control/consumer/rfid_test.py new file mode 100644 index 0000000000..338d5fc15f --- /dev/null +++ b/packages/control/consumer/rfid_test.py @@ -0,0 +1,257 @@ +import json +from copy import deepcopy +from unittest.mock import Mock, call + +import pytest + +from control import data, optional +from control.chargemode import Chargemode +from control.consumer.consumer import Consumer +from control.consumer.consumer_data import ConsumerConfig, RfidReset +from control.consumer.usage import ConsumerUsage +from dataclass_utils import asdict, dataclass_from_dict +from helpermodules.subdata import SubData +from helpermodules.changed_values_handler import ChangedValuesHandler + + +@pytest.fixture +def consumer(monkeypatch): + data.data_init(Mock()) + data.data.optional_data = optional.Optional() + data.data.optional_data.data.rfid.active = True + consumer = Consumer(1) + consumer.module = Mock() + consumer.data.module = Mock(type="mqtt") + consumer.data.config.rfid_enabled = True + consumer.data.config.valid_tags = ["AB??", "1234"] + consumer.data.usage.type = ConsumerUsage.SUSPENDABLE_ONOFF + consumer.data.get.voltages = [230] * 3 + consumer.data.set.switch_interval_elapsed = True + return consumer + + +def test_clear_rfid_publishes_input_resets_through_changed_values_handler(consumer, mock_pub): + consumer.data.get.rfid = "1234" + consumer.data.get.rfid_timestamp = 1000 + consumer.data.set.rfid = "1234" + consumer.data.set.rfid_timestamp = 1000 + previous = deepcopy(consumer.data) + handler = ChangedValuesHandler(Mock()) + mock_pub.pub.reset_mock() + + consumer.clear_rfid() + + assert consumer.data.get.rfid is None + assert consumer.data.get.rfid_timestamp is None + assert consumer.data.set.rfid == "1234" + assert consumer.data.set.rfid_timestamp == 1000 + consumer.module.clear_rfid.assert_called_once_with() + mock_pub.pub.assert_not_called() + + handler._update_value("openWB/set/consumer/1/", previous, consumer.data) + + assert mock_pub.pub.call_args_list == [call("openWB/set/consumer/1/get/rfid", None), + call("openWB/set/consumer/1/get/rfid_timestamp", None)] + + +@pytest.mark.parametrize("mode", list(RfidReset)) +def test_rfid_reset_config_keeps_existing_mqtt_values(mode): + config = dataclass_from_dict(ConsumerConfig, {"rfid_reset": mode.value}) + assert config.rfid_reset is mode + assert asdict(config)["rfid_reset"] == mode.value + + config = ConsumerConfig() + assert config.rfid_reset is RfidReset.AFTER_TIME + subdata = SubData.__new__(SubData) + subdata.set_json_payload_class( + config, Mock(topic="openWB/consumer/1/config", payload=json.dumps({"rfid_reset": mode.value}).encode())) + assert config.rfid_reset is mode + + +@pytest.mark.parametrize("timestamp, expected_timestamp, expected_tag", [ + (None, 1000, "1234"), + (900, 900, "1234"), + (600, None, None), + (0, None, None), +]) +def test_input_timestamp_sets_release_start_and_expiry( + consumer, monkeypatch, timestamp, expected_timestamp, expected_tag): + monkeypatch.setattr("control.consumer.rfid.timecheck.create_timestamp", Mock(return_value=1000)) + consumer.data.get.rfid = "1234" + consumer.data.get.rfid_timestamp = timestamp + + consumer.update_rfid() + + assert consumer.data.set.rfid == expected_tag + assert consumer.data.set.rfid_timestamp == expected_timestamp + assert consumer.data.get.rfid is None + assert consumer.data.get.rfid_timestamp is None + consumer.module.clear_rfid.assert_called_once_with() + + +@pytest.mark.parametrize("tag", ["1234", "unknown"]) +def test_module_input_is_cleared_after_tag_evaluation(consumer, tag): + consumer.data.get.rfid = tag + + def check_evaluated_tag(): + assert consumer.data.get.rfid is None + if tag == "1234": + assert consumer.data.set.rfid == tag + else: + assert consumer.data.set.rfid is None + assert "nicht zugeordnet" in consumer.data.get.state_str + + consumer.module.clear_rfid.side_effect = check_evaluated_tag + consumer.update_rfid() + consumer.module.clear_rfid.assert_called_once_with() + consumer.update_rfid() + consumer.module.clear_rfid.assert_called_once_with() + + +@pytest.mark.parametrize("mode", list(Chargemode)) +def test_locked_consumer_overrides_modes_and_switch_interval(consumer, mode): + consumer.data.usage.chargemode = mode + consumer.data.usage.time_charging.active = True + + current, message, _, submode = consumer.get_parameter() + + assert current == 0 + assert submode == Chargemode.STOP + assert "RFID" in message + + +def test_tag_matching_expiry_and_rescan(consumer, monkeypatch): + clock = Mock(return_value=1000) + monkeypatch.setattr("control.consumer.rfid.timecheck.create_timestamp", clock) + consumer.data.get.rfid = "ab12" + consumer.update_rfid() + assert consumer.data.get.rfid is None + assert consumer.get_parameter()[0] > 0 + + clock.return_value = 1299 + consumer.data.get.rfid = "unknown" + consumer.update_rfid() + assert consumer.data.set.rfid == "ab12" + assert "nicht zugeordnet" in consumer.data.get.state_str + + clock.return_value = 1300 + consumer.update_rfid() + assert consumer.data.set.rfid is None + assert consumer.get_parameter()[0] == 0 + + consumer.data.get.rfid = "1234" + consumer.update_rfid() + assert consumer.data.set.rfid_timestamp == 1300 + assert consumer.get_parameter()[0] > 0 + + +def test_power_end_timeout_waits_for_operation_and_restarts_after_pause(consumer, monkeypatch): + clock = Mock(return_value=1000) + monkeypatch.setattr("control.consumer.rfid.timecheck.create_timestamp", clock) + consumer.data.config.rfid_reset = RfidReset.AFTER_POWER_END + consumer.data.get.rfid = "1234" + consumer.update_rfid() + + clock.return_value = 2000 + consumer.update_rfid() + assert consumer.data.set.rfid == "1234" + assert consumer.data.set.rfid_power_end is None + + consumer.data.get.charge_state = True + consumer.update_rfid() + consumer.data.get.charge_state = False + consumer.update_rfid() + assert consumer.data.set.rfid_power_end == 2000 + + clock.return_value = 2299 + consumer.data.get.charge_state = True + consumer.update_rfid() + assert consumer.data.set.rfid_power_end is None + + clock.return_value = 2500 + consumer.data.get.charge_state = False + consumer.update_rfid() + clock.return_value = 2799 + consumer.update_rfid() + assert consumer.data.set.rfid == "1234" + clock.return_value = 2800 + consumer.update_rfid() + assert consumer.data.set.rfid is None + assert consumer.get_parameter()[0] == 0 + + +@pytest.mark.parametrize("disabled", ["global", "consumer", "unsupported", "extra_meter"]) +def test_inactive_identification_does_not_restrict_operation(consumer, disabled): + if disabled == "global": + data.data.optional_data.data.rfid.active = False + elif disabled == "consumer": + consumer.data.config.rfid_enabled = False + elif disabled == "extra_meter": + consumer.data.extra_meter = 2 + else: + consumer.data.module.type = "shelly" + consumer.data.set.rfid = "old" + consumer.data.get.rfid = "1234" + + consumer.update_rfid() + + assert consumer.data.set.rfid is None + assert consumer.get_parameter()[0] > 0 + + +@pytest.mark.parametrize("extra_meter", [0, 2]) +def test_switching_meter_disables_rfid_and_requires_new_authorization_on_return(consumer, extra_meter): + consumer.data.get.rfid = "1234" + consumer.update_rfid() + assert consumer.rfid_required() + assert consumer.data.set.rfid == "1234" + + consumer.data.extra_meter = extra_meter + assert not consumer.rfid_required() + consumer.update_rfid() + assert consumer.data.set.rfid is None + assert consumer.data.set.rfid_timestamp is None + assert consumer.data.set.rfid_power_seen is False + assert consumer.data.set.rfid_power_end is None + assert consumer.get_parameter()[0] > 0 + + consumer.data.extra_meter = None + assert consumer.rfid_required() + assert consumer.get_parameter()[0] == 0 + + +@pytest.mark.parametrize("usage", [ConsumerUsage.METER_ONLY, ConsumerUsage.SELF_CONTROLLED]) +@pytest.mark.parametrize("pending_tag", [None, "1234"]) +def test_uncontrolled_usage_clears_rfid_and_requires_new_scan_on_return(consumer, usage, pending_tag): + consumer.data.get.rfid = "1234" + consumer.update_rfid() + assert consumer.data.set.rfid == "1234" + consumer.data.set.rfid_power_seen = True + consumer.data.set.rfid_power_end = 1000 + consumer.module.clear_rfid.reset_mock() + + consumer.data.usage.type = usage + consumer.data.get.rfid = pending_tag + consumer.data.get.rfid_timestamp = 1000 if pending_tag else None + assert not consumer.rfid_required() + + consumer.update() + + assert consumer.data.set.rfid is None + assert consumer.data.set.rfid_timestamp is None + assert consumer.data.set.rfid_power_seen is False + assert consumer.data.set.rfid_power_end is None + assert consumer.data.get.rfid is None + assert consumer.data.get.rfid_timestamp is None + if pending_tag: + consumer.module.clear_rfid.assert_called_once_with() + else: + consumer.module.clear_rfid.assert_not_called() + + consumer.data.usage.type = ConsumerUsage.SUSPENDABLE_ONOFF + assert consumer.rfid_required() + assert consumer.get_parameter()[0] == 0 + + consumer.data.get.rfid = "1234" + consumer.update_rfid() + assert consumer.get_parameter()[0] > 0 diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 803dd101f5..64cf820e86 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -1294,13 +1294,16 @@ def process_consumer_topic(self, msg): 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 + elif (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/rfid_power_end$", msg.topic) is not None or re.search("consumer/[0-9]+/set/timestamp_last_current_set$", msg.topic) is not None or re.search("consumer/[0-9]+/set/timestamp_wrote_last_on_time$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/rfid_timestamp$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/power$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/rfid_timestamp$", msg.topic) is not None or re.search("consumer/[0-9]+/get/set_power$", msg.topic) is not None): self._validate_value(msg, float) elif (re.search("consumer/[0-9]+/get/currents$", msg.topic) is not None or @@ -1319,10 +1322,13 @@ def process_consumer_topic(self, msg): elif (re.search("consumer/get/fault_str$", msg.topic) is not None or 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 or + re.search("consumer/[0-9]+/get/rfid$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/rfid$", 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/charge_state$", msg.topic) is not None or re.search("consumer/[0-9]+/get/plug_state$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/rfid_power_seen$", msg.topic) is not None or 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: diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index aa2e69aa40..e74a8f2dfe 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -225,6 +225,12 @@ class UpdateConfig: "^openWB/consumer/[0-9]+/control_parameter/timestamp_switch_on_off$", "^openWB/consumer/[0-9]+/get/charge_state$", "^openWB/consumer/[0-9]+/get/currents$", + "^openWB/consumer/[0-9]+/get/rfid$", + "^openWB/consumer/[0-9]+/get/rfid_timestamp$", + "^openWB/consumer/[0-9]+/set/rfid$", + "^openWB/consumer/[0-9]+/set/rfid_timestamp$", + "^openWB/consumer/[0-9]+/set/rfid_power_end$", + "^openWB/consumer/[0-9]+/set/rfid_power_seen$", "^openWB/consumer/[0-9]+/get/exported$", "^openWB/consumer/[0-9]+/get/fault_state$", "^openWB/consumer/[0-9]+/get/fault_str$", @@ -388,6 +394,8 @@ class UpdateConfig: "^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/rfid$", + "^openWB/mqtt/consumer/[0-9]+/get/rfid_timestamp$", "^openWB/mqtt/consumer/[0-9]+/get/imported$", "^openWB/mqtt/consumer/[0-9]+/get/exported$", "^openWB/mqtt/consumer/[0-9]+/get/power$", diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index fae55fbbdd..37abb69089 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -295,6 +295,8 @@ def __init__( set_power: Optional[float] = None, state: Optional[bool] = False, temperatures: Optional[List[Optional[float]]] = None, + rfid: Optional[str] = None, + rfid_timestamp: Optional[float] = None, ): """Args: imported: total imported energy in Wh @@ -313,3 +315,5 @@ def __init__( self.set_power = set_power self.state = state self.temperatures = temperatures + self.rfid = rfid + self.rfid_timestamp = rfid_timestamp diff --git a/packages/modules/common/configurable_consumer.py b/packages/modules/common/configurable_consumer.py index bc056e40fd..e6902724c9 100644 --- a/packages/modules/common/configurable_consumer.py +++ b/packages/modules/common/configurable_consumer.py @@ -33,7 +33,8 @@ def __init__(self, send_values: Optional[Callable[[CurrentValues], None]] = lambda _values: None, set_power_limit: Optional[Callable] = lambda: None, switch_on: Optional[Callable] = lambda: None, - switch_off: Optional[Callable] = lambda: None,) -> None: + switch_off: Optional[Callable] = lambda: None, + clear_rfid: Optional[Callable] = lambda: None,) -> None: self.config = consumer_config self.module_initializer = initializer self.module_error_handler = error_handler @@ -42,6 +43,7 @@ def __init__(self, self.module_set_power_limit = set_power_limit self.module_switch_on = switch_on self.module_switch_off = switch_off + self.module_clear_rfid = clear_rfid self.error_timestamp = None self.sim_counter = SimCounterConsumer(self.config.id, ComponentType.CONSUMER) @@ -104,3 +106,7 @@ def switch_off(self) -> None: def send_values(self, values: CurrentValues) -> None: with SingleComponentUpdateContext(self.fault_state): self.module_send_values(values) + + def clear_rfid(self) -> None: + with SingleComponentUpdateContext(self.fault_state): + self.module_clear_rfid() diff --git a/packages/modules/common/store/_consumer.py b/packages/modules/common/store/_consumer.py index 006eb09392..ecc3c4401f 100644 --- a/packages/modules/common/store/_consumer.py +++ b/packages/modules/common/store/_consumer.py @@ -31,6 +31,10 @@ def update(self) -> None: 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) + if self.state.rfid is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/rfid", self.state.rfid) + if self.state.rfid_timestamp is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/rfid_timestamp", self.state.rfid_timestamp) class PurgeConsumerState(ValueStore[ConsumerState]): diff --git a/packages/modules/common/store/_consumer_test.py b/packages/modules/common/store/_consumer_test.py new file mode 100644 index 0000000000..f01df80d66 --- /dev/null +++ b/packages/modules/common/store/_consumer_test.py @@ -0,0 +1,18 @@ +import pytest + +from modules.common.component_state import ConsumerState +from modules.common.store import _consumer + + +@pytest.mark.parametrize("tag", ["0012345678", None]) +@pytest.mark.parametrize("timestamp", [1000.5, None]) +def test_consumer_store_forwards_rfid_without_clearing_pending_input(tag, timestamp, mock_pub): + store = _consumer.ConsumerValueStoreBroker(1) + store.set(ConsumerState(rfid=tag, rfid_timestamp=timestamp)) + + store.update() + + for key, value in [("rfid", tag), ("rfid_timestamp", timestamp)]: + topic = f"openWB/set/consumer/1/get/{key}" + calls = [call.args for call in mock_pub.pub.call_args_list if call.args[0] == topic] + assert calls == ([(topic, value)] if value is not None else []) diff --git a/packages/modules/consumers/generic/mqtt/consumer.py b/packages/modules/consumers/generic/mqtt/consumer.py index 811715aa0b..5fbe26d157 100644 --- a/packages/modules/consumers/generic/mqtt/consumer.py +++ b/packages/modules/consumers/generic/mqtt/consumer.py @@ -15,6 +15,10 @@ def create_consumer(config: Mqtt): + def clear_rfid() -> None: + Pub().pub(f"openWB/mqtt/consumer/{config.id}/get/rfid", None) + Pub().pub(f"openWB/mqtt/consumer/{config.id}/get/rfid_timestamp", None) + def update() -> ConsumerState: def parse_received_topics(value: str): return received_topics.get(f"{topic_prefix}{value}", get_default(ConsumerState, value)) @@ -31,21 +35,22 @@ def on_message(client, userdata, message): log.debug(f"Empfange MQTT Daten für Verbraucher {config.id}: {received_topics}") topic_prefix = f"openWB/mqtt/consumer/{config.id}/get/" + rfid = received_topics.get(f"{topic_prefix}rfid") try: power = received_topics[f"{topic_prefix}power"] - imported = parse_received_topics("imported") - exported = parse_received_topics("exported") - return ConsumerState( - power=power, - imported=imported, - exported=exported, - powers=parse_received_topics("powers"), - voltages=parse_received_topics("voltages"), - currents=parse_received_topics("currents"), - temperatures=parse_received_topics("temperatures"), - ) except KeyError: raise KeyError("Es wurden nicht alle notwendigen Daten empfangen.") + return ConsumerState( + power=power, + imported=parse_received_topics("imported"), + exported=parse_received_topics("exported"), + powers=parse_received_topics("powers"), + voltages=parse_received_topics("voltages"), + currents=parse_received_topics("currents"), + temperatures=parse_received_topics("temperatures"), + rfid=str(rfid) if rfid else None, + rfid_timestamp=received_topics.get(f"{topic_prefix}rfid_timestamp") if rfid else None, + ) def set_power_limit(power_limit: float, data: SetLimitData) -> None: Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/power", power_limit) @@ -69,7 +74,8 @@ def send_values(values: CurrentValues) -> None: send_values=send_values, set_power_limit=set_power_limit, switch_on=switch_on, - switch_off=switch_off) + switch_off=switch_off, + clear_rfid=clear_rfid) device_descriptor = DeviceDescriptor(configuration_factory=Mqtt) diff --git a/packages/modules/consumers/generic/mqtt/consumer_test.py b/packages/modules/consumers/generic/mqtt/consumer_test.py new file mode 100644 index 0000000000..28b89f4b13 --- /dev/null +++ b/packages/modules/consumers/generic/mqtt/consumer_test.py @@ -0,0 +1,38 @@ +import json +from unittest.mock import Mock, call + +import pytest + +from modules.consumers.generic.mqtt import consumer as mqtt_consumer +from modules.consumers.generic.mqtt.config import Mqtt + + +@pytest.mark.parametrize("tag", ["0012345678", None]) +@pytest.mark.parametrize("timestamp", [None, 1652683200, 0]) +def test_mqtt_rfid_is_returned_and_only_cleared_on_request(tag, timestamp, monkeypatch, mock_pub): + def broker_factory(name, on_connect, on_message): + def receive(): + values = {"power": 100, "imported": 20, "exported": 0, "rfid": tag} + if timestamp is not None: + values["rfid_timestamp"] = timestamp + for key, value in values.items(): + on_message(None, None, Mock(topic=f"openWB/mqtt/consumer/1/get/{key}", + payload=json.dumps(value).encode())) + return Mock(start_finite_loop=receive) + + configurable = Mock() + monkeypatch.setattr(mqtt_consumer, "BrokerClient", broker_factory) + monkeypatch.setattr(mqtt_consumer, "ConfigurableConsumer", configurable) + mqtt_consumer.create_consumer(Mqtt(id=1)) + + state = configurable.call_args.kwargs["update"]() + + assert state.power == 100 + assert state.rfid == tag + assert state.rfid_timestamp == (timestamp if tag else None) + mock_pub.pub.assert_not_called() + + mock_pub.pub.reset_mock() + configurable.call_args.kwargs["clear_rfid"]() + assert mock_pub.pub.call_args_list == [call("openWB/mqtt/consumer/1/get/rfid", None), + call("openWB/mqtt/consumer/1/get/rfid_timestamp", None)] From 460d6b36bcc1049187581c39f12ac8250af10118 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:28:40 +0100 Subject: [PATCH 2/3] Potential fix for pull request finding 'Import mock call used by parameterized store test' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/modules/common/store/_consumer_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/modules/common/store/_consumer_test.py b/packages/modules/common/store/_consumer_test.py index f01df80d66..1e329f9c78 100644 --- a/packages/modules/common/store/_consumer_test.py +++ b/packages/modules/common/store/_consumer_test.py @@ -1,3 +1,5 @@ +from unittest.mock import call + import pytest from modules.common.component_state import ConsumerState From bb599383b9df0c662b6bd1d0cbdd11dfa1c7cf93 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 26 Oct 2026 12:33:10 +0100 Subject: [PATCH 3/3] flake8 --- packages/modules/common/store/_consumer_test.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/modules/common/store/_consumer_test.py b/packages/modules/common/store/_consumer_test.py index 1e329f9c78..f01df80d66 100644 --- a/packages/modules/common/store/_consumer_test.py +++ b/packages/modules/common/store/_consumer_test.py @@ -1,5 +1,3 @@ -from unittest.mock import call - import pytest from modules.common.component_state import ConsumerState