diff --git a/errors/evse_manager.yaml b/errors/evse_manager.yaml index ce3dfe50a2..8a0140709b 100644 --- a/errors/evse_manager.yaml +++ b/errors/evse_manager.yaml @@ -9,6 +9,8 @@ errors: description: Internal error of the state machine - name: MREC4OverCurrentFailure description: Over current event + - name: MREC5OverVoltage + description: Over voltage event - name: MREC9AuthorizationTimeout description: No authorization was provided within timeout after plugin - name: PowermeterTransactionStartFailed diff --git a/modules/EVSE/EvseManager/CMakeLists.txt b/modules/EVSE/EvseManager/CMakeLists.txt index 2e3d7a9353..11ccd0cf32 100644 --- a/modules/EVSE/EvseManager/CMakeLists.txt +++ b/modules/EVSE/EvseManager/CMakeLists.txt @@ -19,6 +19,7 @@ target_sources(${MODULE_NAME} ErrorHandling.cpp backtrace.cpp PersistentStore.cpp + over_voltage/OverVoltageMonitor.cpp ) target_link_libraries(${MODULE_NAME} diff --git a/modules/EVSE/EvseManager/ErrorHandling.cpp b/modules/EVSE/EvseManager/ErrorHandling.cpp index d804e4eeb1..0f77c141b6 100644 --- a/modules/EVSE/EvseManager/ErrorHandling.cpp +++ b/modules/EVSE/EvseManager/ErrorHandling.cpp @@ -126,6 +126,13 @@ void ErrorHandling::clear_overcurrent_error() { process_error(); } +void ErrorHandling::raise_over_voltage_error(Everest::error::Severity severity, const std::string& description) { + Everest::error::Error error_object = + p_evse->error_factory->create_error("evse_manager/MREC5OverVoltage", "", description, severity); + p_evse->raise_error(error_object); + process_error(); +} + // Find out if the current error set is fatal to charging or not void ErrorHandling::process_error() { const auto fatal = errors_prevent_charging(); diff --git a/modules/EVSE/EvseManager/ErrorHandling.hpp b/modules/EVSE/EvseManager/ErrorHandling.hpp index b26048528b..7f1c5b3ebd 100644 --- a/modules/EVSE/EvseManager/ErrorHandling.hpp +++ b/modules/EVSE/EvseManager/ErrorHandling.hpp @@ -79,6 +79,8 @@ class ErrorHandling { void raise_overcurrent_error(const std::string& description); void clear_overcurrent_error(); + void raise_over_voltage_error(Everest::error::Severity severity, const std::string& description); + void raise_internal_error(const std::string& description); void clear_internal_error(); diff --git a/modules/EVSE/EvseManager/EvseManager.cpp b/modules/EVSE/EvseManager/EvseManager.cpp index 573ad62bda..3cbc34808c 100644 --- a/modules/EVSE/EvseManager/EvseManager.cpp +++ b/modules/EVSE/EvseManager/EvseManager.cpp @@ -204,6 +204,17 @@ void EvseManager::ready() { config.fail_on_powermeter_errors ? r_powermeter_billing() : EMPTY_POWERMETER_VECTOR, r_over_voltage_monitor, config.inoperative_error_use_vendor_id)); + internal_over_voltage_monitor = std::make_unique( + [this](OverVoltageMonitor::FaultType type, const std::string& description) { + if (this->error_handling) { + const auto severity = type == OverVoltageMonitor::FaultType::Emergency + ? Everest::error::Severity::High + : Everest::error::Severity::Medium; + this->error_handling->raise_over_voltage_error(severity, description); + } + }, + std::chrono::milliseconds(config.internal_over_voltage_duration_ms)); + if (not config.lock_connector_in_state_b) { EVLOG_warning << "Unlock connector in CP state B. This violates IEC61851-1:2019 D.6.5 Table D.9 line 4 and " "should not be used in public environments!"; @@ -456,6 +467,10 @@ void EvseManager::ready() { if (not r_over_voltage_monitor.empty()) { r_over_voltage_monitor[0]->call_start(); } + if (internal_over_voltage_monitor) { + internal_over_voltage_monitor->reset(); + internal_over_voltage_monitor->start_monitor(); + } }); r_hlc[0]->subscribe_current_demand_finished([this] { @@ -464,8 +479,21 @@ void EvseManager::ready() { if (not r_over_voltage_monitor.empty()) { r_over_voltage_monitor[0]->call_stop(); } + if (internal_over_voltage_monitor) { + internal_over_voltage_monitor->stop_monitor(); + } }); + // Subscribe to voltage measurements from over_voltage_monitor interface + // The internal monitor acts as a software watchdog following the hardware OVM values + if (not r_over_voltage_monitor.empty()) { + r_over_voltage_monitor[0]->subscribe_voltage_measurement_V([this](float voltage_V) { + if (internal_over_voltage_monitor) { + internal_over_voltage_monitor->update_voltage(voltage_V); + } + }); + } + // Isolation monitoring for DC charging handler if (not r_imd.empty()) { @@ -767,6 +795,10 @@ void EvseManager::ready() { r_over_voltage_monitor[0]->call_set_limits(get_emergency_over_voltage_threshold(), get_error_over_voltage_threshold()); } + if (internal_over_voltage_monitor) { + internal_over_voltage_monitor->set_limits(get_emergency_over_voltage_threshold(), + get_error_over_voltage_threshold()); + } }); r_hlc[0]->subscribe_departure_time([this](const std::string& t) { @@ -1000,6 +1032,10 @@ void EvseManager::ready() { if (not r_over_voltage_monitor.empty() and event == CPEvent::CarUnplugged) { r_over_voltage_monitor[0]->call_reset_over_voltage_error(); } + if (internal_over_voltage_monitor and event == CPEvent::CarUnplugged) { + internal_over_voltage_monitor->stop_monitor(); + internal_over_voltage_monitor->reset(); + } charger->bsp_event_queue.push(event); diff --git a/modules/EVSE/EvseManager/EvseManager.hpp b/modules/EVSE/EvseManager/EvseManager.hpp index d84408b3c0..7d4cbe38dc 100644 --- a/modules/EVSE/EvseManager/EvseManager.hpp +++ b/modules/EVSE/EvseManager/EvseManager.hpp @@ -47,6 +47,7 @@ #include "PersistentStore.hpp" #include "SessionLog.hpp" #include "VarContainer.hpp" +#include "over_voltage/OverVoltageMonitor.hpp" #include "scoped_lock_timeout.hpp" // ev@4bf81b14-a215-475c-a1d3-0a484ae48918:v1 @@ -74,6 +75,7 @@ struct Conf { bool ac_enforce_hlc; bool ac_with_soc; int dc_isolation_voltage_V; + int internal_over_voltage_duration_ms; bool dbg_hlc_auth_after_tstep; int hack_sleep_in_cable_check; int hack_sleep_in_cable_check_volkswagen; @@ -324,6 +326,7 @@ class EvseManager : public Everest::ModuleBase { VarContainer isolation_measurement; VarContainer powersupply_measurement; VarContainer selftest_result; + std::unique_ptr internal_over_voltage_monitor; // Track voltage to earth failures for debouncing int voltage_to_earth_failure_count{0}; diff --git a/modules/EVSE/EvseManager/doc.rst b/modules/EVSE/EvseManager/doc.rst index 28c12cf6eb..405217f185 100644 --- a/modules/EVSE/EvseManager/doc.rst +++ b/modules/EVSE/EvseManager/doc.rst @@ -62,6 +62,26 @@ In addition, on the DC side the following hardware modules can be connected: CableCheck, PreCharge and CurrentDemand steps. * DC power supply: This is the AC/DC converter that actually charges the car. +<<<<<<< HEAD +<<<<<<< HEAD +Software over-voltage supervision is always active during DC charging. The configuration option +``internal_over_voltage_duration_ms`` defines for how long the measured DC voltage +must exceed the negotiated limit before EvseManager raises ``MREC5OverVoltage``. +Set it to ``0`` to trigger immediately once the threshold is crossed. + +||||||| parent of afc3ead44 (feat(over_voltage): Internal over-voltage monitor implementation in EVSEManager) +======= +Software over-voltage supervision is always active. The configuration option +||||||| parent of 7d60e60da (Update modules/EVSE/EvseManager/doc.rst) +Software over-voltage supervision is always active. The configuration option +======= +Software over-voltage supervision is always active during DC charging. The configuration option +>>>>>>> 7d60e60da (Update modules/EVSE/EvseManager/doc.rst) +``internal_over_voltage_duration_ms`` defines for how long the measured DC voltage +must exceed the negotiated limit before EvseManager raises ``MREC5OverVoltage``. +Set it to ``0`` to trigger immediately once the threshold is crossed. + +>>>>>>> afc3ead44 (feat(over_voltage): Internal over-voltage monitor implementation in EVSEManager) Published variables =================== @@ -281,7 +301,7 @@ Powermeter errors cause the EvseManager to become Inoperative, if fail_on_powerm * powermeter/CommunicationFault -When a charging session is stopped because of an error, the EvseManager differentiates between **Emergency Shutdowns** and **Error Shutdowns**. The severity of the +When a charging session is stopped because of an error, the EvseManager differentiates between **Emergency Shutdowns** and **Error Shutdowns**. The severity of the error influences the type of the shudown. Emergency shutdowns are caused by errors with `Severity::High` and error shutdowns are caused by errors with `Severity::Medium` or `Severity::Low`. In case of an **Emergency Shutdown** the EvseManager will immediately: diff --git a/modules/EVSE/EvseManager/manifest.yaml b/modules/EVSE/EvseManager/manifest.yaml index e2a1d4558c..771335a0d1 100644 --- a/modules/EVSE/EvseManager/manifest.yaml +++ b/modules/EVSE/EvseManager/manifest.yaml @@ -98,6 +98,13 @@ config: Default is 0, which means the voltage will be determined according to IEC 61851-23 (2023) CC.4.1.2 type: integer default: 0 + internal_over_voltage_duration_ms: + description: >- + Time in milliseconds the internal software over voltage monitor waits before raising MREC5 once the measured + voltage exceeds the negotiated limit. + type: integer + minimum: 0 + default: 400 dbg_hlc_auth_after_tstep: description: >- Special mode: send HLC auth ok only after t_step_XX is finished (true) or directly when available (false) diff --git a/modules/EVSE/EvseManager/over_voltage/OverVoltageMonitor.cpp b/modules/EVSE/EvseManager/over_voltage/OverVoltageMonitor.cpp new file mode 100644 index 0000000000..31b793c05b --- /dev/null +++ b/modules/EVSE/EvseManager/over_voltage/OverVoltageMonitor.cpp @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#include "over_voltage/OverVoltageMonitor.hpp" + +#include +#include +#include +#include +#include + +namespace module { + +OverVoltageMonitor::OverVoltageMonitor(ErrorCallback callback, std::chrono::milliseconds duration) : + error_callback_(std::move(callback)), duration_(duration) { + timer_thread_ = std::thread(&OverVoltageMonitor::timer_thread_func, this); +} + +OverVoltageMonitor::~OverVoltageMonitor() { + { + std::lock_guard lock(timer_mutex_); + timer_thread_exit_ = true; + timer_armed_ = false; + } + timer_cv_.notify_one(); + if (timer_thread_.joinable()) { + timer_thread_.join(); + } +} + +void OverVoltageMonitor::set_limits(double emergency_limit, double error_limit) { + emergency_limit_ = emergency_limit; + error_limit_ = error_limit; + limits_valid_ = true; +} + +void OverVoltageMonitor::start_monitor() { + fault_latched_ = false; + cancel_error_timer(); + running_ = true; +} + +void OverVoltageMonitor::stop_monitor() { + running_ = false; + cancel_error_timer(); +} + +void OverVoltageMonitor::reset() { + fault_latched_ = false; + cancel_error_timer(); +} + +void OverVoltageMonitor::update_voltage(double voltage_v) { + if (!running_ || fault_latched_ || !limits_valid_) { + return; + } + + if (voltage_v >= emergency_limit_) { + cancel_error_timer(); + trigger_fault(FaultType::Emergency, + fmt::format("Voltage {:.2f} V exceeded emergency limit {:.2f} V.", voltage_v, emergency_limit_)); + return; + } + + if (voltage_v >= error_limit_) { + arm_error_timer(voltage_v); + } else { + cancel_error_timer(); + } +} + +void OverVoltageMonitor::trigger_fault(FaultType type, const std::string& reason) { + fault_latched_ = true; + running_ = false; + cancel_error_timer(); + if (error_callback_) { + error_callback_(type, reason); + } +} + +void OverVoltageMonitor::arm_error_timer(double voltage_v) { + if (duration_.count() == 0) { + trigger_fault(FaultType::Error, fmt::format("Voltage {:.2f} V exceeded limit {:.2f} V for at least {} ms.", + voltage_v, error_limit_, duration_.count())); + return; + } + + { + std::lock_guard lock(timer_mutex_); + if (timer_armed_) { + timer_voltage_snapshot_ = std::max(timer_voltage_snapshot_, voltage_v); + return; + } + timer_armed_ = true; + timer_voltage_snapshot_ = voltage_v; + timer_deadline_ = std::chrono::steady_clock::now() + duration_; + } + timer_cv_.notify_one(); +} + +void OverVoltageMonitor::cancel_error_timer() { + { + std::lock_guard lock(timer_mutex_); + if (!timer_armed_) { + return; + } + timer_armed_ = false; + } + timer_cv_.notify_one(); +} + +void OverVoltageMonitor::timer_thread_func() { + std::unique_lock lock(timer_mutex_); + + while (!timer_thread_exit_) { + // Wait until a timer is armed or exit is requested + timer_cv_.wait(lock, [this] { return timer_thread_exit_ || timer_armed_; }); + if (timer_thread_exit_) { + break; + } + + // Capture the current deadline and wait until it expires or is cancelled/updated + auto deadline = timer_deadline_; + while (!timer_thread_exit_ && timer_armed_) { + if (timer_cv_.wait_until(lock, deadline) == std::cv_status::timeout) { + break; + } + // Woken up: check for exit, cancellation or re-arming with a new deadline + if (timer_thread_exit_ || !timer_armed_ || timer_deadline_ != deadline) { + break; + } + } + + if (timer_thread_exit_) { + break; + } + if (!timer_armed_ || timer_deadline_ != deadline) { + // Timer was cancelled or re-armed; go back to waiting + continue; + } + + // Timer expired with this deadline and is still armed + const double voltage = timer_voltage_snapshot_; + timer_armed_ = false; + + // Release the lock while invoking the callback path + lock.unlock(); + trigger_fault(FaultType::Error, fmt::format("Voltage {:.2f} V exceeded limit {:.2f} V for at least {} ms.", + voltage, error_limit_, duration_.count())); + lock.lock(); + } +} + +} // namespace module diff --git a/modules/EVSE/EvseManager/over_voltage/OverVoltageMonitor.hpp b/modules/EVSE/EvseManager/over_voltage/OverVoltageMonitor.hpp new file mode 100644 index 0000000000..e48c877910 --- /dev/null +++ b/modules/EVSE/EvseManager/over_voltage/OverVoltageMonitor.hpp @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace module { + +/** + * @brief Simple software over-voltage watchdog used by EvseManager. + * + * The monitor observes a DC voltage and compares it against two thresholds: + * + * - @ref FaultType::Emergency: if the measured voltage is greater than or equal to the + * configured emergency limit, an emergency fault is raised immediately. + * - @ref FaultType::Error: if the measured voltage is greater than or equal to the configured + * error limit continuously for at least the configured duration, an error fault is raised. + * + * After a fault has been raised, it is latched until @ref reset() is called and monitoring + * is started again via @ref start_monitor(). + * + * Thread-safety: + * - Public APIs are intended to be called from EvseManager threads and from callbacks of the + * external over_voltage_monitor interface. + * - Internally, a dedicated background thread waits on an error timer condition and synchronizes + * access to timer-related state via an internal mutex and condition variable. + */ +class OverVoltageMonitor { +public: + /** + * @brief Type of over-voltage fault. + * + * - Error: voltage above the error limit for at least the configured duration. + * - Emergency: voltage above the emergency limit, triggers immediately. + */ + enum class FaultType { + Error, + Emergency + }; + + /** + * @brief Callback type used to report detected faults. + * + * The callback is invoked from an internal monitoring context when a fault is detected. + * It receives the fault type and a human-readable description. + */ + using ErrorCallback = std::function; + + /** + * @brief Construct a new OverVoltageMonitor. + * + * @param callback Function that will be called whenever a fault is detected. + * @param duration Duration for which the voltage must stay above the error limit before + * an @ref FaultType::Error is raised. A duration of 0 ms means that an + * error fault will be raised immediately when the error limit is exceeded. + */ + OverVoltageMonitor(ErrorCallback callback, std::chrono::milliseconds duration); + + /** + * @brief Destructor joins the internal timer thread before destroying the object. + */ + ~OverVoltageMonitor(); + + /** + * @brief Configure the error and emergency voltage limits. + * + * This must be called before monitoring can become active. Calling this function marks + * the limits as valid and enables evaluation in @ref update_voltage(). + * + * @param emergency_limit Emergency limit in volts; exceeding this immediately triggers an + * @ref FaultType::Emergency. + * @param error_limit Error limit in volts; exceeding this for at least the configured + * duration triggers an @ref FaultType::Error. + */ + void set_limits(double emergency_limit, double error_limit); + + /** + * @brief Start monitoring of incoming voltage samples. + * + * Clears any latched fault state and cancels a pending error timer, then enables + * evaluation in @ref update_voltage(). + */ + void start_monitor(); + + /** + * @brief Stop monitoring of incoming voltage samples. + * + * Monitoring is disabled and any pending error timer is cancelled. Existing latched + * faults remain active until @ref reset() is called. + */ + void stop_monitor(); + + /** + * @brief Feed a new voltage sample to the monitor. + * + * If monitoring is active and limits have been configured, this function evaluates the + * sample against the configured error and emergency limits and may: + * + * - Trigger an immediate emergency fault. + * - Arm or update an error timer. + * - Cancel an active error timer if the voltage falls back below the error limit. + * + * @param voltage_v Measured DC voltage in volts. + */ + void update_voltage(double voltage_v); + + /** + * @brief Reset the internal fault latch and cancel timers. + * + * This clears any previously raised fault and stops the internal error timer. Monitoring + * remains disabled until @ref start_monitor() is called again. + */ + void reset(); + +private: + void timer_thread_func(); + + void trigger_fault(FaultType type, const std::string& reason); + void arm_error_timer(double voltage_v); + void cancel_error_timer(); + + ErrorCallback error_callback_; + std::chrono::milliseconds duration_; + bool running_{false}; + bool limits_valid_{false}; + bool fault_latched_{false}; + double emergency_limit_{std::numeric_limits::infinity()}; + double error_limit_{std::numeric_limits::infinity()}; + + std::mutex timer_mutex_; + double timer_voltage_snapshot_{0.0}; + std::chrono::steady_clock::time_point timer_deadline_{}; + bool timer_armed_{false}; + bool timer_thread_exit_{false}; + std::condition_variable timer_cv_; + std::thread timer_thread_; +}; + +} // namespace module diff --git a/modules/EVSE/EvseManager/tests/CMakeLists.txt b/modules/EVSE/EvseManager/tests/CMakeLists.txt index fcf095aac0..c373a7b5b5 100644 --- a/modules/EVSE/EvseManager/tests/CMakeLists.txt +++ b/modules/EVSE/EvseManager/tests/CMakeLists.txt @@ -18,9 +18,11 @@ target_sources(${TEST_TARGET_NAME} PRIVATE EventQueueTest.cpp ErrorHandlingTest.cpp IECStateMachineTest.cpp + OverVoltageMonitorTest.cpp ../ErrorHandling.cpp ../IECStateMachine.cpp ../backtrace.cpp + ../over_voltage/OverVoltageMonitor.cpp ) target_compile_definitions(${TEST_TARGET_NAME} PRIVATE diff --git a/modules/EVSE/EvseManager/tests/ChargerTest.cpp b/modules/EVSE/EvseManager/tests/ChargerTest.cpp index 1e417045e8..22a5062ec0 100644 --- a/modules/EVSE/EvseManager/tests/ChargerTest.cpp +++ b/modules/EVSE/EvseManager/tests/ChargerTest.cpp @@ -755,6 +755,9 @@ void ErrorHandling::raise_overcurrent_error(const std::string& description) { void ErrorHandling::clear_overcurrent_error() { } +void ErrorHandling::raise_over_voltage_error(Everest::error::Severity severity, const std::string& description) { +} + void ErrorHandling::raise_internal_error(const std::string& description) { } void ErrorHandling::clear_internal_error() { diff --git a/modules/EVSE/EvseManager/tests/OverVoltageMonitorTest.cpp b/modules/EVSE/EvseManager/tests/OverVoltageMonitorTest.cpp new file mode 100644 index 0000000000..8ac823c67d --- /dev/null +++ b/modules/EVSE/EvseManager/tests/OverVoltageMonitorTest.cpp @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Pionix GmbH and Contributors to EVerest + +#include + +#include "over_voltage/OverVoltageMonitor.hpp" + +#include +#include +#include +#include + +using namespace module; +using namespace std::chrono_literals; + +class OverVoltageMonitorTest : public ::testing::Test { +protected: + struct CallbackState { + std::mutex mtx; + std::condition_variable cv; + bool called{false}; + OverVoltageMonitor::FaultType type; + std::string reason; + }; + + static OverVoltageMonitor make_monitor(CallbackState& state, std::chrono::milliseconds duration) { + return OverVoltageMonitor( + [&state](OverVoltageMonitor::FaultType type, const std::string& reason) { + std::lock_guard lock(state.mtx); + state.called = true; + state.type = type; + state.reason = reason; + state.cv.notify_all(); + }, + duration); + } + + static bool wait_for_callback(CallbackState& state, std::chrono::milliseconds timeout = 500ms) { + std::unique_lock lock(state.mtx); + return state.cv.wait_for(lock, timeout, [&state] { return state.called; }); + } +}; + +TEST_F(OverVoltageMonitorTest, no_fault_below_limits) { + CallbackState state; + auto monitor = make_monitor(state, 100ms); + + monitor.set_limits(450.0, 420.0); + monitor.start_monitor(); + + monitor.update_voltage(400.0); + monitor.update_voltage(410.0); + + EXPECT_FALSE(wait_for_callback(state)); +} + +TEST_F(OverVoltageMonitorTest, emergency_fault_triggers_immediately) { + CallbackState state; + auto monitor = make_monitor(state, 200ms); + + monitor.set_limits(450.0, 420.0); + monitor.start_monitor(); + + monitor.update_voltage(460.0); + + ASSERT_TRUE(wait_for_callback(state)); + EXPECT_EQ(state.type, OverVoltageMonitor::FaultType::Emergency); +} + +TEST_F(OverVoltageMonitorTest, error_fault_triggers_after_duration) { + CallbackState state; + auto monitor = make_monitor(state, 100ms); + + monitor.set_limits(450.0, 420.0); + monitor.start_monitor(); + + monitor.update_voltage(430.0); + + ASSERT_TRUE(wait_for_callback(state, 300ms)); + EXPECT_EQ(state.type, OverVoltageMonitor::FaultType::Error); +} + +TEST_F(OverVoltageMonitorTest, voltage_drop_cancels_error_timer) { + CallbackState state; + auto monitor = make_monitor(state, 150ms); + + monitor.set_limits(450.0, 420.0); + monitor.start_monitor(); + + monitor.update_voltage(430.0); // above error limit + std::this_thread::sleep_for(50ms); + monitor.update_voltage(410.0); // below error limit + + EXPECT_FALSE(wait_for_callback(state, 300ms)); +} + +TEST_F(OverVoltageMonitorTest, zero_duration_triggers_immediately) { + CallbackState state; + auto monitor = make_monitor(state, 0ms); + + monitor.set_limits(450.0, 420.0); + monitor.start_monitor(); + + monitor.update_voltage(425.0); + + ASSERT_TRUE(wait_for_callback(state)); + EXPECT_EQ(state.type, OverVoltageMonitor::FaultType::Error); +} + +TEST_F(OverVoltageMonitorTest, fault_is_latched) { + CallbackState state; + auto monitor = make_monitor(state, 50ms); + + monitor.set_limits(450.0, 420.0); + monitor.start_monitor(); + + monitor.update_voltage(430.0); + ASSERT_TRUE(wait_for_callback(state)); + + { + std::lock_guard lock(state.mtx); + state.called = false; + } + + // Further voltage updates should not retrigger + monitor.update_voltage(460.0); + EXPECT_FALSE(wait_for_callback(state, 200ms)); +} + +TEST_F(OverVoltageMonitorTest, stop_monitor_suppresses_fault) { + CallbackState state; + auto monitor = make_monitor(state, 100ms); + + monitor.set_limits(450.0, 420.0); + monitor.start_monitor(); + + monitor.update_voltage(430.0); + monitor.stop_monitor(); + + EXPECT_FALSE(wait_for_callback(state, 300ms)); +} + +TEST_F(OverVoltageMonitorTest, reset_clears_latched_fault_and_allows_retrigger) { + CallbackState state; + auto monitor = make_monitor(state, 50ms); + + monitor.set_limits(450.0, 420.0); + monitor.start_monitor(); + + // First fault + monitor.update_voltage(430.0); + ASSERT_TRUE(wait_for_callback(state, 300ms)); + EXPECT_EQ(state.type, OverVoltageMonitor::FaultType::Error); + + // Clear callback state + { + std::lock_guard lock(state.mtx); + state.called = false; + } + + // Reset should clear latch and timers + monitor.reset(); + + // Must restart monitoring explicitly + monitor.start_monitor(); + + // Second fault should be possible again + monitor.update_voltage(430.0); + ASSERT_TRUE(wait_for_callback(state, 300ms)); + EXPECT_EQ(state.type, OverVoltageMonitor::FaultType::Error); +} + +TEST_F(OverVoltageMonitorTest, multiple_voltage_updates_update_timer_snapshot) { + CallbackState state; + auto monitor = make_monitor(state, 100ms); + + monitor.set_limits(500.0, 420.0); + monitor.start_monitor(); + + // First update arms the timer + monitor.update_voltage(430.0); + + // Subsequent updates while timer is active + monitor.update_voltage(435.0); + monitor.update_voltage(432.0); + monitor.update_voltage(440.0); // highest value + + ASSERT_TRUE(wait_for_callback(state, 300ms)); + EXPECT_EQ(state.type, OverVoltageMonitor::FaultType::Error); +} \ No newline at end of file