diff --git a/data/config/mosquitto/local/openwb_local.conf b/data/config/mosquitto/local/openwb_local.conf index e96e2071d2..02060b6c81 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 @@ -38,6 +38,7 @@ topic openWB/consumer/+/get/# out 2 topic openWB/consumer/+/set/# out 2 topic openWB/internal_chargepoint/# out 2 +topic openWB/internal_socket/# out 2 topic openWB/io/# out 2 topic openWB/internal_io/# out 2 diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index b6d2bac6ec..bb7c876850 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -972,6 +972,12 @@ "priority": 0, "allow": true }, + { + "acltype": "publishClientReceive", + "topic": "openWB/chargepoint/+/config", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/consumer/+/config", diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index 99cfd8de89..834283b8fb 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -66,7 +66,7 @@ class ConsumerConfig: rfid_enabled: bool = False valid_tags: List[str] = field(default_factory=empty_list_factory) rfid_reset: RfidReset = RfidReset.AFTER_TIME - rfid_timeout: int = 300 + rfid_timeout: int = 3600 @dataclass diff --git a/packages/control/consumer/rfid.py b/packages/control/consumer/rfid.py index dafe50de8c..b4e56813fb 100644 --- a/packages/control/consumer/rfid.py +++ b/packages/control/consumer/rfid.py @@ -6,8 +6,11 @@ from helpermodules import timecheck +RFID_SUPPORTED_MODULE_TYPES = ("mqtt", "wallbox_socket") + + def rfid_supported(module) -> bool: - return getattr(module, "type", None) == "mqtt" + return getattr(module, "type", None) in RFID_SUPPORTED_MODULE_TYPES class ConsumerRfidMixin: diff --git a/packages/control/consumer/rfid_test.py b/packages/control/consumer/rfid_test.py index 338d5fc15f..edf4c04623 100644 --- a/packages/control/consumer/rfid_test.py +++ b/packages/control/consumer/rfid_test.py @@ -8,21 +8,23 @@ from control.chargemode import Chargemode from control.consumer.consumer import Consumer from control.consumer.consumer_data import ConsumerConfig, RfidReset +from control.consumer.rfid import RFID_SUPPORTED_MODULE_TYPES 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): +@pytest.fixture(params=RFID_SUPPORTED_MODULE_TYPES) +def consumer(monkeypatch, request): 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.module = Mock(type=request.param) consumer.data.config.rfid_enabled = True + consumer.data.config.rfid_timeout = 300 consumer.data.config.valid_tags = ["AB??", "1234"] consumer.data.usage.type = ConsumerUsage.SUSPENDABLE_ONOFF consumer.data.get.voltages = [230] * 3 diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index e572e96e88..64a938268d 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -413,6 +413,16 @@ def removeChargepoint(self, connection_id: str, payload: dict) -> None: payload, connection_id, f'Ladepunkt mit ID \'{cp_id}\' existiert nicht.', MessageType.ERROR) return + for consumer in SubData.consumer_data.values(): + module = consumer.data.module + if (getattr(module, "type", None) == "wallbox_socket" and + module.configuration.chargepoint_id == cp_id): + pub_user_message( + payload, connection_id, + f'Ladepunkt mit ID \'{cp_id}\' kann nicht gelöscht werden, da er der Wallbox-Steckdose ' + f'\'{module.name}\' zugeordnet ist. Bitte zuerst die Zuordnung bei der Wallbox-Steckdose ändern.', + MessageType.ERROR) + return cp_type = cp.chargepoint.data.config.type if cp_type == "external_openwb": cp_ip = cp.chargepoint.data.config.configuration.get("ip_address") @@ -1251,6 +1261,9 @@ def removeConsumer(self, connection_id: str, payload: dict) -> None: 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) + consumer = SubData.consumer_data.get(f'consumer{payload["data"]["consumer_id"]}') + if consumer is not None and getattr(consumer.data.module, "type", None) == "wallbox_socket": + consumer.module.switch_off() 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) diff --git a/packages/helpermodules/command_test.py b/packages/helpermodules/command_test.py index 8a20864c0d..d861db0720 100644 --- a/packages/helpermodules/command_test.py +++ b/packages/helpermodules/command_test.py @@ -167,3 +167,22 @@ def test_get_max_id_hierarchy_by_topics(mock_pub, monkeypatch): assert command_instance.max_id_hierarchy == 9 mock_pub.pub.assert_any_call("openWB/set/command/max_id/hierarchy", 9) + + +@pytest.mark.parametrize("consumer_type", ["wallbox_socket", "mqtt"]) +def test_remove_wallbox_socket_switches_off_before_removing_topics(consumer_type, mock_pub, monkeypatch): + order = [] + consumer = SimpleNamespace(data=SimpleNamespace(module=SimpleNamespace(type=consumer_type)), module=Mock()) + consumer.module.switch_off.side_effect = lambda: order.append("off") + branch = Mock() + branch.remove_topics.side_effect = lambda: order.append("remove") + monkeypatch.setattr(command, "ProcessBrokerBranch", Mock(return_value=branch)) + monkeypatch.setattr(command, "pub_user_message", Mock()) + monkeypatch.setattr(SubData, "consumer_data", {"consumer2": consumer}) + monkeypatch.setattr(SubData, "counter_all_data", Mock()) + monkeypatch.setattr(SubData, "system_data", { + "system": SimpleNamespace(data={"security": {"user_management_active": False}})}) + instance = Command.__new__(Command) + instance.max_id_hierarchy = 2 + instance.removeConsumer("test", {"data": {"consumer_id": 2}}) + assert order == (["off", "remove"] if consumer_type == "wallbox_socket" else ["remove"]) diff --git a/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py b/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py index e5a1a35a27..5d5088415e 100644 --- a/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py +++ b/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py @@ -79,6 +79,20 @@ def _get_data_path() -> Path: 'openWB/internal_chargepoint/+/get/state_str', 'openWB/internal_chargepoint/+/get/rfid', 'openWB/internal_chargepoint/+/get/rfid_timestamp', + 'openWB/internal_socket/data/set_state', + 'openWB/internal_socket/data/detected', + 'openWB/internal_socket/data/last_tag', + 'openWB/internal_socket/get/state', + 'openWB/internal_socket/get/rfid', + 'openWB/internal_socket/get/power', + 'openWB/internal_socket/get/imported', + 'openWB/internal_socket/get/exported', + 'openWB/internal_socket/get/currents', + 'openWB/internal_socket/get/voltages', + 'openWB/internal_socket/get/powers', + 'openWB/internal_socket/get/fault_state', + 'openWB/internal_socket/get/fault_str', + 'openWB/internal_socket/get/error_timestamp', # abgesehen vom Fahrzeug können mqtt get-Topics bei aktiver BV nur gepublished werden 'openWB/mqtt/bat/+/get/power', 'openWB/mqtt/bat/+/get/soc', diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 64cf820e86..89179529ec 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -102,6 +102,8 @@ def on_message(self, client: mqtt.Client, userdata, msg: mqtt.MQTTMessage): self.process_command_topic(msg) elif "openWB/set/internal_chargepoint/" in msg.topic: self.process_internal_chargepoint_topic(msg) + elif "openWB/set/internal_socket/" in msg.topic: + self.process_wallbox_socket_topic(msg) elif "openWB/set/LegacySmartHome/" in msg.topic: self.process_legacy_smart_home_topic(msg) elif "openWB/set/consumer/" in msg.topic: @@ -1235,6 +1237,26 @@ def process_internal_chargepoint_topic(self, msg): except Exception: log.exception(f"Fehler im setdata-Modul: Topic {msg.topic}, Value: {msg.payload}") + def process_wallbox_socket_topic(self, msg): + try: + suffix = msg.topic.removeprefix("openWB/set/internal_socket/") + if suffix in ("data/set_state", "data/detected", "get/state"): + self._validate_value(msg, bool) + elif suffix == "get/error_timestamp": + self._validate_value(msg, float, [(0, TIMESTAMP_2100)]) + elif suffix == "get/fault_state": + self._validate_value(msg, int, [(0, 2)]) + elif suffix in ("data/last_tag", "get/fault_str", "get/rfid"): + self._validate_value(msg, str) + elif suffix in ("get/power", "get/imported", "get/exported"): + self._validate_value(msg, float) + elif suffix in ("get/currents", "get/voltages", "get/powers"): + self._validate_value(msg, "json") + else: + self.__unknown_topic(msg) + except Exception: + log.exception(f"Fehler im setdata-Modul: Topic {msg.topic}, Value: {msg.payload}") + def process_legacy_smart_home_topic(self, msg): """Handler für die alten SmartHome-Topics diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 26f20853d3..8635e217f8 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -46,6 +46,7 @@ from modules.internal_chargepoint_handler.internal_chargepoint_handler_config import ( GlobalHandlerData, InternalChargepoint, RfidData) from modules.vehicles.manual.config import ManualSoc +from modules.internal_chargepoint_handler.wallbox_socket_config import WallboxSocket log = logging.getLogger(__name__) mqtt_log = logging.getLogger("mqtt") @@ -76,6 +77,7 @@ class SubData: "global_data": GlobalHandlerData(), "rfid_data": RfidData()} io_actions = io_device.IoActions() + wallbox_socket_data = WallboxSocket() io_states: Dict[str, io_device.IoStates] = {} optional_data = optional.Optional() system_data = {"system": system.System()} @@ -157,6 +159,7 @@ def on_connect(self, client: mqtt.Client, userdata, flags: dict, rc: int): ("openWB/consumer/+/control_parameter/#", 2), ("openWB/command/command_completed", 2), ("openWB/internal_chargepoint/#", 2), + ("openWB/internal_socket/#", 2), # MQTT Bridge Topics vor "openWB/system/+" abonnieren, damit sie auch vor # "openWB/system/subdata_initialized" empfangen werden! ("openWB/system/mqtt/bridge/+", 2), @@ -205,6 +208,8 @@ def on_message(self, client: mqtt.Client, userdata, msg: mqtt.MQTTMessage): self.process_io_topic(self.io_states, msg) elif "openWB/internal_chargepoint/" in msg.topic: self.process_internal_chargepoint_topic(client, self.internal_chargepoint_data, msg) + elif "openWB/internal_socket/" in msg.topic: + self.process_wallbox_socket_topic(msg) elif "openWB/optional/" in msg.topic: self.process_optional_topic(self.optional_data, msg) elif "openWB/counter/" in msg.topic: @@ -1250,6 +1255,15 @@ def set_internal_chargepoint_configured(self): internal_configured = False self.internal_chargepoint_data["global_data"].configured = internal_configured + def process_wallbox_socket_topic(self, msg): + try: + if msg.topic.startswith("openWB/internal_socket/data/"): + self.set_json_payload_class(self.wallbox_socket_data.data, msg) + elif msg.topic.startswith("openWB/internal_socket/get/"): + self.set_json_payload_class(self.wallbox_socket_data.get, msg) + except Exception: + log.exception("Fehler im subdata-Modul für die Wallbox-Steckdose") + def process_legacy_smarthome_topic(self, client: mqtt.Client, var: counter_all.CounterAll, msg: mqtt.MQTTMessage): """ Handler für die SmartHome-Topics des alten diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index e74a8f2dfe..2de067e918 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -365,6 +365,21 @@ class UpdateConfig: "^openWB/internal_chargepoint/[0-1]/get/rfid$", "^openWB/internal_chargepoint/[0-1]/get/rfid_timestamp$", + "^openWB/internal_socket/data/set_state$", + "^openWB/internal_socket/data/detected$", + "^openWB/internal_socket/data/last_tag$", + "^openWB/internal_socket/get/state$", + "^openWB/internal_socket/get/rfid$", + "^openWB/internal_socket/get/power$", + "^openWB/internal_socket/get/imported$", + "^openWB/internal_socket/get/exported$", + "^openWB/internal_socket/get/currents$", + "^openWB/internal_socket/get/voltages$", + "^openWB/internal_socket/get/powers$", + "^openWB/internal_socket/get/fault_state$", + "^openWB/internal_socket/get/fault_str$", + "^openWB/internal_socket/get/error_timestamp$", + "^openWB/io/action/[0-9]+/config$", "^openWB/io/action/[0-9]+/timestamp$", "^openWB/io/states/[0-9]+/get/analog_input$", diff --git a/packages/modules/common/component_type.py b/packages/modules/common/component_type.py index 2f8cb58a65..48b89ba9fb 100644 --- a/packages/modules/common/component_type.py +++ b/packages/modules/common/component_type.py @@ -10,6 +10,7 @@ class ComponentType(Enum): COUNTER = "counter" FLEXIBLE_TARIFF = "dynamic_tariff" GRID_FEE = "grid_tariff" + WALLBOX_SOCKET = "wallbox_socket" INVERTER = "inverter" IO = "io" VEHICLE = "vehicle" @@ -37,6 +38,8 @@ def type_to_topic_mapping(component_type: str) -> str: return "consumer" elif "counter" in component_type: return "counter" + elif "wallbox_socket" in component_type: + return "internal_socket" elif "inverter" in component_type: return "pv" elif ComponentType.FLEXIBLE_TARIFF.value in component_type: diff --git a/packages/modules/common/configurable_consumer.py b/packages/modules/common/configurable_consumer.py index e6902724c9..2e0c34e69f 100644 --- a/packages/modules/common/configurable_consumer.py +++ b/packages/modules/common/configurable_consumer.py @@ -92,21 +92,21 @@ def update(self): self.store.set(consumer_state) def set_power_limit(self, power_limit: float, data: SetLimitData) -> None: - with SingleComponentUpdateContext(self.fault_state): + with SingleComponentUpdateContext(self.fault_state, update_always=False): self.module_set_power_limit(power_limit, data) def switch_on(self) -> None: - with SingleComponentUpdateContext(self.fault_state): + with SingleComponentUpdateContext(self.fault_state, update_always=False): self.module_switch_on() def switch_off(self) -> None: - with SingleComponentUpdateContext(self.fault_state): + with SingleComponentUpdateContext(self.fault_state, update_always=False): self.module_switch_off() def send_values(self, values: CurrentValues) -> None: - with SingleComponentUpdateContext(self.fault_state): + with SingleComponentUpdateContext(self.fault_state, update_always=False): self.module_send_values(values) def clear_rfid(self) -> None: - with SingleComponentUpdateContext(self.fault_state): + with SingleComponentUpdateContext(self.fault_state, update_always=False): self.module_clear_rfid() diff --git a/packages/modules/common/fault_state.py b/packages/modules/common/fault_state.py index 499725ac52..cdf6b2695d 100644 --- a/packages/modules/common/fault_state.py +++ b/packages/modules/common/fault_state.py @@ -50,7 +50,8 @@ def store_error(self) -> None: traceback.format_exc()) topic = component_type.type_to_topic_mapping(self.component_info.type) if (self.component_info.type == component_type.ComponentType.FLEXIBLE_TARIFF.value or - self.component_info.type == component_type.ComponentType.GRID_FEE.value): + self.component_info.type == component_type.ComponentType.GRID_FEE.value or + self.component_info.type == component_type.ComponentType.WALLBOX_SOCKET.value): topic_prefix = f"openWB/set/{topic}" else: topic_prefix = f"openWB/set/{topic}/{self.component_info.id}" diff --git a/packages/modules/common/hardware_check.py b/packages/modules/common/hardware_check.py index 59c0a8c616..0d0ff31c15 100644 --- a/packages/modules/common/hardware_check.py +++ b/packages/modules/common/hardware_check.py @@ -1,38 +1,19 @@ -import logging -import time -import pymodbus -from typing import Any, Optional, Protocol, Tuple, Union +from typing import Optional -from modules.common.component_state import CounterState, EvseState -from modules.common.evse import Evse +from modules.common.component_state import CounterState from modules.common.fault_state import FaultState -from modules.common.modbus import ModbusSerialClient_, ModbusTcpClient_ -log = logging.getLogger(__name__) - -EVSE_MIN_FIRMWARE = 7 -MAX_ATTEMPTS = 3 -RETRY_DELAY_SECONDS = 0.3 - -READ_RS485_ADAPTER_AGAIN = "Erneutes Auslesen des {}" -READ_USB_ADAPTER_AGAIN = READ_RS485_ADAPTER_AGAIN.format('USB-Adapters') -READ_LAN_ADAPTER_AGAIN = READ_RS485_ADAPTER_AGAIN.format('LAN-Konverters') -METER_PROBLEM = "Erneutes Auslesen des Zählers" READ_METER_VOLTAGES_AGAIN = "Erneutes Auslesen der Spannungen am Zähler: {}V" -METER_NO_SERIAL_NUMBER = ("Die Seriennummer des Zählers für das Ladelog kann nicht ausgelesen werden. Wenn Du die " - "Seriennummer für Abrechnungszwecke benötigst, wende Dich bitte an unseren Support. Die " - "Funktionalität wird dadurch nicht beeinträchtigt!") -READ_EVSE_AGAIN = "Erneutes Auslesen der EVSE" def check_meter_values(counter_state: CounterState, fault_state: Optional[FaultState] = None) -> None: - meter_msg = _check_meter_values(counter_state) + meter_msg = check_meter(counter_state) if fault_state and meter_msg: fault_state.warning(meter_msg) -def _check_meter_values(counter_state: CounterState) -> Optional[str]: +def check_meter(counter_state: CounterState) -> Optional[str]: # Nur prüfen, dass keine Phase ausgefallen ist # Es gibt einige Fälle, in denen die Normtoleranzen der Netzspannung nicht eingehalten werden, aber kein Defekt # vorliegt und der Kunde nicht eingreifen muss. Dann soll keine Warnung angezeigt werden. @@ -42,89 +23,3 @@ def _check_meter_values(counter_state: CounterState) -> Optional[str]: if (voltages[1] == 0 and voltages[2] > 30) or voltages[0] == 0: return READ_METER_VOLTAGES_AGAIN.format(voltages) return None - - -class ClientHandlerProtocol(Protocol): - @property - def client(self) -> Union[ModbusSerialClient_, ModbusTcpClient_]: ... - @property - def local_charge_point_num(self) -> int: ... - @property - def fault_state(self) -> FaultState: ... - @property - def evse_client(self) -> Evse: ... - @property - def meter_client(self) -> Any: ... - @property - def read_error(self) -> int: ... - @property - def handle_exception(self, exception: Exception) -> bool: ... - @property - def request_and_check_hardware(self, fault_state: FaultState) -> Tuple[EvseState, CounterState]: ... - @property - def check_meter(self) -> Tuple[bool, Optional[str], CounterState]: ... - - -class SeriesHardwareCheckMixin: - def __init__(self) -> None: - pass - - def handle_exception(self: ClientHandlerProtocol, exception: Exception): - # separated for test purposes - if (isinstance(self.client, ModbusTcpClient_) and - isinstance(exception, pymodbus.exceptions.ConnectionException)): - raise exception - else: - return False - - def request_and_check_hardware(self: ClientHandlerProtocol, - fault_state: FaultState) -> Tuple[EvseState, CounterState]: - evse_check_passed = False - evse_state: EvseState - # 2x Retry bei EVSE-Auslesen vor dem Absetzen einer Fehlermeldung - try: - with self.client: - for attempt in range(MAX_ATTEMPTS): - try: - evse_state = self.evse_client.get_evse_state() - evse_check_passed = True - break - except Exception as e: - evse_check_passed = self.handle_exception(e) - # nur warten, wenn danach noch ein Versuch folgt - if attempt < MAX_ATTEMPTS - 1 and evse_check_passed is False: - time.sleep(RETRY_DELAY_SECONDS) - except Exception as e: - evse_check_passed = self.handle_exception(e) - meter_check_passed, meter_error_msg, counter_state = self.check_meter() - if meter_check_passed is False and evse_check_passed is False: - if isinstance(self.client, ModbusTcpClient_): - raise Exception(READ_LAN_ADAPTER_AGAIN) - else: - raise Exception(READ_USB_ADAPTER_AGAIN) - if meter_check_passed is False: - if evse_check_passed is False: - if isinstance(self.client, ModbusTcpClient_): - raise Exception(READ_LAN_ADAPTER_AGAIN) - else: - raise Exception(READ_USB_ADAPTER_AGAIN) - else: - raise Exception(meter_error_msg) - elif evse_check_passed and meter_check_passed and meter_error_msg is not None: - fault_state.warning(meter_error_msg) - if evse_check_passed is False: - if meter_error_msg is not None: - raise Exception(f"{READ_EVSE_AGAIN} {meter_error_msg}") - else: - raise Exception(READ_EVSE_AGAIN) - return evse_state, counter_state - - def check_meter(self: ClientHandlerProtocol) -> Tuple[bool, Optional[str], CounterState]: - try: - with self.client: - counter_state = self.meter_client.get_counter_state() - if counter_state.serial_number == "0" or counter_state.serial_number is None: - log.warning(METER_NO_SERIAL_NUMBER) - return True, _check_meter_values(counter_state), counter_state - except Exception: - return False, METER_PROBLEM, None diff --git a/packages/modules/common/hardware_check_test.py b/packages/modules/common/hardware_check_test.py index 6da1ae58b1..c567e6b633 100644 --- a/packages/modules/common/hardware_check_test.py +++ b/packages/modules/common/hardware_check_test.py @@ -7,12 +7,12 @@ from modules.common import hardware_check from modules.common.component_state import CounterState, EvseState from modules.common.evse import Evse -from modules.common.hardware_check import ( - READ_EVSE_AGAIN, READ_LAN_ADAPTER_AGAIN, READ_METER_VOLTAGES_AGAIN, METER_PROBLEM, READ_USB_ADAPTER_AGAIN, - SeriesHardwareCheckMixin, _check_meter_values) +from modules.common.hardware_check import READ_METER_VOLTAGES_AGAIN, check_meter from modules.common.modbus import NO_CONNECTION, ModbusSerialClient_, ModbusTcpClient_ from modules.conftest import SAMPLE_IP, SAMPLE_PORT from modules.internal_chargepoint_handler.clients import ClientHandler +from modules.internal_chargepoint_handler.internal_hardware_check import ( + METER_PROBLEM, READ_EVSE_AGAIN, READ_LAN_ADAPTER_AGAIN, READ_USB_ADAPTER_AGAIN, SeriesHardwareCheckMixin) @pytest.mark.parametrize( @@ -129,13 +129,13 @@ def test_check_meter_values_voltages(voltages, power, expected_msg, monkeypatch) # setup counter_state = Mock(voltages=voltages, currents=[0, 0, 0], powers=[0, 0, 0], power=power) # execution - msg = _check_meter_values(counter_state) + msg = check_meter(counter_state) # assert assert msg == expected_msg if expected_msg is None else expected_msg.format(voltages) -@patch('modules.common.hardware_check.ClientHandlerProtocol') +@patch('modules.internal_chargepoint_handler.internal_hardware_check.ClientHandlerProtocol') @pytest.mark.parametrize("serial_number, voltages, expected", [(12345, [230]*3, (True, None, CounterState)), (Exception(), [230]*3, (False, METER_PROBLEM, None))]) @@ -161,7 +161,7 @@ def test_check_meter( monkeypatch.setattr(hardware_check, "check_meter_values", mock_check_meter_values) # Act - result = mixin.check_meter(MockClientHandlerProtocol) + result = mixin.check_meter(MockClientHandlerProtocol, mock_meter_client) # Assert assert result[0] == expected[0] diff --git a/packages/modules/common/store/__init__.py b/packages/modules/common/store/__init__.py index 7ce3c7f905..16b66fb06a 100644 --- a/packages/modules/common/store/__init__.py +++ b/packages/modules/common/store/__init__.py @@ -4,5 +4,6 @@ from modules.common.store._chargepoint_internal import get_internal_chargepoint_value_store from modules.common.store._factory import get_component_value_store from modules.common.store._io import get_io_value_store +from modules.common.store._wallbox_socket import get_wallbox_socket_value_store from modules.common.store._tariff import get_flexible_tariff_value_store, get_grid_fee_value_store from modules.common.store._forecast import get_forecast_value_store diff --git a/packages/modules/common/store/_wallbox_socket.py b/packages/modules/common/store/_wallbox_socket.py new file mode 100644 index 0000000000..2f33001257 --- /dev/null +++ b/packages/modules/common/store/_wallbox_socket.py @@ -0,0 +1,23 @@ +from modules.common.component_state import ConsumerState +from modules.common.store._api import LoggingValueStore, ValueStore +from modules.common.store._broker import pub_to_broker + + +class WallboxSocketValueStore(ValueStore[ConsumerState]): + def set(self, state: ConsumerState) -> None: + self.state = state + + def update(self): + topic_prefix = "openWB/set/internal_socket/get" + pub_to_broker(f"{topic_prefix}/voltages", self.state.voltages, 2) + pub_to_broker(f"{topic_prefix}/currents", self.state.currents, 2) + pub_to_broker(f"{topic_prefix}/imported", self.state.imported, 2) + pub_to_broker(f"{topic_prefix}/exported", self.state.exported, 2) + pub_to_broker(f"{topic_prefix}/power", self.state.power, 2) + pub_to_broker(f"{topic_prefix}/powers", self.state.powers, 2) + pub_to_broker(f"{topic_prefix}/state", self.state.state) + pub_to_broker(f"{topic_prefix}/rfid", self.state.rfid) + + +def get_wallbox_socket_value_store() -> ValueStore[ConsumerState]: + return LoggingValueStore(WallboxSocketValueStore()) diff --git a/packages/modules/configuration.py b/packages/modules/configuration.py index 0219453ac8..51ba0984bf 100644 --- a/packages/modules/configuration.py +++ b/packages/modules/configuration.py @@ -5,6 +5,7 @@ import dataclass_utils from helpermodules.pub import Pub +from modules.devices.vendors import READABLE_GROUP_NAME as READABLE_VENDOR_GROUP_NAME, VendorGroup from modules.io_actions.groups import READABLE_GROUP_NAME, ActionGroup import sys log = logging.getLogger(__name__) @@ -237,16 +238,6 @@ def update_nested_dict(dictionary: Dict, update: Dict) -> Dict: return dictionary def get_vendor_groups() -> Dict: - 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"/"devices").glob('**/vendor.py') for path in path_list: @@ -259,7 +250,7 @@ def get_vendor_group_name(group: str) -> str: f".devices.{vendor_path}.vendor", "modules").vendor_descriptor.configuration_factory() entry = { vendor_info.group: { - "group_name": get_vendor_group_name(vendor_info.group), + "group_name": READABLE_VENDOR_GROUP_NAME[VendorGroup(vendor_info.group)], "vendors": { vendor_path: { "vendor_name": vendor_info.vendor, @@ -484,16 +475,6 @@ def get_vendor_consumers(vendor: str) -> Dict: 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: @@ -506,7 +487,7 @@ def get_vendor_group_name(group: str) -> str: 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), + "group_name": READABLE_VENDOR_GROUP_NAME[VendorGroup(vendor_info.group)], "vendors": { vendor_path: { "vendor_name": vendor_info.vendor, diff --git a/packages/modules/consumers/openwb/__init__.py b/packages/modules/consumers/openwb/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/openwb/vendor.py b/packages/modules/consumers/openwb/vendor.py new file mode 100644 index 0000000000..f07445ac46 --- /dev/null +++ b/packages/modules/consumers/openwb/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 = "openWB" + self.group = VendorGroup.OPENWB.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/openwb/wallbox_socket/__init__.py b/packages/modules/consumers/openwb/wallbox_socket/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/openwb/wallbox_socket/config.py b/packages/modules/consumers/openwb/wallbox_socket/config.py new file mode 100644 index 0000000000..bfe8a4e1c6 --- /dev/null +++ b/packages/modules/consumers/openwb/wallbox_socket/config.py @@ -0,0 +1,24 @@ +from typing import Optional, Tuple + +from control.consumer.usage import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class WallboxSocketConfiguration: + def __init__(self, chargepoint_id: Optional[int] = None): + self.chargepoint_id = chargepoint_id + + +@auto_str +class WallboxSocket(ConsumerSetup[WallboxSocketConfiguration]): + def __init__(self, name: str = "Wallbox-Steckdose", + type: str = "wallbox_socket", + id: int = 0, + configuration: Optional[WallboxSocketConfiguration] = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.CONTINUOUS), **kwargs): + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory().type, + configuration=configuration or WallboxSocketConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/openwb/wallbox_socket/consumer.py b/packages/modules/consumers/openwb/wallbox_socket/consumer.py new file mode 100644 index 0000000000..746a4d627f --- /dev/null +++ b/packages/modules/consumers/openwb/wallbox_socket/consumer.py @@ -0,0 +1,77 @@ +import logging + +from control import data +from helpermodules.broker import BrokerClient +from helpermodules.pub import pub_single +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.openwb.wallbox_socket.config import WallboxSocket + +log = logging.getLogger(__name__) + + +def create_consumer(config: WallboxSocket): + def clear_rfid(): + pub_single("openWB/set/internal_socket/data/last_tag", None, hostname=get_host()) + + def get_host(): + if config.configuration.chargepoint_id is None: + return "localhost" + try: + cp_module = data.data.cp_data[f"cp{config.configuration.chargepoint_id}"].chargepoint_module + except KeyError: + raise KeyError(("Bitte einen vorhandenen openWB-Ladepunkt auswählen.")) + return cp_module.config.configuration.ip_address + + def update(): + received_topics = {} + + def parse_received_topics(value: str): + return received_topics.get(f"{prefix}{value}", get_default(ConsumerState, value)) + + def on_connect(client, userdata, flags, rc): + client.subscribe("openWB/internal_socket/get/#") + + def on_message(client, userdata, message): + received_topics[message.topic] = decode_payload(message.payload) + + BrokerClient(f"subscribeWallboxSocketConsumer{config.id}", on_connect, on_message, + host=get_host(), port=1886 if get_host() == "localhost" else 1883).start_finite_loop(timeout=2) + prefix = "openWB/internal_socket/get/" + log.debug(f"Empfange MQTT Daten für Verbraucher {config.id}: {received_topics}") + fault_state = received_topics.get(f"{prefix}fault_state") + if fault_state == 2: + consumer.fault_state.error(received_topics.get( + f"{prefix}fault_str", "Fehlerstatus vom internen Ladepunkt")) + elif fault_state == 1: + consumer.fault_state.warning(received_topics.get( + f"{prefix}fault_str", "Warnung vom internen Ladepunkt")) + return ConsumerState( + power=received_topics[f"{prefix}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=parse_received_topics("rfid"), + ) + + def switch(state): + pub_single("openWB/set/internal_socket/data/set_state", state, hostname=get_host()) + + consumer = ConfigurableConsumer(consumer_config=config, + update=update, + switch_on=lambda: switch(True), + switch_off=lambda: switch(False), + clear_rfid=clear_rfid) + return consumer + + +device_descriptor = DeviceDescriptor( + configuration_factory=WallboxSocket, + compatibility_device_note=("Eine gleichzeitige Verwendung von Freischaltung per RFID und separater Leistunsmessung " + "ist nicht möglich.")) diff --git a/packages/modules/consumers/openwb/wallbox_socket/consumer_test.py b/packages/modules/consumers/openwb/wallbox_socket/consumer_test.py new file mode 100644 index 0000000000..38e1280578 --- /dev/null +++ b/packages/modules/consumers/openwb/wallbox_socket/consumer_test.py @@ -0,0 +1,60 @@ +import json +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from control import data +from modules.consumers.openwb.wallbox_socket import consumer as wallbox_socket_consumer +from modules.consumers.openwb.wallbox_socket.config import WallboxSocket, WallboxSocketConfiguration + + +@pytest.mark.parametrize("tag", ["0012345678", None]) +@pytest.mark.parametrize("cp_type, host, duo_num", [ + ("internal_openwb", "localhost", 0), + ("external_openwb", "192.168.1.40", 1), +]) +def test_wallbox_socket_rfid_uses_selected_reader_and_clears_only_on_request( + tag, cp_type, host, duo_num, monkeypatch, mock_pub): + cp_module = Mock(config=SimpleNamespace( + type=cp_type, configuration=SimpleNamespace(ip_address=host, duo_num=duo_num))) + data.data_init(Mock()) + monkeypatch.setattr(data.data, "cp_data", {"cp4": SimpleNamespace(chargepoint_module=cp_module)}) + rfid_topic = "openWB/internal_socket/get/rfid" + + def broker_factory(name, on_connect, on_message, **kwargs): + assert kwargs == {"host": host, "port": 1886 if host == "localhost" else 1883} + + def receive(timeout): + client = Mock() + on_connect(client, None, None, 0) + assert [call.args[0] for call in client.subscribe.call_args_list] == [ + "openWB/internal_socket/get/#"] + topics = {"openWB/internal_socket/get/power": 100, rfid_topic: tag, + "openWB/internal_chargepoint/9/get/rfid": "wrong-reader"} + for topic, value in topics.items(): + on_message(None, None, Mock(topic=topic, payload=json.dumps(value).encode())) + return Mock(start_finite_loop=receive) + + configurable = Mock() + monkeypatch.setattr(wallbox_socket_consumer, "BrokerClient", broker_factory) + monkeypatch.setattr(wallbox_socket_consumer, "ConfigurableConsumer", configurable) + publisher = Mock() + monkeypatch.setattr(wallbox_socket_consumer, "pub_single", publisher) + wallbox_socket_consumer.create_consumer( + WallboxSocket(id=1, configuration=WallboxSocketConfiguration(chargepoint_id=4))) + + mock_pub.reset_mock() + state = configurable.call_args.kwargs["update"]() + + assert state.power == 100 + assert state.rfid == tag + publisher.assert_not_called() + cp_module.clear_rfid.assert_not_called() + + publisher.reset_mock() + configurable.call_args.kwargs["clear_rfid"]() + + assert [call.args for call in publisher.call_args_list] == [ + ("openWB/set/internal_socket/data/last_tag", None)] + assert all(call.kwargs == {"hostname": host} for call in publisher.call_args_list) diff --git a/packages/modules/devices/vendors.py b/packages/modules/devices/vendors.py index 7dabd03ad1..0fe4497444 100644 --- a/packages/modules/devices/vendors.py +++ b/packages/modules/devices/vendors.py @@ -5,3 +5,10 @@ class VendorGroup(Enum): GENERIC = "generic" OPENWB = "openwb" VENDORS = "vendors" + + +READABLE_GROUP_NAME = { + VendorGroup.GENERIC: "herstellerunabhängig", + VendorGroup.OPENWB: "openWB", + VendorGroup.VENDORS: "andere Hersteller", +} diff --git a/packages/modules/internal_chargepoint_handler/clients.py b/packages/modules/internal_chargepoint_handler/clients.py index a66175c0c2..0b488c10b6 100644 --- a/packages/modules/internal_chargepoint_handler/clients.py +++ b/packages/modules/internal_chargepoint_handler/clients.py @@ -1,35 +1,20 @@ import logging -from typing import List, NamedTuple, Optional, Tuple, Union +from typing import List, Optional, Tuple, Union from helpermodules.logger import ModifyLoglevelContext from modules.chargepoints.internal_openwb.config import InternalChargepointMode from modules.common.fault_state import FaultState -from modules.common.hardware_check import SeriesHardwareCheckMixin from modules.common.modbus import ModbusSerialClient_, ModbusTcpClient_ -from modules.common import mpm3pm, sdm from modules.common import evse -from modules.common import b23 from modules.common.serial_modbus_devices import get_serial_modbus_devices, BUS_SOURCES +from modules.internal_chargepoint_handler.hardware_definition import ( + CP0_METERS, CP1_METERS, EVSE_ID_CP0, EVSE_ID_ONE_BUS_CP1, EVSE_ID_ONE_BUS_SE_CP1, + EVSE_ID_SE_CP0, EVSE_ID_TWO_BUSSES_CP1, EVSE_MIN_FIRMWARE, MeterClient, meter_config) +from modules.internal_chargepoint_handler.internal_hardware_check import SeriesHardwareCheckMixin log = logging.getLogger(__name__) -MeterClient = Union[mpm3pm.Mpm3pm, sdm.Sdm630_72, b23.B23] -MeterType = Union[type[mpm3pm.Mpm3pm], type[sdm.Sdm630_72], type[b23.B23]] -meter_config = NamedTuple("meter_config", [('type', MeterType), ('modbus_id', int)]) -CP0_METERS = [meter_config(mpm3pm.Mpm3pm, modbus_id=5), - meter_config(sdm.Sdm630_72, modbus_id=105), - meter_config(b23.B23, modbus_id=201)] - -CP1_METERS = [meter_config(mpm3pm.Mpm3pm, modbus_id=6), meter_config(sdm.Sdm630_72, modbus_id=106)] - -EVSE_ID_CP0 = [1] -EVSE_ID_SE_CP0 = [11] -EVSE_ID_TWO_BUSSES_CP1 = [1, 2] -EVSE_ID_ONE_BUS_CP1 = [2] -EVSE_ID_ONE_BUS_SE_CP1 = [12] -EVSE_MIN_FIRMWARE = 7 - class ClientHandler(SeriesHardwareCheckMixin): def __init__(self, @@ -102,22 +87,25 @@ def get_pins_cp_interruption(self) -> int: def client_factory(mode: InternalChargepointMode, local_charge_point_num: int, fault_state: FaultState, - created_client_handler: Optional[ClientHandler] = None) -> ClientHandler: + created_client: Optional[Union[ModbusSerialClient_, ModbusTcpClient_]] = None) -> ClientHandler: serial_client, evse_ids = get_modbus_client( - mode, local_charge_point_num, created_client_handler, fault_state) + mode, local_charge_point_num, created_client, fault_state) return ClientHandler(local_charge_point_num, serial_client, evse_ids, fault_state) def get_modbus_client(mode: InternalChargepointMode, local_charge_point_num: int, - created_client_handler: Optional[ClientHandler] = None, + created_client: Optional[Union[ModbusSerialClient_, ModbusTcpClient_]] = None, fault_state: Optional[FaultState] = None) -> Tuple[Union[ModbusSerialClient_, ModbusTcpClient_], List[int]]: resolved_devices, counter = get_serial_modbus_devices() if counter == 0: # Wenn kein USB-Gerät gefunden wird, wird der Modbus-Anschluss der AddOn-Platine genutzt (/dev/serial0) - serial_client = ModbusSerialClient_("/dev/serial0") + if local_charge_point_num == 1 and created_client is not None: + serial_client = created_client + else: + serial_client = ModbusSerialClient_("/dev/serial0") if local_charge_point_num == 0: evse_ids = EVSE_ID_CP0 else: @@ -135,8 +123,8 @@ def get_modbus_client(mode: InternalChargepointMode, # Don't create two clients for one source! with ModifyLoglevelContext(log, logging.DEBUG): log.debug("LP1 gleiches Device wie LP0") - if created_client_handler: - serial_client = created_client_handler.client + if created_client is not None: + serial_client = created_client else: serial_client = ModbusSerialClient_(resolved_devices[0]) if mode == InternalChargepointMode.SE: diff --git a/packages/modules/internal_chargepoint_handler/hardware_definition.py b/packages/modules/internal_chargepoint_handler/hardware_definition.py new file mode 100644 index 0000000000..9ef120b2d0 --- /dev/null +++ b/packages/modules/internal_chargepoint_handler/hardware_definition.py @@ -0,0 +1,23 @@ +from typing import NamedTuple, Union + +from modules.common import mpm3pm, sdm +from modules.common import b23 + + +MeterClient = Union[mpm3pm.Mpm3pm, sdm.Sdm120, sdm.Sdm630_72, b23.B23] +MeterType = Union[type[mpm3pm.Mpm3pm], type[sdm.Sdm630_72], type[b23.B23], type[sdm.Sdm120]] +meter_config = NamedTuple("meter_config", [('type', MeterType), ('modbus_id', int)]) +CP0_METERS = [meter_config(mpm3pm.Mpm3pm, modbus_id=5), + meter_config(sdm.Sdm630_72, modbus_id=105), + meter_config(b23.B23, modbus_id=201)] + +CP1_METERS = [meter_config(mpm3pm.Mpm3pm, modbus_id=6), meter_config(sdm.Sdm630_72, modbus_id=106)] + +WALLBOX_SOCKET_METERS = [meter_config(sdm.Sdm120, modbus_id=54)] + +EVSE_ID_CP0 = [1] +EVSE_ID_SE_CP0 = [11] +EVSE_ID_TWO_BUSSES_CP1 = [1, 2] +EVSE_ID_ONE_BUS_CP1 = [2] +EVSE_ID_ONE_BUS_SE_CP1 = [12] +EVSE_MIN_FIRMWARE = 7 diff --git a/packages/modules/internal_chargepoint_handler/internal_chargepoint_handler.py b/packages/modules/internal_chargepoint_handler/internal_chargepoint_handler.py index ba5eaef0df..f21ec869f4 100644 --- a/packages/modules/internal_chargepoint_handler/internal_chargepoint_handler.py +++ b/packages/modules/internal_chargepoint_handler/internal_chargepoint_handler.py @@ -7,16 +7,22 @@ from typing import Optional from helpermodules import timecheck -from helpermodules.logger import clear_in_memory_log_handler +from helpermodules import pub +from helpermodules.logger import ModifyLoglevelContext, clear_in_memory_log_handler from helpermodules.pub import Pub from helpermodules.subdata import SubData from modules.chargepoints.internal_openwb.config import InternalChargepointMode from modules.common.component_context import SingleComponentUpdateContext from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.modbus import ModbusSerialClient_ +from modules.common.serial_modbus_devices import BUS_SOURCES, get_serial_modbus_devices from modules.internal_chargepoint_handler import chargepoint_module -from modules.internal_chargepoint_handler.clients import ClientHandler, client_factory +from modules.internal_chargepoint_handler.clients import ClientHandler, client_factory, get_modbus_client +from modules.internal_chargepoint_handler.hardware_definition import WALLBOX_SOCKET_METERS from modules.internal_chargepoint_handler.pro_plus import ProPlus from modules.internal_chargepoint_handler.socket import Socket +from modules.internal_chargepoint_handler.wallbox_socket import WallboxSocketModule +from modules.internal_chargepoint_handler.wallbox_socket_config import WallboxSocket, WallboxSocketData from modules.internal_chargepoint_handler.internal_chargepoint_handler_config import ( GlobalHandlerData, InternalChargepoint, InternalChargepointData, RfidData) log = logging.getLogger(__name__) @@ -83,14 +89,26 @@ def __thread_cp_interruption(self, duration: int) -> None: f"openWB/internal_chargepoint/{self.cp_module.local_charge_point_num}/data/cp_interruption_duration", 0) +class UpdateWallboxSocketState: + def __init__(self, wallbox_socket_module: WallboxSocketModule) -> None: + self.module = wallbox_socket_module + + def update_state(self, data: WallboxSocketData, heartbeat_expired: bool) -> None: + if not data.set_state or heartbeat_expired: + self.module.set_state(False) + else: + self.module.set_state(True) + + class InternalChargepointHandler: def __init__(self, mode: InternalChargepointMode, global_data: GlobalHandlerData, - parent_cp0: str, + parent_cp0: InternalChargepoint, hierarchy_id_cp0: int, - parent_cp1: Optional[str], + parent_cp1: Optional[InternalChargepoint], hierarchy_id_cp1: Optional[int], + parent_wallbox_socket: WallboxSocket, event_start: Event, event_stop: Event) -> None: log.debug(f"Init internal chargepoint as {mode}") @@ -99,28 +117,37 @@ def __init__(self, self.heartbeat = False self.fault_state_info_cp0 = FaultState( ComponentInfo(0, "Interner Ladepunkt 0", "internal_chargepoint", hierarchy_id=hierarchy_id_cp0)) - fault_state_info_cp1 = FaultState( - ComponentInfo(1, "Interner Ladepunkt 1", "internal_chargepoint", hierarchy_id=hierarchy_id_cp1)) + self.fault_state_info_wallbox_socket = FaultState(ComponentInfo(None, "Wallbox-Steckdose", "wallbox_socket")) + with SingleComponentUpdateContext(self.fault_state_info_cp0, reraise=True): - self.init_gpio() - try: - with SingleComponentUpdateContext(self.fault_state_info_cp0, reraise=True): - # Allgemeine Fehlermeldungen an LP 1: - if mode == InternalChargepointMode.PRO_PLUS: - self.cp0_client_handler = None - else: - self.cp0_client_handler = client_factory(mode, 0, self.fault_state_info_cp0) + self.init_gpio(mode) + self.cp0_client_handler = None + self.cp0_modbus_client = None + self.cp0 = None + self.wallbox_socket = None + if mode != InternalChargepointMode.PRO_PLUS: + with SingleComponentUpdateContext(self.fault_state_info_cp0): + self.cp0_modbus_client, evse_ids = get_modbus_client( + mode, 0, fault_state=self.fault_state_info_cp0) + if parent_wallbox_socket.data.detected and self.cp0_modbus_client is not None: + with SingleComponentUpdateContext(self.fault_state_info_wallbox_socket): + self.wallbox_socket = HandlerWallboxSocket( + self.cp0_modbus_client, global_data, parent_wallbox_socket) + if self.cp0_modbus_client is not None or mode == InternalChargepointMode.PRO_PLUS: + with SingleComponentUpdateContext(self.fault_state_info_cp0): + if self.cp0_modbus_client is not None: + self.cp0_client_handler = ClientHandler( + 0, self.cp0_modbus_client, evse_ids, self.fault_state_info_cp0) self.cp0 = HandlerChargepoint(self.cp0_client_handler, 0, mode, global_data, parent_cp0, hierarchy_id_cp0) - except Exception: - self.cp0_client_handler = None - self.cp0 = None try: if ((mode == InternalChargepointMode.DUO or mode == InternalChargepointMode.SE) and - hierarchy_id_cp0 is not None): + hierarchy_id_cp1 is not None): + fault_state_info_cp1 = FaultState( + ComponentInfo(1, "Interner Ladepunkt 1", "internal_chargepoint", hierarchy_id=hierarchy_id_cp1)) with SingleComponentUpdateContext(fault_state_info_cp1, reraise=True): log.debug("Zweiter Ladepunkt für Duo konfiguriert.") - self.cp1_client_handler = client_factory(mode, 1, fault_state_info_cp1, self.cp0_client_handler) + self.cp1_client_handler = client_factory(mode, 1, fault_state_info_cp1, self.cp0_modbus_client) self.cp1 = HandlerChargepoint(self.cp1_client_handler, 1, mode, global_data, parent_cp1, hierarchy_id_cp1) else: @@ -130,7 +157,7 @@ def __init__(self, self.cp1 = None self.cp1_client_handler = None - def init_gpio(self) -> None: + def init_gpio(self, mode: InternalChargepointMode) -> None: GPIO.setwarnings(False) GPIO.setmode(GPIO.BOARD) GPIO.setup(37, GPIO.OUT) @@ -139,9 +166,14 @@ def init_gpio(self) -> None: GPIO.setup(29, GPIO.OUT) GPIO.setup(11, GPIO.OUT) GPIO.setup(15, GPIO.OUT) - # GPIOs for socket - GPIO.setup(23, GPIO.OUT) - GPIO.setup(26, GPIO.OUT) + + if mode in (InternalChargepointMode.SERIES, InternalChargepointMode.SE): + GPIO.setup(26, GPIO.OUT, initial=GPIO.LOW) + GPIO.setup(23, GPIO.OUT, initial=GPIO.LOW) + else: + # GPIOs for socket + GPIO.setup(23, GPIO.OUT) + GPIO.setup(26, GPIO.OUT) GPIO.setup(19, GPIO.IN, pull_up_down=GPIO.PUD_UP) def loop(self) -> None: @@ -154,23 +186,29 @@ def _loop(): data = copy.deepcopy(SubData.internal_chargepoint_data) log.debug(data) log.setLevel(SubData.system_data["system"].data["debug_level"]) - heartbeat_cp0, heartbeat_cp1 = True, True + heartbeat_cp0, heartbeat_cp1 = self.cp0 is not None, True if self.cp0: heartbeat_cp0 = self.cp0.update(data["global_data"], data["cp0"].data, data["rfid_data"]) if self.cp1: heartbeat_cp1 = self.cp1.update(data["global_data"], data["cp1"].data, data["rfid_data"]) - self.heartbeat = True if heartbeat_cp0 and heartbeat_cp1 else False + if self.wallbox_socket: + wallbox_socket_data = copy.deepcopy(SubData.wallbox_socket_data.data) + heartbeat_wallbox_socket = self.wallbox_socket.update(data["global_data"], wallbox_socket_data) + self.heartbeat = True if heartbeat_cp0 and heartbeat_cp1 and ( + self.wallbox_socket is None or heartbeat_wallbox_socket) else False time.sleep(1.1) # Allgemeine Fehlermeldungen an LP 1 with SingleComponentUpdateContext(self.fault_state_info_cp0, update_always=False): clients = [] is_pro_plus = self.cp0 is not None and self.cp0.mode == InternalChargepointMode.PRO_PLUS if not is_pro_plus: + if self.cp0_modbus_client is not None: + clients.append(self.cp0_modbus_client) for handler in (self.cp0_client_handler, self.cp1_client_handler): if handler is not None and handler.client not in clients: clients.append(handler.client) if not clients: - log.error("Kein ClientHandler vorhanden. Beende.") + log.error("Kein Modbus-Client vorhanden. Beende.") return with ExitStack() as stack: @@ -234,12 +272,57 @@ def _check_heartbeat_expired(self, heartbeat) -> bool: return False +class HandlerWallboxSocket: + def __init__(self, + client: ModbusSerialClient_, + global_data: GlobalHandlerData, + wallbox_socket: WallboxSocket) -> None: + self.module = WallboxSocketModule(client, wallbox_socket) + self.global_data = global_data + with SingleComponentUpdateContext(self.module.fault_state): + self.update_state = UpdateWallboxSocketState(self.module) + + def update(self, global_data: GlobalHandlerData, data: WallboxSocketData) -> bool: + with SingleComponentUpdateContext(self.module.fault_state): + self.module.get_values(data.last_tag) + heartbeat_expired = self._check_heartbeat_expired(global_data.heartbeat) + self.update_state.update_state(data, heartbeat_expired) + return True + return False + + def _check_heartbeat_expired(self, heartbeat) -> bool: + if heartbeat+80 < timecheck.create_timestamp(): + log.error(f"Heartbeat Fehler seit {timecheck.create_timestamp()-heartbeat}" + "s keine Verbindung. Stoppe Ladung.") + return True + else: + return False + + class GeneralInternalChargepointHandler: def __init__(self) -> None: self.event_stop = Event() self.event_start = Event() + def auto_detect_wallbox_socket(self): + try: + device, count = get_serial_modbus_devices() + if count == 1 and device[0] in BUS_SOURCES: + port = device[0] + client = ModbusSerialClient_(port) + fault_state = FaultState(ComponentInfo(None, "Wallbox-Steckdose", "wallbox_socket")) + if ClientHandler.find_meter_client(WALLBOX_SOCKET_METERS, client, fault_state) is not None: + pub.pub_single("openWB/internal_socket/data/detected", True) + with ModifyLoglevelContext(log, logging.DEBUG): + log.debug("Wallbox-Steckdose verbaut.") + return + with ModifyLoglevelContext(log, logging.DEBUG): + log.debug("Es konnte keine Wallbox-Steckdose erkannt werden.") + except Exception: + log.exception("Fehler bei der automatischen Erkennung einer Wallbox-Steckdose") + def handler(self): + self.auto_detect_wallbox_socket() while True: try: self.event_start.wait() @@ -266,6 +349,7 @@ def handler(self): hierarchy_id_cp0, data["cp1"], hierarchy_id_cp1, + copy.deepcopy(SubData.wallbox_socket_data), self.event_start, self.event_stop) self.internal_chargepoint_handler.loop() diff --git a/packages/modules/internal_chargepoint_handler/internal_hardware_check.py b/packages/modules/internal_chargepoint_handler/internal_hardware_check.py new file mode 100644 index 0000000000..1bc9cd8ec1 --- /dev/null +++ b/packages/modules/internal_chargepoint_handler/internal_hardware_check.py @@ -0,0 +1,113 @@ +import logging +import time +import pymodbus +from typing import Any, Optional, Protocol, Tuple, Union + +from modules.common.component_state import CounterState, EvseState +from modules.common.evse import Evse +from modules.common.fault_state import FaultState +from modules.common.hardware_check import check_meter +from modules.common.modbus import ModbusSerialClient_, ModbusTcpClient_ +from modules.internal_chargepoint_handler.hardware_definition import MeterClient + +log = logging.getLogger(__name__) + + +EVSE_MIN_FIRMWARE = 7 +MAX_ATTEMPTS = 3 +RETRY_DELAY_SECONDS = 0.3 + +READ_RS485_ADAPTER_AGAIN = "Erneutes Auslesen des {}" +READ_USB_ADAPTER_AGAIN = READ_RS485_ADAPTER_AGAIN.format('USB-Adapters') +READ_LAN_ADAPTER_AGAIN = READ_RS485_ADAPTER_AGAIN.format('LAN-Konverters') +METER_PROBLEM = "Erneutes Auslesen des Zählers" +METER_NO_SERIAL_NUMBER = ("Die Seriennummer des Zählers für das Ladelog kann nicht ausgelesen werden. Wenn Du die " + "Seriennummer für Abrechnungszwecke benötigst, wende Dich bitte an unseren Support. Die " + "Funktionalität wird dadurch nicht beeinträchtigt!") +READ_EVSE_AGAIN = "Erneutes Auslesen der EVSE" + + +class ClientHandlerProtocol(Protocol): + @property + def client(self) -> Union[ModbusSerialClient_, ModbusTcpClient_]: ... + @property + def local_charge_point_num(self) -> int: ... + @property + def fault_state(self) -> FaultState: ... + @property + def evse_client(self) -> Evse: ... + @property + def meter_client(self) -> Any: ... + @property + def read_error(self) -> int: ... + @property + def handle_exception(self, exception: Exception) -> bool: ... + @property + def request_and_check_hardware(self, fault_state: FaultState) -> Tuple[EvseState, CounterState]: ... + @property + def check_meter(self) -> Tuple[bool, Optional[str], CounterState]: ... + + +class SeriesHardwareCheckMixin: + def __init__(self) -> None: + pass + + def handle_exception(self: ClientHandlerProtocol, exception: Exception): + # separated for test purposes + if (isinstance(self.client, ModbusTcpClient_) and + isinstance(exception, pymodbus.exceptions.ConnectionException)): + raise exception + else: + return False + + def request_and_check_hardware(self: ClientHandlerProtocol, + fault_state: FaultState) -> Tuple[EvseState, CounterState]: + evse_check_passed = False + evse_state: EvseState + # 2x Retry bei EVSE-Auslesen vor dem Absetzen einer Fehlermeldung + try: + with self.client: + for attempt in range(MAX_ATTEMPTS): + try: + evse_state = self.evse_client.get_evse_state() + evse_check_passed = True + break + except Exception as e: + evse_check_passed = self.handle_exception(e) + # nur warten, wenn danach noch ein Versuch folgt + if attempt < MAX_ATTEMPTS - 1 and evse_check_passed is False: + time.sleep(RETRY_DELAY_SECONDS) + except Exception as e: + evse_check_passed = self.handle_exception(e) + meter_check_passed, meter_error_msg, counter_state = self.check_meter(self.meter_client) + if meter_check_passed is False and evse_check_passed is False: + if isinstance(self.client, ModbusTcpClient_): + raise Exception(READ_LAN_ADAPTER_AGAIN) + else: + raise Exception(READ_USB_ADAPTER_AGAIN) + if meter_check_passed is False: + if evse_check_passed is False: + if isinstance(self.client, ModbusTcpClient_): + raise Exception(READ_LAN_ADAPTER_AGAIN) + else: + raise Exception(READ_USB_ADAPTER_AGAIN) + else: + raise Exception(meter_error_msg) + elif evse_check_passed and meter_check_passed and meter_error_msg is not None: + fault_state.warning(meter_error_msg) + if evse_check_passed is False: + if meter_error_msg is not None: + raise Exception(f"{READ_EVSE_AGAIN} {meter_error_msg}") + else: + raise Exception(READ_EVSE_AGAIN) + return evse_state, counter_state + + def check_meter(self: ClientHandlerProtocol, meter_client: MeterClient) -> Tuple[bool, Optional[str], CounterState]: + try: + with self.client: + counter_state = meter_client.get_counter_state() + if counter_state.serial_number == "0" or counter_state.serial_number is None: + log.warning(METER_NO_SERIAL_NUMBER) + return True, check_meter(counter_state), counter_state + except Exception: + return False, METER_PROBLEM, None diff --git a/packages/modules/internal_chargepoint_handler/rfid.py b/packages/modules/internal_chargepoint_handler/rfid.py index 46878c99e7..72fca9db54 100644 --- a/packages/modules/internal_chargepoint_handler/rfid.py +++ b/packages/modules/internal_chargepoint_handler/rfid.py @@ -129,6 +129,7 @@ async def _read_events(self, device: InputDevice): if len(key_string) > 0: log.debug(f"RFID-String: {key_string}") Pub().pub("openWB/set/internal_chargepoint/last_tag", key_string) + Pub().pub("openWB/set/internal_socket/data/last_tag", key_string) key_string = "" else: log.debug(f"new key: {data.scancode} - {ecodes.KEY[data.scancode]}") diff --git a/packages/modules/internal_chargepoint_handler/wallbox_socket.py b/packages/modules/internal_chargepoint_handler/wallbox_socket.py new file mode 100644 index 0000000000..5902756032 --- /dev/null +++ b/packages/modules/internal_chargepoint_handler/wallbox_socket.py @@ -0,0 +1,109 @@ +import logging +import time +from typing import Optional, Tuple, Union + +from helpermodules.utils.error_handling import ErrorTimerContext +from modules.common.component_context import SingleComponentUpdateContext +from modules.common.component_state import ConsumerState, CounterState +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.hardware_check import check_meter +from modules.common.modbus import ModbusSerialClient_, ModbusTcpClient_ +from modules.common.store import get_wallbox_socket_value_store +from modules.internal_chargepoint_handler.hardware_definition import CP0_METERS, WALLBOX_SOCKET_METERS, MeterClient +from modules.internal_chargepoint_handler.internal_hardware_check import ( + METER_NO_SERIAL_NUMBER, METER_PROBLEM, READ_USB_ADAPTER_AGAIN) +from modules.internal_chargepoint_handler.wallbox_socket_config import WallboxSocket + +log = logging.getLogger(__name__) + +try: + import RPi.GPIO as GPIO +except ImportError: + log.info("failed to import RPi.GPIO! maybe we are not running on a pi") + + +class WallboxSocketModule: + def __init__(self, client: Union[ModbusSerialClient_, ModbusTcpClient_], wallbox_socket: WallboxSocket) -> None: + self.store_wallbox_socket = get_wallbox_socket_value_store() + self.fault_state = FaultState(ComponentInfo(None, "Wallbox-Steckdose", "wallbox_socket")) + self.client_error_context = ErrorTimerContext( + "openWB/set/internal_socket/get/error_timestamp", "Wallbox-Steckdose bleibt ausgeschaltet.", + hide_exception=True) + self.client_error_context.error_timestamp = wallbox_socket.get.error_timestamp + self._client = client + self.meter_client: Optional[MeterClient] = None + self.request_and_check_wallbox_socket() + self.current_state = False + self.old_wallbox_socket_state = ConsumerState(power=0, currents=[0]*3, powers=[0]*3, state=False) + + def check_meter(self, meter_client: MeterClient) -> Tuple[bool, Optional[str], Optional[CounterState]]: + try: + with self._client: + counter_state = meter_client.get_counter_state() + if counter_state.serial_number == "0" or counter_state.serial_number is None: + log.warning(METER_NO_SERIAL_NUMBER) + return True, check_meter(counter_state), counter_state + except Exception: + return False, METER_PROBLEM, None + + def request_and_check_wallbox_socket(self) -> CounterState: + try: + if self.meter_client is None: + meter_type, modbus_id = WALLBOX_SOCKET_METERS[0] + self.meter_client = meter_type(modbus_id, self._client, self.fault_state) + meter_check_passed, meter_error_msg, counter_state = self.check_meter(self.meter_client) + except Exception: + meter_check_passed, meter_error_msg, counter_state = False, METER_PROBLEM, None + if meter_check_passed is False: + # Probe the chargepoint meter only to distinguish a meter failure from a bus failure. + for meter_type, modbus_id in CP0_METERS: + try: + diagnostic_meter = meter_type(modbus_id, self._client, self.fault_state) + except Exception: + continue + if self.check_meter(diagnostic_meter)[0]: + raise Exception(meter_error_msg) + raise Exception(READ_USB_ADAPTER_AGAIN) + if meter_error_msg is not None: + self.fault_state.warning(meter_error_msg) + return counter_state + + def get_values(self, last_tag: Optional[str] = None) -> ConsumerState: + wallbox_socket_state = self.old_wallbox_socket_state + with self.client_error_context: + counter_state = self.request_and_check_wallbox_socket() + + time.sleep(0.1) + self.client_error_context.reset_error_counter() + + wallbox_socket_state = ConsumerState( + power=counter_state.power, + currents=counter_state.currents, + imported=counter_state.imported, + exported=counter_state.exported, + powers=counter_state.powers, + voltages=counter_state.voltages, + state=self.current_state, + rfid=last_tag, + ) + + if self.client_error_context.error_counter_exceeded(): + wallbox_socket_state = ConsumerState( + power=0, currents=[0]*3, powers=[0]*3, state=False, + imported=self.old_wallbox_socket_state.imported, exported=self.old_wallbox_socket_state.exported) + + self.store_wallbox_socket.set(wallbox_socket_state) + self.store_wallbox_socket.update() + self.old_wallbox_socket_state = wallbox_socket_state + return self.old_wallbox_socket_state + + def set_state(self, state: bool) -> None: + with SingleComponentUpdateContext(self.fault_state, update_always=False): + direction_pin, enable_pin = 23, 26 # BOARD numbering: BCM 11, BCM 7 + if state: + GPIO.output(direction_pin, GPIO.HIGH) + GPIO.output(enable_pin, GPIO.HIGH) + else: + GPIO.output(enable_pin, GPIO.LOW) + GPIO.output(direction_pin, GPIO.LOW) + self.current_state = state diff --git a/packages/modules/internal_chargepoint_handler/wallbox_socket_config.py b/packages/modules/internal_chargepoint_handler/wallbox_socket_config.py new file mode 100644 index 0000000000..be1a2859d4 --- /dev/null +++ b/packages/modules/internal_chargepoint_handler/wallbox_socket_config.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from dataclass_utils.factories import currents_list_factory, voltages_list_factory +from helpermodules.constants import NO_ERROR + + +@dataclass +class WallboxSocketData: + detected: bool = False + set_state: bool = False + last_tag: Optional[str] = None + + +@dataclass +class WallboxSocketGet: + rfid: Optional[str] = None + state: bool = False + power: float = 0 + imported: float = 0 + exported: float = 0 + currents: List[float] = field(default_factory=currents_list_factory) + voltages: List[float] = field(default_factory=voltages_list_factory) + powers: List[float] = field(default_factory=currents_list_factory) + fault_state: int = 0 + fault_str: str = NO_ERROR + error_timestamp: Optional[float] = None + + +@dataclass +class WallboxSocket: + data: WallboxSocketData = field(default_factory=WallboxSocketData) + get: WallboxSocketGet = field(default_factory=WallboxSocketGet) diff --git a/packages/modules/internal_chargepoint_handler/wallbox_socket_module_test.py b/packages/modules/internal_chargepoint_handler/wallbox_socket_module_test.py new file mode 100644 index 0000000000..c667e39bd1 --- /dev/null +++ b/packages/modules/internal_chargepoint_handler/wallbox_socket_module_test.py @@ -0,0 +1,40 @@ +from unittest.mock import MagicMock, Mock + +import pytest + +from modules.common.component_state import CounterState, ConsumerState +from modules.common.store import _wallbox_socket +from modules.internal_chargepoint_handler import wallbox_socket + + +@pytest.mark.parametrize("tag", ["0012345678", None]) +def test_wallbox_socket_measurement_publishes_current_rfid_and_clears_previous_tag(tag, monkeypatch): + publisher = Mock() + monkeypatch.setattr(_wallbox_socket, "pub_to_broker", publisher) + monkeypatch.setattr(wallbox_socket.time, "sleep", Mock()) + module = wallbox_socket.WallboxSocketModule.__new__(wallbox_socket.WallboxSocketModule) + module._client = Mock() + module._client = MagicMock() + module.meter_client = Mock() + module.meter_client.get_counter_state.return_value = CounterState( + power=100, imported=20, exported=0, currents=[1, 0, 0], voltages=[230, 0, 0]) + module.client_error_context = MagicMock() + module.client_error_context.error_counter_exceeded.return_value = False + module.fault_state = Mock() + module.current_state = False + module.measurement_valid = False + module.old_wallbox_socket_state = ConsumerState(power=0) + module.store_wallbox_socket = _wallbox_socket.WallboxSocketValueStore() + + state = module.get_values(tag) + + assert isinstance(state, ConsumerState) + assert state.rfid == tag + assert state.power == 100 + publisher.assert_any_call("openWB/set/internal_socket/get/rfid", tag) + publisher.reset_mock() + + state = module.get_values(None) + + assert state.rfid is None + publisher.assert_any_call("openWB/set/internal_socket/get/rfid", None)