From 93bac53d7fa051c2dffac26556b86641c4bc297e Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:18:18 -0800 Subject: [PATCH 01/18] add payload mode --- .../Components/ModeManager/ModeManager.cpp | 158 ++++++++++- .../Components/ModeManager/ModeManager.fpp | 31 +++ .../Components/ModeManager/ModeManager.hpp | 42 ++- .../Top/ReferenceDeploymentPackets.fppi | 1 + .../test/int/payload_mode_test.py | 248 ++++++++++++++++++ Makefile | 13 +- README.md | 6 + 7 files changed, 484 insertions(+), 15 deletions(-) create mode 100644 FprimeZephyrReference/test/int/payload_mode_test.py diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp index e697db4f..7a030858 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp @@ -18,7 +18,11 @@ namespace Components { // ---------------------------------------------------------------------- ModeManager ::ModeManager(const char* const compName) - : ModeManagerComponentBase(compName), m_mode(SystemMode::NORMAL), m_safeModeEntryCount(0), m_runCounter(0) {} + : ModeManagerComponentBase(compName), + m_mode(SystemMode::NORMAL), + m_safeModeEntryCount(0), + m_payloadModeEntryCount(0), + m_runCounter(0) {} ModeManager ::~ModeManager() {} @@ -44,11 +48,20 @@ void ModeManager ::forceSafeMode_handler(FwIndexType portNum) { // Provides immediate safe mode entry for critical component-detected faults this->log_WARNING_HI_ExternalFaultDetected(); - if (this->m_mode == SystemMode::NORMAL) { + // Allow safe mode entry from NORMAL or PAYLOAD_MODE (emergency override) + if (this->m_mode == SystemMode::NORMAL || this->m_mode == SystemMode::PAYLOAD_MODE) { this->enterSafeMode("External component request"); } } +void ModeManager ::forcePayloadMode_handler(FwIndexType portNum) { + // Force entry into payload mode (called by other components) + // Only allowed from NORMAL mode (not from SAFE_MODE) + if (this->m_mode == SystemMode::NORMAL) { + this->enterPayloadMode("External component request"); + } +} + Components::SystemMode ModeManager ::getMode_handler(FwIndexType portNum) { // Return the current system mode // Convert internal C++ enum to FPP-generated enum type @@ -63,7 +76,8 @@ void ModeManager ::FORCE_SAFE_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) { // Force entry into safe mode this->log_ACTIVITY_HI_ManualSafeModeEntry(); - if (this->m_mode == SystemMode::NORMAL) { + // Allow safe mode entry from NORMAL or PAYLOAD_MODE (emergency override) + if (this->m_mode == SystemMode::NORMAL || this->m_mode == SystemMode::PAYLOAD_MODE) { this->enterSafeMode("Ground command"); } @@ -87,6 +101,48 @@ void ModeManager ::EXIT_SAFE_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) { this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::OK); } +void ModeManager ::ENTER_PAYLOAD_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) { + // Command to enter payload mode - only allowed from NORMAL mode + + // Check if currently in safe mode (not allowed) + if (this->m_mode == SystemMode::SAFE_MODE) { + Fw::LogStringArg cmdNameStr("ENTER_PAYLOAD_MODE"); + Fw::LogStringArg reasonStr("Cannot enter payload mode from safe mode"); + this->log_WARNING_LO_CommandValidationFailed(cmdNameStr, reasonStr); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::VALIDATION_ERROR); + return; + } + + // Check if already in payload mode + if (this->m_mode == SystemMode::PAYLOAD_MODE) { + // Already in payload mode - success (idempotent) + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::OK); + return; + } + + // Enter payload mode + this->log_ACTIVITY_HI_ManualPayloadModeEntry(); + this->enterPayloadMode("Ground command"); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::OK); +} + +void ModeManager ::EXIT_PAYLOAD_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) { + // Command to exit payload mode + + // Check if currently in payload mode + if (this->m_mode != SystemMode::PAYLOAD_MODE) { + Fw::LogStringArg cmdNameStr("EXIT_PAYLOAD_MODE"); + Fw::LogStringArg reasonStr("Not currently in payload mode"); + this->log_WARNING_LO_CommandValidationFailed(cmdNameStr, reasonStr); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::VALIDATION_ERROR); + return; + } + + // Exit payload mode + this->exitPayloadMode(); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::OK); +} + // ---------------------------------------------------------------------- // Private helper methods // ---------------------------------------------------------------------- @@ -103,10 +159,11 @@ void ModeManager ::loadState() { if (status == Os::File::OP_OK && bytesRead == sizeof(PersistentState)) { // Validate state data before restoring - if (state.mode <= static_cast(SystemMode::SAFE_MODE)) { + if (state.mode <= static_cast(SystemMode::PAYLOAD_MODE)) { // Valid mode value - restore state this->m_mode = static_cast(state.mode); this->m_safeModeEntryCount = state.safeModeEntryCount; + this->m_payloadModeEntryCount = state.payloadModeEntryCount; // Restore physical hardware state to match loaded mode if (this->m_mode == SystemMode::SAFE_MODE) { @@ -116,6 +173,13 @@ void ModeManager ::loadState() { // Log that we're restoring safe mode (not entering it fresh) Fw::LogStringArg reasonStr("State restored from persistent storage"); this->log_WARNING_HI_EnteringSafeMode(reasonStr); + } else if (this->m_mode == SystemMode::PAYLOAD_MODE) { + // PAYLOAD_MODE - turn on payload switches + this->turnOnPayload(); + + // Log that we're restoring payload mode + Fw::LogStringArg reasonStr("State restored from persistent storage"); + this->log_ACTIVITY_HI_EnteringPayloadMode(reasonStr); } else { // NORMAL mode - ensure components are turned on this->turnOnComponents(); @@ -124,6 +188,7 @@ void ModeManager ::loadState() { // Corrupted state - use defaults this->m_mode = SystemMode::NORMAL; this->m_safeModeEntryCount = 0; + this->m_payloadModeEntryCount = 0; this->turnOnComponents(); } } @@ -133,6 +198,7 @@ void ModeManager ::loadState() { // File doesn't exist or can't be opened - initialize to default state this->m_mode = SystemMode::NORMAL; this->m_safeModeEntryCount = 0; + this->m_payloadModeEntryCount = 0; this->turnOnComponents(); } } @@ -151,6 +217,7 @@ void ModeManager ::saveState() { PersistentState state; state.mode = static_cast(this->m_mode); state.safeModeEntryCount = this->m_safeModeEntryCount; + state.payloadModeEntryCount = this->m_payloadModeEntryCount; FwSizeType bytesToWrite = sizeof(PersistentState); FwSizeType bytesWritten = bytesToWrite; @@ -223,6 +290,62 @@ void ModeManager ::exitSafeMode() { this->saveState(); } +void ModeManager ::enterPayloadMode(const char* reasonOverride) { + // Transition to payload mode + this->m_mode = SystemMode::PAYLOAD_MODE; + this->m_payloadModeEntryCount++; + + // Build reason string + Fw::LogStringArg reasonStr; + char reasonBuf[100]; + if (reasonOverride != nullptr) { + reasonStr = reasonOverride; + } else { + snprintf(reasonBuf, sizeof(reasonBuf), "Unknown"); + reasonStr = reasonBuf; + } + + this->log_ACTIVITY_HI_EnteringPayloadMode(reasonStr); + + // Turn on payload switches (6 & 7) + this->turnOnPayload(); + + // Update telemetry + this->tlmWrite_CurrentMode(static_cast(this->m_mode)); + this->tlmWrite_PayloadModeEntryCount(this->m_payloadModeEntryCount); + + // Notify other components of mode change with new mode value + if (this->isConnected_modeChanged_OutputPort(0)) { + Components::SystemMode fppMode = static_cast(this->m_mode); + this->modeChanged_out(0, fppMode); + } + + // Save state + this->saveState(); +} + +void ModeManager ::exitPayloadMode() { + // Transition back to normal mode + this->m_mode = SystemMode::NORMAL; + + this->log_ACTIVITY_HI_ExitingPayloadMode(); + + // Turn off payload switches + this->turnOffPayload(); + + // Update telemetry + this->tlmWrite_CurrentMode(static_cast(this->m_mode)); + + // Notify other components of mode change with new mode value + if (this->isConnected_modeChanged_OutputPort(0)) { + Components::SystemMode fppMode = static_cast(this->m_mode); + this->modeChanged_out(0, fppMode); + } + + // Save state + this->saveState(); +} + void ModeManager ::turnOffNonCriticalComponents() { // Turn OFF: // - Satellite faces 0-5 (LoadSwitch instances 0-5) @@ -240,16 +363,37 @@ void ModeManager ::turnOffNonCriticalComponents() { } void ModeManager ::turnOnComponents() { - // Turn ON all load switches to restore normal operation + // Turn ON face load switches (0-5) to restore normal operation + // Note: Payload switches (6-7) are NOT turned on here - they require PAYLOAD_MODE - // Send turn on signal to all 8 load switches - for (FwIndexType i = 0; i < 8; i++) { + // Send turn on signal to face load switches only (indices 0-5) + for (FwIndexType i = 0; i < 6; i++) { if (this->isConnected_loadSwitchTurnOn_OutputPort(i)) { this->loadSwitchTurnOn_out(i); } } } +void ModeManager ::turnOnPayload() { + // Turn ON payload load switches (6 = payload power, 7 = payload battery) + if (this->isConnected_loadSwitchTurnOn_OutputPort(6)) { + this->loadSwitchTurnOn_out(6); + } + if (this->isConnected_loadSwitchTurnOn_OutputPort(7)) { + this->loadSwitchTurnOn_out(7); + } +} + +void ModeManager ::turnOffPayload() { + // Turn OFF payload load switches (6 = payload power, 7 = payload battery) + if (this->isConnected_loadSwitchTurnOff_OutputPort(6)) { + this->loadSwitchTurnOff_out(6); + } + if (this->isConnected_loadSwitchTurnOff_OutputPort(7)) { + this->loadSwitchTurnOff_out(7); + } +} + F32 ModeManager ::getCurrentVoltage(bool& valid) { // Call the voltage get port to get current system voltage if (this->isConnected_voltageGet_OutputPort(0)) { diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp index 5f9fe612..f1fe6420 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp @@ -4,6 +4,7 @@ module Components { enum SystemMode { NORMAL = 0 @< Normal operational mode SAFE_MODE = 1 @< Safe mode with non-critical components powered off + PAYLOAD_MODE = 2 @< Payload mode with payload power and battery enabled } @ Port for notifying about mode changes @@ -26,6 +27,9 @@ module Components { @ Port to force safe mode entry (callable by other components) async input port forceSafeMode: Fw.Signal + @ Port to force payload mode entry (callable by other components) + async input port forcePayloadMode: Fw.Signal + @ Port to query the current system mode sync input port getMode: Components.GetSystemMode @@ -57,6 +61,14 @@ module Components { @ Only succeeds if currently in safe mode sync command EXIT_SAFE_MODE() + @ Command to enter payload mode + @ Only succeeds if currently in normal mode + sync command ENTER_PAYLOAD_MODE() + + @ Command to exit payload mode + @ Only succeeds if currently in payload mode + sync command EXIT_PAYLOAD_MODE() + # ---------------------------------------------------------------------- # Events # ---------------------------------------------------------------------- @@ -83,6 +95,22 @@ module Components { severity warning high \ format "External fault detected - external component forced safe mode" + @ Event emitted when entering payload mode + event EnteringPayloadMode( + reason: string size 100 @< Reason for entering payload mode + ) \ + severity activity high \ + format "ENTERING PAYLOAD MODE: {}" + + @ Event emitted when exiting payload mode + event ExitingPayloadMode() \ + severity activity high \ + format "Exiting payload mode" + + @ Event emitted when payload mode is manually commanded + event ManualPayloadModeEntry() \ + severity activity high \ + format "Payload mode entry commanded manually" @ Event emitted when command validation fails event CommandValidationFailed( @@ -110,6 +138,9 @@ module Components { @ Number of times safe mode has been entered telemetry SafeModeEntryCount: U32 + @ Number of times payload mode has been entered + telemetry PayloadModeEntryCount: U32 + ############################################################################### # Standard AC Ports: Required for Channels, Events, Commands, and Parameters # diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp index a32878cb..73f4103d 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp @@ -50,6 +50,12 @@ class ModeManager : public ModeManagerComponentBase { void forceSafeMode_handler(FwIndexType portNum //!< The port number ) override; + //! Handler implementation for forcePayloadMode + //! + //! Port to force payload mode entry (callable by other components) + void forcePayloadMode_handler(FwIndexType portNum //!< The port number + ) override; + //! Handler implementation for getMode //! //! Port to query the current system mode @@ -70,6 +76,16 @@ class ModeManager : public ModeManagerComponentBase { U32 cmdSeq //!< The command sequence number ) override; + //! Handler implementation for command ENTER_PAYLOAD_MODE + void ENTER_PAYLOAD_MODE_cmdHandler(FwOpcodeType opCode, //!< The opcode + U32 cmdSeq //!< The command sequence number + ) override; + + //! Handler implementation for command EXIT_PAYLOAD_MODE + void EXIT_PAYLOAD_MODE_cmdHandler(FwOpcodeType opCode, //!< The opcode + U32 cmdSeq //!< The command sequence number + ) override; + private: // ---------------------------------------------------------------------- // Private helper methods @@ -87,9 +103,21 @@ class ModeManager : public ModeManagerComponentBase { //! Exit safe mode void exitSafeMode(); + //! Enter payload mode with optional reason override + void enterPayloadMode(const char* reason = nullptr); + + //! Exit payload mode + void exitPayloadMode(); + //! Turn off non-critical components void turnOffNonCriticalComponents(); + //! Turn on payload (load switches 6 & 7) + void turnOnPayload(); + + //! Turn off payload (load switches 6 & 7) + void turnOffPayload(); + //! Turn on components (restore normal operation) void turnOnComponents(); @@ -104,21 +132,23 @@ class ModeManager : public ModeManagerComponentBase { // ---------------------------------------------------------------------- //! System mode enumeration - enum class SystemMode : U8 { NORMAL = 0, SAFE_MODE = 1 }; + enum class SystemMode : U8 { NORMAL = 0, SAFE_MODE = 1, PAYLOAD_MODE = 2 }; //! Persistent state structure struct PersistentState { - U8 mode; //!< Current mode (SystemMode) - U32 safeModeEntryCount; //!< Number of times safe mode entered + U8 mode; //!< Current mode (SystemMode) + U32 safeModeEntryCount; //!< Number of times safe mode entered + U32 payloadModeEntryCount; //!< Number of times payload mode entered }; // ---------------------------------------------------------------------- // Private member variables // ---------------------------------------------------------------------- - SystemMode m_mode; //!< Current system mode - U32 m_safeModeEntryCount; //!< Counter for safe mode entries - U32 m_runCounter; //!< Counter for run handler calls (1Hz) + SystemMode m_mode; //!< Current system mode + U32 m_safeModeEntryCount; //!< Counter for safe mode entries + U32 m_payloadModeEntryCount; //!< Counter for payload mode entries + U32 m_runCounter; //!< Counter for run handler calls (1Hz) static constexpr const char* STATE_FILE_PATH = "/mode_state.bin"; //!< State file path }; diff --git a/FprimeZephyrReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi b/FprimeZephyrReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi index 472d9d6b..eaede325 100644 --- a/FprimeZephyrReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi +++ b/FprimeZephyrReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi @@ -13,6 +13,7 @@ telemetry packets ReferenceDeploymentPackets { ReferenceDeployment.startupManager.QuiescenceEndTime ReferenceDeployment.modeManager.CurrentMode ReferenceDeployment.modeManager.SafeModeEntryCount + ReferenceDeployment.modeManager.PayloadModeEntryCount } packet HealthWarnings id 2 group 1 { diff --git a/FprimeZephyrReference/test/int/payload_mode_test.py b/FprimeZephyrReference/test/int/payload_mode_test.py new file mode 100644 index 00000000..365ceb3c --- /dev/null +++ b/FprimeZephyrReference/test/int/payload_mode_test.py @@ -0,0 +1,248 @@ +""" +payload_mode_test.py: + +Integration tests for the ModeManager component (payload mode). + +Tests cover: +- Payload mode entry and exit +- Mode transition validation (cannot enter from safe mode) +- Emergency safe mode override from payload mode +- State persistence + +Total: 4 tests +""" + +import time + +import pytest +from common import proves_send_and_assert_command +from fprime_gds.common.data_types.ch_data import ChData +from fprime_gds.common.testing_fw.api import IntegrationTestAPI + +component = "ReferenceDeployment.modeManager" +payload_load_switch_channels = [ + "ReferenceDeployment.payloadPowerLoadSwitch.IsOn", + "ReferenceDeployment.payloadBatteryLoadSwitch.IsOn", +] +all_load_switch_channels = [ + "ReferenceDeployment.face4LoadSwitch.IsOn", + "ReferenceDeployment.face0LoadSwitch.IsOn", + "ReferenceDeployment.face1LoadSwitch.IsOn", + "ReferenceDeployment.face2LoadSwitch.IsOn", + "ReferenceDeployment.face3LoadSwitch.IsOn", + "ReferenceDeployment.face5LoadSwitch.IsOn", + "ReferenceDeployment.payloadPowerLoadSwitch.IsOn", + "ReferenceDeployment.payloadBatteryLoadSwitch.IsOn", +] + + +@pytest.fixture(autouse=True) +def setup_and_teardown(fprime_test_api: IntegrationTestAPI, start_gds): + """ + Setup before each test and cleanup after. + Ensures clean state by exiting any mode back to NORMAL. + """ + # Setup: Try to get to a clean NORMAL state + try: + proves_send_and_assert_command(fprime_test_api, f"{component}.EXIT_SAFE_MODE") + except Exception: + pass + try: + proves_send_and_assert_command( + fprime_test_api, f"{component}.EXIT_PAYLOAD_MODE" + ) + except Exception: + pass + + # Clear event and telemetry history before test + fprime_test_api.clear_histories() + + yield + + # Teardown: Return to clean NORMAL state for next test + try: + proves_send_and_assert_command(fprime_test_api, f"{component}.EXIT_SAFE_MODE") + except Exception: + pass + try: + proves_send_and_assert_command( + fprime_test_api, f"{component}.EXIT_PAYLOAD_MODE" + ) + except Exception: + pass + + +# ============================================================================== +# Payload Mode Tests +# ============================================================================== + + +def test_payload_01_enter_exit_payload_mode( + fprime_test_api: IntegrationTestAPI, start_gds +): + """ + Test ENTER_PAYLOAD_MODE and EXIT_PAYLOAD_MODE commands. + Verifies: + - EnteringPayloadMode event is emitted + - CurrentMode telemetry = 2 (PAYLOAD_MODE) + - Payload load switches (6 & 7) are ON + - ExitingPayloadMode event is emitted on exit + - CurrentMode returns to 0 (NORMAL) + """ + # Enter payload mode + proves_send_and_assert_command( + fprime_test_api, + f"{component}.ENTER_PAYLOAD_MODE", + events=[f"{component}.ManualPayloadModeEntry"], + ) + + time.sleep(2) + + # Verify mode is PAYLOAD_MODE (2) + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 2, "Should be in PAYLOAD_MODE" + + # Verify payload load switches are ON + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) + for channel in payload_load_switch_channels: + value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() + if isinstance(value, str): + assert value.upper() == "ON", f"{channel} should be ON in payload mode" + else: + assert value == 1, f"{channel} should be ON in payload mode" + + # Exit payload mode + proves_send_and_assert_command( + fprime_test_api, + f"{component}.EXIT_PAYLOAD_MODE", + events=[f"{component}.ExitingPayloadMode"], + ) + + time.sleep(2) + + # Verify mode is NORMAL (0) + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 0, "Should be in NORMAL mode" + + # Verify payload load switches are OFF in NORMAL mode + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) + for channel in payload_load_switch_channels: + value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() + if isinstance(value, str): + assert value.upper() == "OFF", f"{channel} should be OFF in NORMAL mode" + else: + assert value == 0, f"{channel} should be OFF in NORMAL mode" + + +def test_payload_02_cannot_enter_from_safe_mode( + fprime_test_api: IntegrationTestAPI, start_gds +): + """ + Test that ENTER_PAYLOAD_MODE fails when in safe mode. + Must exit safe mode to NORMAL first. + """ + # Enter safe mode first + proves_send_and_assert_command(fprime_test_api, f"{component}.FORCE_SAFE_MODE") + time.sleep(2) + + # Verify in safe mode + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 1, "Should be in SAFE_MODE" + + # Try to enter payload mode - should fail + fprime_test_api.clear_histories() + with pytest.raises(Exception): + proves_send_and_assert_command( + fprime_test_api, f"{component}.ENTER_PAYLOAD_MODE" + ) + + # Verify CommandValidationFailed event + fprime_test_api.assert_event(f"{component}.CommandValidationFailed", timeout=3) + + # Verify still in safe mode + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 1, "Should still be in SAFE_MODE" + + +def test_payload_03_safe_mode_override_from_payload( + fprime_test_api: IntegrationTestAPI, start_gds +): + """ + Test that FORCE_SAFE_MODE works from PAYLOAD_MODE (emergency override). + Verifies: + - Can enter safe mode from payload mode + - EnteringSafeMode event is emitted + - CurrentMode = 1 (SAFE_MODE) + - All load switches are OFF + """ + # Enter payload mode first + proves_send_and_assert_command(fprime_test_api, f"{component}.ENTER_PAYLOAD_MODE") + time.sleep(2) + + # Verify in payload mode + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 2, "Should be in PAYLOAD_MODE" + + # Force safe mode (emergency override) + fprime_test_api.clear_histories() + proves_send_and_assert_command( + fprime_test_api, + f"{component}.FORCE_SAFE_MODE", + events=[f"{component}.ManualSafeModeEntry"], + ) + + time.sleep(2) + + # Verify mode is SAFE_MODE (1) + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 1, "Should be in SAFE_MODE" + + # Verify all load switches are OFF + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) + for channel in all_load_switch_channels: + value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() + if isinstance(value, str): + assert value.upper() == "OFF", f"{channel} should be OFF in safe mode" + else: + assert value == 0, f"{channel} should be OFF in safe mode" + + +def test_payload_04_state_persists(fprime_test_api: IntegrationTestAPI, start_gds): + """ + Test that payload mode state persists to /mode_state.bin file. + Note: Full persistence test would require reboot, this verifies state is saved. + """ + # Enter payload mode to trigger state save + proves_send_and_assert_command(fprime_test_api, f"{component}.ENTER_PAYLOAD_MODE") + time.sleep(2) + + # Verify mode is saved as PAYLOAD_MODE + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 2, "Mode should be saved as PAYLOAD_MODE" + + # Verify PayloadModeEntryCount is tracking + count_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.PayloadModeEntryCount", timeout=5 + ) + assert count_result.get_val() >= 1, "PayloadModeEntryCount should be at least 1" diff --git a/Makefile b/Makefile index 1574e41a..9afd5561 100644 --- a/Makefile +++ b/Makefile @@ -53,8 +53,17 @@ build: submodules zephyr fprime-venv generate-if-needed ## Build FPrime-Zephyr P @$(UV_RUN) fprime-util build .PHONY: test-integration -test-integration: uv - @$(UV_RUN) pytest FprimeZephyrReference/test/int --deployment build-artifacts/zephyr/fprime-zephyr-deployment +test-integration: uv ## Run integration tests (set TEST= to run a single file) + @TARGET="FprimeZephyrReference/test/int"; \ + if [ -n "$(TEST)" ]; then \ + case "$(TEST)" in \ + *.py) TARGET="FprimeZephyrReference/test/int/$(TEST)" ;; \ + *) TARGET="FprimeZephyrReference/test/int/$(TEST).py" ;; \ + esac; \ + [ -e "$$TARGET" ] || { echo "Specified test file $$TARGET not found"; exit 1; }; \ + fi; \ + echo "Running integration tests at $$TARGET"; \ + $(UV_RUN) pytest $$TARGET --deployment build-artifacts/zephyr/fprime-zephyr-deployment .PHONY: bootloader bootloader: uv diff --git a/README.md b/README.md index d70a075e..82276357 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ Then, in another terminal, run the following command to execute the integration make test-integration ``` +To run a single integration test file, set `TEST` to the filename (with or without `.py`): +```sh +make test-integration TEST=mode_manager_test +make test-integration TEST=mode_manager_test.py +``` + ## Running The Radio With CircuitPython To test the radio setup easily, you can use CircuitPython code on one board and fprime-zephyr on another. This provides a simple client/server setup and lets you observe what data is being sent through the radio. From e02705520f971ce821af32cfd1bd2b8dca6a9ef2 Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:25:16 -0800 Subject: [PATCH 02/18] update sdd --- .../Components/ModeManager/docs/sdd.md | 148 ++++++++++++++---- 1 file changed, 117 insertions(+), 31 deletions(-) diff --git a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md index d3233d6e..4acd7ca9 100644 --- a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md +++ b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md @@ -1,19 +1,13 @@ # Components::ModeManager -The ModeManager component manages system operational modes and orchestrates transitions (NORMAL, SAFE_MODE, and planned PAYLOAD and HIBERNATION modes). It evaluates watchdog faults and communication timeouts to make mode decisions, controls power to non‑critical subsystems during transitions, and maintains/persists mode state across reboots to ensure consistent post‑recovery behavior. +The ModeManager component manages system operational modes and orchestrates transitions across NORMAL, SAFE_MODE, and PAYLOAD_MODE. It evaluates watchdog faults and communication timeouts to make mode decisions, controls power to non‑critical subsystems during transitions, and maintains/persists mode state across reboots to ensure consistent post‑recovery behavior. -Planned additions: -- PAYLOAD mode — A mid‑power operational mode that prioritizes payload activity while limiting non‑critical subsystems; intended for mission operations when power is constrained but payload operation must continue. -- HIBERNATION mode — A deep low‑power state for long‑term battery preservation, with strict wake conditions (e.g., RTC alarm, hardware wake interrupt); minimal subsystems remain active. -- ModeManager will extend its API and telemetry: new commands (ENTER/EXIT_PAYLOAD, ENTER/EXIT_HIBERNATION), an expanded SystemMode enumeration, modeChanged notifications reflecting new modes, and getMode support for queries. -- Persistence and validation: new modes will be persisted like existing modes, restored on boot, and have explicit validation for entry/exit (e.g., exit from HIBERNATION requires approved wake condition). - -These mode additions will be integrated incrementally with corresponding telemetry, events, commands, unit tests, and integration tests to verify correct behavior, power control, and persistence semantics. +Future work: a HIBERNATION mode remains planned; it will follow the same persistence and validation patterns once implemented. ## Requirements | Name | Description | Validation | |---|---|---| -| MM0001 | The ModeManager shall maintain two distinct operational modes: NORMAL and SAFE_MODE | Integration Testing | +| MM0001 | The ModeManager shall maintain three operational modes: NORMAL, SAFE_MODE, and PAYLOAD_MODE | Integration Testing | | MM0002 | The ModeManager shall enter safe mode when commanded manually via FORCE_SAFE_MODE command | Integration Testing | | MM0003 | The ModeManager shall enter safe mode when requested by external components via forceSafeMode port | Integration Testing | | MM0004 | The ModeManager shall exit safe mode only via explicit EXIT_SAFE_MODE command | Integration Testing | @@ -25,6 +19,13 @@ These mode additions will be integrated incrementally with corresponding telemet | MM0010 | The ModeManager shall track and report the number of times safe mode has been entered | Integration Testing | | MM0011 | The ModeManager shall allow downstream components to query the current mode via getMode port | Unit Testing | | MM0012 | The ModeManager shall notify downstream components of mode changes with the new mode value | Unit Testing | +| MM0013 | The ModeManager shall enter payload mode when commanded via ENTER_PAYLOAD_MODE while in NORMAL and reject entry from SAFE_MODE | Integration Testing | +| MM0014 | The ModeManager shall enter payload mode when requested by external components via forcePayloadMode port while in NORMAL | Integration Testing | +| MM0015 | The ModeManager shall exit payload mode only via explicit EXIT_PAYLOAD_MODE command and reject exit when not in payload mode | Integration Testing | +| MM0016 | The ModeManager shall turn on payload load switches (indices 6 and 7) when entering payload mode and turn them off when exiting payload mode | Integration Testing | +| MM0017 | The ModeManager shall track and report the number of times payload mode has been entered | Integration Testing | +| MM0018 | The ModeManager shall persist payload mode state and payload mode entry count to non-volatile storage and restore them on initialization | Integration Testing | +| MM0019 | The ModeManager shall allow FORCE_SAFE_MODE to override payload mode and transition to safe mode | Integration Testing | ## Usage Examples @@ -39,7 +40,7 @@ The ModeManager component operates as an active component that manages system-wi - Begins 1Hz periodic execution via rate group 2. **Normal Operation** - - Updates telemetry channels (CurrentMode, SafeModeEntryCount) + - Updates telemetry channels (CurrentMode, SafeModeEntryCount, PayloadModeEntryCount) - Responds to mode query requests from downstream components 3. **Safe Mode Entry** @@ -54,7 +55,31 @@ The ModeManager component operates as an active component that manages system-wi - Notifies downstream components via `modeChanged` port - Persists state to flash storage -4. **Safe Mode Exit** +4. **Payload Mode Entry** + - Can be triggered by: + - Ground command: `ENTER_PAYLOAD_MODE` (only allowed from NORMAL) + - External component request via `forcePayloadMode` port (only allowed from NORMAL) + - Actions performed: + - Transitions mode to PAYLOAD_MODE + - Increments payload mode entry counter + - Emits `EnteringPayloadMode` event and, for commands, `ManualPayloadModeEntry` + - Turns on payload load switches (indices 6 and 7) + - Notifies downstream components via `modeChanged` port + - Updates telemetry (CurrentMode, PayloadModeEntryCount) + - Persists state to flash storage + +5. **Payload Mode Exit** + - Triggered by ground command: `EXIT_PAYLOAD_MODE` + - Validates currently in payload mode before allowing exit + - Actions performed: + - Transitions mode to NORMAL + - Emits `ExitingPayloadMode` event + - Turns off payload load switches (indices 6 and 7) + - Notifies downstream components via `modeChanged` port + - Updates telemetry + - Persists state to flash storage + +6. **Safe Mode Exit** - Triggered only by ground command: `EXIT_SAFE_MODE` - Validates currently in safe mode before allowing exit - Actions performed: @@ -64,7 +89,7 @@ The ModeManager component operates as an active component that manages system-wi - Notifies downstream components via `modeChanged` port - Persists state to flash storage -5. **Mode Queries** +7. **Mode Queries** - Downstream components can call `getMode` port to query current mode - Returns immediate synchronous response with current mode @@ -80,6 +105,7 @@ classDiagram <> - m_mode: SystemMode - m_safeModeEntryCount: U32 + - m_payloadModeEntryCount: U32 - m_runCounter: U32 - STATE_FILE_PATH: const char* + ModeManager(const char* compName) @@ -87,21 +113,29 @@ classDiagram + init(FwSizeType queueDepth, FwEnumStoreType instance) - run_handler(FwIndexType portNum, U32 context) - forceSafeMode_handler(FwIndexType portNum) + - forcePayloadMode_handler(FwIndexType portNum) - getMode_handler(FwIndexType portNum): SystemMode - FORCE_SAFE_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) - EXIT_SAFE_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) + - ENTER_PAYLOAD_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) + - EXIT_PAYLOAD_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) - loadState() - saveState() - enterSafeMode(const char* reason) - exitSafeMode() + - enterPayloadMode(const char* reason) + - exitPayloadMode() - turnOffNonCriticalComponents() - turnOnComponents() + - turnOnPayload() + - turnOffPayload() - getCurrentVoltage(bool& valid): F32 } class SystemMode { <> NORMAL = 0 SAFE_MODE = 1 + PAYLOAD_MODE = 2 } } ModeManagerComponentBase <|-- ModeManager : inherits @@ -115,6 +149,7 @@ classDiagram |---|---|---|---| | run | Svc.Sched | sync | Receives periodic calls from rate group (1Hz) for telemetry updates | | forceSafeMode | Fw.Signal | async | Receives safe mode requests from external components detecting faults | +| forcePayloadMode | Fw.Signal | async | Receives payload mode requests from external components while in NORMAL | | getMode | Components.GetSystemMode | sync | Allows downstream components to query current system mode | ### Output Ports @@ -129,14 +164,16 @@ classDiagram | Name | Type | Description | |---|---|---| -| m_mode | SystemMode | Current operational mode (NORMAL or SAFE_MODE) | +| m_mode | SystemMode | Current operational mode (NORMAL, SAFE_MODE, or PAYLOAD_MODE) | | m_safeModeEntryCount | U32 | Number of times safe mode has been entered since initial deployment | +| m_payloadModeEntryCount | U32 | Number of times payload mode has been entered since initial deployment | | m_runCounter | U32 | Counter for 1Hz run handler calls | ### Persistent State The component persists the following state to `/mode_state.bin`: - Current mode (U8) - Safe mode entry count (U32) +- Payload mode entry count (U32) This state is loaded on initialization and saved on every mode transition. @@ -203,6 +240,48 @@ sequenceDiagram ModeManager->>Ground: Command response OK ``` +### Payload Mode Entry (Command) +```mermaid +sequenceDiagram + participant Ground + participant ModeManager + participant LoadSwitches + participant DownstreamComponents + participant FlashStorage + + Ground->>ModeManager: ENTER_PAYLOAD_MODE command + ModeManager->>ModeManager: Validate currently in NORMAL + ModeManager->>ModeManager: Emit ManualPayloadModeEntry event + ModeManager->>ModeManager: Set m_mode = PAYLOAD_MODE + ModeManager->>ModeManager: Increment m_payloadModeEntryCount + ModeManager->>ModeManager: Emit EnteringPayloadMode event + ModeManager->>LoadSwitches: Turn on payload switches (6 & 7) + ModeManager->>ModeManager: Update telemetry + ModeManager->>DownstreamComponents: modeChanged_out(PAYLOAD_MODE) + ModeManager->>FlashStorage: Save state to /mode_state.bin + ModeManager->>Ground: Command response OK +``` + +### Payload Mode Exit (Command) +```mermaid +sequenceDiagram + participant Ground + participant ModeManager + participant LoadSwitches + participant DownstreamComponents + participant FlashStorage + + Ground->>ModeManager: EXIT_PAYLOAD_MODE command + ModeManager->>ModeManager: Validate currently in PAYLOAD_MODE + ModeManager->>ModeManager: Set m_mode = NORMAL + ModeManager->>ModeManager: Emit ExitingPayloadMode event + ModeManager->>LoadSwitches: Turn off payload switches (6 & 7) + ModeManager->>ModeManager: Update telemetry + ModeManager->>DownstreamComponents: modeChanged_out(NORMAL) + ModeManager->>FlashStorage: Save state to /mode_state.bin + ModeManager->>Ground: Command response OK +``` + ### Mode Query ```mermaid sequenceDiagram @@ -211,7 +290,7 @@ sequenceDiagram DownstreamComponent->>ModeManager: getMode() port call ModeManager->>ModeManager: Read m_mode - ModeManager-->>DownstreamComponent: Return current mode (NORMAL or SAFE_MODE) + ModeManager-->>DownstreamComponent: Return current mode (NORMAL, SAFE_MODE, or PAYLOAD_MODE) ``` ### Periodic Execution (1Hz) @@ -231,6 +310,8 @@ sequenceDiagram |---|---|---| | FORCE_SAFE_MODE | None | Forces the system into safe mode immediately. Emits ManualSafeModeEntry event. Can be called from any mode (idempotent). | | EXIT_SAFE_MODE | None | Exits safe mode and returns to normal operation. Fails with CommandValidationFailed if not currently in safe mode. | +| ENTER_PAYLOAD_MODE | None | Enters payload mode from NORMAL. Fails with CommandValidationFailed if issued from SAFE_MODE or if already in payload mode (idempotent success when already in payload). Emits ManualPayloadModeEntry event. | +| EXIT_PAYLOAD_MODE | None | Exits payload mode and returns to normal operation. Fails with CommandValidationFailed if not currently in payload mode. | ## Events @@ -240,6 +321,9 @@ sequenceDiagram | ExitingSafeMode | ACTIVITY_HI | None | Emitted when exiting safe mode and returning to normal operation | | ManualSafeModeEntry | ACTIVITY_HI | None | Emitted when safe mode is manually commanded via FORCE_SAFE_MODE | | ExternalFaultDetected | WARNING_HI | None | Emitted when an external component triggers safe mode via forceSafeMode port | +| EnteringPayloadMode | ACTIVITY_HI | reason: string size 100 | Emitted when entering payload mode, includes reason (e.g., "Ground command", "External component request") | +| ExitingPayloadMode | ACTIVITY_HI | None | Emitted when exiting payload mode and returning to normal operation | +| ManualPayloadModeEntry | ACTIVITY_HI | None | Emitted when payload mode is manually commanded via ENTER_PAYLOAD_MODE | | CommandValidationFailed | WARNING_LO | cmdName: string size 50
reason: string size 100 | Emitted when a command fails validation (e.g., EXIT_SAFE_MODE when not in safe mode) | | StatePersistenceFailure | WARNING_LO | operation: string size 20
status: I32 | Emitted when state save/load operations fail | @@ -247,39 +331,40 @@ sequenceDiagram | Name | Type | Update Rate | Description | |---|---|---|---| -| CurrentMode | U8 | 1Hz | Current system mode (0 = NORMAL, 1 = SAFE_MODE) | +| CurrentMode | U8 | 1Hz | Current system mode (0 = NORMAL, 1 = SAFE_MODE, 2 = PAYLOAD_MODE) | | SafeModeEntryCount | U32 | On change | Number of times safe mode has been entered (persists across reboots) | +| PayloadModeEntryCount | U32 | On change | Number of times payload mode has been entered (persists across reboots) | ## Load Switch Mapping The ModeManager controls 8 load switches that power non-critical satellite subsystems: -| Index | Subsystem | Safe Mode State | -|---|---|---| -| 0 | Satellite Face 0 | OFF | -| 1 | Satellite Face 1 | OFF | -| 2 | Satellite Face 2 | OFF | -| 3 | Satellite Face 3 | OFF | -| 4 | Satellite Face 4 | OFF | -| 5 | Satellite Face 5 | OFF | -| 6 | Payload Power | OFF | -| 7 | Payload Battery | OFF | +| Index | Subsystem | Safe Mode State | Payload Mode State | +|---|---|---|---| +| 0 | Satellite Face 0 | OFF | ON | +| 1 | Satellite Face 1 | OFF | ON | +| 2 | Satellite Face 2 | OFF | ON | +| 3 | Satellite Face 3 | OFF | ON | +| 4 | Satellite Face 4 | OFF | ON | +| 5 | Satellite Face 5 | OFF | ON | +| 6 | Payload Power | OFF | ON | +| 7 | Payload Battery | OFF | ON | ## Integration Tests -See `FprimeZephyrReference/test/int/mode_manager_test.py` for comprehensive integration tests covering: +See `FprimeZephyrReference/test/int/mode_manager_test.py` and `FprimeZephyrReference/test/int/payload_mode_test.py` for comprehensive integration tests covering: | Test | Description | Coverage | |---|---|---| | test_01_initial_telemetry | Verifies initial telemetry can be read | Basic functionality | | test_04_force_safe_mode_command | Tests FORCE_SAFE_MODE command enters safe mode | Safe mode entry | -| test_05_safe_mode_increments_counter | Verifies SafeModeEntryCount increments | State tracking | | test_06_safe_mode_turns_off_load_switches | Verifies all load switches turn off in safe mode | Power management | -| test_07_safe_mode_emits_event | Verifies EnteringSafeMode event with correct reason | Event emission | -| test_13_exit_safe_mode_fails_not_in_safe_mode | Tests EXIT_SAFE_MODE validation | Error handling | | test_14_exit_safe_mode_success | Tests successful safe mode exit | Safe mode exit | -| test_18_force_safe_mode_idempotent | Tests FORCE_SAFE_MODE is idempotent | Edge cases | -| test_19_safe_mode_state_persists | Verifies mode persistence to flash | State persistence | +| test_19_safe_mode_state_persists | Verifies safe mode persistence to flash | State persistence | +| test_payload_01_enter_exit_payload_mode | Validates payload mode entry/exit, events, telemetry, payload load switches | Payload mode entry/exit | +| test_payload_02_cannot_enter_from_safe_mode | Ensures ENTER_PAYLOAD_MODE fails from SAFE_MODE | Command validation | +| test_payload_03_safe_mode_override_from_payload | Ensures FORCE_SAFE_MODE overrides payload mode | Emergency override | +| test_payload_04_state_persists | Verifies payload mode and counters persist | Payload persistence | ## Design Decisions @@ -312,4 +397,5 @@ The FORCE_SAFE_MODE command can be called from any mode without error. If alread ## Change Log | Date | Description | |---|---| +| 2026-02-18 | Added PAYLOAD_MODE (commands, events, telemetry, persistence, payload load switch control) and documented payload integration tests | | 2025-11-19 | Added getMode query port and enhanced modeChanged to carry mode value | From f8336e0d7e76ab4f7f14870ff190208c9a43e6c8 Mon Sep 17 00:00:00 2001 From: Sam Yu <25761223+yudataguy@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:45:39 -0800 Subject: [PATCH 03/18] Update FprimeZephyrReference/Components/ModeManager/docs/sdd.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Components/ModeManager/docs/sdd.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md index 4acd7ca9..e8ac389f 100644 --- a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md +++ b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md @@ -341,15 +341,16 @@ The ModeManager controls 8 load switches that power non-critical satellite subsy | Index | Subsystem | Safe Mode State | Payload Mode State | |---|---|---|---| -| 0 | Satellite Face 0 | OFF | ON | -| 1 | Satellite Face 1 | OFF | ON | -| 2 | Satellite Face 2 | OFF | ON | -| 3 | Satellite Face 3 | OFF | ON | -| 4 | Satellite Face 4 | OFF | ON | -| 5 | Satellite Face 5 | OFF | ON | +| 0 | Satellite Face 0 | OFF | PREV/OFF | +| 1 | Satellite Face 1 | OFF | PREV/OFF | +| 2 | Satellite Face 2 | OFF | PREV/OFF | +| 3 | Satellite Face 3 | OFF | PREV/OFF | +| 4 | Satellite Face 4 | OFF | PREV/OFF | +| 5 | Satellite Face 5 | OFF | PREV/OFF | | 6 | Payload Power | OFF | ON | | 7 | Payload Battery | OFF | ON | +> **Note:** In payload mode, only the payload switches (indices 6 & 7) are explicitly turned ON. The state of face switches (0-5) depends on their state prior to entering payload mode (typically OFF after safe mode). See requirement MM0016 and implementation for details. ## Integration Tests See `FprimeZephyrReference/test/int/mode_manager_test.py` and `FprimeZephyrReference/test/int/payload_mode_test.py` for comprehensive integration tests covering: From 2809b15fe581f6747285b568cf3c6356c42b7bf4 Mon Sep 17 00:00:00 2001 From: Sam Yu <25761223+yudataguy@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:50:24 -0800 Subject: [PATCH 04/18] Update FprimeZephyrReference/Components/ModeManager/docs/sdd.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- FprimeZephyrReference/Components/ModeManager/docs/sdd.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md index e8ac389f..6eb03bbb 100644 --- a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md +++ b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md @@ -398,5 +398,5 @@ The FORCE_SAFE_MODE command can be called from any mode without error. If alread ## Change Log | Date | Description | |---|---| -| 2026-02-18 | Added PAYLOAD_MODE (commands, events, telemetry, persistence, payload load switch control) and documented payload integration tests | +| 2025-11-20 | Added PAYLOAD_MODE (commands, events, telemetry, persistence, payload load switch control) and documented payload integration tests | | 2025-11-19 | Added getMode query port and enhanced modeChanged to carry mode value | From 1e8d449b088c770f1640b6c47834ae587d5b2a81 Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:51:29 -0800 Subject: [PATCH 05/18] fix restore payload - face issues, update sdd --- .../Components/ModeManager/ModeManager.cpp | 9 +++++-- .../Components/ModeManager/docs/sdd.md | 27 ++++++++++--------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp index 7a030858..2d1b1014 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp @@ -174,8 +174,9 @@ void ModeManager ::loadState() { Fw::LogStringArg reasonStr("State restored from persistent storage"); this->log_WARNING_HI_EnteringSafeMode(reasonStr); } else if (this->m_mode == SystemMode::PAYLOAD_MODE) { - // PAYLOAD_MODE - turn on payload switches - this->turnOnPayload(); + // PAYLOAD_MODE - turn on face switches AND payload switches + this->turnOnComponents(); // Face switches (0-5) + this->turnOnPayload(); // Payload switches (6-7) // Log that we're restoring payload mode Fw::LogStringArg reasonStr("State restored from persistent storage"); @@ -333,6 +334,10 @@ void ModeManager ::exitPayloadMode() { // Turn off payload switches this->turnOffPayload(); + // Ensure face switches (0-5) are ON for NORMAL mode + // This guarantees consistent state regardless of transition path + this->turnOnComponents(); + // Update telemetry this->tlmWrite_CurrentMode(static_cast(this->m_mode)); diff --git a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md index 4acd7ca9..f19d500e 100644 --- a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md +++ b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md @@ -13,7 +13,7 @@ Future work: a HIBERNATION mode remains planned; it will follow the same persist | MM0004 | The ModeManager shall exit safe mode only via explicit EXIT_SAFE_MODE command | Integration Testing | | MM0005 | The ModeManager shall prevent exit from safe mode when not currently in safe mode | Integration Testing | | MM0006 | The ModeManager shall turn off all 8 load switches when entering safe mode | Integration Testing | -| MM0007 | The ModeManager shall turn on all 8 load switches when exiting safe mode | Integration Testing | +| MM0007 | The ModeManager shall turn on face load switches (indices 0-5) when exiting safe mode; payload switches remain off until payload mode | Integration Testing | | MM0008 | The ModeManager shall persist current mode and safe mode entry count to non-volatile storage | Integration Testing | | MM0009 | The ModeManager shall restore mode state from persistent storage on initialization | Integration Testing | | MM0010 | The ModeManager shall track and report the number of times safe mode has been entered | Integration Testing | @@ -42,6 +42,7 @@ The ModeManager component operates as an active component that manages system-wi 2. **Normal Operation** - Updates telemetry channels (CurrentMode, SafeModeEntryCount, PayloadModeEntryCount) - Responds to mode query requests from downstream components + - Keeps payload load switches (indices 6 and 7) off unless payload mode is explicitly entered 3. **Safe Mode Entry** - Can be triggered by: @@ -85,7 +86,7 @@ The ModeManager component operates as an active component that manages system-wi - Actions performed: - Transitions mode to NORMAL - Emits `ExitingSafeMode` event - - Turns on all 8 load switches + - Turns on face load switches (indices 0-5); payload switches remain off until explicitly entering payload mode - Notifies downstream components via `modeChanged` port - Persists state to flash storage @@ -233,7 +234,7 @@ sequenceDiagram ModeManager->>ModeManager: Validate currently in SAFE_MODE ModeManager->>ModeManager: Set m_mode = NORMAL ModeManager->>ModeManager: Emit ExitingSafeMode event - ModeManager->>LoadSwitches: Turn on all 8 switches + ModeManager->>LoadSwitches: Turn on face switches (indices 0-5) ModeManager->>ModeManager: Update telemetry ModeManager->>DownstreamComponents: modeChanged_out(NORMAL) ModeManager->>FlashStorage: Save state to /mode_state.bin @@ -339,16 +340,16 @@ sequenceDiagram The ModeManager controls 8 load switches that power non-critical satellite subsystems: -| Index | Subsystem | Safe Mode State | Payload Mode State | -|---|---|---|---| -| 0 | Satellite Face 0 | OFF | ON | -| 1 | Satellite Face 1 | OFF | ON | -| 2 | Satellite Face 2 | OFF | ON | -| 3 | Satellite Face 3 | OFF | ON | -| 4 | Satellite Face 4 | OFF | ON | -| 5 | Satellite Face 5 | OFF | ON | -| 6 | Payload Power | OFF | ON | -| 7 | Payload Battery | OFF | ON | +| Index | Subsystem | Normal State | Safe Mode State | Payload Mode State | +|---|---|---|---|---| +| 0 | Satellite Face 0 | ON | OFF | ON | +| 1 | Satellite Face 1 | ON | OFF | ON | +| 2 | Satellite Face 2 | ON | OFF | ON | +| 3 | Satellite Face 3 | ON | OFF | ON | +| 4 | Satellite Face 4 | ON | OFF | ON | +| 5 | Satellite Face 5 | ON | OFF | ON | +| 6 | Payload Power | OFF | OFF | ON | +| 7 | Payload Battery | OFF | OFF | ON | ## Integration Tests From 62a100c45e8249b5f26344d74327d60d635b3af0 Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Tue, 25 Nov 2025 22:55:26 -0800 Subject: [PATCH 06/18] consistency --- .../Components/ModeManager/docs/sdd.md | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md index 5b95481e..5bd0c267 100644 --- a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md +++ b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md @@ -340,18 +340,18 @@ sequenceDiagram The ModeManager controls 8 load switches that power non-critical satellite subsystems: -| Index | Subsystem | Safe Mode State | Payload Mode State | -|---|---|---|---| -| 0 | Satellite Face 0 | OFF | PREV/OFF | -| 1 | Satellite Face 1 | OFF | PREV/OFF | -| 2 | Satellite Face 2 | OFF | PREV/OFF | -| 3 | Satellite Face 3 | OFF | PREV/OFF | -| 4 | Satellite Face 4 | OFF | PREV/OFF | -| 5 | Satellite Face 5 | OFF | PREV/OFF | -| 6 | Payload Power | OFF | ON | -| 7 | Payload Battery | OFF | ON | - -> **Note:** In payload mode, only the payload switches (indices 6 & 7) are explicitly turned ON. The state of face switches (0-5) depends on their state prior to entering payload mode (typically OFF after safe mode). See requirement MM0016 and implementation for details. +| Index | Subsystem | NORMAL State | SAFE_MODE State | PAYLOAD_MODE State | +|---|---|---|---|---| +| 0 | Satellite Face 0 | ON | OFF | ON | +| 1 | Satellite Face 1 | ON | OFF | ON | +| 2 | Satellite Face 2 | ON | OFF | ON | +| 3 | Satellite Face 3 | ON | OFF | ON | +| 4 | Satellite Face 4 | ON | OFF | ON | +| 5 | Satellite Face 5 | ON | OFF | ON | +| 6 | Payload Power | OFF | OFF | ON | +| 7 | Payload Battery | OFF | OFF | ON | + +> **Note:** PAYLOAD_MODE can only be entered from NORMAL mode (not from SAFE_MODE). When restoring PAYLOAD_MODE from persistent storage after a reboot, both face switches (0-5) and payload switches (6-7) are explicitly turned ON to ensure consistent state. ## Integration Tests See `FprimeZephyrReference/test/int/mode_manager_test.py` and `FprimeZephyrReference/test/int/payload_mode_test.py` for comprehensive integration tests covering: @@ -399,5 +399,5 @@ The FORCE_SAFE_MODE command can be called from any mode without error. If alread ## Change Log | Date | Description | |---|---| -| 2025-11-20 | Added PAYLOAD_MODE (commands, events, telemetry, persistence, payload load switch control) and documented payload integration tests | +| 2025-11-25 | Added PAYLOAD_MODE (commands, events, telemetry, persistence, payload load switch control) and documented payload integration tests | | 2025-11-19 | Added getMode query port and enhanced modeChanged to carry mode value | From 05ff516f88cb393a35d5db454a6501ea0c609c25 Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:53:51 -0800 Subject: [PATCH 07/18] remove component calling safe mode --- .../Components/ModeManager/ModeManager.cpp | 8 -------- .../Components/ModeManager/ModeManager.fpp | 3 --- .../Components/ModeManager/ModeManager.hpp | 6 ------ .../Components/ModeManager/docs/sdd.md | 12 ++++-------- 4 files changed, 4 insertions(+), 25 deletions(-) diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp index 2d1b1014..ca672a2b 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp @@ -54,14 +54,6 @@ void ModeManager ::forceSafeMode_handler(FwIndexType portNum) { } } -void ModeManager ::forcePayloadMode_handler(FwIndexType portNum) { - // Force entry into payload mode (called by other components) - // Only allowed from NORMAL mode (not from SAFE_MODE) - if (this->m_mode == SystemMode::NORMAL) { - this->enterPayloadMode("External component request"); - } -} - Components::SystemMode ModeManager ::getMode_handler(FwIndexType portNum) { // Return the current system mode // Convert internal C++ enum to FPP-generated enum type diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp index f1fe6420..c65dd493 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp @@ -27,9 +27,6 @@ module Components { @ Port to force safe mode entry (callable by other components) async input port forceSafeMode: Fw.Signal - @ Port to force payload mode entry (callable by other components) - async input port forcePayloadMode: Fw.Signal - @ Port to query the current system mode sync input port getMode: Components.GetSystemMode diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp index 73f4103d..79862387 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp @@ -50,12 +50,6 @@ class ModeManager : public ModeManagerComponentBase { void forceSafeMode_handler(FwIndexType portNum //!< The port number ) override; - //! Handler implementation for forcePayloadMode - //! - //! Port to force payload mode entry (callable by other components) - void forcePayloadMode_handler(FwIndexType portNum //!< The port number - ) override; - //! Handler implementation for getMode //! //! Port to query the current system mode diff --git a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md index 5bd0c267..3e9d83d5 100644 --- a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md +++ b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md @@ -20,8 +20,7 @@ Future work: a HIBERNATION mode remains planned; it will follow the same persist | MM0011 | The ModeManager shall allow downstream components to query the current mode via getMode port | Unit Testing | | MM0012 | The ModeManager shall notify downstream components of mode changes with the new mode value | Unit Testing | | MM0013 | The ModeManager shall enter payload mode when commanded via ENTER_PAYLOAD_MODE while in NORMAL and reject entry from SAFE_MODE | Integration Testing | -| MM0014 | The ModeManager shall enter payload mode when requested by external components via forcePayloadMode port while in NORMAL | Integration Testing | -| MM0015 | The ModeManager shall exit payload mode only via explicit EXIT_PAYLOAD_MODE command and reject exit when not in payload mode | Integration Testing | +| MM0014 | The ModeManager shall exit payload mode only via explicit EXIT_PAYLOAD_MODE command and reject exit when not in payload mode | Integration Testing | | MM0016 | The ModeManager shall turn on payload load switches (indices 6 and 7) when entering payload mode and turn them off when exiting payload mode | Integration Testing | | MM0017 | The ModeManager shall track and report the number of times payload mode has been entered | Integration Testing | | MM0018 | The ModeManager shall persist payload mode state and payload mode entry count to non-volatile storage and restore them on initialization | Integration Testing | @@ -57,9 +56,7 @@ The ModeManager component operates as an active component that manages system-wi - Persists state to flash storage 4. **Payload Mode Entry** - - Can be triggered by: - - Ground command: `ENTER_PAYLOAD_MODE` (only allowed from NORMAL) - - External component request via `forcePayloadMode` port (only allowed from NORMAL) + - Triggered by ground command: `ENTER_PAYLOAD_MODE` (only allowed from NORMAL) - Actions performed: - Transitions mode to PAYLOAD_MODE - Increments payload mode entry counter @@ -114,7 +111,6 @@ classDiagram + init(FwSizeType queueDepth, FwEnumStoreType instance) - run_handler(FwIndexType portNum, U32 context) - forceSafeMode_handler(FwIndexType portNum) - - forcePayloadMode_handler(FwIndexType portNum) - getMode_handler(FwIndexType portNum): SystemMode - FORCE_SAFE_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) - EXIT_SAFE_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) @@ -150,7 +146,6 @@ classDiagram |---|---|---|---| | run | Svc.Sched | sync | Receives periodic calls from rate group (1Hz) for telemetry updates | | forceSafeMode | Fw.Signal | async | Receives safe mode requests from external components detecting faults | -| forcePayloadMode | Fw.Signal | async | Receives payload mode requests from external components while in NORMAL | | getMode | Components.GetSystemMode | sync | Allows downstream components to query current system mode | ### Output Ports @@ -322,7 +317,7 @@ sequenceDiagram | ExitingSafeMode | ACTIVITY_HI | None | Emitted when exiting safe mode and returning to normal operation | | ManualSafeModeEntry | ACTIVITY_HI | None | Emitted when safe mode is manually commanded via FORCE_SAFE_MODE | | ExternalFaultDetected | WARNING_HI | None | Emitted when an external component triggers safe mode via forceSafeMode port | -| EnteringPayloadMode | ACTIVITY_HI | reason: string size 100 | Emitted when entering payload mode, includes reason (e.g., "Ground command", "External component request") | +| EnteringPayloadMode | ACTIVITY_HI | reason: string size 100 | Emitted when entering payload mode, includes reason (e.g., "Ground command") | | ExitingPayloadMode | ACTIVITY_HI | None | Emitted when exiting payload mode and returning to normal operation | | ManualPayloadModeEntry | ACTIVITY_HI | None | Emitted when payload mode is manually commanded via ENTER_PAYLOAD_MODE | | CommandValidationFailed | WARNING_LO | cmdName: string size 50
reason: string size 100 | Emitted when a command fails validation (e.g., EXIT_SAFE_MODE when not in safe mode) | @@ -399,5 +394,6 @@ The FORCE_SAFE_MODE command can be called from any mode without error. If alread ## Change Log | Date | Description | |---|---| +| 2025-11-26 | Removed forcePayloadMode port - payload mode now only entered via ENTER_PAYLOAD_MODE ground command | | 2025-11-25 | Added PAYLOAD_MODE (commands, events, telemetry, persistence, payload load switch control) and documented payload integration tests | | 2025-11-19 | Added getMode query port and enhanced modeChanged to carry mode value | From fddb10f8348db23114dbe5751b7a226c874d0042 Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:35:43 -0800 Subject: [PATCH 08/18] change mode enum values --- .../Components/ModeManager/ModeManager.cpp | 34 +++++++---- .../Components/ModeManager/ModeManager.fpp | 6 +- .../Components/ModeManager/ModeManager.hpp | 4 +- .../Components/ModeManager/docs/sdd.md | 19 +++--- .../test/int/mode_manager_test.py | 35 +++++------ .../test/int/payload_mode_test.py | 59 ++++++++----------- 6 files changed, 79 insertions(+), 78 deletions(-) diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp index ca672a2b..8295fe25 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp @@ -45,13 +45,14 @@ void ModeManager ::run_handler(FwIndexType portNum, U32 context) { void ModeManager ::forceSafeMode_handler(FwIndexType portNum) { // Force entry into safe mode (called by other components) - // Provides immediate safe mode entry for critical component-detected faults + // Only allowed from NORMAL (sequential +1/-1 transitions) this->log_WARNING_HI_ExternalFaultDetected(); - // Allow safe mode entry from NORMAL or PAYLOAD_MODE (emergency override) - if (this->m_mode == SystemMode::NORMAL || this->m_mode == SystemMode::PAYLOAD_MODE) { + // Only enter safe mode from NORMAL - payload mode must be exited first + if (this->m_mode == SystemMode::NORMAL) { this->enterSafeMode("External component request"); } + // Note: Request ignored if in PAYLOAD_MODE or already in SAFE_MODE } Components::SystemMode ModeManager ::getMode_handler(FwIndexType portNum) { @@ -65,14 +66,26 @@ Components::SystemMode ModeManager ::getMode_handler(FwIndexType portNum) { // ---------------------------------------------------------------------- void ModeManager ::FORCE_SAFE_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) { - // Force entry into safe mode - this->log_ACTIVITY_HI_ManualSafeModeEntry(); + // Force entry into safe mode - only allowed from NORMAL (sequential +1/-1 transitions) - // Allow safe mode entry from NORMAL or PAYLOAD_MODE (emergency override) - if (this->m_mode == SystemMode::NORMAL || this->m_mode == SystemMode::PAYLOAD_MODE) { - this->enterSafeMode("Ground command"); + // Reject if in payload mode - must exit payload mode first + if (this->m_mode == SystemMode::PAYLOAD_MODE) { + Fw::LogStringArg cmdNameStr("FORCE_SAFE_MODE"); + Fw::LogStringArg reasonStr("Must exit payload mode first"); + this->log_WARNING_LO_CommandValidationFailed(cmdNameStr, reasonStr); + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::VALIDATION_ERROR); + return; } + // Already in safe mode - idempotent success + if (this->m_mode == SystemMode::SAFE_MODE) { + this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::OK); + return; + } + + // Enter safe mode from NORMAL + this->log_ACTIVITY_HI_ManualSafeModeEntry(); + this->enterSafeMode("Ground command"); this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::OK); } @@ -150,8 +163,9 @@ void ModeManager ::loadState() { status = file.read(reinterpret_cast(&state), bytesRead, Os::File::WaitType::WAIT); if (status == Os::File::OP_OK && bytesRead == sizeof(PersistentState)) { - // Validate state data before restoring - if (state.mode <= static_cast(SystemMode::PAYLOAD_MODE)) { + // Validate state data before restoring (valid range: 1-3 for SAFE, NORMAL, PAYLOAD) + if (state.mode >= static_cast(SystemMode::SAFE_MODE) && + state.mode <= static_cast(SystemMode::PAYLOAD_MODE)) { // Valid mode value - restore state this->m_mode = static_cast(state.mode); this->m_safeModeEntryCount = state.safeModeEntryCount; diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp index c65dd493..09e73743 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp @@ -1,10 +1,10 @@ module Components { - @ System mode enumeration + @ System mode enumeration (values ordered for +1/-1 sequential transitions) enum SystemMode { - NORMAL = 0 @< Normal operational mode SAFE_MODE = 1 @< Safe mode with non-critical components powered off - PAYLOAD_MODE = 2 @< Payload mode with payload power and battery enabled + NORMAL = 2 @< Normal operational mode + PAYLOAD_MODE = 3 @< Payload mode with payload power and battery enabled } @ Port for notifying about mode changes diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp index 79862387..a4b9b9b5 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp @@ -125,8 +125,8 @@ class ModeManager : public ModeManagerComponentBase { // Private enums and types // ---------------------------------------------------------------------- - //! System mode enumeration - enum class SystemMode : U8 { NORMAL = 0, SAFE_MODE = 1, PAYLOAD_MODE = 2 }; + //! System mode enumeration (values ordered for +1/-1 sequential transitions) + enum class SystemMode : U8 { SAFE_MODE = 1, NORMAL = 2, PAYLOAD_MODE = 3 }; //! Persistent state structure struct PersistentState { diff --git a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md index 3e9d83d5..22d80144 100644 --- a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md +++ b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md @@ -24,7 +24,7 @@ Future work: a HIBERNATION mode remains planned; it will follow the same persist | MM0016 | The ModeManager shall turn on payload load switches (indices 6 and 7) when entering payload mode and turn them off when exiting payload mode | Integration Testing | | MM0017 | The ModeManager shall track and report the number of times payload mode has been entered | Integration Testing | | MM0018 | The ModeManager shall persist payload mode state and payload mode entry count to non-volatile storage and restore them on initialization | Integration Testing | -| MM0019 | The ModeManager shall allow FORCE_SAFE_MODE to override payload mode and transition to safe mode | Integration Testing | +| MM0019 | The ModeManager shall reject FORCE_SAFE_MODE from payload mode (must exit payload mode first for sequential transitions) | Integration Testing | ## Usage Examples @@ -44,7 +44,7 @@ The ModeManager component operates as an active component that manages system-wi - Keeps payload load switches (indices 6 and 7) off unless payload mode is explicitly entered 3. **Safe Mode Entry** - - Can be triggered by: + - Can be triggered by (only from NORMAL mode - sequential transitions enforced): - Ground command: `FORCE_SAFE_MODE` - External component request via `forceSafeMode` port - Actions performed: @@ -130,9 +130,9 @@ classDiagram } class SystemMode { <> - NORMAL = 0 SAFE_MODE = 1 - PAYLOAD_MODE = 2 + NORMAL = 2 + PAYLOAD_MODE = 3 } } ModeManagerComponentBase <|-- ModeManager : inherits @@ -304,7 +304,7 @@ sequenceDiagram | Name | Arguments | Description | |---|---|---| -| FORCE_SAFE_MODE | None | Forces the system into safe mode immediately. Emits ManualSafeModeEntry event. Can be called from any mode (idempotent). | +| FORCE_SAFE_MODE | None | Forces the system into safe mode. Only allowed from NORMAL mode (rejects from PAYLOAD_MODE with validation error). Emits ManualSafeModeEntry event. Idempotent when already in safe mode. | | EXIT_SAFE_MODE | None | Exits safe mode and returns to normal operation. Fails with CommandValidationFailed if not currently in safe mode. | | ENTER_PAYLOAD_MODE | None | Enters payload mode from NORMAL. Fails with CommandValidationFailed if issued from SAFE_MODE or if already in payload mode (idempotent success when already in payload). Emits ManualPayloadModeEntry event. | | EXIT_PAYLOAD_MODE | None | Exits payload mode and returns to normal operation. Fails with CommandValidationFailed if not currently in payload mode. | @@ -327,7 +327,7 @@ sequenceDiagram | Name | Type | Update Rate | Description | |---|---|---|---| -| CurrentMode | U8 | 1Hz | Current system mode (0 = NORMAL, 1 = SAFE_MODE, 2 = PAYLOAD_MODE) | +| CurrentMode | U8 | 1Hz | Current system mode (1 = SAFE_MODE, 2 = NORMAL, 3 = PAYLOAD_MODE) | | SafeModeEntryCount | U32 | On change | Number of times safe mode has been entered (persists across reboots) | | PayloadModeEntryCount | U32 | On change | Number of times payload mode has been entered (persists across reboots) | @@ -360,7 +360,7 @@ See `FprimeZephyrReference/test/int/mode_manager_test.py` and `FprimeZephyrRefer | test_19_safe_mode_state_persists | Verifies safe mode persistence to flash | State persistence | | test_payload_01_enter_exit_payload_mode | Validates payload mode entry/exit, events, telemetry, payload load switches | Payload mode entry/exit | | test_payload_02_cannot_enter_from_safe_mode | Ensures ENTER_PAYLOAD_MODE fails from SAFE_MODE | Command validation | -| test_payload_03_safe_mode_override_from_payload | Ensures FORCE_SAFE_MODE overrides payload mode | Emergency override | +| test_payload_03_safe_mode_rejected_from_payload | Ensures FORCE_SAFE_MODE is rejected from payload mode (sequential transitions) | Command validation | | test_payload_04_state_persists | Verifies payload mode and counters persist | Payload persistence | ## Design Decisions @@ -388,12 +388,13 @@ Mode state is persisted to `/mode_state.bin` to maintain operational context acr This ensures the system resumes in the correct mode after recovery. -### Idempotent Safe Mode Entry -The FORCE_SAFE_MODE command can be called from any mode without error. If already in safe mode, it succeeds without re-entering. This simplifies fault handling logic in external components. +### Sequential Mode Transitions +Mode transitions follow a +1/-1 sequential pattern: SAFE_MODE(1) ↔ NORMAL(2) ↔ PAYLOAD_MODE(3). Direct jumps (e.g., PAYLOAD→SAFE) are not allowed - users must exit payload mode first before entering safe mode. FORCE_SAFE_MODE is idempotent when already in safe mode. ## Change Log | Date | Description | |---|---| +| 2025-11-26 | Reordered enum values (SAFE=1, NORMAL=2, PAYLOAD=3) for sequential +1/-1 transitions; FORCE_SAFE_MODE now rejected from payload mode | | 2025-11-26 | Removed forcePayloadMode port - payload mode now only entered via ENTER_PAYLOAD_MODE ground command | | 2025-11-25 | Added PAYLOAD_MODE (commands, events, telemetry, persistence, payload load switch control) and documented payload integration tests | | 2025-11-19 | Added getMode query port and enhanced modeChanged to carry mode value | diff --git a/FprimeZephyrReference/test/int/mode_manager_test.py b/FprimeZephyrReference/test/int/mode_manager_test.py index 6bdd0429..7f7d11b3 100644 --- a/FprimeZephyrReference/test/int/mode_manager_test.py +++ b/FprimeZephyrReference/test/int/mode_manager_test.py @@ -11,6 +11,8 @@ - Edge cases Total: 9 tests + +Mode enum values: SAFE_MODE=1, NORMAL=2, PAYLOAD_MODE=3 """ import time @@ -87,12 +89,12 @@ def test_01_initial_telemetry(fprime_test_api: IntegrationTestAPI, start_gds): # Trigger telemetry update by sending Health packet (ID 1) proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) - # Read CurrentMode telemetry (0 = NORMAL, 1 = SAFE_MODE) + # Read CurrentMode telemetry (1 = SAFE_MODE, 2 = NORMAL, 3 = PAYLOAD_MODE) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", start="NOW", timeout=3 ) current_mode = mode_result.get_val() - assert current_mode in [0, 1], f"Invalid mode value: {current_mode}" + assert current_mode in [1, 2, 3], f"Invalid mode value: {current_mode}" # ============================================================================== @@ -103,13 +105,11 @@ def test_01_initial_telemetry(fprime_test_api: IntegrationTestAPI, start_gds): def test_04_force_safe_mode_command(fprime_test_api: IntegrationTestAPI, start_gds): """ Test FORCE_SAFE_MODE command enters safe mode by checking telemetry. + Note: With idempotent behavior, ManualSafeModeEntry is only emitted when + transitioning from NORMAL, not when already in SAFE_MODE. """ - # Send FORCE_SAFE_MODE command - still expect ManualSafeModeEntry for logging - proves_send_and_assert_command( - fprime_test_api, - f"{component}.FORCE_SAFE_MODE", - events=[f"{component}.ManualSafeModeEntry"], - ) + # Send FORCE_SAFE_MODE command (idempotent - succeeds even if already in safe mode) + proves_send_and_assert_command(fprime_test_api, f"{component}.FORCE_SAFE_MODE") # Wait for mode transition (happens in 1Hz rate group) time.sleep(3) @@ -201,7 +201,7 @@ def test_13_exit_safe_mode_fails_not_in_safe_mode( f"{component}.CurrentMode", start="NOW", timeout=3 ) - if mode_result.get_val() != 0: + if mode_result.get_val() != 2: pytest.skip("Not in NORMAL mode - cannot test this scenario") # Try to exit safe mode when not in it - should fail @@ -219,7 +219,7 @@ def test_14_exit_safe_mode_success(fprime_test_api: IntegrationTestAPI, start_gd Test EXIT_SAFE_MODE succeeds. Verifies: - ExitingSafeMode event is emitted - - CurrentMode returns to NORMAL (0) + - CurrentMode returns to NORMAL (2) """ # Enter safe mode proves_send_and_assert_command(fprime_test_api, f"{component}.FORCE_SAFE_MODE") @@ -234,12 +234,12 @@ def test_14_exit_safe_mode_success(fprime_test_api: IntegrationTestAPI, start_gd time.sleep(2) - # Verify mode is NORMAL (0) + # Verify mode is NORMAL (2) proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", start="NOW", timeout=3 ) - assert mode_result.get_val() == 0, "Should be in NORMAL mode" + assert mode_result.get_val() == 2, "Should be in NORMAL mode" # ============================================================================== @@ -250,7 +250,7 @@ def test_14_exit_safe_mode_success(fprime_test_api: IntegrationTestAPI, start_gd def test_18_force_safe_mode_idempotent(fprime_test_api: IntegrationTestAPI, start_gds): """ Test that calling FORCE_SAFE_MODE while already in safe mode is idempotent. - Should succeed and not cause issues. + Should succeed without emitting events (no re-entry). """ # Enter safe mode first time proves_send_and_assert_command(fprime_test_api, f"{component}.FORCE_SAFE_MODE") @@ -259,13 +259,8 @@ def test_18_force_safe_mode_idempotent(fprime_test_api: IntegrationTestAPI, star # Clear event history fprime_test_api.clear_histories() - # Force safe mode again - should succeed without EnteringSafeMode event - # But ManualSafeModeEntry event is still emitted (logging) - proves_send_and_assert_command( - fprime_test_api, - f"{component}.FORCE_SAFE_MODE", - events=[f"{component}.ManualSafeModeEntry"], - ) + # Force safe mode again - should succeed silently (no events, no re-entry) + proves_send_and_assert_command(fprime_test_api, f"{component}.FORCE_SAFE_MODE") # Verify still in safe mode time.sleep(1) diff --git a/FprimeZephyrReference/test/int/payload_mode_test.py b/FprimeZephyrReference/test/int/payload_mode_test.py index 365ceb3c..b8b76aa3 100644 --- a/FprimeZephyrReference/test/int/payload_mode_test.py +++ b/FprimeZephyrReference/test/int/payload_mode_test.py @@ -6,10 +6,12 @@ Tests cover: - Payload mode entry and exit - Mode transition validation (cannot enter from safe mode) -- Emergency safe mode override from payload mode +- Sequential transitions (FORCE_SAFE_MODE rejected from payload mode) - State persistence Total: 4 tests + +Mode enum values: SAFE_MODE=1, NORMAL=2, PAYLOAD_MODE=3 """ import time @@ -84,10 +86,10 @@ def test_payload_01_enter_exit_payload_mode( Test ENTER_PAYLOAD_MODE and EXIT_PAYLOAD_MODE commands. Verifies: - EnteringPayloadMode event is emitted - - CurrentMode telemetry = 2 (PAYLOAD_MODE) + - CurrentMode telemetry = 3 (PAYLOAD_MODE) - Payload load switches (6 & 7) are ON - ExitingPayloadMode event is emitted on exit - - CurrentMode returns to 0 (NORMAL) + - CurrentMode returns to 2 (NORMAL) """ # Enter payload mode proves_send_and_assert_command( @@ -98,12 +100,12 @@ def test_payload_01_enter_exit_payload_mode( time.sleep(2) - # Verify mode is PAYLOAD_MODE (2) + # Verify mode is PAYLOAD_MODE (3) proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) - assert mode_result.get_val() == 2, "Should be in PAYLOAD_MODE" + assert mode_result.get_val() == 3, "Should be in PAYLOAD_MODE" # Verify payload load switches are ON proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) @@ -123,12 +125,12 @@ def test_payload_01_enter_exit_payload_mode( time.sleep(2) - # Verify mode is NORMAL (0) + # Verify mode is NORMAL (2) proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) - assert mode_result.get_val() == 0, "Should be in NORMAL mode" + assert mode_result.get_val() == 2, "Should be in NORMAL mode" # Verify payload load switches are OFF in NORMAL mode proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) @@ -176,16 +178,16 @@ def test_payload_02_cannot_enter_from_safe_mode( assert mode_result.get_val() == 1, "Should still be in SAFE_MODE" -def test_payload_03_safe_mode_override_from_payload( +def test_payload_03_safe_mode_rejected_from_payload( fprime_test_api: IntegrationTestAPI, start_gds ): """ - Test that FORCE_SAFE_MODE works from PAYLOAD_MODE (emergency override). + Test that FORCE_SAFE_MODE is rejected from PAYLOAD_MODE (sequential transitions). + Must exit payload mode first before entering safe mode. Verifies: - - Can enter safe mode from payload mode - - EnteringSafeMode event is emitted - - CurrentMode = 1 (SAFE_MODE) - - All load switches are OFF + - FORCE_SAFE_MODE fails with validation error from payload mode + - CommandValidationFailed event is emitted + - Remains in PAYLOAD_MODE (3) """ # Enter payload mode first proves_send_and_assert_command(fprime_test_api, f"{component}.ENTER_PAYLOAD_MODE") @@ -196,33 +198,22 @@ def test_payload_03_safe_mode_override_from_payload( mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) - assert mode_result.get_val() == 2, "Should be in PAYLOAD_MODE" + assert mode_result.get_val() == 3, "Should be in PAYLOAD_MODE" - # Force safe mode (emergency override) + # Try to force safe mode - should fail (sequential transitions required) fprime_test_api.clear_histories() - proves_send_and_assert_command( - fprime_test_api, - f"{component}.FORCE_SAFE_MODE", - events=[f"{component}.ManualSafeModeEntry"], - ) + with pytest.raises(Exception): + proves_send_and_assert_command(fprime_test_api, f"{component}.FORCE_SAFE_MODE") - time.sleep(2) + # Verify CommandValidationFailed event + fprime_test_api.assert_event(f"{component}.CommandValidationFailed", timeout=3) - # Verify mode is SAFE_MODE (1) + # Verify still in payload mode proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) - assert mode_result.get_val() == 1, "Should be in SAFE_MODE" - - # Verify all load switches are OFF - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) - for channel in all_load_switch_channels: - value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() - if isinstance(value, str): - assert value.upper() == "OFF", f"{channel} should be OFF in safe mode" - else: - assert value == 0, f"{channel} should be OFF in safe mode" + assert mode_result.get_val() == 3, "Should still be in PAYLOAD_MODE" def test_payload_04_state_persists(fprime_test_api: IntegrationTestAPI, start_gds): @@ -234,12 +225,12 @@ def test_payload_04_state_persists(fprime_test_api: IntegrationTestAPI, start_gd proves_send_and_assert_command(fprime_test_api, f"{component}.ENTER_PAYLOAD_MODE") time.sleep(2) - # Verify mode is saved as PAYLOAD_MODE + # Verify mode is saved as PAYLOAD_MODE (3) proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) - assert mode_result.get_val() == 2, "Mode should be saved as PAYLOAD_MODE" + assert mode_result.get_val() == 3, "Mode should be saved as PAYLOAD_MODE" # Verify PayloadModeEntryCount is tracking count_result: ChData = fprime_test_api.assert_telemetry( From a7d55082e5a35ac9aa2044e2eff2ae6ee81ee929 Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:45:57 -0800 Subject: [PATCH 09/18] fix the safe mode emit spam --- FprimeZephyrReference/Components/ModeManager/ModeManager.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp index 8295fe25..616a0a9f 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp @@ -46,10 +46,8 @@ void ModeManager ::run_handler(FwIndexType portNum, U32 context) { void ModeManager ::forceSafeMode_handler(FwIndexType portNum) { // Force entry into safe mode (called by other components) // Only allowed from NORMAL (sequential +1/-1 transitions) - this->log_WARNING_HI_ExternalFaultDetected(); - - // Only enter safe mode from NORMAL - payload mode must be exited first if (this->m_mode == SystemMode::NORMAL) { + this->log_WARNING_HI_ExternalFaultDetected(); this->enterSafeMode("External component request"); } // Note: Request ignored if in PAYLOAD_MODE or already in SAFE_MODE From 5b0cc7e385fc00b6b597b1ca4f4b2607d78c3880 Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Fri, 28 Nov 2025 18:32:39 -0800 Subject: [PATCH 10/18] update ci run group to sam (temp) --- .github/workflows/ci.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b6a683a0..ea7fa53e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,7 +17,8 @@ jobs: make fmt build: - runs-on: deathstar + runs-on: + group: sam steps: - uses: actions/checkout@v4 From 286cd57f29bbaeaab1498f77ec6361cb40f6864f Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Fri, 28 Nov 2025 22:50:20 -0800 Subject: [PATCH 11/18] rever runner group --- .github/workflows/ci.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ea7fa53e..b6a683a0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,8 +17,7 @@ jobs: make fmt build: - runs-on: - group: sam + runs-on: deathstar steps: - uses: actions/checkout@v4 From a44949df56a4366f62a88bc463cc442084bff1c1 Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Sat, 29 Nov 2025 09:54:49 -0800 Subject: [PATCH 12/18] add normal <-> payload mode auto change --- .../Components/ModeManager/ModeManager.cpp | 66 +++++- .../Components/ModeManager/ModeManager.fpp | 7 + .../Components/ModeManager/ModeManager.hpp | 11 +- .../Components/ModeManager/docs/sdd.md | 65 +++++- .../test/int/payload_mode_test.py | 194 +++++++++++++++++- 5 files changed, 329 insertions(+), 14 deletions(-) diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp index 616a0a9f..783e48b8 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp @@ -22,7 +22,8 @@ ModeManager ::ModeManager(const char* const compName) m_mode(SystemMode::NORMAL), m_safeModeEntryCount(0), m_payloadModeEntryCount(0), - m_runCounter(0) {} + m_runCounter(0), + m_lowVoltageCounter(0) {} ModeManager ::~ModeManager() {} @@ -39,6 +40,33 @@ void ModeManager ::run_handler(FwIndexType portNum, U32 context) { // Increment run counter (1Hz tick counter) this->m_runCounter++; + // Check for low voltage fault when in PAYLOAD_MODE + if (this->m_mode == SystemMode::PAYLOAD_MODE) { + bool valid = false; + F32 voltage = this->getCurrentVoltage(valid); + + // Treat both low voltage AND invalid readings as fault conditions + // Invalid readings (sensor failure/disconnection) should not mask brownout protection + bool isFault = !valid || (voltage < LOW_VOLTAGE_THRESHOLD); + + if (isFault) { + this->m_lowVoltageCounter++; + + if (this->m_lowVoltageCounter >= LOW_VOLTAGE_DEBOUNCE_SECONDS) { + // Trigger automatic exit from payload mode + // Use 0.0 for voltage if reading was invalid + this->exitPayloadModeAutomatic(valid ? voltage : 0.0f); + this->m_lowVoltageCounter = 0; // Reset counter + } + } else { + // Voltage OK and valid - reset counter + this->m_lowVoltageCounter = 0; + } + } else { + // Not in payload mode - reset counter + this->m_lowVoltageCounter = 0; + } + // Update telemetry this->tlmWrite_CurrentMode(static_cast(this->m_mode)); } @@ -299,6 +327,7 @@ void ModeManager ::enterPayloadMode(const char* reasonOverride) { // Transition to payload mode this->m_mode = SystemMode::PAYLOAD_MODE; this->m_payloadModeEntryCount++; + this->m_lowVoltageCounter = 0; // Reset low voltage counter on mode entry // Build reason string Fw::LogStringArg reasonStr; @@ -312,8 +341,10 @@ void ModeManager ::enterPayloadMode(const char* reasonOverride) { this->log_ACTIVITY_HI_EnteringPayloadMode(reasonStr); - // Turn on payload switches (6 & 7) - this->turnOnPayload(); + // Turn on ALL load switches (faces 0-5 AND payload 6-7) + // This ensures proper state even after automatic fault exit + this->turnOnComponents(); // Face switches (0-5) + this->turnOnPayload(); // Payload switches (6-7) // Update telemetry this->tlmWrite_CurrentMode(static_cast(this->m_mode)); @@ -330,7 +361,7 @@ void ModeManager ::enterPayloadMode(const char* reasonOverride) { } void ModeManager ::exitPayloadMode() { - // Transition back to normal mode + // Transition back to normal mode (manual exit) this->m_mode = SystemMode::NORMAL; this->log_ACTIVITY_HI_ExitingPayloadMode(); @@ -338,8 +369,8 @@ void ModeManager ::exitPayloadMode() { // Turn off payload switches this->turnOffPayload(); - // Ensure face switches (0-5) are ON for NORMAL mode - // This guarantees consistent state regardless of transition path + // Ensure face switches are ON for NORMAL mode + // This guarantees consistent state even if faces were turned off during payload mode this->turnOnComponents(); // Update telemetry @@ -355,6 +386,29 @@ void ModeManager ::exitPayloadMode() { this->saveState(); } +void ModeManager ::exitPayloadModeAutomatic(F32 voltage) { + // Automatic exit from payload mode due to fault condition (e.g., low voltage) + // More aggressive than manual exit - turns off ALL switches + this->m_mode = SystemMode::NORMAL; + + this->log_WARNING_HI_AutoPayloadModeExit(voltage); + + // Turn OFF all load switches (aggressive - includes faces 0-5 and payload 6-7) + this->turnOffNonCriticalComponents(); + + // Update telemetry + this->tlmWrite_CurrentMode(static_cast(this->m_mode)); + + // Notify other components of mode change with new mode value + if (this->isConnected_modeChanged_OutputPort(0)) { + Components::SystemMode fppMode = static_cast(this->m_mode); + this->modeChanged_out(0, fppMode); + } + + // Save state + this->saveState(); +} + void ModeManager ::turnOffNonCriticalComponents() { // Turn OFF: // - Satellite faces 0-5 (LoadSwitch instances 0-5) diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp index 09e73743..8887b968 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp @@ -117,6 +117,13 @@ module Components { severity warning low \ format "Command {} failed: {}" + @ Event emitted when automatically exiting payload mode due to low voltage + event AutoPayloadModeExit( + voltage: F32 @< Voltage that triggered the exit + ) \ + severity warning high \ + format "AUTO EXIT PAYLOAD MODE: Low voltage detected ({}V)" + @ Event emitted when state persistence fails event StatePersistenceFailure( operation: string size 20 @< Operation that failed (save/load) diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp index a4b9b9b5..1d3e65c8 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp @@ -100,9 +100,13 @@ class ModeManager : public ModeManagerComponentBase { //! Enter payload mode with optional reason override void enterPayloadMode(const char* reason = nullptr); - //! Exit payload mode + //! Exit payload mode (manual) void exitPayloadMode(); + //! Exit payload mode automatically due to fault condition + //! More aggressive than manual exit - turns off all switches + void exitPayloadModeAutomatic(F32 voltage); + //! Turn off non-critical components void turnOffNonCriticalComponents(); @@ -143,8 +147,13 @@ class ModeManager : public ModeManagerComponentBase { U32 m_safeModeEntryCount; //!< Counter for safe mode entries U32 m_payloadModeEntryCount; //!< Counter for payload mode entries U32 m_runCounter; //!< Counter for run handler calls (1Hz) + U32 m_lowVoltageCounter; //!< Counter for consecutive low voltage readings static constexpr const char* STATE_FILE_PATH = "/mode_state.bin"; //!< State file path + + // Voltage threshold constants for payload mode protection + static constexpr F32 LOW_VOLTAGE_THRESHOLD = 7.2f; //!< Voltage threshold in volts + static constexpr U32 LOW_VOLTAGE_DEBOUNCE_SECONDS = 10; //!< Consecutive seconds below threshold }; } // namespace Components diff --git a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md index 22d80144..0ef25ac8 100644 --- a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md +++ b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md @@ -21,10 +21,12 @@ Future work: a HIBERNATION mode remains planned; it will follow the same persist | MM0012 | The ModeManager shall notify downstream components of mode changes with the new mode value | Unit Testing | | MM0013 | The ModeManager shall enter payload mode when commanded via ENTER_PAYLOAD_MODE while in NORMAL and reject entry from SAFE_MODE | Integration Testing | | MM0014 | The ModeManager shall exit payload mode only via explicit EXIT_PAYLOAD_MODE command and reject exit when not in payload mode | Integration Testing | -| MM0016 | The ModeManager shall turn on payload load switches (indices 6 and 7) when entering payload mode and turn them off when exiting payload mode | Integration Testing | +| MM0016 | The ModeManager shall turn on all load switches (faces 0-5 and payload 6-7) when entering payload mode and turn off payload load switches when exiting payload mode | Integration Testing | | MM0017 | The ModeManager shall track and report the number of times payload mode has been entered | Integration Testing | | MM0018 | The ModeManager shall persist payload mode state and payload mode entry count to non-volatile storage and restore them on initialization | Integration Testing | | MM0019 | The ModeManager shall reject FORCE_SAFE_MODE from payload mode (must exit payload mode first for sequential transitions) | Integration Testing | +| MM0020 | While in payload mode, the ModeManager shall monitor bus voltage once per second and automatically exit payload mode after 10 consecutive seconds below 7.2V or upon invalid voltage readings | Integration Testing (manual + debounced behavior) | +| MM0021 | Automatic payload mode exit shall emit AutoPayloadModeExit and turn off all 8 load switches | Integration Testing (manual) | ## Usage Examples @@ -61,7 +63,7 @@ The ModeManager component operates as an active component that manages system-wi - Transitions mode to PAYLOAD_MODE - Increments payload mode entry counter - Emits `EnteringPayloadMode` event and, for commands, `ManualPayloadModeEntry` - - Turns on payload load switches (indices 6 and 7) + - Turns on **all load switches** (faces 0-5 and payload 6-7) to ensure consistent power state even after fault exits - Notifies downstream components via `modeChanged` port - Updates telemetry (CurrentMode, PayloadModeEntryCount) - Persists state to flash storage @@ -73,11 +75,21 @@ The ModeManager component operates as an active component that manages system-wi - Transitions mode to NORMAL - Emits `ExitingPayloadMode` event - Turns off payload load switches (indices 6 and 7) + - Ensures face load switches (0-5) are turned ON for NORMAL mode - Notifies downstream components via `modeChanged` port - Updates telemetry - Persists state to flash storage -6. **Safe Mode Exit** +6. **Automatic Payload Mode Exit (Low Voltage)** + - Triggered when in PAYLOAD_MODE and bus voltage is below 7.2V (or invalid) for 10 consecutive 1Hz checks + - Actions performed: + - Emits `AutoPayloadModeExit` with measured/0.0V value + - Transitions mode to NORMAL + - Turns off **all 8 load switches** (faces and payload) aggressively + - Notifies downstream components via `modeChanged` port + - Updates telemetry and persists state + +7. **Safe Mode Exit** - Triggered only by ground command: `EXIT_SAFE_MODE` - Validates currently in safe mode before allowing exit - Actions performed: @@ -122,11 +134,15 @@ classDiagram - exitSafeMode() - enterPayloadMode(const char* reason) - exitPayloadMode() + - exitPayloadModeAutomatic(F32 voltage) - turnOffNonCriticalComponents() - turnOnComponents() - turnOnPayload() - turnOffPayload() - getCurrentVoltage(bool& valid): F32 + - m_lowVoltageCounter: U32 + - LOW_VOLTAGE_THRESHOLD: F32 + - LOW_VOLTAGE_DEBOUNCE_SECONDS: U32 } class SystemMode { <> @@ -144,7 +160,7 @@ classDiagram ### Input Ports | Name | Type | Kind | Description | |---|---|---|---| -| run | Svc.Sched | sync | Receives periodic calls from rate group (1Hz) for telemetry updates | +| run | Svc.Sched | sync | Receives periodic calls from rate group (1Hz) for telemetry updates and low-voltage monitoring while in PAYLOAD_MODE | | forceSafeMode | Fw.Signal | async | Receives safe mode requests from external components detecting faults | | getMode | Components.GetSystemMode | sync | Allows downstream components to query current system mode | @@ -164,6 +180,7 @@ classDiagram | m_safeModeEntryCount | U32 | Number of times safe mode has been entered since initial deployment | | m_payloadModeEntryCount | U32 | Number of times payload mode has been entered since initial deployment | | m_runCounter | U32 | Counter for 1Hz run handler calls | +| m_lowVoltageCounter | U32 | Debounce counter for consecutive low/invalid voltage readings while in PAYLOAD_MODE | ### Persistent State The component persists the following state to `/mode_state.bin`: @@ -251,7 +268,7 @@ sequenceDiagram ModeManager->>ModeManager: Set m_mode = PAYLOAD_MODE ModeManager->>ModeManager: Increment m_payloadModeEntryCount ModeManager->>ModeManager: Emit EnteringPayloadMode event - ModeManager->>LoadSwitches: Turn on payload switches (6 & 7) + ModeManager->>LoadSwitches: Turn on all load switches (faces 0-5 and payload 6 & 7) ModeManager->>ModeManager: Update telemetry ModeManager->>DownstreamComponents: modeChanged_out(PAYLOAD_MODE) ModeManager->>FlashStorage: Save state to /mode_state.bin @@ -272,12 +289,33 @@ sequenceDiagram ModeManager->>ModeManager: Set m_mode = NORMAL ModeManager->>ModeManager: Emit ExitingPayloadMode event ModeManager->>LoadSwitches: Turn off payload switches (6 & 7) + ModeManager->>LoadSwitches: Turn on face switches (0-5) to normalize NORMAL state ModeManager->>ModeManager: Update telemetry ModeManager->>DownstreamComponents: modeChanged_out(NORMAL) ModeManager->>FlashStorage: Save state to /mode_state.bin ModeManager->>Ground: Command response OK ``` +### Automatic Payload Mode Exit (Low Voltage) +```mermaid +sequenceDiagram + participant ModeManager + participant INA219 + participant LoadSwitches + participant DownstreamComponents + participant FlashStorage + + ModeManager->>INA219: voltageGet() + INA219-->>ModeManager: Voltage / invalid + ModeManager->>ModeManager: Debounce low-voltage/invalid readings (10 consecutive seconds) + ModeManager->>ModeManager: On debounce hit: Emit AutoPayloadModeExit(voltage) + ModeManager->>ModeManager: Set m_mode = NORMAL + ModeManager->>LoadSwitches: Turn off all 8 switches (faces and payload) + ModeManager->>ModeManager: Update telemetry + ModeManager->>DownstreamComponents: modeChanged_out(NORMAL) + ModeManager->>FlashStorage: Save state to /mode_state.bin +``` + ### Mode Query ```mermaid sequenceDiagram @@ -297,6 +335,8 @@ sequenceDiagram RateGroup->>ModeManager: run(portNum, context) ModeManager->>ModeManager: Increment m_runCounter + ModeManager->>ModeManager: If PAYLOAD_MODE: read voltage, debounce low-voltage counter (10s window) + ModeManager->>ModeManager: If sustained low/invalid voltage: exitPayloadModeAutomatic() ModeManager->>ModeManager: Write CurrentMode telemetry ``` @@ -320,6 +360,7 @@ sequenceDiagram | EnteringPayloadMode | ACTIVITY_HI | reason: string size 100 | Emitted when entering payload mode, includes reason (e.g., "Ground command") | | ExitingPayloadMode | ACTIVITY_HI | None | Emitted when exiting payload mode and returning to normal operation | | ManualPayloadModeEntry | ACTIVITY_HI | None | Emitted when payload mode is manually commanded via ENTER_PAYLOAD_MODE | +| AutoPayloadModeExit | WARNING_HI | voltage: F32 | Emitted when automatically exiting payload mode due to low/invalid voltage (voltage value reported; 0.0 if invalid) | | CommandValidationFailed | WARNING_LO | cmdName: string size 50
reason: string size 100 | Emitted when a command fails validation (e.g., EXIT_SAFE_MODE when not in safe mode) | | StatePersistenceFailure | WARNING_LO | operation: string size 20
status: I32 | Emitted when state save/load operations fail | @@ -346,7 +387,7 @@ The ModeManager controls 8 load switches that power non-critical satellite subsy | 6 | Payload Power | OFF | OFF | ON | | 7 | Payload Battery | OFF | OFF | ON | -> **Note:** PAYLOAD_MODE can only be entered from NORMAL mode (not from SAFE_MODE). When restoring PAYLOAD_MODE from persistent storage after a reboot, both face switches (0-5) and payload switches (6-7) are explicitly turned ON to ensure consistent state. +> **Notes:** PAYLOAD_MODE can only be entered from NORMAL mode (not from SAFE_MODE). When restoring PAYLOAD_MODE from persistent storage after a reboot, both face switches (0-5) and payload switches (6-7) are explicitly turned ON to ensure consistent state. Automatic payload exits (low/invalid voltage) aggressively turn **all** switches OFF; manual payload exits leave faces ON and only shed payload loads. ## Integration Tests See `FprimeZephyrReference/test/int/mode_manager_test.py` and `FprimeZephyrReference/test/int/payload_mode_test.py` for comprehensive integration tests covering: @@ -362,6 +403,9 @@ See `FprimeZephyrReference/test/int/mode_manager_test.py` and `FprimeZephyrRefer | test_payload_02_cannot_enter_from_safe_mode | Ensures ENTER_PAYLOAD_MODE fails from SAFE_MODE | Command validation | | test_payload_03_safe_mode_rejected_from_payload | Ensures FORCE_SAFE_MODE is rejected from payload mode (sequential transitions) | Command validation | | test_payload_04_state_persists | Verifies payload mode and counters persist | Payload persistence | +| test_payload_05_manual_exit_face_switches_remain_on | Verifies manual payload exit leaves faces ON and payload OFF | Payload exit power behavior | +| test_payload_06_voltage_monitoring_active | Verifies voltage telemetry is present in payload mode and no false auto-exit when voltage healthy | Low-voltage monitoring sanity | +| test_payload_07_auto_exit_low_voltage (manual) | Manual test to validate debounced low-voltage auto-exit, AutoPayloadModeExit event, and full load-shed | Low-voltage protection | ## Design Decisions @@ -388,12 +432,21 @@ Mode state is persisted to `/mode_state.bin` to maintain operational context acr This ensures the system resumes in the correct mode after recovery. +### Low-Voltage Payload Protection +Payload mode is protected by a debounced low-voltage monitor: +- Voltage sampled at 1Hz while in PAYLOAD_MODE via `voltageGet` +- Threshold: 7.2V; invalid readings are treated as faults to avoid masking brownouts +- Debounce: 10 consecutive low/invalid readings before acting +- Action: emit `AutoPayloadModeExit`, set mode to NORMAL, and turn off all 8 load switches +- Re-entering payload mode re-powers faces and payload switches to restore a consistent state + ### Sequential Mode Transitions Mode transitions follow a +1/-1 sequential pattern: SAFE_MODE(1) ↔ NORMAL(2) ↔ PAYLOAD_MODE(3). Direct jumps (e.g., PAYLOAD→SAFE) are not allowed - users must exit payload mode first before entering safe mode. FORCE_SAFE_MODE is idempotent when already in safe mode. ## Change Log | Date | Description | |---|---| +| 2026-11-29 | Added low-voltage monitoring with debounced automatic payload exit, AutoPayloadModeExit event, and documentation/tests for aggressive load shed and manual exit power state | | 2025-11-26 | Reordered enum values (SAFE=1, NORMAL=2, PAYLOAD=3) for sequential +1/-1 transitions; FORCE_SAFE_MODE now rejected from payload mode | | 2025-11-26 | Removed forcePayloadMode port - payload mode now only entered via ENTER_PAYLOAD_MODE ground command | | 2025-11-25 | Added PAYLOAD_MODE (commands, events, telemetry, persistence, payload load switch control) and documented payload integration tests | diff --git a/FprimeZephyrReference/test/int/payload_mode_test.py b/FprimeZephyrReference/test/int/payload_mode_test.py index b8b76aa3..29ff77c8 100644 --- a/FprimeZephyrReference/test/int/payload_mode_test.py +++ b/FprimeZephyrReference/test/int/payload_mode_test.py @@ -8,8 +8,11 @@ - Mode transition validation (cannot enter from safe mode) - Sequential transitions (FORCE_SAFE_MODE rejected from payload mode) - State persistence +- Manual exit behavior (face switches remain on) +- Voltage monitoring active in payload mode (no false triggers) +- Automatic exit due to low voltage (manual test - requires voltage control) -Total: 4 tests +Total: 7 tests (6 automated, 1 manual) Mode enum values: SAFE_MODE=1, NORMAL=2, PAYLOAD_MODE=3 """ @@ -22,6 +25,14 @@ from fprime_gds.common.testing_fw.api import IntegrationTestAPI component = "ReferenceDeployment.modeManager" +face_load_switch_channels = [ + "ReferenceDeployment.face4LoadSwitch.IsOn", + "ReferenceDeployment.face0LoadSwitch.IsOn", + "ReferenceDeployment.face1LoadSwitch.IsOn", + "ReferenceDeployment.face2LoadSwitch.IsOn", + "ReferenceDeployment.face3LoadSwitch.IsOn", + "ReferenceDeployment.face5LoadSwitch.IsOn", +] payload_load_switch_channels = [ "ReferenceDeployment.payloadPowerLoadSwitch.IsOn", "ReferenceDeployment.payloadBatteryLoadSwitch.IsOn", @@ -237,3 +248,184 @@ def test_payload_04_state_persists(fprime_test_api: IntegrationTestAPI, start_gd f"{component}.PayloadModeEntryCount", timeout=5 ) assert count_result.get_val() >= 1, "PayloadModeEntryCount should be at least 1" + + +def test_payload_05_manual_exit_face_switches_remain_on( + fprime_test_api: IntegrationTestAPI, start_gds +): + """ + Test that manual EXIT_PAYLOAD_MODE leaves face switches ON. + Verifies: + - Face switches (0-5) remain ON after manual exit + - Payload switches (6-7) are turned OFF + - Mode returns to NORMAL (2) + """ + # Enter payload mode + proves_send_and_assert_command(fprime_test_api, f"{component}.ENTER_PAYLOAD_MODE") + time.sleep(2) + + # Verify all switches are ON in payload mode + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) + for channel in all_load_switch_channels: + value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() + if isinstance(value, str): + assert value.upper() == "ON", f"{channel} should be ON in payload mode" + else: + assert value == 1, f"{channel} should be ON in payload mode" + + # Exit payload mode manually + proves_send_and_assert_command( + fprime_test_api, + f"{component}.EXIT_PAYLOAD_MODE", + events=[f"{component}.ExitingPayloadMode"], + ) + time.sleep(2) + + # Verify mode is NORMAL (2) + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 2, "Should be in NORMAL mode" + + # Verify face switches (0-5) are still ON + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) + for channel in face_load_switch_channels: + value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() + if isinstance(value, str): + assert value.upper() == "ON", ( + f"{channel} should remain ON after manual exit" + ) + else: + assert value == 1, f"{channel} should remain ON after manual exit" + + # Verify payload switches (6-7) are OFF + for channel in payload_load_switch_channels: + value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() + if isinstance(value, str): + assert value.upper() == "OFF", f"{channel} should be OFF after manual exit" + else: + assert value == 0, f"{channel} should be OFF after manual exit" + + +def test_payload_06_voltage_monitoring_active( + fprime_test_api: IntegrationTestAPI, start_gds +): + """ + Test that voltage monitoring is active during payload mode. + Verifies: + - System voltage telemetry is available + - Voltage is above threshold (no auto-exit should occur) + - Mode remains in PAYLOAD_MODE after 5 seconds (no false triggers) + + This test verifies the infrastructure works without requiring + manual voltage control. The actual low-voltage exit is tested + in test_payload_07_auto_exit_low_voltage (manual test). + """ + # Enter payload mode + proves_send_and_assert_command(fprime_test_api, f"{component}.ENTER_PAYLOAD_MODE") + time.sleep(2) + + # Verify in payload mode + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 3, "Should be in PAYLOAD_MODE" + + # Verify system voltage telemetry is available and valid + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["2"]) + voltage_result: ChData = fprime_test_api.assert_telemetry( + "ReferenceDeployment.ina219SysManager.Voltage", timeout=5 + ) + voltage = voltage_result.get_val() + assert voltage > 0, f"Voltage should be positive, got {voltage}" + + # Note: We don't assert voltage > 7.2V because board supply can fluctuate. + # If voltage is actually low, auto-exit is correct behavior. + print(f"Current system voltage: {voltage}V (threshold: 7.2V)") + + # Wait 5 seconds + time.sleep(5) + + # Check final mode + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + final_mode = mode_result.get_val() + + # Check for auto-exit events + events = fprime_test_api.get_event_test_history() + auto_exit_events = [ + e for e in events if "AutoPayloadModeExit" in str(e.get_template().get_name()) + ] + + if voltage >= 7.2: + # Voltage was healthy - should remain in payload mode + assert final_mode == 3, "Should still be in PAYLOAD_MODE (no false auto-exit)" + assert len(auto_exit_events) == 0, ( + "Should not have triggered AutoPayloadModeExit" + ) + else: + # Voltage was low - auto-exit may have triggered (correct behavior) + print( + "Note: Voltage was below threshold. Auto-exit may have occurred (expected)." + ) + # Don't fail the test - low voltage triggering exit is correct behavior + + +@pytest.mark.skip(reason="Requires voltage control - run manually with low battery") +def test_payload_07_auto_exit_low_voltage( + fprime_test_api: IntegrationTestAPI, start_gds +): + """ + Test automatic exit from payload mode due to low voltage. + + MANUAL TEST - requires ability to control battery voltage: + 1. Enter payload mode + 2. Lower battery voltage below 7.2V for 10+ seconds + 3. Verify AutoPayloadModeExit event is emitted + 4. Verify mode returns to NORMAL (2) + 5. Verify ALL switches (faces 0-5 AND payload 6-7) are OFF + + Expected behavior: + - Voltage must be below 7.2V for 10 consecutive seconds (1Hz checks) + - Invalid voltage readings also count as faults + - AutoPayloadModeExit event emitted with voltage value (0.0V if invalid) + - Mode changes to NORMAL + - All 8 load switches turned OFF (more aggressive than manual exit) + """ + # Enter payload mode + proves_send_and_assert_command(fprime_test_api, f"{component}.ENTER_PAYLOAD_MODE") + time.sleep(2) + + # Verify in payload mode + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 3, "Should be in PAYLOAD_MODE" + + # --- MANUAL STEP: Lower voltage below 7.2V for 10+ seconds --- + # Wait for automatic exit (would need voltage control here) + time.sleep(15) + + # Verify AutoPayloadModeExit event + fprime_test_api.assert_event(f"{component}.AutoPayloadModeExit", timeout=5) + + # Verify mode is NORMAL (2) + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 2, "Should be in NORMAL mode after auto exit" + + # Verify ALL switches are OFF (aggressive behavior) + proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) + for channel in all_load_switch_channels: + value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() + if isinstance(value, str): + assert value.upper() == "OFF", f"{channel} should be OFF after auto exit" + else: + assert value == 0, f"{channel} should be OFF after auto exit" From 0e6cfed510684d813e29ec80fffe01d5170575fb Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Sat, 29 Nov 2025 10:30:15 -0800 Subject: [PATCH 13/18] fix review feedback --- .../Components/ModeManager/ModeManager.cpp | 23 ++++- .../Components/ModeManager/ModeManager.hpp | 3 + .../Components/ModeManager/docs/sdd.md | 2 +- .../test/int/payload_mode_test.py | 90 ++++++++++++++----- Makefile | 2 +- 5 files changed, 92 insertions(+), 28 deletions(-) diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp index 783e48b8..a9903a74 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp @@ -23,7 +23,16 @@ ModeManager ::ModeManager(const char* const compName) m_safeModeEntryCount(0), m_payloadModeEntryCount(0), m_runCounter(0), - m_lowVoltageCounter(0) {} + m_lowVoltageCounter(0) { + // Compile-time verification that internal SystemMode enum matches FPP-generated enum + // This prevents silent mismatches when casting between enum types + static_assert(static_cast(SystemMode::SAFE_MODE) == static_cast(Components::SystemMode::SAFE_MODE), + "Internal SAFE_MODE value must match FPP enum"); + static_assert(static_cast(SystemMode::NORMAL) == static_cast(Components::SystemMode::NORMAL), + "Internal NORMAL value must match FPP enum"); + static_assert(static_cast(SystemMode::PAYLOAD_MODE) == static_cast(Components::SystemMode::PAYLOAD_MODE), + "Internal PAYLOAD_MODE value must match FPP enum"); +} ModeManager ::~ModeManager() {} @@ -40,6 +49,12 @@ void ModeManager ::run_handler(FwIndexType portNum, U32 context) { // Increment run counter (1Hz tick counter) this->m_runCounter++; + // Low-voltage protection for payload mode: + // - Debounce: Requires 10 consecutive fault readings (at 1Hz) to avoid spurious triggers + // from transient voltage dips during load switching or sensor noise + // - Invalid readings: Treated as faults (fail-safe) because sensor failure during payload + // operation could mask a real brownout condition + // - Threshold: 7.2V chosen as minimum safe operating voltage for payload components // Check for low voltage fault when in PAYLOAD_MODE if (this->m_mode == SystemMode::PAYLOAD_MODE) { bool valid = false; @@ -274,7 +289,7 @@ void ModeManager ::enterSafeMode(const char* reasonOverride) { // Build reason string Fw::LogStringArg reasonStr; - char reasonBuf[100]; + char reasonBuf[REASON_STRING_SIZE]; if (reasonOverride != nullptr) { reasonStr = reasonOverride; } else { @@ -331,7 +346,7 @@ void ModeManager ::enterPayloadMode(const char* reasonOverride) { // Build reason string Fw::LogStringArg reasonStr; - char reasonBuf[100]; + char reasonBuf[REASON_STRING_SIZE]; if (reasonOverride != nullptr) { reasonStr = reasonOverride; } else { @@ -363,6 +378,7 @@ void ModeManager ::enterPayloadMode(const char* reasonOverride) { void ModeManager ::exitPayloadMode() { // Transition back to normal mode (manual exit) this->m_mode = SystemMode::NORMAL; + this->m_lowVoltageCounter = 0; // Reset low voltage counter on mode exit this->log_ACTIVITY_HI_ExitingPayloadMode(); @@ -390,6 +406,7 @@ void ModeManager ::exitPayloadModeAutomatic(F32 voltage) { // Automatic exit from payload mode due to fault condition (e.g., low voltage) // More aggressive than manual exit - turns off ALL switches this->m_mode = SystemMode::NORMAL; + this->m_lowVoltageCounter = 0; // Reset low voltage counter on mode exit this->log_WARNING_HI_AutoPayloadModeExit(voltage); diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp index 1d3e65c8..5674cd71 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp @@ -154,6 +154,9 @@ class ModeManager : public ModeManagerComponentBase { // Voltage threshold constants for payload mode protection static constexpr F32 LOW_VOLTAGE_THRESHOLD = 7.2f; //!< Voltage threshold in volts static constexpr U32 LOW_VOLTAGE_DEBOUNCE_SECONDS = 10; //!< Consecutive seconds below threshold + + // Buffer size for reason strings (must match FPP string size definitions) + static constexpr FwSizeType REASON_STRING_SIZE = 100; //!< Matches FPP reason: string size 100 }; } // namespace Components diff --git a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md index 0ef25ac8..f1536064 100644 --- a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md +++ b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md @@ -446,7 +446,7 @@ Mode transitions follow a +1/-1 sequential pattern: SAFE_MODE(1) ↔ NORMAL(2) ## Change Log | Date | Description | |---|---| -| 2026-11-29 | Added low-voltage monitoring with debounced automatic payload exit, AutoPayloadModeExit event, and documentation/tests for aggressive load shed and manual exit power state | +| 2025-11-29 | Added low-voltage monitoring with debounced automatic payload exit, AutoPayloadModeExit event, and documentation/tests for aggressive load shed and manual exit power state | | 2025-11-26 | Reordered enum values (SAFE=1, NORMAL=2, PAYLOAD=3) for sequential +1/-1 transitions; FORCE_SAFE_MODE now rejected from payload mode | | 2025-11-26 | Removed forcePayloadMode port - payload mode now only entered via ENTER_PAYLOAD_MODE ground command | | 2025-11-25 | Added PAYLOAD_MODE (commands, events, telemetry, persistence, payload load switch control) and documented payload integration tests | diff --git a/FprimeZephyrReference/test/int/payload_mode_test.py b/FprimeZephyrReference/test/int/payload_mode_test.py index 29ff77c8..c7be87e8 100644 --- a/FprimeZephyrReference/test/int/payload_mode_test.py +++ b/FprimeZephyrReference/test/int/payload_mode_test.py @@ -48,6 +48,14 @@ "ReferenceDeployment.payloadBatteryLoadSwitch.IsOn", ] +# Telemetry packet IDs for CdhCore.tlmSend.SEND_PKT command +MODE_TELEMETRY_PACKET_ID = "1" # ModeManager telemetry (CurrentMode, etc.) +VOLTAGE_TELEMETRY_PACKET_ID = "2" # INA219 voltage telemetry +LOAD_SWITCH_TELEMETRY_PACKET_ID = "9" # Load switch telemetry (IsOn states) + +# Voltage threshold for payload mode auto-exit (must match LOW_VOLTAGE_THRESHOLD in ModeManager.hpp) +LOW_VOLTAGE_THRESHOLD = 7.2 # Volts + @pytest.fixture(autouse=True) def setup_and_teardown(fprime_test_api: IntegrationTestAPI, start_gds): @@ -112,14 +120,18 @@ def test_payload_01_enter_exit_payload_mode( time.sleep(2) # Verify mode is PAYLOAD_MODE (3) - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) assert mode_result.get_val() == 3, "Should be in PAYLOAD_MODE" # Verify payload load switches are ON - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [LOAD_SWITCH_TELEMETRY_PACKET_ID] + ) for channel in payload_load_switch_channels: value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() if isinstance(value, str): @@ -137,14 +149,18 @@ def test_payload_01_enter_exit_payload_mode( time.sleep(2) # Verify mode is NORMAL (2) - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) assert mode_result.get_val() == 2, "Should be in NORMAL mode" # Verify payload load switches are OFF in NORMAL mode - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [LOAD_SWITCH_TELEMETRY_PACKET_ID] + ) for channel in payload_load_switch_channels: value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() if isinstance(value, str): @@ -165,7 +181,9 @@ def test_payload_02_cannot_enter_from_safe_mode( time.sleep(2) # Verify in safe mode - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) @@ -182,7 +200,9 @@ def test_payload_02_cannot_enter_from_safe_mode( fprime_test_api.assert_event(f"{component}.CommandValidationFailed", timeout=3) # Verify still in safe mode - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) @@ -205,7 +225,9 @@ def test_payload_03_safe_mode_rejected_from_payload( time.sleep(2) # Verify in payload mode - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) @@ -220,7 +242,9 @@ def test_payload_03_safe_mode_rejected_from_payload( fprime_test_api.assert_event(f"{component}.CommandValidationFailed", timeout=3) # Verify still in payload mode - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) @@ -237,7 +261,9 @@ def test_payload_04_state_persists(fprime_test_api: IntegrationTestAPI, start_gd time.sleep(2) # Verify mode is saved as PAYLOAD_MODE (3) - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) @@ -265,7 +291,9 @@ def test_payload_05_manual_exit_face_switches_remain_on( time.sleep(2) # Verify all switches are ON in payload mode - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [LOAD_SWITCH_TELEMETRY_PACKET_ID] + ) for channel in all_load_switch_channels: value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() if isinstance(value, str): @@ -282,14 +310,18 @@ def test_payload_05_manual_exit_face_switches_remain_on( time.sleep(2) # Verify mode is NORMAL (2) - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) assert mode_result.get_val() == 2, "Should be in NORMAL mode" # Verify face switches (0-5) are still ON - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [LOAD_SWITCH_TELEMETRY_PACKET_ID] + ) for channel in face_load_switch_channels: value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() if isinstance(value, str): @@ -327,29 +359,35 @@ def test_payload_06_voltage_monitoring_active( time.sleep(2) # Verify in payload mode - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) assert mode_result.get_val() == 3, "Should be in PAYLOAD_MODE" # Verify system voltage telemetry is available and valid - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["2"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [VOLTAGE_TELEMETRY_PACKET_ID] + ) voltage_result: ChData = fprime_test_api.assert_telemetry( "ReferenceDeployment.ina219SysManager.Voltage", timeout=5 ) voltage = voltage_result.get_val() assert voltage > 0, f"Voltage should be positive, got {voltage}" - # Note: We don't assert voltage > 7.2V because board supply can fluctuate. + # Note: We don't assert voltage > threshold because board supply can fluctuate. # If voltage is actually low, auto-exit is correct behavior. - print(f"Current system voltage: {voltage}V (threshold: 7.2V)") + print(f"Current system voltage: {voltage}V (threshold: {LOW_VOLTAGE_THRESHOLD}V)") - # Wait 5 seconds - time.sleep(5) + # Wait 11 seconds + time.sleep(11) # Check final mode - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) @@ -361,7 +399,7 @@ def test_payload_06_voltage_monitoring_active( e for e in events if "AutoPayloadModeExit" in str(e.get_template().get_name()) ] - if voltage >= 7.2: + if voltage >= LOW_VOLTAGE_THRESHOLD: # Voltage was healthy - should remain in payload mode assert final_mode == 3, "Should still be in PAYLOAD_MODE (no false auto-exit)" assert len(auto_exit_events) == 0, ( @@ -401,7 +439,9 @@ def test_payload_07_auto_exit_low_voltage( time.sleep(2) # Verify in payload mode - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) @@ -415,14 +455,18 @@ def test_payload_07_auto_exit_low_voltage( fprime_test_api.assert_event(f"{component}.AutoPayloadModeExit", timeout=5) # Verify mode is NORMAL (2) - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["1"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) mode_result: ChData = fprime_test_api.assert_telemetry( f"{component}.CurrentMode", timeout=5 ) assert mode_result.get_val() == 2, "Should be in NORMAL mode after auto exit" # Verify ALL switches are OFF (aggressive behavior) - proves_send_and_assert_command(fprime_test_api, "CdhCore.tlmSend.SEND_PKT", ["9"]) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [LOAD_SWITCH_TELEMETRY_PACKET_ID] + ) for channel in all_load_switch_channels: value = fprime_test_api.assert_telemetry(channel, timeout=5).get_val() if isinstance(value, str): diff --git a/Makefile b/Makefile index 9afd5561..6354c0b9 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ test-integration: uv ## Run integration tests (set TEST= to run a *.py) TARGET="FprimeZephyrReference/test/int/$(TEST)" ;; \ *) TARGET="FprimeZephyrReference/test/int/$(TEST).py" ;; \ esac; \ - [ -e "$$TARGET" ] || { echo "Specified test file $$TARGET not found"; exit 1; }; \ + [ -e "$$TARGET" ] || { echo "Error: Test file $$TARGET not found" >&2; exit 1; }; \ fi; \ echo "Running integration tests at $$TARGET"; \ $(UV_RUN) pytest $$TARGET --deployment build-artifacts/zephyr/fprime-zephyr-deployment From 79b620203452d2264787bae90d867641c7c52c32 Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Sat, 29 Nov 2025 10:41:44 -0800 Subject: [PATCH 14/18] improve based on review --- .../Components/ModeManager/ModeManager.cpp | 3 +++ .../test/int/payload_mode_test.py | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp index a9903a74..7e816f31 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp @@ -162,6 +162,9 @@ void ModeManager ::ENTER_PAYLOAD_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq // Check if already in payload mode if (this->m_mode == SystemMode::PAYLOAD_MODE) { // Already in payload mode - success (idempotent) + // Reset low voltage counter to ensure consistent state (operator may be + // re-sending command intentionally to reset any accumulated fault count) + this->m_lowVoltageCounter = 0; this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::OK); return; } diff --git a/FprimeZephyrReference/test/int/payload_mode_test.py b/FprimeZephyrReference/test/int/payload_mode_test.py index c7be87e8..0b506ec0 100644 --- a/FprimeZephyrReference/test/int/payload_mode_test.py +++ b/FprimeZephyrReference/test/int/payload_mode_test.py @@ -53,8 +53,11 @@ VOLTAGE_TELEMETRY_PACKET_ID = "2" # INA219 voltage telemetry LOAD_SWITCH_TELEMETRY_PACKET_ID = "9" # Load switch telemetry (IsOn states) -# Voltage threshold for payload mode auto-exit (must match LOW_VOLTAGE_THRESHOLD in ModeManager.hpp) +# Voltage threshold for payload mode auto-exit (must match ModeManager.hpp constants) LOW_VOLTAGE_THRESHOLD = 7.2 # Volts +LOW_VOLTAGE_DEBOUNCE_SECONDS = ( + 10 # Consecutive seconds below threshold before auto-exit +) @pytest.fixture(autouse=True) @@ -348,7 +351,7 @@ def test_payload_06_voltage_monitoring_active( Verifies: - System voltage telemetry is available - Voltage is above threshold (no auto-exit should occur) - - Mode remains in PAYLOAD_MODE after 5 seconds (no false triggers) + - Mode remains in PAYLOAD_MODE after 11 seconds (no false triggers) This test verifies the infrastructure works without requiring manual voltage control. The actual low-voltage exit is tested @@ -381,8 +384,8 @@ def test_payload_06_voltage_monitoring_active( # If voltage is actually low, auto-exit is correct behavior. print(f"Current system voltage: {voltage}V (threshold: {LOW_VOLTAGE_THRESHOLD}V)") - # Wait 11 seconds - time.sleep(11) + # Wait for debounce period + 1 second margin to verify no false triggers + time.sleep(LOW_VOLTAGE_DEBOUNCE_SECONDS + 1) # Check final mode proves_send_and_assert_command( @@ -422,13 +425,13 @@ def test_payload_07_auto_exit_low_voltage( MANUAL TEST - requires ability to control battery voltage: 1. Enter payload mode - 2. Lower battery voltage below 7.2V for 10+ seconds + 2. Lower battery voltage below LOW_VOLTAGE_THRESHOLD for LOW_VOLTAGE_DEBOUNCE_SECONDS 3. Verify AutoPayloadModeExit event is emitted 4. Verify mode returns to NORMAL (2) 5. Verify ALL switches (faces 0-5 AND payload 6-7) are OFF Expected behavior: - - Voltage must be below 7.2V for 10 consecutive seconds (1Hz checks) + - Voltage must be below threshold for debounce period (1Hz checks) - Invalid voltage readings also count as faults - AutoPayloadModeExit event emitted with voltage value (0.0V if invalid) - Mode changes to NORMAL @@ -447,9 +450,9 @@ def test_payload_07_auto_exit_low_voltage( ) assert mode_result.get_val() == 3, "Should be in PAYLOAD_MODE" - # --- MANUAL STEP: Lower voltage below 7.2V for 10+ seconds --- + # --- MANUAL STEP: Lower voltage below threshold for debounce period --- # Wait for automatic exit (would need voltage control here) - time.sleep(15) + time.sleep(LOW_VOLTAGE_DEBOUNCE_SECONDS + 5) # Extra margin for manual test # Verify AutoPayloadModeExit event fprime_test_api.assert_event(f"{component}.AutoPayloadModeExit", timeout=5) From f19e98e9a7bdaf6b97a6ef716a27d6cbe778cdaa Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Sat, 29 Nov 2025 10:50:54 -0800 Subject: [PATCH 15/18] improve test logging --- .../test/int/payload_mode_test.py | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/FprimeZephyrReference/test/int/payload_mode_test.py b/FprimeZephyrReference/test/int/payload_mode_test.py index 0b506ec0..2652cb3e 100644 --- a/FprimeZephyrReference/test/int/payload_mode_test.py +++ b/FprimeZephyrReference/test/int/payload_mode_test.py @@ -17,6 +17,7 @@ Mode enum values: SAFE_MODE=1, NORMAL=2, PAYLOAD_MODE=3 """ +import logging import time import pytest @@ -24,6 +25,8 @@ from fprime_gds.common.data_types.ch_data import ChData from fprime_gds.common.testing_fw.api import IntegrationTestAPI +logger = logging.getLogger(__name__) + component = "ReferenceDeployment.modeManager" face_load_switch_channels = [ "ReferenceDeployment.face4LoadSwitch.IsOn", @@ -65,18 +68,21 @@ def setup_and_teardown(fprime_test_api: IntegrationTestAPI, start_gds): """ Setup before each test and cleanup after. Ensures clean state by exiting any mode back to NORMAL. + + Note: Exit commands fail with VALIDATION_ERROR if not in that mode, + which is expected behavior. We log these for debugging but don't fail. """ # Setup: Try to get to a clean NORMAL state try: proves_send_and_assert_command(fprime_test_api, f"{component}.EXIT_SAFE_MODE") - except Exception: - pass + except Exception as e: + logger.debug(f"Setup: EXIT_SAFE_MODE skipped (not in safe mode): {e}") try: proves_send_and_assert_command( fprime_test_api, f"{component}.EXIT_PAYLOAD_MODE" ) - except Exception: - pass + except Exception as e: + logger.debug(f"Setup: EXIT_PAYLOAD_MODE skipped (not in payload mode): {e}") # Clear event and telemetry history before test fprime_test_api.clear_histories() @@ -86,14 +92,14 @@ def setup_and_teardown(fprime_test_api: IntegrationTestAPI, start_gds): # Teardown: Return to clean NORMAL state for next test try: proves_send_and_assert_command(fprime_test_api, f"{component}.EXIT_SAFE_MODE") - except Exception: - pass + except Exception as e: + logger.debug(f"Teardown: EXIT_SAFE_MODE skipped (not in safe mode): {e}") try: proves_send_and_assert_command( fprime_test_api, f"{component}.EXIT_PAYLOAD_MODE" ) - except Exception: - pass + except Exception as e: + logger.debug(f"Teardown: EXIT_PAYLOAD_MODE skipped (not in payload mode): {e}") # ============================================================================== @@ -351,7 +357,7 @@ def test_payload_06_voltage_monitoring_active( Verifies: - System voltage telemetry is available - Voltage is above threshold (no auto-exit should occur) - - Mode remains in PAYLOAD_MODE after 11 seconds (no false triggers) + - Mode remains in PAYLOAD_MODE after debounce period + 1 second (11 seconds total, no false triggers) This test verifies the infrastructure works without requiring manual voltage control. The actual low-voltage exit is tested From 54112b987eb3de9a12e8f233817ed3976379b5f0 Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:00:25 -0800 Subject: [PATCH 16/18] improve test --- .../test/int/payload_mode_test.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/FprimeZephyrReference/test/int/payload_mode_test.py b/FprimeZephyrReference/test/int/payload_mode_test.py index 2652cb3e..9ae3c272 100644 --- a/FprimeZephyrReference/test/int/payload_mode_test.py +++ b/FprimeZephyrReference/test/int/payload_mode_test.py @@ -388,11 +388,23 @@ def test_payload_06_voltage_monitoring_active( # Note: We don't assert voltage > threshold because board supply can fluctuate. # If voltage is actually low, auto-exit is correct behavior. - print(f"Current system voltage: {voltage}V (threshold: {LOW_VOLTAGE_THRESHOLD}V)") + print(f"Initial system voltage: {voltage}V (threshold: {LOW_VOLTAGE_THRESHOLD}V)") # Wait for debounce period + 1 second margin to verify no false triggers time.sleep(LOW_VOLTAGE_DEBOUNCE_SECONDS + 1) + # Sample voltage again after the wait - voltage may have changed during the period + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [VOLTAGE_TELEMETRY_PACKET_ID] + ) + voltage_result_post: ChData = fprime_test_api.assert_telemetry( + "ReferenceDeployment.ina219SysManager.Voltage", timeout=5 + ) + voltage_post = voltage_result_post.get_val() + print( + f"Post-wait system voltage: {voltage_post}V (threshold: {LOW_VOLTAGE_THRESHOLD}V)" + ) + # Check final mode proves_send_and_assert_command( fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] @@ -408,14 +420,15 @@ def test_payload_06_voltage_monitoring_active( e for e in events if "AutoPayloadModeExit" in str(e.get_template().get_name()) ] - if voltage >= LOW_VOLTAGE_THRESHOLD: - # Voltage was healthy - should remain in payload mode + # Use post-wait voltage for assertion (voltage may have changed during wait) + if voltage_post >= LOW_VOLTAGE_THRESHOLD: + # Voltage is healthy now - should remain in payload mode assert final_mode == 3, "Should still be in PAYLOAD_MODE (no false auto-exit)" assert len(auto_exit_events) == 0, ( "Should not have triggered AutoPayloadModeExit" ) else: - # Voltage was low - auto-exit may have triggered (correct behavior) + # Voltage is low - auto-exit may have triggered (correct behavior) print( "Note: Voltage was below threshold. Auto-exit may have occurred (expected)." ) From 28a55229c79d13983c68362efc33bc79173bbe6a Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Sat, 29 Nov 2025 11:50:38 -0800 Subject: [PATCH 17/18] init implementation --- .../Components/ModeManager/ModeManager.cpp | 196 +++++++++++++++--- .../Components/ModeManager/ModeManager.fpp | 40 ++++ .../Components/ModeManager/ModeManager.hpp | 34 ++- .../Components/ResetManager/ResetManager.cpp | 12 ++ .../Components/ResetManager/ResetManager.fpp | 3 + .../Top/ReferenceDeploymentPackets.fppi | 1 + .../ReferenceDeployment/Top/topology.fpp | 4 + 7 files changed, 258 insertions(+), 32 deletions(-) diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp index 7e816f31..2b41f768 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp @@ -23,7 +23,10 @@ ModeManager ::ModeManager(const char* const compName) m_safeModeEntryCount(0), m_payloadModeEntryCount(0), m_runCounter(0), - m_lowVoltageCounter(0) { + m_lowVoltageCounter(0), + m_safeModeReason(Components::SafeModeReason::NONE), + m_safeModeVoltageCounter(0), + m_recoveryVoltageCounter(0) { // Compile-time verification that internal SystemMode enum matches FPP-generated enum // This prevents silent mismatches when casting between enum types static_assert(static_cast(SystemMode::SAFE_MODE) == static_cast(Components::SystemMode::SAFE_MODE), @@ -49,19 +52,18 @@ void ModeManager ::run_handler(FwIndexType portNum, U32 context) { // Increment run counter (1Hz tick counter) this->m_runCounter++; - // Low-voltage protection for payload mode: - // - Debounce: Requires 10 consecutive fault readings (at 1Hz) to avoid spurious triggers - // from transient voltage dips during load switching or sensor noise - // - Invalid readings: Treated as faults (fail-safe) because sensor failure during payload - // operation could mask a real brownout condition - // - Threshold: 7.2V chosen as minimum safe operating voltage for payload components - // Check for low voltage fault when in PAYLOAD_MODE - if (this->m_mode == SystemMode::PAYLOAD_MODE) { - bool valid = false; - F32 voltage = this->getCurrentVoltage(valid); + // Get current voltage (used by multiple mode checks) + bool valid = false; + F32 voltage = this->getCurrentVoltage(valid); - // Treat both low voltage AND invalid readings as fault conditions - // Invalid readings (sensor failure/disconnection) should not mask brownout protection + // Mode-specific voltage monitoring + if (this->m_mode == SystemMode::PAYLOAD_MODE) { + // Low-voltage protection for payload mode: + // - Debounce: Requires 10 consecutive fault readings (at 1Hz) to avoid spurious triggers + // from transient voltage dips during load switching or sensor noise + // - Invalid readings: Treated as faults (fail-safe) because sensor failure during payload + // operation could mask a real brownout condition + // - Threshold: 7.2V chosen as minimum safe operating voltage for payload components bool isFault = !valid || (voltage < LOW_VOLTAGE_THRESHOLD); if (isFault) { @@ -77,13 +79,64 @@ void ModeManager ::run_handler(FwIndexType portNum, U32 context) { // Voltage OK and valid - reset counter this->m_lowVoltageCounter = 0; } - } else { - // Not in payload mode - reset counter + + // Reset other counters when in payload mode + this->m_safeModeVoltageCounter = 0; + this->m_recoveryVoltageCounter = 0; + + } else if (this->m_mode == SystemMode::NORMAL) { + // Low-voltage protection for normal mode -> safe mode entry: + // - Threshold: 6.7V triggers safe mode entry + // - Debounce: 10 consecutive seconds below threshold + bool isFault = !valid || (voltage < SAFE_MODE_ENTRY_VOLTAGE); + + if (isFault) { + this->m_safeModeVoltageCounter++; + + if (this->m_safeModeVoltageCounter >= SAFE_MODE_DEBOUNCE_SECONDS) { + // Trigger automatic entry into safe mode + this->log_WARNING_HI_AutoSafeModeEntry(Components::SafeModeReason::LOW_BATTERY, valid ? voltage : 0.0f); + this->enterSafeMode(Components::SafeModeReason::LOW_BATTERY); + this->m_safeModeVoltageCounter = 0; // Reset counter + } + } else { + // Voltage OK and valid - reset counter + this->m_safeModeVoltageCounter = 0; + } + + // Reset other counters when in normal mode this->m_lowVoltageCounter = 0; + this->m_recoveryVoltageCounter = 0; + + } else if (this->m_mode == SystemMode::SAFE_MODE) { + // Auto-recovery from safe mode (only if reason is LOW_BATTERY): + // - Threshold: Voltage > 8.0V triggers auto-recovery + // - Debounce: 10 consecutive seconds above threshold + // - SYSTEM_FAULT or GROUND_COMMAND require manual EXIT_SAFE_MODE command + if (this->m_safeModeReason == Components::SafeModeReason::LOW_BATTERY) { + if (valid && voltage > SAFE_MODE_RECOVERY_VOLTAGE) { + this->m_recoveryVoltageCounter++; + + if (this->m_recoveryVoltageCounter >= SAFE_MODE_DEBOUNCE_SECONDS) { + // Trigger automatic exit from safe mode + this->exitSafeModeAutomatic(voltage); + this->m_recoveryVoltageCounter = 0; // Reset counter + } + } else { + // Voltage not recovered yet - reset counter + this->m_recoveryVoltageCounter = 0; + } + } + // Note: If reason is SYSTEM_FAULT or GROUND_COMMAND, no auto-recovery - wait for manual command + + // Reset other counters when in safe mode + this->m_lowVoltageCounter = 0; + this->m_safeModeVoltageCounter = 0; } // Update telemetry this->tlmWrite_CurrentMode(static_cast(this->m_mode)); + this->tlmWrite_CurrentSafeModeReason(this->m_safeModeReason); } void ModeManager ::forceSafeMode_handler(FwIndexType portNum) { @@ -91,7 +144,7 @@ void ModeManager ::forceSafeMode_handler(FwIndexType portNum) { // Only allowed from NORMAL (sequential +1/-1 transitions) if (this->m_mode == SystemMode::NORMAL) { this->log_WARNING_HI_ExternalFaultDetected(); - this->enterSafeMode("External component request"); + this->enterSafeMode(Components::SafeModeReason::EXTERNAL_REQUEST); } // Note: Request ignored if in PAYLOAD_MODE or already in SAFE_MODE } @@ -102,6 +155,31 @@ Components::SystemMode ModeManager ::getMode_handler(FwIndexType portNum) { return static_cast(this->m_mode); } +void ModeManager ::prepareForReboot_handler(FwIndexType portNum) { + // Called before intentional reboot to set clean shutdown flag + // This allows us to detect unintended reboots on next startup + this->log_ACTIVITY_HI_PreparingForReboot(); + + // Save state with clean shutdown flag set + // We directly write to file here to ensure the flag is persisted + Os::File file; + Os::File::Status status = file.open(STATE_FILE_PATH, Os::File::OPEN_CREATE); + + if (status == Os::File::OP_OK) { + PersistentState state; + state.mode = static_cast(this->m_mode); + state.safeModeEntryCount = this->m_safeModeEntryCount; + state.payloadModeEntryCount = this->m_payloadModeEntryCount; + state.safeModeReason = static_cast(this->m_safeModeReason); + state.cleanShutdown = 1; // Mark as clean shutdown + + FwSizeType bytesToWrite = sizeof(PersistentState); + FwSizeType bytesWritten = bytesToWrite; + (void)file.write(reinterpret_cast(&state), bytesWritten, Os::File::WaitType::WAIT); + file.close(); + } +} + // ---------------------------------------------------------------------- // Handler implementations for commands // ---------------------------------------------------------------------- @@ -126,7 +204,7 @@ void ModeManager ::FORCE_SAFE_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) { // Enter safe mode from NORMAL this->log_ACTIVITY_HI_ManualSafeModeEntry(); - this->enterSafeMode("Ground command"); + this->enterSafeMode(Components::SafeModeReason::GROUND_COMMAND); this->cmdResponse_out(opCode, cmdSeq, Fw::CmdResponse::OK); } @@ -200,6 +278,8 @@ void ModeManager ::loadState() { Os::File file; Os::File::Status status = file.open(STATE_FILE_PATH, Os::File::OPEN_READ); + bool unintendedReboot = false; + if (status == Os::File::OP_OK) { PersistentState state; FwSizeType size = sizeof(PersistentState); @@ -214,6 +294,15 @@ void ModeManager ::loadState() { this->m_mode = static_cast(state.mode); this->m_safeModeEntryCount = state.safeModeEntryCount; this->m_payloadModeEntryCount = state.payloadModeEntryCount; + this->m_safeModeReason = static_cast(state.safeModeReason); + + // Check for unintended reboot: + // If cleanShutdown flag is NOT set (0) and we were in NORMAL or PAYLOAD mode, + // this indicates an unintended reboot (crash, watchdog, power loss, etc.) + if (state.cleanShutdown == 0 && + (this->m_mode == SystemMode::NORMAL || this->m_mode == SystemMode::PAYLOAD_MODE)) { + unintendedReboot = true; + } // Restore physical hardware state to match loaded mode if (this->m_mode == SystemMode::SAFE_MODE) { @@ -240,6 +329,7 @@ void ModeManager ::loadState() { this->m_mode = SystemMode::NORMAL; this->m_safeModeEntryCount = 0; this->m_payloadModeEntryCount = 0; + this->m_safeModeReason = Components::SafeModeReason::NONE; this->turnOnComponents(); } } @@ -250,8 +340,21 @@ void ModeManager ::loadState() { this->m_mode = SystemMode::NORMAL; this->m_safeModeEntryCount = 0; this->m_payloadModeEntryCount = 0; + this->m_safeModeReason = Components::SafeModeReason::NONE; this->turnOnComponents(); } + + // Handle unintended reboot detection AFTER basic state restoration + // This ensures we enter safe mode due to system fault + if (unintendedReboot) { + this->log_WARNING_HI_UnintendedRebootDetected(); + this->enterSafeMode(Components::SafeModeReason::SYSTEM_FAULT); + } + + // Clear clean shutdown flag for next boot detection + // This ensures that if the system crashes before the next intentional reboot, + // we'll detect it as an unintended reboot + this->saveState(); } void ModeManager ::saveState() { @@ -269,6 +372,8 @@ void ModeManager ::saveState() { state.mode = static_cast(this->m_mode); state.safeModeEntryCount = this->m_safeModeEntryCount; state.payloadModeEntryCount = this->m_payloadModeEntryCount; + state.safeModeReason = static_cast(this->m_safeModeReason); + state.cleanShutdown = 0; // Default to unclean - only prepareForReboot sets this to 1 FwSizeType bytesToWrite = sizeof(PersistentState); FwSizeType bytesWritten = bytesToWrite; @@ -285,19 +390,30 @@ void ModeManager ::saveState() { file.close(); } -void ModeManager ::enterSafeMode(const char* reasonOverride) { +void ModeManager ::enterSafeMode(Components::SafeModeReason reason) { // Transition to safe mode this->m_mode = SystemMode::SAFE_MODE; this->m_safeModeEntryCount++; + this->m_safeModeReason = reason; - // Build reason string + // Build reason string for event log Fw::LogStringArg reasonStr; - char reasonBuf[REASON_STRING_SIZE]; - if (reasonOverride != nullptr) { - reasonStr = reasonOverride; - } else { - snprintf(reasonBuf, sizeof(reasonBuf), "Unknown"); - reasonStr = reasonBuf; + switch (reason) { + case Components::SafeModeReason::LOW_BATTERY: + reasonStr = "Low battery voltage"; + break; + case Components::SafeModeReason::SYSTEM_FAULT: + reasonStr = "System fault (unintended reboot)"; + break; + case Components::SafeModeReason::GROUND_COMMAND: + reasonStr = "Ground command"; + break; + case Components::SafeModeReason::EXTERNAL_REQUEST: + reasonStr = "External component request"; + break; + default: + reasonStr = "Unknown"; + break; } this->log_WARNING_HI_EnteringSafeMode(reasonStr); @@ -308,6 +424,7 @@ void ModeManager ::enterSafeMode(const char* reasonOverride) { // Update telemetry this->tlmWrite_CurrentMode(static_cast(this->m_mode)); this->tlmWrite_SafeModeEntryCount(this->m_safeModeEntryCount); + this->tlmWrite_CurrentSafeModeReason(this->m_safeModeReason); // Notify other components of mode change with new mode value if (this->isConnected_modeChanged_OutputPort(0)) { @@ -320,8 +437,9 @@ void ModeManager ::enterSafeMode(const char* reasonOverride) { } void ModeManager ::exitSafeMode() { - // Transition back to normal mode + // Transition back to normal mode (manual command) this->m_mode = SystemMode::NORMAL; + this->m_safeModeReason = Components::SafeModeReason::NONE; // Clear reason on exit this->log_ACTIVITY_HI_ExitingSafeMode(); @@ -330,6 +448,32 @@ void ModeManager ::exitSafeMode() { // Update telemetry this->tlmWrite_CurrentMode(static_cast(this->m_mode)); + this->tlmWrite_CurrentSafeModeReason(this->m_safeModeReason); + + // Notify other components of mode change with new mode value + if (this->isConnected_modeChanged_OutputPort(0)) { + Components::SystemMode fppMode = static_cast(this->m_mode); + this->modeChanged_out(0, fppMode); + } + + // Save state + this->saveState(); +} + +void ModeManager ::exitSafeModeAutomatic(F32 voltage) { + // Automatic exit from safe mode due to voltage recovery + // Only called when safe mode reason is LOW_BATTERY and voltage > 8.0V + this->m_mode = SystemMode::NORMAL; + this->m_safeModeReason = Components::SafeModeReason::NONE; // Clear reason on exit + + this->log_ACTIVITY_HI_AutoSafeModeExit(voltage); + + // Turn on components (restore normal operation) + this->turnOnComponents(); + + // Update telemetry + this->tlmWrite_CurrentMode(static_cast(this->m_mode)); + this->tlmWrite_CurrentSafeModeReason(this->m_safeModeReason); // Notify other components of mode change with new mode value if (this->isConnected_modeChanged_OutputPort(0)) { diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp index 8887b968..a4c522b6 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp @@ -7,6 +7,15 @@ module Components { PAYLOAD_MODE = 3 @< Payload mode with payload power and battery enabled } + @ Reason for entering safe mode (used for recovery decision logic) + enum SafeModeReason { + NONE = 0 @< Not in safe mode or reason cleared + LOW_BATTERY = 1 @< Entered due to low voltage condition + SYSTEM_FAULT = 2 @< Entered due to unintended reboot/system fault + GROUND_COMMAND = 3 @< Entered via ground command + EXTERNAL_REQUEST = 4 @< Entered via external component request + } + @ Port for notifying about mode changes port SystemModeChanged(mode: SystemMode) @@ -30,6 +39,9 @@ module Components { @ Port to query the current system mode sync input port getMode: Components.GetSystemMode + @ Port called before intentional reboot to set clean shutdown flag + sync input port prepareForReboot: Fw.Signal + # ---------------------------------------------------------------------- # Output Ports # ---------------------------------------------------------------------- @@ -132,6 +144,31 @@ module Components { severity warning low \ format "State persistence {} failed with status {}" + @ Event emitted when automatically entering safe mode due to low voltage + event AutoSafeModeEntry( + reason: SafeModeReason @< Reason for entering safe mode + voltage: F32 @< Voltage that triggered the entry (0 if N/A) + ) \ + severity warning high \ + format "AUTO SAFE MODE ENTRY: reason={} voltage={}V" + + @ Event emitted when automatically exiting safe mode due to voltage recovery + event AutoSafeModeExit( + voltage: F32 @< Voltage that triggered recovery + ) \ + severity activity high \ + format "AUTO SAFE MODE EXIT: Voltage recovered to {}V" + + @ Event emitted when unintended reboot is detected + event UnintendedRebootDetected() \ + severity warning high \ + format "UNINTENDED REBOOT DETECTED: Entering safe mode" + + @ Event emitted when preparing for intentional reboot + event PreparingForReboot() \ + severity activity high \ + format "Preparing for intentional reboot - setting clean shutdown flag" + # ---------------------------------------------------------------------- # Telemetry # ---------------------------------------------------------------------- @@ -145,6 +182,9 @@ module Components { @ Number of times payload mode has been entered telemetry PayloadModeEntryCount: U32 + @ Current safe mode reason (NONE if not in safe mode) + telemetry CurrentSafeModeReason: SafeModeReason + ############################################################################### # Standard AC Ports: Required for Channels, Events, Commands, and Parameters # diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp index 5674cd71..78133ea7 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.hpp @@ -56,6 +56,12 @@ class ModeManager : public ModeManagerComponentBase { Components::SystemMode getMode_handler(FwIndexType portNum //!< The port number ) override; + //! Handler implementation for prepareForReboot + //! + //! Port called before intentional reboot to set clean shutdown flag + void prepareForReboot_handler(FwIndexType portNum //!< The port number + ) override; + // ---------------------------------------------------------------------- // Handler implementations for commands // ---------------------------------------------------------------------- @@ -91,12 +97,16 @@ class ModeManager : public ModeManagerComponentBase { //! Save persistent state to file void saveState(); - //! Enter safe mode with optional reason override - void enterSafeMode(const char* reason = nullptr); + //! Enter safe mode with specified reason + void enterSafeMode(Components::SafeModeReason reason); - //! Exit safe mode + //! Exit safe mode (manual command) void exitSafeMode(); + //! Exit safe mode automatically due to voltage recovery + //! Only allowed when safe mode reason is LOW_BATTERY + void exitSafeModeAutomatic(F32 voltage); + //! Enter payload mode with optional reason override void enterPayloadMode(const char* reason = nullptr); @@ -132,11 +142,13 @@ class ModeManager : public ModeManagerComponentBase { //! System mode enumeration (values ordered for +1/-1 sequential transitions) enum class SystemMode : U8 { SAFE_MODE = 1, NORMAL = 2, PAYLOAD_MODE = 3 }; - //! Persistent state structure + //! Persistent state structure (v2: includes safe mode reason and clean shutdown flag) struct PersistentState { U8 mode; //!< Current mode (SystemMode) U32 safeModeEntryCount; //!< Number of times safe mode entered U32 payloadModeEntryCount; //!< Number of times payload mode entered + U8 safeModeReason; //!< Reason for current safe mode (SafeModeReason) + U8 cleanShutdown; //!< Flag indicating if last shutdown was intentional (1=clean, 0=unclean) }; // ---------------------------------------------------------------------- @@ -147,14 +159,24 @@ class ModeManager : public ModeManagerComponentBase { U32 m_safeModeEntryCount; //!< Counter for safe mode entries U32 m_payloadModeEntryCount; //!< Counter for payload mode entries U32 m_runCounter; //!< Counter for run handler calls (1Hz) - U32 m_lowVoltageCounter; //!< Counter for consecutive low voltage readings + U32 m_lowVoltageCounter; //!< Counter for consecutive low voltage readings (payload mode exit) + + // Safe mode specific state + Components::SafeModeReason m_safeModeReason; //!< Reason for current safe mode entry + U32 m_safeModeVoltageCounter; //!< Counter for consecutive low voltage readings (safe mode entry) + U32 m_recoveryVoltageCounter; //!< Counter for consecutive high voltage readings (safe mode exit) static constexpr const char* STATE_FILE_PATH = "/mode_state.bin"; //!< State file path // Voltage threshold constants for payload mode protection - static constexpr F32 LOW_VOLTAGE_THRESHOLD = 7.2f; //!< Voltage threshold in volts + static constexpr F32 LOW_VOLTAGE_THRESHOLD = 7.2f; //!< Voltage threshold for payload mode exit static constexpr U32 LOW_VOLTAGE_DEBOUNCE_SECONDS = 10; //!< Consecutive seconds below threshold + // Voltage threshold constants for safe mode entry/exit (Normal <-> Safe) + static constexpr F32 SAFE_MODE_ENTRY_VOLTAGE = 6.7f; //!< Voltage threshold for safe mode entry + static constexpr F32 SAFE_MODE_RECOVERY_VOLTAGE = 8.0f; //!< Voltage threshold for safe mode auto-recovery + static constexpr U32 SAFE_MODE_DEBOUNCE_SECONDS = 10; //!< Consecutive seconds for safe mode transitions + // Buffer size for reason strings (must match FPP string size definitions) static constexpr FwSizeType REASON_STRING_SIZE = 100; //!< Matches FPP reason: string size 100 }; diff --git a/FprimeZephyrReference/Components/ResetManager/ResetManager.cpp b/FprimeZephyrReference/Components/ResetManager/ResetManager.cpp index a4cb814a..b15bb362 100644 --- a/FprimeZephyrReference/Components/ResetManager/ResetManager.cpp +++ b/FprimeZephyrReference/Components/ResetManager/ResetManager.cpp @@ -56,6 +56,12 @@ void ResetManager ::handleColdReset() { // Log the cold reset event this->log_ACTIVITY_HI_INITIATE_COLD_RESET(); + // Notify ModeManager to set clean shutdown flag before rebooting + // This allows ModeManager to detect unintended reboots on next startup + if (this->isConnected_prepareForReboot_OutputPort(0)) { + this->prepareForReboot_out(0); + } + sys_reboot(SYS_REBOOT_COLD); } @@ -63,6 +69,12 @@ void ResetManager ::handleWarmReset() { // Log the warm reset event this->log_ACTIVITY_HI_INITIATE_WARM_RESET(); + // Notify ModeManager to set clean shutdown flag before rebooting + // This allows ModeManager to detect unintended reboots on next startup + if (this->isConnected_prepareForReboot_OutputPort(0)) { + this->prepareForReboot_out(0); + } + sys_reboot(SYS_REBOOT_WARM); } diff --git a/FprimeZephyrReference/Components/ResetManager/ResetManager.fpp b/FprimeZephyrReference/Components/ResetManager/ResetManager.fpp index 5ee3eb58..e73c4823 100644 --- a/FprimeZephyrReference/Components/ResetManager/ResetManager.fpp +++ b/FprimeZephyrReference/Components/ResetManager/ResetManager.fpp @@ -20,6 +20,9 @@ module Components { @ Port to invoke a warm reset sync input port warmReset: Fw.Signal + @ Port to notify ModeManager before reboot (sets clean shutdown flag) + output port prepareForReboot: Fw.Signal + ############################################################################### # Standard AC Ports: Required for Channels, Events, Commands, and Parameters # ############################################################################### diff --git a/FprimeZephyrReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi b/FprimeZephyrReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi index eaede325..5c78f69d 100644 --- a/FprimeZephyrReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi +++ b/FprimeZephyrReference/ReferenceDeployment/Top/ReferenceDeploymentPackets.fppi @@ -14,6 +14,7 @@ telemetry packets ReferenceDeploymentPackets { ReferenceDeployment.modeManager.CurrentMode ReferenceDeployment.modeManager.SafeModeEntryCount ReferenceDeployment.modeManager.PayloadModeEntryCount + ReferenceDeployment.modeManager.CurrentSafeModeReason } packet HealthWarnings id 2 group 1 { diff --git a/FprimeZephyrReference/ReferenceDeployment/Top/topology.fpp b/FprimeZephyrReference/ReferenceDeployment/Top/topology.fpp index 0390534e..adb69798 100644 --- a/FprimeZephyrReference/ReferenceDeployment/Top/topology.fpp +++ b/FprimeZephyrReference/ReferenceDeployment/Top/topology.fpp @@ -233,6 +233,10 @@ module ReferenceDeployment { # Voltage monitoring from system power manager modeManager.voltageGet -> ina219SysManager.voltageGet + # Connection for clean shutdown notification from ResetManager + # Allows ModeManager to detect unintended reboots + resetManager.prepareForReboot -> modeManager.prepareForReboot + # Load switch control connections # The load switch index mapping below is non-sequential because it matches the physical board layout and wiring order. # This ordering ensures that software indices correspond to the hardware arrangement for easier maintenance and debugging. From ef87174f4443476d8d0c78590e8235ea461fde0c Mon Sep 17 00:00:00 2001 From: "Sam S. Yu" <25761223+yudataguy@users.noreply.github.com> Date: Sat, 29 Nov 2025 15:56:04 -0800 Subject: [PATCH 18/18] update safe to normal mode auto switch --- .../Components/ModeManager/ModeManager.cpp | 59 +- .../Components/ModeManager/ModeManager.fpp | 6 + .../Components/ModeManager/docs/sdd.md | 247 ++++++- .../test/int/safe_mode_test.py | 671 ++++++++++++++++++ 4 files changed, 950 insertions(+), 33 deletions(-) create mode 100644 FprimeZephyrReference/test/int/safe_mode_test.py diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp index 2b41f768..c1fd8c84 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.cpp @@ -145,8 +145,12 @@ void ModeManager ::forceSafeMode_handler(FwIndexType portNum) { if (this->m_mode == SystemMode::NORMAL) { this->log_WARNING_HI_ExternalFaultDetected(); this->enterSafeMode(Components::SafeModeReason::EXTERNAL_REQUEST); + } else if (this->m_mode == SystemMode::PAYLOAD_MODE) { + // Log that external fault was detected but we can't act on it + // System must go PAYLOAD_MODE -> NORMAL -> SAFE_MODE sequentially + this->log_WARNING_LO_ExternalFaultIgnoredInPayloadMode(); } - // Note: Request ignored if in PAYLOAD_MODE or already in SAFE_MODE + // Note: If already in SAFE_MODE, silently ignore (already in safest state) } Components::SystemMode ModeManager ::getMode_handler(FwIndexType portNum) { @@ -165,19 +169,32 @@ void ModeManager ::prepareForReboot_handler(FwIndexType portNum) { Os::File file; Os::File::Status status = file.open(STATE_FILE_PATH, Os::File::OPEN_CREATE); - if (status == Os::File::OP_OK) { - PersistentState state; - state.mode = static_cast(this->m_mode); - state.safeModeEntryCount = this->m_safeModeEntryCount; - state.payloadModeEntryCount = this->m_payloadModeEntryCount; - state.safeModeReason = static_cast(this->m_safeModeReason); - state.cleanShutdown = 1; // Mark as clean shutdown - - FwSizeType bytesToWrite = sizeof(PersistentState); - FwSizeType bytesWritten = bytesToWrite; - (void)file.write(reinterpret_cast(&state), bytesWritten, Os::File::WaitType::WAIT); - file.close(); + if (status != Os::File::OP_OK) { + // Log failure - next boot will be misclassified as unintended reboot + Fw::LogStringArg opStr("shutdown-open"); + this->log_WARNING_LO_StatePersistenceFailure(opStr, static_cast(status)); + return; } + + PersistentState state; + state.mode = static_cast(this->m_mode); + state.safeModeEntryCount = this->m_safeModeEntryCount; + state.payloadModeEntryCount = this->m_payloadModeEntryCount; + state.safeModeReason = static_cast(this->m_safeModeReason); + state.cleanShutdown = 1; // Mark as clean shutdown + + FwSizeType bytesToWrite = sizeof(PersistentState); + FwSizeType bytesWritten = bytesToWrite; + Os::File::Status writeStatus = file.write(reinterpret_cast(&state), bytesWritten, Os::File::WaitType::WAIT); + + // Check if write succeeded and correct number of bytes written + if (writeStatus != Os::File::OP_OK || bytesWritten != bytesToWrite) { + // Log failure - next boot will be misclassified as unintended reboot + Fw::LogStringArg opStr("shutdown-write"); + this->log_WARNING_LO_StatePersistenceFailure(opStr, static_cast(writeStatus)); + } + + file.close(); } // ---------------------------------------------------------------------- @@ -325,18 +342,32 @@ void ModeManager ::loadState() { this->turnOnComponents(); } } else { - // Corrupted state - use defaults + // Corrupted state (invalid mode value) - use defaults + Fw::LogStringArg opStr("load-corrupt"); + this->log_WARNING_LO_StatePersistenceFailure(opStr, static_cast(state.mode)); this->m_mode = SystemMode::NORMAL; this->m_safeModeEntryCount = 0; this->m_payloadModeEntryCount = 0; this->m_safeModeReason = Components::SafeModeReason::NONE; this->turnOnComponents(); } + } else { + // Read failed or file truncated - use defaults + Fw::LogStringArg opStr("load-read"); + this->log_WARNING_LO_StatePersistenceFailure(opStr, static_cast(status)); + this->m_mode = SystemMode::NORMAL; + this->m_safeModeEntryCount = 0; + this->m_payloadModeEntryCount = 0; + this->m_safeModeReason = Components::SafeModeReason::NONE; + this->turnOnComponents(); } file.close(); } else { // File doesn't exist or can't be opened - initialize to default state + // Note: This is expected on first boot, so we use a different operation string + Fw::LogStringArg opStr("load-open"); + this->log_WARNING_LO_StatePersistenceFailure(opStr, static_cast(status)); this->m_mode = SystemMode::NORMAL; this->m_safeModeEntryCount = 0; this->m_payloadModeEntryCount = 0; diff --git a/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp b/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp index a4c522b6..5262724e 100644 --- a/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp +++ b/FprimeZephyrReference/Components/ModeManager/ModeManager.fpp @@ -169,6 +169,12 @@ module Components { severity activity high \ format "Preparing for intentional reboot - setting clean shutdown flag" + @ Event emitted when external safe mode request is ignored in PAYLOAD_MODE + @ The system must transition PAYLOAD_MODE -> NORMAL -> SAFE_MODE sequentially + event ExternalFaultIgnoredInPayloadMode() \ + severity warning low \ + format "External fault detected but ignored - in PAYLOAD_MODE (must exit to NORMAL first)" + # ---------------------------------------------------------------------- # Telemetry # ---------------------------------------------------------------------- diff --git a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md index f1536064..1ae795d9 100644 --- a/FprimeZephyrReference/Components/ModeManager/docs/sdd.md +++ b/FprimeZephyrReference/Components/ModeManager/docs/sdd.md @@ -1,6 +1,6 @@ # Components::ModeManager -The ModeManager component manages system operational modes and orchestrates transitions across NORMAL, SAFE_MODE, and PAYLOAD_MODE. It evaluates watchdog faults and communication timeouts to make mode decisions, controls power to non‑critical subsystems during transitions, and maintains/persists mode state across reboots to ensure consistent post‑recovery behavior. +The ModeManager component manages system operational modes and orchestrates transitions across NORMAL, SAFE_MODE, and PAYLOAD_MODE. It monitors battery voltage, detects unintended reboots, and makes autonomous mode decisions to protect the satellite. It controls power to non‑critical subsystems during transitions and maintains/persists mode state across reboots to ensure consistent post‑recovery behavior. Future work: a HIBERNATION mode remains planned; it will follow the same persistence and validation patterns once implemented. @@ -10,11 +10,11 @@ Future work: a HIBERNATION mode remains planned; it will follow the same persist | MM0001 | The ModeManager shall maintain three operational modes: NORMAL, SAFE_MODE, and PAYLOAD_MODE | Integration Testing | | MM0002 | The ModeManager shall enter safe mode when commanded manually via FORCE_SAFE_MODE command | Integration Testing | | MM0003 | The ModeManager shall enter safe mode when requested by external components via forceSafeMode port | Integration Testing | -| MM0004 | The ModeManager shall exit safe mode only via explicit EXIT_SAFE_MODE command | Integration Testing | +| MM0004 | The ModeManager shall exit safe mode via EXIT_SAFE_MODE command or automatically when voltage recovers (LOW_BATTERY reason only) | Integration Testing | | MM0005 | The ModeManager shall prevent exit from safe mode when not currently in safe mode | Integration Testing | | MM0006 | The ModeManager shall turn off all 8 load switches when entering safe mode | Integration Testing | | MM0007 | The ModeManager shall turn on face load switches (indices 0-5) when exiting safe mode; payload switches remain off until payload mode | Integration Testing | -| MM0008 | The ModeManager shall persist current mode and safe mode entry count to non-volatile storage | Integration Testing | +| MM0008 | The ModeManager shall persist current mode, safe mode entry count, safe mode reason, and clean shutdown flag to non-volatile storage | Integration Testing | | MM0009 | The ModeManager shall restore mode state from persistent storage on initialization | Integration Testing | | MM0010 | The ModeManager shall track and report the number of times safe mode has been entered | Integration Testing | | MM0011 | The ModeManager shall allow downstream components to query the current mode via getMode port | Unit Testing | @@ -27,6 +27,13 @@ Future work: a HIBERNATION mode remains planned; it will follow the same persist | MM0019 | The ModeManager shall reject FORCE_SAFE_MODE from payload mode (must exit payload mode first for sequential transitions) | Integration Testing | | MM0020 | While in payload mode, the ModeManager shall monitor bus voltage once per second and automatically exit payload mode after 10 consecutive seconds below 7.2V or upon invalid voltage readings | Integration Testing (manual + debounced behavior) | | MM0021 | Automatic payload mode exit shall emit AutoPayloadModeExit and turn off all 8 load switches | Integration Testing (manual) | +| MM0022 | While in NORMAL mode, the ModeManager shall automatically enter safe mode after 10 consecutive seconds with voltage below 6.7V (reason: LOW_BATTERY) | Integration Testing (manual) | +| MM0023 | While in SAFE_MODE with reason LOW_BATTERY, the ModeManager shall automatically exit to NORMAL after 10 consecutive seconds with voltage above 8.0V | Integration Testing (manual) | +| MM0024 | SAFE_MODE with reason SYSTEM_FAULT or GROUND_COMMAND shall NOT auto-recover; manual EXIT_SAFE_MODE is required | Integration Testing | +| MM0025 | The ModeManager shall detect unintended reboots via clean shutdown flag and enter SAFE_MODE with reason SYSTEM_FAULT | Integration Testing (manual) | +| MM0026 | The ModeManager shall set clean shutdown flag when notified via prepareForReboot port before intentional reboot | Integration Testing | +| MM0027 | The ModeManager shall track and report the current safe mode reason via telemetry | Integration Testing | +| MM0028 | The ModeManager shall log when forceSafeMode request is ignored while in PAYLOAD_MODE | Integration Testing | ## Usage Examples @@ -89,17 +96,57 @@ The ModeManager component operates as an active component that manages system-wi - Notifies downstream components via `modeChanged` port - Updates telemetry and persists state -7. **Safe Mode Exit** - - Triggered only by ground command: `EXIT_SAFE_MODE` +7. **Safe Mode Exit (Manual)** + - Triggered by ground command: `EXIT_SAFE_MODE` - Validates currently in safe mode before allowing exit - Actions performed: - Transitions mode to NORMAL + - Clears safe mode reason to NONE - Emits `ExitingSafeMode` event - Turns on face load switches (indices 0-5); payload switches remain off until explicitly entering payload mode - Notifies downstream components via `modeChanged` port - Persists state to flash storage -7. **Mode Queries** +8. **Automatic Safe Mode Entry (Low Voltage)** + - Triggered when in NORMAL mode and bus voltage is below 6.7V for 10 consecutive 1Hz checks + - Actions performed: + - Emits `AutoSafeModeEntry` with measured voltage value + - Transitions mode to SAFE_MODE with reason LOW_BATTERY + - Increments safe mode entry counter + - Emits `EnteringSafeMode` event with reason "Low battery voltage" + - Turns off all 8 load switches + - Notifies downstream components via `modeChanged` port + - Persists state to flash storage + +9. **Automatic Safe Mode Exit (Voltage Recovery)** + - Triggered when in SAFE_MODE with reason LOW_BATTERY and bus voltage is above 8.0V for 10 consecutive 1Hz checks + - Does NOT apply to SYSTEM_FAULT, GROUND_COMMAND, or EXTERNAL_REQUEST reasons (manual exit required) + - Actions performed: + - Emits `AutoSafeModeExit` with measured voltage value + - Transitions mode to NORMAL + - Clears safe mode reason to NONE + - Emits `ExitingSafeMode` event + - Turns on face load switches (indices 0-5) + - Notifies downstream components via `modeChanged` port + - Persists state to flash storage + +10. **Unintended Reboot Detection** + - On initialization, checks clean shutdown flag from persistent state + - If flag is false (unclean shutdown), system enters SAFE_MODE with reason SYSTEM_FAULT + - Actions performed: + - Emits `UnintendedRebootDetected` event + - Enters safe mode via `enterSafeMode(SYSTEM_FAULT)` + - The clean shutdown flag is set via `prepareForReboot` port before intentional reboots + +11. **Clean Shutdown Handling** + - When ResetManager is about to perform intentional reboot, it calls `prepareForReboot` port + - Actions performed: + - Emits `PreparingForReboot` event + - Sets clean shutdown flag to true + - Persists state to flash storage + - This prevents false unintended reboot detection on next boot + +12. **Mode Queries** - Downstream components can call `getMode` port to query current mode - Returns immediate synchronous response with current mode @@ -114,15 +161,18 @@ classDiagram class ModeManager { <> - m_mode: SystemMode + - m_safeModeReason: SafeModeReason - m_safeModeEntryCount: U32 - m_payloadModeEntryCount: U32 - m_runCounter: U32 + - m_cleanShutdownFlag: bool - STATE_FILE_PATH: const char* + ModeManager(const char* compName) + ~ModeManager() + init(FwSizeType queueDepth, FwEnumStoreType instance) - run_handler(FwIndexType portNum, U32 context) - forceSafeMode_handler(FwIndexType portNum) + - prepareForReboot_handler(FwIndexType portNum) - getMode_handler(FwIndexType portNum): SystemMode - FORCE_SAFE_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) - EXIT_SAFE_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) @@ -130,7 +180,7 @@ classDiagram - EXIT_PAYLOAD_MODE_cmdHandler(FwOpcodeType opCode, U32 cmdSeq) - loadState() - saveState() - - enterSafeMode(const char* reason) + - enterSafeMode(SafeModeReason reason) - exitSafeMode() - enterPayloadMode(const char* reason) - exitPayloadMode() @@ -141,8 +191,11 @@ classDiagram - turnOffPayload() - getCurrentVoltage(bool& valid): F32 - m_lowVoltageCounter: U32 - - LOW_VOLTAGE_THRESHOLD: F32 - - LOW_VOLTAGE_DEBOUNCE_SECONDS: U32 + - m_highVoltageCounter: U32 + - PAYLOAD_LOW_VOLTAGE_THRESHOLD: F32 = 7.2V + - SAFE_MODE_VOLTAGE_THRESHOLD: F32 = 6.7V + - SAFE_MODE_RECOVERY_VOLTAGE_THRESHOLD: F32 = 8.0V + - LOW_VOLTAGE_DEBOUNCE_SECONDS: U32 = 10 } class SystemMode { <> @@ -150,9 +203,18 @@ classDiagram NORMAL = 2 PAYLOAD_MODE = 3 } + class SafeModeReason { + <> + NONE = 0 + LOW_BATTERY = 1 + SYSTEM_FAULT = 2 + GROUND_COMMAND = 3 + EXTERNAL_REQUEST = 4 + } } ModeManagerComponentBase <|-- ModeManager : inherits ModeManager --> SystemMode : uses + ModeManager --> SafeModeReason : uses ``` ## Port Descriptions @@ -160,8 +222,9 @@ classDiagram ### Input Ports | Name | Type | Kind | Description | |---|---|---|---| -| run | Svc.Sched | sync | Receives periodic calls from rate group (1Hz) for telemetry updates and low-voltage monitoring while in PAYLOAD_MODE | -| forceSafeMode | Fw.Signal | async | Receives safe mode requests from external components detecting faults | +| run | Svc.Sched | sync | Receives periodic calls from rate group (1Hz) for telemetry updates, low-voltage monitoring in PAYLOAD_MODE, and auto-recovery monitoring in SAFE_MODE | +| forceSafeMode | Fw.Signal | async | Receives safe mode requests from external components detecting faults (only acts when in NORMAL mode; logs warning in PAYLOAD_MODE) | +| prepareForReboot | Fw.Signal | async | Called by ResetManager before intentional reboot; sets clean shutdown flag to prevent false unintended reboot detection | | getMode | Components.GetSystemMode | sync | Allows downstream components to query current system mode | ### Output Ports @@ -177,18 +240,23 @@ classDiagram | Name | Type | Description | |---|---|---| | m_mode | SystemMode | Current operational mode (NORMAL, SAFE_MODE, or PAYLOAD_MODE) | +| m_safeModeReason | SafeModeReason | Reason for current safe mode entry (NONE, LOW_BATTERY, SYSTEM_FAULT, GROUND_COMMAND, EXTERNAL_REQUEST) | | m_safeModeEntryCount | U32 | Number of times safe mode has been entered since initial deployment | | m_payloadModeEntryCount | U32 | Number of times payload mode has been entered since initial deployment | | m_runCounter | U32 | Counter for 1Hz run handler calls | -| m_lowVoltageCounter | U32 | Debounce counter for consecutive low/invalid voltage readings while in PAYLOAD_MODE | +| m_lowVoltageCounter | U32 | Debounce counter for consecutive low/invalid voltage readings (used in PAYLOAD_MODE for auto-exit and NORMAL mode for auto-safe-mode-entry) | +| m_highVoltageCounter | U32 | Debounce counter for consecutive high voltage readings (used in SAFE_MODE for auto-recovery when reason is LOW_BATTERY) | +| m_cleanShutdownFlag | bool | Flag indicating if last shutdown was clean (set via prepareForReboot port); used for unintended reboot detection | ### Persistent State The component persists the following state to `/mode_state.bin`: - Current mode (U8) - Safe mode entry count (U32) - Payload mode entry count (U32) +- Safe mode reason (U8) +- Clean shutdown flag (U8) -This state is loaded on initialization and saved on every mode transition. +This state is loaded on initialization and saved on every mode transition and before intentional reboots. ## Sequence Diagrams @@ -332,14 +400,111 @@ sequenceDiagram sequenceDiagram participant RateGroup participant ModeManager + participant INA219 RateGroup->>ModeManager: run(portNum, context) ModeManager->>ModeManager: Increment m_runCounter - ModeManager->>ModeManager: If PAYLOAD_MODE: read voltage, debounce low-voltage counter (10s window) - ModeManager->>ModeManager: If sustained low/invalid voltage: exitPayloadModeAutomatic() + alt PAYLOAD_MODE + ModeManager->>INA219: voltageGet() + INA219-->>ModeManager: voltage / invalid + ModeManager->>ModeManager: Debounce low-voltage (<7.2V) counter + ModeManager->>ModeManager: If 10 consecutive: exitPayloadModeAutomatic() + else NORMAL + ModeManager->>INA219: voltageGet() + INA219-->>ModeManager: voltage / invalid + ModeManager->>ModeManager: Debounce low-voltage (<6.7V) counter + ModeManager->>ModeManager: If 10 consecutive: enterSafeMode(LOW_BATTERY) + else SAFE_MODE with reason LOW_BATTERY + ModeManager->>INA219: voltageGet() + INA219-->>ModeManager: voltage / invalid + ModeManager->>ModeManager: Debounce high-voltage (>8.0V) counter + ModeManager->>ModeManager: If 10 consecutive: exitSafeMode() auto-recovery + end ModeManager->>ModeManager: Write CurrentMode telemetry ``` +### Automatic Safe Mode Entry (Low Voltage) +```mermaid +sequenceDiagram + participant ModeManager + participant INA219 + participant LoadSwitches + participant DownstreamComponents + participant FlashStorage + + Note over ModeManager: In NORMAL mode, voltage <6.7V for 10s + ModeManager->>INA219: voltageGet() + INA219-->>ModeManager: Low voltage (e.g., 6.5V) + ModeManager->>ModeManager: Increment m_lowVoltageCounter + Note over ModeManager: After 10 consecutive low readings... + ModeManager->>ModeManager: Emit AutoSafeModeEntry(voltage) + ModeManager->>ModeManager: Set m_mode = SAFE_MODE + ModeManager->>ModeManager: Set m_safeModeReason = LOW_BATTERY + ModeManager->>ModeManager: Increment m_safeModeEntryCount + ModeManager->>ModeManager: Emit EnteringSafeMode("Low battery voltage") + ModeManager->>LoadSwitches: Turn off all 8 switches + ModeManager->>DownstreamComponents: modeChanged_out(SAFE_MODE) + ModeManager->>FlashStorage: Save state to /mode_state.bin +``` + +### Automatic Safe Mode Exit (Voltage Recovery) +```mermaid +sequenceDiagram + participant ModeManager + participant INA219 + participant LoadSwitches + participant DownstreamComponents + participant FlashStorage + + Note over ModeManager: In SAFE_MODE with reason LOW_BATTERY, voltage >8.0V for 10s + ModeManager->>INA219: voltageGet() + INA219-->>ModeManager: High voltage (e.g., 8.2V) + ModeManager->>ModeManager: Increment m_highVoltageCounter + Note over ModeManager: After 10 consecutive high readings... + ModeManager->>ModeManager: Emit AutoSafeModeExit(voltage) + ModeManager->>ModeManager: Set m_mode = NORMAL + ModeManager->>ModeManager: Set m_safeModeReason = NONE + ModeManager->>ModeManager: Emit ExitingSafeMode + ModeManager->>LoadSwitches: Turn on face switches (0-5) + ModeManager->>DownstreamComponents: modeChanged_out(NORMAL) + ModeManager->>FlashStorage: Save state to /mode_state.bin +``` + +### Unintended Reboot Detection +```mermaid +sequenceDiagram + participant System + participant ModeManager + participant FlashStorage + participant LoadSwitches + participant DownstreamComponents + + System->>ModeManager: init() + ModeManager->>FlashStorage: loadState() + FlashStorage-->>ModeManager: State with cleanShutdownFlag = false + ModeManager->>ModeManager: Detect unintended reboot + ModeManager->>ModeManager: Emit UnintendedRebootDetected + ModeManager->>ModeManager: enterSafeMode(SYSTEM_FAULT) + ModeManager->>ModeManager: Emit EnteringSafeMode("Unintended reboot detected") + ModeManager->>LoadSwitches: Turn off all 8 switches + ModeManager->>DownstreamComponents: modeChanged_out(SAFE_MODE) + ModeManager->>FlashStorage: Save state (cleanShutdownFlag = false) +``` + +### Prepare for Reboot (Clean Shutdown) +```mermaid +sequenceDiagram + participant ResetManager + participant ModeManager + participant FlashStorage + + ResetManager->>ModeManager: prepareForReboot port call + ModeManager->>ModeManager: Emit PreparingForReboot + ModeManager->>ModeManager: Set m_cleanShutdownFlag = true + ModeManager->>FlashStorage: Save state with cleanShutdownFlag = true + ResetManager->>ResetManager: sys_reboot() +``` + ## Commands | Name | Arguments | Description | @@ -353,22 +518,28 @@ sequenceDiagram | Name | Severity | Arguments | Description | |---|---|---|---| -| EnteringSafeMode | WARNING_HI | reason: string size 100 | Emitted when entering safe mode, includes reason (e.g., "Ground command", "External component request") | +| EnteringSafeMode | WARNING_HI | reason: string size 100 | Emitted when entering safe mode, includes reason (e.g., "Ground command", "Low battery voltage") | | ExitingSafeMode | ACTIVITY_HI | None | Emitted when exiting safe mode and returning to normal operation | | ManualSafeModeEntry | ACTIVITY_HI | None | Emitted when safe mode is manually commanded via FORCE_SAFE_MODE | | ExternalFaultDetected | WARNING_HI | None | Emitted when an external component triggers safe mode via forceSafeMode port | +| AutoSafeModeEntry | WARNING_HI | voltage: F32 | Emitted when automatically entering safe mode due to low voltage (<6.7V for 10s) | +| AutoSafeModeExit | ACTIVITY_HI | voltage: F32 | Emitted when automatically exiting safe mode due to voltage recovery (>8.0V for 10s, only for LOW_BATTERY reason) | +| UnintendedRebootDetected | WARNING_HI | None | Emitted on initialization when clean shutdown flag is false, indicating an unexpected reboot | +| PreparingForReboot | ACTIVITY_HI | None | Emitted when prepareForReboot port is called before an intentional reboot | +| ExternalFaultIgnoredInPayloadMode | WARNING_LO | None | Emitted when forceSafeMode request is ignored because system is in PAYLOAD_MODE (must exit to NORMAL first) | | EnteringPayloadMode | ACTIVITY_HI | reason: string size 100 | Emitted when entering payload mode, includes reason (e.g., "Ground command") | | ExitingPayloadMode | ACTIVITY_HI | None | Emitted when exiting payload mode and returning to normal operation | | ManualPayloadModeEntry | ACTIVITY_HI | None | Emitted when payload mode is manually commanded via ENTER_PAYLOAD_MODE | | AutoPayloadModeExit | WARNING_HI | voltage: F32 | Emitted when automatically exiting payload mode due to low/invalid voltage (voltage value reported; 0.0 if invalid) | | CommandValidationFailed | WARNING_LO | cmdName: string size 50
reason: string size 100 | Emitted when a command fails validation (e.g., EXIT_SAFE_MODE when not in safe mode) | -| StatePersistenceFailure | WARNING_LO | operation: string size 20
status: I32 | Emitted when state save/load operations fail | +| StatePersistenceFailure | WARNING_LO | operation: string size 20
status: I32 | Emitted when state save/load operations fail (operations: save-open, save-write, load-open, load-read, load-corrupt, shutdown-open, shutdown-write) | ## Telemetry | Name | Type | Update Rate | Description | |---|---|---|---| | CurrentMode | U8 | 1Hz | Current system mode (1 = SAFE_MODE, 2 = NORMAL, 3 = PAYLOAD_MODE) | +| CurrentSafeModeReason | U8 | On change | Current safe mode reason (0 = NONE, 1 = LOW_BATTERY, 2 = SYSTEM_FAULT, 3 = GROUND_COMMAND, 4 = EXTERNAL_REQUEST). Only valid when in SAFE_MODE. | | SafeModeEntryCount | U32 | On change | Number of times safe mode has been entered (persists across reboots) | | PayloadModeEntryCount | U32 | On change | Number of times payload mode has been entered (persists across reboots) | @@ -390,15 +561,21 @@ The ModeManager controls 8 load switches that power non-critical satellite subsy > **Notes:** PAYLOAD_MODE can only be entered from NORMAL mode (not from SAFE_MODE). When restoring PAYLOAD_MODE from persistent storage after a reboot, both face switches (0-5) and payload switches (6-7) are explicitly turned ON to ensure consistent state. Automatic payload exits (low/invalid voltage) aggressively turn **all** switches OFF; manual payload exits leave faces ON and only shed payload loads. ## Integration Tests -See `FprimeZephyrReference/test/int/mode_manager_test.py` and `FprimeZephyrReference/test/int/payload_mode_test.py` for comprehensive integration tests covering: +See `FprimeZephyrReference/test/int/mode_manager_test.py`, `FprimeZephyrReference/test/int/payload_mode_test.py`, and `FprimeZephyrReference/test/int/safe_mode_test.py` for comprehensive integration tests covering: | Test | Description | Coverage | |---|---|---| +| **mode_manager_test.py** | | | | test_01_initial_telemetry | Verifies initial telemetry can be read | Basic functionality | | test_04_force_safe_mode_command | Tests FORCE_SAFE_MODE command enters safe mode | Safe mode entry | +| test_05_safe_mode_increments_counter | Verifies SafeModeEntryCount increments | Counter tracking | | test_06_safe_mode_turns_off_load_switches | Verifies all load switches turn off in safe mode | Power management | +| test_07_safe_mode_emits_event | Verifies EnteringSafeMode event with correct reason | Event emission | +| test_13_exit_safe_mode_fails_not_in_safe_mode | Verifies EXIT_SAFE_MODE fails when not in safe mode | Command validation | | test_14_exit_safe_mode_success | Tests successful safe mode exit | Safe mode exit | +| test_18_force_safe_mode_idempotent | Verifies FORCE_SAFE_MODE is idempotent when already in safe mode | Idempotency | | test_19_safe_mode_state_persists | Verifies safe mode persistence to flash | State persistence | +| **payload_mode_test.py** | | | | test_payload_01_enter_exit_payload_mode | Validates payload mode entry/exit, events, telemetry, payload load switches | Payload mode entry/exit | | test_payload_02_cannot_enter_from_safe_mode | Ensures ENTER_PAYLOAD_MODE fails from SAFE_MODE | Command validation | | test_payload_03_safe_mode_rejected_from_payload | Ensures FORCE_SAFE_MODE is rejected from payload mode (sequential transitions) | Command validation | @@ -406,6 +583,16 @@ See `FprimeZephyrReference/test/int/mode_manager_test.py` and `FprimeZephyrRefer | test_payload_05_manual_exit_face_switches_remain_on | Verifies manual payload exit leaves faces ON and payload OFF | Payload exit power behavior | | test_payload_06_voltage_monitoring_active | Verifies voltage telemetry is present in payload mode and no false auto-exit when voltage healthy | Low-voltage monitoring sanity | | test_payload_07_auto_exit_low_voltage (manual) | Manual test to validate debounced low-voltage auto-exit, AutoPayloadModeExit event, and full load-shed | Low-voltage protection | +| **safe_mode_test.py** | | | +| test_safe_01_auto_entry_low_voltage (manual) | Tests automatic safe mode entry when voltage drops below 6.7V for 10s | Auto safe mode entry | +| test_safe_02_auto_exit_voltage_recovery (manual) | Tests automatic safe mode exit when voltage recovers above 8.0V for 10s (LOW_BATTERY only) | Auto safe mode exit | +| test_safe_03_no_auto_exit_system_fault (manual) | Verifies no auto-exit for SYSTEM_FAULT reason | Manual exit required | +| test_safe_04_no_auto_exit_ground_command (manual) | Verifies no auto-exit for GROUND_COMMAND reason | Manual exit required | +| test_safe_05_unintended_reboot_detection (manual) | Tests safe mode entry on unintended reboot detection | Reboot detection | +| test_safe_06_clean_shutdown_flag (manual) | Verifies clean shutdown flag prevents false reboot detection | Clean shutdown | +| test_safe_07_safe_mode_reason_telemetry | Verifies CurrentSafeModeReason telemetry channel | Reason tracking | +| test_safe_08_external_fault_ignored_in_payload | Verifies forceSafeMode logs warning when ignored in PAYLOAD_MODE | Sequential transitions | +| test_safe_09_cascade_payload_to_safe_mode (manual) | Tests cascade PAYLOAD_MODE → NORMAL → SAFE_MODE on very low voltage | Cascade transitions | ## Design Decisions @@ -441,11 +628,33 @@ Payload mode is protected by a debounced low-voltage monitor: - Re-entering payload mode re-powers faces and payload switches to restore a consistent state ### Sequential Mode Transitions -Mode transitions follow a +1/-1 sequential pattern: SAFE_MODE(1) ↔ NORMAL(2) ↔ PAYLOAD_MODE(3). Direct jumps (e.g., PAYLOAD→SAFE) are not allowed - users must exit payload mode first before entering safe mode. FORCE_SAFE_MODE is idempotent when already in safe mode. +Mode transitions follow a +1/-1 sequential pattern: SAFE_MODE(1) ↔ NORMAL(2) ↔ PAYLOAD_MODE(3). Direct jumps (e.g., PAYLOAD→SAFE) are not allowed - users must exit payload mode first before entering safe mode. FORCE_SAFE_MODE is idempotent when already in safe mode. When forceSafeMode is called in PAYLOAD_MODE, it logs a warning (`ExternalFaultIgnoredInPayloadMode`) to inform operators that manual exit is required first. + +### Automatic Safe Mode Entry/Exit +The component monitors voltage to automatically manage safe mode transitions: +- **Entry**: When in NORMAL mode and voltage drops below 6.7V for 10 consecutive seconds, the system automatically enters safe mode with reason LOW_BATTERY. +- **Exit (Auto-Recovery)**: When in SAFE_MODE with reason LOW_BATTERY and voltage rises above 8.0V for 10 consecutive seconds, the system automatically exits to NORMAL mode. +- **No Auto-Recovery**: Safe mode entries caused by SYSTEM_FAULT, GROUND_COMMAND, or EXTERNAL_REQUEST require manual EXIT_SAFE_MODE command. This ensures operator awareness after critical faults. + +The hysteresis gap (6.7V entry vs 8.0V exit) prevents oscillation between modes. + +### Unintended Reboot Detection +The component uses a clean shutdown flag to detect unexpected reboots: +- Before intentional reboots, ResetManager calls the `prepareForReboot` port, which sets the clean shutdown flag to true. +- On initialization, if the flag is false, the system detected an unintended reboot (watchdog reset, power glitch, crash) and enters safe mode with reason SYSTEM_FAULT. +- This ensures the satellite enters a known-safe state after any unexpected restart. + +### Safe Mode Reason Tracking +Each safe mode entry records its reason, enabling different recovery behaviors: +- **LOW_BATTERY**: Automatic recovery possible when voltage recovers +- **SYSTEM_FAULT**: Manual intervention required (reboot, hardware issue) +- **GROUND_COMMAND**: Manual intervention required (operator-initiated) +- **EXTERNAL_REQUEST**: Manual intervention required (component fault) ## Change Log | Date | Description | |---|---| +| 2025-11-29 | Added automatic safe mode entry/exit based on voltage (6.7V/8.0V thresholds), unintended reboot detection via clean shutdown flag, SafeModeReason enum (NONE, LOW_BATTERY, SYSTEM_FAULT, GROUND_COMMAND, EXTERNAL_REQUEST), CurrentSafeModeReason telemetry, prepareForReboot port, ExternalFaultIgnoredInPayloadMode logging, error handling for state persistence operations, and comprehensive safe_mode_test.py tests | | 2025-11-29 | Added low-voltage monitoring with debounced automatic payload exit, AutoPayloadModeExit event, and documentation/tests for aggressive load shed and manual exit power state | | 2025-11-26 | Reordered enum values (SAFE=1, NORMAL=2, PAYLOAD=3) for sequential +1/-1 transitions; FORCE_SAFE_MODE now rejected from payload mode | | 2025-11-26 | Removed forcePayloadMode port - payload mode now only entered via ENTER_PAYLOAD_MODE ground command | diff --git a/FprimeZephyrReference/test/int/safe_mode_test.py b/FprimeZephyrReference/test/int/safe_mode_test.py new file mode 100644 index 00000000..5e34e867 --- /dev/null +++ b/FprimeZephyrReference/test/int/safe_mode_test.py @@ -0,0 +1,671 @@ +""" +safe_mode_test.py: + +Integration tests for the ModeManager component (Normal <-> Safe mode transitions). + +Tests cover: +- CurrentSafeModeReason telemetry verification +- Safe mode reason tracking (GROUND_COMMAND, EXTERNAL_REQUEST) +- SYSTEM_FAULT reason blocks auto-recovery (requires manual command) +- Auto safe mode entry due to low voltage (manual test - requires voltage control) +- Auto safe mode exit/recovery when voltage > 8V (manual test - requires voltage control) +- Unintended reboot detection (manual test - requires reboot) + +Total: 8 tests (4 automated, 4 manual) + +SafeModeReason enum values: NONE=0, LOW_BATTERY=1, SYSTEM_FAULT=2, GROUND_COMMAND=3, EXTERNAL_REQUEST=4 +Mode enum values: SAFE_MODE=1, NORMAL=2, PAYLOAD_MODE=3 +""" + +import logging +import time + +import pytest +from common import proves_send_and_assert_command +from fprime_gds.common.data_types.ch_data import ChData +from fprime_gds.common.testing_fw.api import IntegrationTestAPI + +logger = logging.getLogger(__name__) + +component = "ReferenceDeployment.modeManager" + +# Telemetry packet IDs for CdhCore.tlmSend.SEND_PKT command +MODE_TELEMETRY_PACKET_ID = ( + "1" # ModeManager telemetry (CurrentMode, SafeModeReason, etc.) +) +VOLTAGE_TELEMETRY_PACKET_ID = "10" # PowerMonitor telemetry (includes INA219 voltage) + +# Voltage thresholds (must match ModeManager.hpp constants) +SAFE_MODE_ENTRY_VOLTAGE = 6.7 # Volts - threshold for entering safe mode from NORMAL +SAFE_MODE_RECOVERY_VOLTAGE = 8.0 # Volts - threshold for auto-recovery from safe mode +SAFE_MODE_DEBOUNCE_SECONDS = 10 # Consecutive seconds for safe mode transitions + +# SafeModeReason enum values (must match ModeManager.fpp) +SAFE_MODE_REASON_NONE = 0 +SAFE_MODE_REASON_LOW_BATTERY = 1 +SAFE_MODE_REASON_SYSTEM_FAULT = 2 +SAFE_MODE_REASON_GROUND_COMMAND = 3 +SAFE_MODE_REASON_EXTERNAL_REQUEST = 4 + + +@pytest.fixture(autouse=True) +def setup_and_teardown(fprime_test_api: IntegrationTestAPI, start_gds): + """ + Setup before each test and cleanup after. + Ensures clean state by exiting safe mode if needed. + """ + # Setup: Try to get to a clean NORMAL state + try: + proves_send_and_assert_command(fprime_test_api, f"{component}.EXIT_SAFE_MODE") + except Exception as e: + logger.debug(f"Setup: EXIT_SAFE_MODE skipped (not in safe mode): {e}") + try: + proves_send_and_assert_command( + fprime_test_api, f"{component}.EXIT_PAYLOAD_MODE" + ) + except Exception as e: + logger.debug(f"Setup: EXIT_PAYLOAD_MODE skipped (not in payload mode): {e}") + + # Clear event and telemetry history before test + fprime_test_api.clear_histories() + + yield + + # Teardown: Return to clean NORMAL state for next test + try: + proves_send_and_assert_command(fprime_test_api, f"{component}.EXIT_SAFE_MODE") + except Exception as e: + logger.debug(f"Teardown: EXIT_SAFE_MODE skipped (not in safe mode): {e}") + try: + proves_send_and_assert_command( + fprime_test_api, f"{component}.EXIT_PAYLOAD_MODE" + ) + except Exception as e: + logger.debug(f"Teardown: EXIT_PAYLOAD_MODE skipped (not in payload mode): {e}") + + +# ============================================================================== +# SafeModeReason Telemetry Tests +# ============================================================================== + + +def test_safe_01_initial_safe_mode_reason_is_none( + fprime_test_api: IntegrationTestAPI, start_gds +): + """ + Test that CurrentSafeModeReason telemetry is NONE when not in safe mode. + """ + # Ensure we're in NORMAL mode + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + + if mode_result.get_val() != 2: + # Not in NORMAL mode, skip test + pytest.skip("Not in NORMAL mode - cannot verify initial reason") + + # Verify safe mode reason is NONE + reason_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentSafeModeReason", timeout=5 + ) + reason_val = reason_result.get_val() + + # Handle both numeric and string representations + if isinstance(reason_val, str): + assert "NONE" in reason_val.upper(), ( + f"Safe mode reason should be NONE, got {reason_val}" + ) + else: + assert reason_val == SAFE_MODE_REASON_NONE, ( + f"Safe mode reason should be NONE (0), got {reason_val}" + ) + + +def test_safe_02_ground_command_sets_reason( + fprime_test_api: IntegrationTestAPI, start_gds +): + """ + Test that FORCE_SAFE_MODE command sets reason to GROUND_COMMAND. + Verifies: + - CurrentSafeModeReason = GROUND_COMMAND after command + - EnteringSafeMode event contains "Ground command" + """ + # Enter safe mode via ground command + fprime_test_api.clear_histories() + proves_send_and_assert_command( + fprime_test_api, + f"{component}.FORCE_SAFE_MODE", + events=[f"{component}.ManualSafeModeEntry"], + ) + time.sleep(2) + + # Verify safe mode reason is GROUND_COMMAND + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + reason_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentSafeModeReason", timeout=5 + ) + reason_val = reason_result.get_val() + + if isinstance(reason_val, str): + assert "GROUND_COMMAND" in reason_val.upper(), ( + f"Safe mode reason should be GROUND_COMMAND, got {reason_val}" + ) + else: + assert reason_val == SAFE_MODE_REASON_GROUND_COMMAND, ( + f"Safe mode reason should be GROUND_COMMAND (3), got {reason_val}" + ) + + # Verify EnteringSafeMode event mentions ground command + events = fprime_test_api.get_event_test_history() + entering_events = [ + e for e in events if "EnteringSafeMode" in str(e.get_template().get_name()) + ] + assert len(entering_events) > 0, "Should have EnteringSafeMode event" + assert "Ground command" in entering_events[-1].get_display_text(), ( + "EnteringSafeMode reason should mention Ground command" + ) + + +def test_safe_03_exit_clears_reason(fprime_test_api: IntegrationTestAPI, start_gds): + """ + Test that EXIT_SAFE_MODE clears the safe mode reason to NONE. + """ + # Enter safe mode + proves_send_and_assert_command(fprime_test_api, f"{component}.FORCE_SAFE_MODE") + time.sleep(2) + + # Verify in safe mode with reason set + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 1, "Should be in SAFE_MODE" + + # Exit safe mode + proves_send_and_assert_command( + fprime_test_api, + f"{component}.EXIT_SAFE_MODE", + events=[f"{component}.ExitingSafeMode"], + ) + time.sleep(2) + + # Verify reason is cleared to NONE + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + reason_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentSafeModeReason", timeout=5 + ) + reason_val = reason_result.get_val() + + if isinstance(reason_val, str): + assert "NONE" in reason_val.upper(), ( + f"Safe mode reason should be NONE after exit, got {reason_val}" + ) + else: + assert reason_val == SAFE_MODE_REASON_NONE, ( + f"Safe mode reason should be NONE (0) after exit, got {reason_val}" + ) + + +def test_safe_04_no_auto_recovery_for_ground_command( + fprime_test_api: IntegrationTestAPI, start_gds +): + """ + Test that safe mode entered via GROUND_COMMAND does NOT auto-recover. + Even if voltage is healthy, must use EXIT_SAFE_MODE command. + Verifies: + - Safe mode persists after debounce period + margin + - No AutoSafeModeExit event is emitted + """ + # Enter safe mode via ground command + proves_send_and_assert_command(fprime_test_api, f"{component}.FORCE_SAFE_MODE") + time.sleep(2) + + # Verify in safe mode with GROUND_COMMAND reason + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 1, "Should be in SAFE_MODE" + + fprime_test_api.clear_histories() + + # Wait for debounce period + margin (should NOT auto-recover) + logger.info( + f"Waiting {SAFE_MODE_DEBOUNCE_SECONDS + 3} seconds to verify no auto-recovery..." + ) + time.sleep(SAFE_MODE_DEBOUNCE_SECONDS + 3) + + # Verify still in safe mode + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 1, ( + "Should still be in SAFE_MODE (no auto-recovery for GROUND_COMMAND reason)" + ) + + # Verify no AutoSafeModeExit event + events = fprime_test_api.get_event_test_history() + auto_exit_events = [ + e for e in events if "AutoSafeModeExit" in str(e.get_template().get_name()) + ] + assert len(auto_exit_events) == 0, ( + "Should NOT have AutoSafeModeExit event for GROUND_COMMAND reason" + ) + + +# ============================================================================== +# Manual Tests - Require Voltage Control +# ============================================================================== + + +@pytest.mark.skip(reason="Requires voltage control - run manually with low battery") +def test_safe_05_auto_entry_low_voltage(fprime_test_api: IntegrationTestAPI, start_gds): + """ + Test automatic entry into safe mode due to low voltage. + + MANUAL TEST - requires ability to control battery voltage: + 1. Ensure in NORMAL mode + 2. Lower battery voltage below SAFE_MODE_ENTRY_VOLTAGE (6.7V) for SAFE_MODE_DEBOUNCE_SECONDS + 3. Verify AutoSafeModeEntry event is emitted with reason=LOW_BATTERY + 4. Verify mode changes to SAFE_MODE (1) + 5. Verify CurrentSafeModeReason = LOW_BATTERY + + Expected behavior: + - Voltage must be below 6.7V threshold for 10 consecutive seconds (1Hz checks) + - Invalid voltage readings also count as faults + - AutoSafeModeEntry event emitted with reason=LOW_BATTERY and voltage value + - Mode changes to SAFE_MODE + - All 8 load switches turned OFF + """ + # Ensure in NORMAL mode + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 2, "Should start in NORMAL mode" + + fprime_test_api.clear_histories() + + # --- MANUAL STEP: Lower voltage below 6.7V for 10+ seconds --- + logger.info( + f"MANUAL: Lower voltage below {SAFE_MODE_ENTRY_VOLTAGE}V for {SAFE_MODE_DEBOUNCE_SECONDS}+ seconds" + ) + time.sleep(SAFE_MODE_DEBOUNCE_SECONDS + 5) + + # Verify AutoSafeModeEntry event + fprime_test_api.assert_event(f"{component}.AutoSafeModeEntry", timeout=5) + + # Verify mode is SAFE_MODE (1) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 1, "Should be in SAFE_MODE after low voltage" + + # Verify reason is LOW_BATTERY + reason_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentSafeModeReason", timeout=5 + ) + reason_val = reason_result.get_val() + if isinstance(reason_val, str): + assert "LOW_BATTERY" in reason_val.upper(), ( + f"Safe mode reason should be LOW_BATTERY, got {reason_val}" + ) + else: + assert reason_val == SAFE_MODE_REASON_LOW_BATTERY, ( + f"Safe mode reason should be LOW_BATTERY (1), got {reason_val}" + ) + + +@pytest.mark.skip( + reason="Requires voltage control - run manually with battery recovery" +) +def test_safe_06_auto_recovery_voltage(fprime_test_api: IntegrationTestAPI, start_gds): + """ + Test automatic recovery from safe mode when voltage recovers. + + MANUAL TEST - requires ability to control battery voltage: + 1. Enter safe mode with LOW_BATTERY reason (use test_safe_05 first or simulate) + 2. Raise battery voltage above SAFE_MODE_RECOVERY_VOLTAGE (8.0V) for SAFE_MODE_DEBOUNCE_SECONDS + 3. Verify AutoSafeModeExit event is emitted with recovered voltage + 4. Verify mode changes to NORMAL (2) + 5. Verify CurrentSafeModeReason = NONE + + Expected behavior: + - Only works if reason is LOW_BATTERY (not SYSTEM_FAULT or GROUND_COMMAND) + - Voltage must be above 8.0V threshold for 10 consecutive seconds + - AutoSafeModeExit event emitted with voltage value + - Mode changes to NORMAL + - Face switches (0-5) turned ON + """ + # This test assumes we're in safe mode with LOW_BATTERY reason + # In real testing, run test_safe_05 first or manually set up the state + + # Verify in safe mode with LOW_BATTERY reason + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 1, "Should be in SAFE_MODE" + + reason_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentSafeModeReason", timeout=5 + ) + reason_val = reason_result.get_val() + # Skip if not LOW_BATTERY reason + if isinstance(reason_val, str): + if "LOW_BATTERY" not in reason_val.upper(): + pytest.skip( + "Safe mode reason is not LOW_BATTERY - auto-recovery won't work" + ) + else: + if reason_val != SAFE_MODE_REASON_LOW_BATTERY: + pytest.skip( + "Safe mode reason is not LOW_BATTERY - auto-recovery won't work" + ) + + fprime_test_api.clear_histories() + + # --- MANUAL STEP: Raise voltage above 8.0V for 10+ seconds --- + logger.info( + f"MANUAL: Raise voltage above {SAFE_MODE_RECOVERY_VOLTAGE}V for {SAFE_MODE_DEBOUNCE_SECONDS}+ seconds" + ) + time.sleep(SAFE_MODE_DEBOUNCE_SECONDS + 5) + + # Verify AutoSafeModeExit event + fprime_test_api.assert_event(f"{component}.AutoSafeModeExit", timeout=5) + + # Verify mode is NORMAL (2) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 2, "Should be in NORMAL mode after recovery" + + # Verify reason is cleared to NONE + reason_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentSafeModeReason", timeout=5 + ) + reason_val = reason_result.get_val() + if isinstance(reason_val, str): + assert "NONE" in reason_val.upper(), ( + f"Safe mode reason should be NONE after recovery, got {reason_val}" + ) + else: + assert reason_val == SAFE_MODE_REASON_NONE, ( + f"Safe mode reason should be NONE (0) after recovery, got {reason_val}" + ) + + +@pytest.mark.skip(reason="Requires reboot - run manually with power cycle") +def test_safe_07_unintended_reboot_detection( + fprime_test_api: IntegrationTestAPI, start_gds +): + """ + Test unintended reboot detection and automatic safe mode entry. + + MANUAL TEST - requires power cycle without clean shutdown: + 1. Ensure in NORMAL mode + 2. Power cycle the board WITHOUT using COLD_RESET or WARM_RESET commands + 3. On boot, verify UnintendedRebootDetected event is emitted + 4. Verify mode is SAFE_MODE (1) + 5. Verify CurrentSafeModeReason = SYSTEM_FAULT + 6. Verify auto-recovery does NOT work (must use EXIT_SAFE_MODE) + + Expected behavior: + - cleanShutdown flag is 0 (not set by prepareForReboot) + - On boot, ModeManager detects unintended reboot + - Enters SAFE_MODE with reason = SYSTEM_FAULT + - Requires manual EXIT_SAFE_MODE to recover (no auto-recovery) + """ + # This test requires a power cycle, so we can only verify the aftermath + + # Check for UnintendedRebootDetected event (may have been emitted at boot) + events = fprime_test_api.get_event_test_history() + unintended_events = [ + e + for e in events + if "UnintendedRebootDetected" in str(e.get_template().get_name()) + ] + + if len(unintended_events) == 0: + pytest.skip("No UnintendedRebootDetected event - board may have booted cleanly") + + # Verify in safe mode + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 1, "Should be in SAFE_MODE after unintended reboot" + + # Verify reason is SYSTEM_FAULT + reason_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentSafeModeReason", timeout=5 + ) + reason_val = reason_result.get_val() + if isinstance(reason_val, str): + assert "SYSTEM_FAULT" in reason_val.upper(), ( + f"Safe mode reason should be SYSTEM_FAULT, got {reason_val}" + ) + else: + assert reason_val == SAFE_MODE_REASON_SYSTEM_FAULT, ( + f"Safe mode reason should be SYSTEM_FAULT (2), got {reason_val}" + ) + + +@pytest.mark.skip(reason="Requires reboot - run manually to verify clean shutdown") +def test_safe_08_clean_reboot_no_safe_mode( + fprime_test_api: IntegrationTestAPI, start_gds +): + """ + Test that intentional reboot (via COLD_RESET/WARM_RESET) does NOT trigger safe mode. + + MANUAL TEST - requires reboot via command: + 1. Ensure in NORMAL mode + 2. Issue COLD_RESET or WARM_RESET command + 3. After reboot, verify NO UnintendedRebootDetected event + 4. Verify mode restored to previous state (NORMAL) + 5. Verify CurrentSafeModeReason = NONE + + Expected behavior: + - prepareForReboot is called before sys_reboot() + - cleanShutdown flag is set to 1 + - On boot, ModeManager sees clean shutdown, no safe mode entry + - Mode restored from persistent state + """ + # Ensure in NORMAL mode before reboot + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 2, "Should be in NORMAL mode before reboot" + + # Issue reboot command (this will disconnect GDS) + logger.info("Issuing COLD_RESET command - GDS will disconnect") + fprime_test_api.send_command("ReferenceDeployment.resetManager.COLD_RESET", []) + + # --- MANUAL STEP: Reconnect GDS after reboot --- + logger.info("MANUAL: Reconnect GDS after board reboots") + time.sleep(30) # Wait for reboot + + # After reconnect, verify no unintended reboot detection + fprime_test_api.clear_histories() + time.sleep(5) # Allow time for events to be received + + events = fprime_test_api.get_event_test_history() + unintended_events = [ + e + for e in events + if "UnintendedRebootDetected" in str(e.get_template().get_name()) + ] + assert len(unintended_events) == 0, ( + "Should NOT have UnintendedRebootDetected event after clean reboot" + ) + + # Verify in NORMAL mode + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 2, "Should be in NORMAL mode after clean reboot" + + # Verify reason is NONE + reason_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentSafeModeReason", timeout=5 + ) + reason_val = reason_result.get_val() + if isinstance(reason_val, str): + assert "NONE" in reason_val.upper(), ( + f"Safe mode reason should be NONE after clean reboot, got {reason_val}" + ) + else: + assert reason_val == SAFE_MODE_REASON_NONE, ( + f"Safe mode reason should be NONE (0) after clean reboot, got {reason_val}" + ) + + +@pytest.mark.skip( + reason="Requires voltage control - run manually with very low battery" +) +def test_safe_09_cascade_payload_to_safe_mode( + fprime_test_api: IntegrationTestAPI, start_gds +): + """ + Test cascading mode transitions when voltage drops below both thresholds. + + MANUAL TEST - requires ability to control battery voltage: + 1. Start in PAYLOAD_MODE + 2. Drop voltage below SAFE_MODE_ENTRY_VOLTAGE (6.7V) - this is below both thresholds + 3. After ~10s: Verify PAYLOAD_MODE → NORMAL transition (voltage < 7.2V) + 4. After ~10 more seconds: Verify NORMAL → SAFE_MODE transition (voltage < 6.7V) + 5. Verify final state is SAFE_MODE with reason = LOW_BATTERY + + Expected behavior: + - Total time: ~20 seconds (10s debounce for each transition) + - First transition: ExitingPayloadMode event (automatic due to low voltage) + - Second transition: AutoSafeModeEntry event with reason=LOW_BATTERY + - Final mode: SAFE_MODE (1) + - Final reason: LOW_BATTERY (1) + + This tests the cascading protection when voltage drops critically low during + payload operations - the system should step down through NORMAL before entering SAFE_MODE. + """ + # First, enter PAYLOAD_MODE + proves_send_and_assert_command( + fprime_test_api, + f"{component}.ENTER_PAYLOAD_MODE", + events=[f"{component}.EnteringPayloadMode"], + ) + time.sleep(2) + + # Verify in PAYLOAD_MODE + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 3, "Should be in PAYLOAD_MODE" + + fprime_test_api.clear_histories() + + # --- MANUAL STEP: Drop voltage below 6.7V (below both thresholds) --- + logger.info( + f"MANUAL: Drop voltage below {SAFE_MODE_ENTRY_VOLTAGE}V (below both thresholds)" + ) + logger.info("This will trigger two transitions:") + logger.info( + f" 1. PAYLOAD_MODE → NORMAL after {SAFE_MODE_DEBOUNCE_SECONDS}s (voltage < 7.2V)" + ) + logger.info( + f" 2. NORMAL → SAFE_MODE after {SAFE_MODE_DEBOUNCE_SECONDS}s more (voltage < 6.7V)" + ) + + # Wait for first transition: PAYLOAD_MODE → NORMAL + logger.info( + f"Waiting {SAFE_MODE_DEBOUNCE_SECONDS + 3}s for PAYLOAD_MODE → NORMAL transition..." + ) + time.sleep(SAFE_MODE_DEBOUNCE_SECONDS + 3) + + # Verify ExitingPayloadMode event (automatic exit due to low voltage) + # Note: The actual event might be ExitingPayloadModeAutomatic or similar + events = fprime_test_api.get_event_test_history() + exit_payload_events = [ + e for e in events if "ExitingPayloadMode" in str(e.get_template().get_name()) + ] + assert len(exit_payload_events) > 0, ( + "Should have ExitingPayloadMode event after first transition" + ) + + # Verify now in NORMAL mode (intermediate state) + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + # Mode could be NORMAL (2) or already SAFE_MODE (1) if second transition happened + assert mode_result.get_val() in [1, 2], ( + f"Should be in NORMAL or SAFE_MODE, got {mode_result.get_val()}" + ) + + if mode_result.get_val() == 2: + # Still in NORMAL - wait for second transition + logger.info( + f"In NORMAL mode. Waiting {SAFE_MODE_DEBOUNCE_SECONDS + 3}s for NORMAL → SAFE_MODE transition..." + ) + time.sleep(SAFE_MODE_DEBOUNCE_SECONDS + 3) + + # Verify AutoSafeModeEntry event + fprime_test_api.assert_event(f"{component}.AutoSafeModeEntry", timeout=5) + + # Verify final state is SAFE_MODE + proves_send_and_assert_command( + fprime_test_api, "CdhCore.tlmSend.SEND_PKT", [MODE_TELEMETRY_PACKET_ID] + ) + mode_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentMode", timeout=5 + ) + assert mode_result.get_val() == 1, "Should be in SAFE_MODE after cascade" + + # Verify reason is LOW_BATTERY + reason_result: ChData = fprime_test_api.assert_telemetry( + f"{component}.CurrentSafeModeReason", timeout=5 + ) + reason_val = reason_result.get_val() + if isinstance(reason_val, str): + assert "LOW_BATTERY" in reason_val.upper(), ( + f"Safe mode reason should be LOW_BATTERY after cascade, got {reason_val}" + ) + else: + assert reason_val == SAFE_MODE_REASON_LOW_BATTERY, ( + f"Safe mode reason should be LOW_BATTERY (1) after cascade, got {reason_val}" + )