fix FDIR reaction

This commit is contained in:
Robin Mueller
2026-09-16 13:05:17 +02:00
parent 61c581d301
commit 7b9c0ca21d
3 changed files with 101 additions and 60 deletions
+23 -40
View File
@@ -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<DeviceModeSelect>,
/// 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<SpiFaultModeSelect>,
}
#[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)))?;
+2
View File
@@ -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");
+76 -20
View File
@@ -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();