diff --git a/satrs-example/client/src/main.rs b/satrs-example/client/src/main.rs index 26b7e45..ed90001 100644 --- a/satrs-example/client/src/main.rs +++ b/satrs-example/client/src/main.rs @@ -30,41 +30,12 @@ pub struct Cli { #[derive(clap::Subcommand)] enum Commands { - /// Commands addressed to a component in the OBSW. - #[command(subcommand)] - Obsw(ObswCommand), - /// Commands that talk straight to minisim's own control port, bypassing the OBSW entirely. - /// - /// This is the same control port the OBSW's internal sim client uses. Kept separate from - /// `Obsw`, since these commands have no OBSW component to address and only make sense in a - /// test/simulation environment, never something the real flight software could ask for. - #[command(subcommand)] - Sim(SimCommand), -} - -#[derive(clap::Subcommand)] -enum ObswCommand { Mgm0(MgmArgs), Mgm1(MgmArgs), MgmAssy(MgmAssemblyArgs), AcsSubsystem(SubsystemArgs), } -#[derive(clap::Subcommand)] -enum SimCommand { - Fault(SimFaultArgs), -} - -#[derive(Debug, PartialEq, Eq, Clone, Copy, clap::Parser)] -struct SimFaultArgs { - /// Fault mode to inject on the simulated MGM0 SPI bus. - #[arg(value_enum)] - mode: SpiFaultModeSelect, -} - -// MGM1 is not selectable here: MgmRequestLis3Mdl::TARGET is hardcoded to Mgm0Lis3Mdl, so a -// request built from this payload always reaches the MGM0 model regardless of intent. This -// appears to be a pre-existing minisim limitation, not something specific to fault injection. #[derive(Debug, PartialEq, Eq, Clone, Copy, clap::ValueEnum)] enum SpiFaultModeSelect { None, @@ -90,6 +61,12 @@ struct MgmArgs { request_hk: bool, #[arg(short, long)] mode: Option, + /// Inject (or clear) an SPI bus failure on the simulated device, bypassing the OBSW. + /// + /// Only takes effect for MGM0: minisim always routes this fault to the MGM0 model + /// regardless of which MGM the request names (a pre-existing minisim limitation). + #[arg(long, value_enum)] + spi_fault: Option, } #[derive(Debug, PartialEq, Eq, Clone, Copy, clap::Parser)] @@ -132,7 +109,15 @@ fn handle_mgm_command( addr: SocketAddr, target_id: types::ComponentId, args: MgmArgs, -) { +) -> anyhow::Result<()> { + if let Some(mode) = args.spi_fault { + if target_id != types::ComponentId::AcsMgm0 { + bail!( + "SPI fault injection is only supported for MGM0 right now (minisim limitation)" + ); + } + inject_mgm_failure(mode.into())?; + } if args.ping { let request = types::ccsds::CcsdsTcPacketOwned::new_with_request( SpacePacketHeader::new_from_apid(u11::new(Apid::Acs as u16)), @@ -188,6 +173,7 @@ fn handle_mgm_command( let request_packet = request.to_vec(); client.send_to(&request_packet, addr).unwrap(); } + Ok(()) } fn setup_logger(level: log::LevelFilter) -> Result<(), fern::InitError> { @@ -247,16 +233,13 @@ fn main() -> anyhow::Result<()> { } if let Some(cmd) = cli.commands { match cmd { - Commands::Sim(SimCommand::Fault(sim_fault_args)) => { - inject_sim_fault(sim_fault_args.mode.into())?; + Commands::Mgm0(args) => { + handle_mgm_command(&client, addr, types::ComponentId::AcsMgm0, args)? } - Commands::Obsw(ObswCommand::Mgm0(args)) => { - handle_mgm_command(&client, addr, types::ComponentId::AcsMgm0, args) + Commands::Mgm1(args) => { + handle_mgm_command(&client, addr, types::ComponentId::AcsMgm1, args)? } - Commands::Obsw(ObswCommand::Mgm1(args)) => { - handle_mgm_command(&client, addr, types::ComponentId::AcsMgm1, args) - } - Commands::Obsw(ObswCommand::MgmAssy(mgm_assembly_args)) => { + Commands::MgmAssy(mgm_assembly_args) => { let target_id = types::ComponentId::AcsMgmAssembly; if mgm_assembly_args.ping { let request = types::ccsds::CcsdsTcPacketOwned::new_with_request( @@ -303,7 +286,7 @@ fn main() -> anyhow::Result<()> { client.send_to(&request_packet, addr).unwrap(); } } - Commands::Obsw(ObswCommand::AcsSubsystem(subsystem_args)) => { + Commands::AcsSubsystem(subsystem_args) => { let target_id = types::ComponentId::AcsSubsystem; if subsystem_args.ping { let request = types::ccsds::CcsdsTcPacketOwned::new_with_request( @@ -373,7 +356,7 @@ fn main() -> anyhow::Result<()> { /// Confirms the simulator is actually reachable first (same ping/pong check the OBSW's own /// internal sim client does, see `SimClientUdp::attempt_connection`), since a fire-and-forget /// UDP send would otherwise silently do nothing if minisim is not running. -fn inject_sim_fault(mode: SpiFaultMode) -> anyhow::Result<()> { +fn inject_mgm_failure(mode: SpiFaultMode) -> anyhow::Result<()> { let sim_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), SIM_CTRL_PORT); let sim_socket = UdpSocket::bind("127.0.0.1:0")?; sim_socket.set_read_timeout(Some(Duration::from_millis(200)))?; diff --git a/satrs-example/minisim/src/controller.rs b/satrs-example/minisim/src/controller.rs index ca43a40..c805128 100644 --- a/satrs-example/minisim/src/controller.rs +++ b/satrs-example/minisim/src/controller.rs @@ -124,6 +124,7 @@ impl SimController { } match sim_ctrl_request { SimCtrlRequest::Ping => { + log::info!("received ping request, a client is connecting"); self.reply_sender .send(SimReply::new(&SimCtrlReply::Pong)) .expect("sending reply from sim controller failed"); @@ -160,6 +161,7 @@ impl SimController { _ => panic!("invalid mgm index"), }; + log::info!("MGM{mgm_idx}: setting SPI fault mode to {fault_mode:?}"); self.simulation .process_event(MagnetometerModel::set_spi_fault, fault_mode, addr) .expect("event execution error for mgm"); diff --git a/satrs-example/src/acs/mgm.rs b/satrs-example/src/acs/mgm.rs index 0eae501..ba3e0c0 100644 --- a/satrs-example/src/acs/mgm.rs +++ b/satrs-example/src/acs/mgm.rs @@ -424,6 +424,14 @@ impl MgmHandlerLis3Mdl { // TODO: Event? Health-table changes are currently invisible to the ground // except through this log line. Likely applies to other health/mode // transitions across the example app too, not just this one. + // Do not restart an already pending Off transition: poll_sensor still calls + // this every cycle the fault persists, and current stays Normal until the + // transition completes, so re-triggering here would keep resetting the + // transition state machine before it can ever finish. + if self.mode_helpers.target != Some(DeviceMode::Off) { + log::warn!("{}: commanding device off due to fault", self.id.str()); + self.start_transition(DeviceMode::Off, true); + } } } } @@ -441,29 +449,35 @@ impl MgmHandlerLis3Mdl { return; } let target_mode = self.mode_helpers.target.unwrap(); - if target_mode == DeviceMode::On || target_mode == DeviceMode::Normal { - if self.mode_helpers.transition_state == TransitionState::Idle { - let result = self - .switch_helper - .send_switch_on_cmd(MessageMetadata::new(0, self.id as u32), self.switch_id()); - if result.is_err() { - // Could not send switch command.. still continue with transition. - log::error!("failed to send switch on command"); - } - self.mode_helpers.transition_state = TransitionState::PowerSwitching; + let switch_target_on = target_mode != DeviceMode::Off; + if self.mode_helpers.transition_state == TransitionState::Idle { + let result = if switch_target_on { + self.switch_helper + .send_switch_on_cmd(MessageMetadata::new(0, self.id as u32), self.switch_id()) + } else { + self.switch_helper + .send_switch_off_cmd(MessageMetadata::new(0, self.id as u32), self.switch_id()) + }; + if result.is_err() { + // Could not send switch command.. still continue with transition. + log::error!( + "failed to send switch {} command", + if switch_target_on { "on" } else { "off" } + ); } - if self.mode_helpers.transition_state == TransitionState::PowerSwitching { - if self.switch_helper.is_switch_on(self.switch_id()) { - log::info!("switch is on"); - self.mode_helpers.transition_state = TransitionState::Done; - } else if self.mode_helpers.timed_out() { - self.handle_mode_transition_failure(); - } - } - if self.mode_helpers.transition_state == TransitionState::Done { - self.handle_mode_reached(); + self.mode_helpers.transition_state = TransitionState::PowerSwitching; + } + if self.mode_helpers.transition_state == TransitionState::PowerSwitching { + if self.switch_helper.is_switch_on(self.switch_id()) == switch_target_on { + log::info!("switch is {}", if switch_target_on { "on" } else { "off" }); + self.mode_helpers.transition_state = TransitionState::Done; + } else if self.mode_helpers.timed_out() { + self.handle_mode_transition_failure(); } } + if self.mode_helpers.transition_state == TransitionState::Done { + self.handle_mode_reached(); + } } // Should be called to complete a mode transition which failed. @@ -889,6 +903,48 @@ mod tests { assert!(!testbench.handler.shared_mgm_set.lock().unwrap().valid); } + #[test] + fn test_spi_fault_above_threshold_commands_device_off() { + let mut testbench = MgmTestbench::new(); + testbench.switch_to_normal(); + // Drain the switch-on request left over from switch_to_normal(). + testbench + .switch_rx + .try_recv() + .expect("no switch-on request sent"); + testbench.test_spi_interface().next_mgm_data = MgmLis3RawValues { + x: -1, + y: -1, + z: -1, + }; + for _ in 0..SPI_FAULT_THRESHOLD + 1 { + testbench.handler.periodic_operation(); + } + assert_eq!( + testbench.health_table.health(ComponentId::AcsMgm0.into()), + Some(HealthState::Faulty) + ); + // The Off transition was only started on the last iteration above, so it has not sent + // its switch-off request yet: drive one more cycle to let it do so. + testbench.handler.periodic_operation(); + let switch_req = testbench + .switch_rx + .try_recv() + .expect("no switch-off request sent after fault"); + assert_eq!(switch_req.message.switch_id, SwitchId::Mgm0); + assert_eq!(switch_req.message.target_state, SwitchStateBinary::Off); + + // Simulate the PCDU acting on the switch-off request. + testbench + .shared_switch_set + .lock() + .unwrap() + .set_switch_state(SwitchId::Mgm0, SwitchState::Off); + testbench.handler.periodic_operation(); + + assert_eq!(testbench.handler.mode(), DeviceMode::Off); + } + #[test] fn test_spi_fault_does_not_override_external_control() { let mut testbench = MgmTestbench::new();