From 144f60b4847a3ab206aa9b4081405188ebaae028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piet=20G=C3=B6mpel?= Date: Thu, 15 Jan 2026 16:00:40 +0100 Subject: [PATCH 1/2] fix(EvseManager): Prevent start of a Session if error is active and allowing HLC module to signaling error if HLC session is initiated. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Piet Gömpel --- modules/EVSE/EvseManager/Charger.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/EVSE/EvseManager/Charger.cpp b/modules/EVSE/EvseManager/Charger.cpp index af20620483..ecdcaa6d91 100644 --- a/modules/EVSE/EvseManager/Charger.cpp +++ b/modules/EVSE/EvseManager/Charger.cpp @@ -238,6 +238,12 @@ void Charger::run_state_machine() { case EvseState::WaitingForAuthentication: + // Wait here until all errors are cleared + if (stop_charging_on_fatal_error_internal()) { + signal_hlc_error(types::iso15118::EvseError::Error_EmergencyShutdown); + break; + } + // Explicitly do not allow to be powered on. This is important // to make sure control_pilot does not switch on relais even if // we start PWM here From 17845d9fc0b7cfa91f07d35a703dbb5d3f9e1cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piet=20G=C3=B6mpel?= Date: Thu, 15 Jan 2026 16:07:19 +0100 Subject: [PATCH 2/2] test(core): Add smoke tests for PWM and ISO15118 AC and DC charging including one test case that verifies that no session starts when an error is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Piet Gömpel --- .../testing/core_utils/probe_module.py | 46 +++- tests/core_tests/basic_charging_tests.py | 30 --- tests/core_tests/smoke_tests.py | 231 ++++++++++++++++++ 3 files changed, 276 insertions(+), 31 deletions(-) delete mode 100644 tests/core_tests/basic_charging_tests.py create mode 100644 tests/core_tests/smoke_tests.py diff --git a/applications/utils/everest-testing/src/everest/testing/core_utils/probe_module.py b/applications/utils/everest-testing/src/everest/testing/core_utils/probe_module.py index f5542e972b..6253be3aed 100644 --- a/applications/utils/everest-testing/src/everest/testing/core_utils/probe_module.py +++ b/applications/utils/everest-testing/src/everest/testing/core_utils/probe_module.py @@ -3,9 +3,10 @@ import threading from queue import Queue -from typing import Any, Callable +from typing import Any, Callable, Optional from everest.framework import Module, RuntimeSession +from everest.framework import error class ProbeModule: """ @@ -108,6 +109,49 @@ def subscribe_variable_to_queue(self, connection_id: str, var_name: str): lambda message, _queue=queue: _queue.put(message)) return queue + def raise_error(self, implementation_id: str, error_obj: error.Error): + """ + Raise an error from an interface the probe module implements. + - implementation_id: the id of the implementation, as used by other modules requiring it in the runtime config + - error_obj: the Error object to raise + """ + self._mod.raise_error(implementation_id, error_obj) + + def clear_error(self, implementation_id: str, error_type: str, sub_type: Optional[str] = None): + """ + Clear an error from an interface the probe module implements. + - implementation_id: the id of the implementation, as used by other modules requiring it in the runtime config + - error_type: the type of the error to clear + - sub_type: optional sub-type of the error to clear + """ + if sub_type is not None: + self._mod.clear_error(implementation_id, error_type, sub_type) + else: + self._mod.clear_error(implementation_id, error_type) + + def subscribe_error(self, connection_id: str, error_type: str, + callback: Callable[[error.Error], None], + clear_callback: Callable[[error.Error], None]): + """ + Subscribe to a specific error type from a module required by the probe module. + - connection_id: the id of the connection, as specified for the probe module in the runtime config + - error_type: the type of errors to subscribe to + - callback: a function to handle when the error is raised, accepting an Error object + - clear_callback: a function to handle when the error is cleared, accepting an Error object + """ + self._mod.subscribe_error(self._setup.connections[connection_id][0], error_type, callback, clear_callback) + + def subscribe_all_errors(self, connection_id: str, + callback: Callable[[error.Error], None], + clear_callback: Callable[[error.Error], None]): + """ + Subscribe to all errors from a module required by the probe module. + - connection_id: the id of the connection, as specified for the probe module in the runtime config + - callback: a function to handle when any error is raised, accepting an Error object + - clear_callback: a function to handle when any error is cleared, accepting an Error object + """ + self._mod.subscribe_all_errors(self._setup.connections[connection_id][0], callback, clear_callback) + def _ready(self): """ Internal function: callback triggered by the EVerest framework when all modules have been initialized diff --git a/tests/core_tests/basic_charging_tests.py b/tests/core_tests/basic_charging_tests.py deleted file mode 100644 index f5aa9edbb1..0000000000 --- a/tests/core_tests/basic_charging_tests.py +++ /dev/null @@ -1,30 +0,0 @@ - -#!/usr/bin/env python3 -# SPDX-License-Identifier: Apache-2.0 -# Copyright 2020 - 2022 Pionix GmbH and Contributors to EVerest - -import logging -import pytest - -from everest.testing.core_utils.fixtures import * -from everest.testing.core_utils.everest_core import EverestCore -# from validations.user_functions import * - -# FIXME (aw): broken -# @pytest.mark.asyncio -# async def test_001_charge_defined_ammount(everest_core: EverestCore, test_control_module: TestControlModule): -# logging.info(">>>>>>>>> test_001_charge_defined_ammount <<<<<<<<<") - -# everest_core.start(standalone_module='test_control_module') -# logging.info("everest-core ready, waiting for test control module") - -# test_control_module_handle = test_control_module.start(everest_core.everest_config_path) -# logging.info("test_control_module started") - -# test_control_module_handle.start_charging(mode = 'raw', charge_sim_string = "sleep 1;iec_wait_pwr_ready;sleep 1;draw_power_regulated 16,3;sleep 20;unplug") - -# # assert that charged energy is > target -# assert await wait_for_and_validate_event(test_control_module, exp_event='transaction_started', exp_data=None, validation_function=set_test_memory, timeout=20) -# assert await wait_for_and_validate_event(test_control_module, exp_event='transaction_finished', exp_data=float(56.0), validation_function=validate_transaction_charged_amount, timeout=30) - -# everest_core.stop() diff --git a/tests/core_tests/smoke_tests.py b/tests/core_tests/smoke_tests.py new file mode 100644 index 0000000000..02a98b2f07 --- /dev/null +++ b/tests/core_tests/smoke_tests.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright Pionix GmbH and Contributors to EVerest + +import pytest +import asyncio +from datetime import datetime, timezone +from unittest.mock import Mock + +from everest.testing.core_utils.common import Requirement +from everest.testing.core_utils.fixtures import * +from everest.testing.core_utils.controller.test_controller_interface import ( + TestController, +) +from everest.testing.core_utils.everest_core import EverestCore +from everest.testing.core_utils.probe_module import ProbeModule + + +async def wait_for_session_events(mock, expected_events, timeout=30): + """Wait for specific events to appear in the mock's call list in the exact order.""" + start_time = asyncio.get_event_loop().time() + + while asyncio.get_event_loop().time() - start_time < timeout: + events = [] + for call in mock.call_args_list: + event_data = call[0][0] + event_type = event_data.get("event") + events.append(event_type) + + # Check if expected_events appear in order (as a subsequence) + expected_idx = 0 + for event in events: + if expected_idx < len(expected_events) and event == expected_events[expected_idx]: + expected_idx += 1 + + if expected_idx == len(expected_events): + return events + + await asyncio.sleep(0.1) + + raise TimeoutError(f"Timeout waiting for events {expected_events} in order. Got: {events}") + + +async def wait_for_ready(mock, timeout=5): + """Wait until the ready mock has been called.""" + start_time = asyncio.get_event_loop().time() + + while asyncio.get_event_loop().time() - start_time < timeout: + if mock.call_count > 0: + return + await asyncio.sleep(0.1) + + raise TimeoutError("Timeout waiting for ready signal.") + + +async def wait_for_error(mock, timeout=5): + """Wait until the error mock has been called.""" + start_time = asyncio.get_event_loop().time() + + while asyncio.get_event_loop().time() - start_time < timeout: + if mock.call_count > 0: + return + await asyncio.sleep(0.1) + + raise TimeoutError("Timeout waiting for error signal.") + + +async def setup_probe_module( + test_controller: TestController, everest_core: EverestCore +): + """Initialize test controller and probe module, wait for ready. Returns probe_module.""" + test_controller.start() + probe_module = ProbeModule(everest_core.get_runtime_session()) + + ready_mock = Mock() + probe_module.subscribe_variable("evse_manager", "ready", ready_mock) + + probe_module.start() + await probe_module.wait_to_be_ready() + await wait_for_ready(ready_mock, timeout=5) + + return probe_module + + +def setup_session_event_monitoring(probe_module: ProbeModule, connection_id: str): + """Subscribe to session events from a connection. Returns session_event_mock.""" + session_event_mock = Mock() + probe_module.subscribe_variable(connection_id, "session_event", session_event_mock) + return session_event_mock + + +def setup_error_monitoring(probe_module: ProbeModule, connection_id: str): + """Subscribe to error events from a connection. Returns error_raised_mock and error_cleared_mock.""" + error_raised_mock = Mock() + error_cleared_mock = Mock() + probe_module.subscribe_all_errors( + connection_id, error_raised_mock, error_cleared_mock + ) + return error_raised_mock, error_cleared_mock + + +@pytest.mark.asyncio +@pytest.mark.probe_module( + connections={"evse_manager": [Requirement("connector_1", "evse")]} +) +@pytest.mark.everest_core_config("config-sil.yaml") +async def test_pwm_ac_session( + test_controller: TestController, everest_core: EverestCore +): + """ + Test session events of a basic PWM AC charging session. + """ + probe_module = await setup_probe_module(test_controller, everest_core) + session_event_mock = setup_session_event_monitoring(probe_module, "evse_manager") + + expected_events = [ + "SessionStarted", + "AuthRequired", + "Authorized", + "TransactionStarted", + "PrepareCharging", + "ChargingStarted", + ] + + test_controller.plug_in() + await wait_for_session_events(session_event_mock, expected_events) + + test_controller.plug_out() + await wait_for_session_events( + session_event_mock, ["TransactionFinished", "SessionFinished"] + ) + + +@pytest.mark.asyncio +@pytest.mark.probe_module( + connections={"evse_manager": [Requirement("connector_1", "evse")]} +) +@pytest.mark.everest_core_config("config-sil.yaml") +async def test_iso15118_ac_session( + test_controller: TestController, everest_core: EverestCore +): + """ + Test session events of an ISO 15118 AC charging session. + """ + probe_module = await setup_probe_module(test_controller, everest_core) + session_event_mock = setup_session_event_monitoring(probe_module, "evse_manager") + + expected_events = [ + "SessionStarted", + "AuthRequired", + "Authorized", + "TransactionStarted", + "PrepareCharging", + "ChargingStarted", + ] + + test_controller.plug_in_ac_iso() + await wait_for_session_events(session_event_mock, expected_events) + + test_controller.plug_out() + await wait_for_session_events( + session_event_mock, ["TransactionFinished", "SessionFinished"] + ) + + +@pytest.mark.asyncio +@pytest.mark.probe_module( + connections={"evse_manager": [Requirement("evse_manager", "evse")]} +) +@pytest.mark.everest_core_config("config-sil-dc.yaml") +async def test_iso15118_dc_session( + test_controller: TestController, everest_core: EverestCore +): + """ + Test session events of an ISO 15118 DC charging session. + """ + + probe_module = await setup_probe_module(test_controller, everest_core) + + session_event_mock = setup_session_event_monitoring(probe_module, "evse_manager") + + expected_events = [ + "SessionStarted", + "AuthRequired", + "Authorized", + "TransactionStarted", + "PrepareCharging", + "ChargingStarted", + ] + + test_controller.plug_in_dc_iso() + await wait_for_session_events(session_event_mock, expected_events) + + test_controller.plug_out() + await wait_for_session_events( + session_event_mock, ["TransactionFinished", "SessionFinished"] + ) + + +@pytest.mark.asyncio +@pytest.mark.probe_module( + connections={"evse_manager": [Requirement("evse_manager", "evse")]} +) +@pytest.mark.everest_core_config("config-sil-dc.yaml") +async def test_iso15118_dc_session_error_before_session( + test_controller: TestController, everest_core: EverestCore +): + """ + Test session events of an ISO 15118 DC charging session with an error before the session. + """ + + probe_module = await setup_probe_module(test_controller, everest_core) + session_event_mock = setup_session_event_monitoring(probe_module, "evse_manager") + error_raised_mock, error_cleared_mock = setup_error_monitoring( + probe_module, "evse_manager" + ) + + test_controller.raise_error("MREC2GroundFailure") + + # Verify that error was raised + await wait_for_error(error_raised_mock) + assert error_raised_mock.called, "Error should have been raised" + + test_controller.plug_in_dc_iso() + + # wait for any session events for a short time + await asyncio.sleep(10) + + assert ( + session_event_mock.call_count == 0 + ), "No session events should occur while error is active"