feat(firmware): validate configuration and migrate sensor-driven flight APIs
Validate configuration through its owning schemas and keep board declarations separate from shared F722 resource ownership. Bound USB transitions, isolate transport failures and retain restart-only activation. Migrate navigation and flight consumers with the sensor and schema contracts. Require explicit installation data and synchronous output authority; board motor outputs remain inhibited. Validation: assembled-prefix host tests, strict Clippy, platform/USB tests and both ARM release builds. Final-source coverage and scoped Miri/Kani evidence are retained separately from hardware qualification.
This commit is contained in:
1 parent
fb1b8af2d1
commit
f9a61a086f
211 files changed
+45657
-12740
No files matched your search
@@ -0,0 +1,413 @@
|
||||
//! Sensor-driven lifecycle and output boundary regressions.
|
||||
mod support;
|
||||
use kaptan_flight_runtime::*;
|
||||
use support::*;
|
||||
|
||||
#[test]
|
||||
fn sensor_startup_arm_binding_and_ground_disarm() {
|
||||
let (mut ep, mut out, now) = ready();
|
||||
assert_eq!(ep.status().ground_state, GroundState::Grounded);
|
||||
let s = ep
|
||||
.command(
|
||||
request(&ep, 1, FlightCommand::Arm, now),
|
||||
time(now),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.lifecycle, CommandLifecycle::Accepted);
|
||||
assert_eq!(s.arm, Some(ArmId(1)));
|
||||
assert_eq!(ep.status().phase, VehiclePhase::PreArm);
|
||||
let binding = out.outputs.last().unwrap();
|
||||
assert_eq!(binding.arm(), s.arm);
|
||||
assert!(!binding.permitted());
|
||||
let s = ep
|
||||
.command(
|
||||
request(&ep, 2, FlightCommand::GroundDisarm, now),
|
||||
time(now),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.lifecycle, CommandLifecycle::Completed);
|
||||
assert_eq!(ep.status().phase, VehiclePhase::Disarmed);
|
||||
assert_eq!(out.outputs.last().unwrap().arm(), None);
|
||||
}
|
||||
#[test]
|
||||
fn command_ttl_replay_and_epoch_do_not_renew_authority() {
|
||||
let (mut ep, mut out, now) = ready();
|
||||
let zero = request(&ep, 0, FlightCommand::Arm, now);
|
||||
assert_eq!(
|
||||
ep.command(zero, time(now), &mut out).unwrap().reason,
|
||||
CommandReason::Replay
|
||||
);
|
||||
let mut wrong = request(&ep, 50, FlightCommand::Arm, now);
|
||||
wrong.context.owner = CommandOwner(999);
|
||||
assert_eq!(
|
||||
ep.command(wrong, time(now), &mut out).unwrap().reason,
|
||||
CommandReason::WrongOwner
|
||||
);
|
||||
assert_eq!(ep.status().command_highwater, None);
|
||||
let mut expired = request(&ep, 1, FlightCommand::Arm, now);
|
||||
expired.context.expires_at = time(now);
|
||||
assert_eq!(
|
||||
ep.command(expired, time(now), &mut out).unwrap().lifecycle,
|
||||
CommandLifecycle::Rejected
|
||||
);
|
||||
assert_eq!(ep.status().command_highwater, Some(RequestSequence(1)));
|
||||
assert_eq!(
|
||||
ep.command(
|
||||
request(&ep, 1, FlightCommand::Arm, now),
|
||||
time(now),
|
||||
&mut out
|
||||
)
|
||||
.unwrap()
|
||||
.reason,
|
||||
CommandReason::Replay
|
||||
);
|
||||
ep.command(
|
||||
request(&ep, 2, FlightCommand::Arm, now),
|
||||
time(now),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap();
|
||||
for t in (now + 10_000..=now + 600_000).step_by(10_000) {
|
||||
feed(&mut ep, &mut out, t);
|
||||
}
|
||||
assert_eq!(ep.status().phase, VehiclePhase::ArmedIdle); // TTL expired, arm completed.
|
||||
ep.command(
|
||||
request(&ep, 3, FlightCommand::GroundDisarm, now + 600_000),
|
||||
time(now + 600_000),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap();
|
||||
let old = ep.status().epoch;
|
||||
let seq = out.outputs.last().unwrap().sequence();
|
||||
let epoch = ep
|
||||
.begin_epoch(CommandOwner(8), time(now + 600_000), &mut out)
|
||||
.unwrap();
|
||||
assert_ne!(old, epoch);
|
||||
assert_eq!(out.outputs.last().unwrap().sequence(), seq + 1);
|
||||
assert_eq!(ep.status().at, time(now + 600_000));
|
||||
}
|
||||
#[test]
|
||||
fn failed_sink_consumes_sequence_and_attempts_new_inhibition() {
|
||||
let (mut ep, mut out, now) = armed();
|
||||
ep.command(
|
||||
request(
|
||||
&ep,
|
||||
2,
|
||||
FlightCommand::Takeoff {
|
||||
height_above_launch_metres: 2.,
|
||||
},
|
||||
now,
|
||||
),
|
||||
time(now),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap();
|
||||
out.fail_enabled = true;
|
||||
out.fail_inhibit = true;
|
||||
let attempts = out.outputs.len();
|
||||
let err = feed_result(&mut ep, &mut out, now + 10_000).unwrap_err();
|
||||
assert_eq!(out.outputs.len(), attempts + 2);
|
||||
assert_eq!(err.primary, EndpointFailure::Output("enabled"));
|
||||
assert_eq!(err.inhibition, Some("inhibit"));
|
||||
assert_eq!(ep.status().phase, VehiclePhase::Terminated);
|
||||
let n = out.outputs.len();
|
||||
assert!(out.outputs[n - 2].permitted());
|
||||
assert!(!out.outputs[n - 1].permitted());
|
||||
assert_eq!(
|
||||
out.outputs[n - 1].sequence(),
|
||||
out.outputs[n - 2].sequence() + 1
|
||||
);
|
||||
assert!(
|
||||
ep.begin_epoch(CommandOwner(1), time(now + 10_000), &mut out)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_arm_binding_publication_retains_the_failed_transaction() {
|
||||
let (mut ep, mut out, now) = ready();
|
||||
out.fail_inhibit = true;
|
||||
let error = ep
|
||||
.command(
|
||||
request(&ep, 1, FlightCommand::Arm, now),
|
||||
time(now),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.primary, EndpointFailure::Output("inhibit"));
|
||||
assert_eq!(ep.status().phase, VehiclePhase::Terminated);
|
||||
assert_eq!(ep.status().arm, None);
|
||||
let failed = ep.status().last_terminal_command.unwrap();
|
||||
assert_eq!(failed.sequence, RequestSequence(1));
|
||||
assert_eq!(failed.lifecycle, CommandLifecycle::Failed);
|
||||
assert_eq!(failed.arm, Some(ArmId(1)));
|
||||
assert!(!out.outputs.last().unwrap().permitted());
|
||||
}
|
||||
#[test]
|
||||
fn asynchronous_tick_deadline_and_clock_fault_inhibit() {
|
||||
let (mut ep, mut out, now) = armed();
|
||||
ep.command(
|
||||
request(
|
||||
&ep,
|
||||
2,
|
||||
FlightCommand::Takeoff {
|
||||
height_above_launch_metres: 2.,
|
||||
},
|
||||
now,
|
||||
),
|
||||
time(now),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap();
|
||||
feed(&mut ep, &mut out, now + 10_000);
|
||||
let record = *out.outputs.last().unwrap();
|
||||
assert!(record.permitted());
|
||||
assert!(
|
||||
record.valid_until().as_microseconds() <= record.produced_at().as_microseconds() + 20_000
|
||||
);
|
||||
ep.tick(time(now + 10_000), &mut out).unwrap();
|
||||
assert_eq!(*out.outputs.last().unwrap(), record);
|
||||
let error = ep.tick(time(now + 30_001), &mut out).unwrap_err();
|
||||
assert_eq!(error.primary, EndpointFailure::ControlDeadline);
|
||||
assert!(!out.outputs.last().unwrap().permitted());
|
||||
let error = ep.tick(time(now), &mut out).unwrap_err();
|
||||
assert_eq!(error.primary, EndpointFailure::ClockRegression);
|
||||
assert_eq!(ep.status().phase, VehiclePhase::Terminated);
|
||||
}
|
||||
#[test]
|
||||
fn ordinary_prearm_health_failure_clears_arm_without_terminal_fault() {
|
||||
let (mut ep, mut out, now) = ready();
|
||||
ep.command(
|
||||
request(&ep, 1, FlightCommand::Arm, now),
|
||||
time(now),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap();
|
||||
ep.tick(time(now + 25_000), &mut out).unwrap();
|
||||
assert_eq!(ep.status().phase, VehiclePhase::Disarmed);
|
||||
assert_eq!(ep.status().arm, None);
|
||||
assert_eq!(
|
||||
ep.status().last_terminal_command.unwrap().lifecycle,
|
||||
CommandLifecycle::Failed
|
||||
);
|
||||
assert!(!out.outputs.last().unwrap().permitted());
|
||||
}
|
||||
#[test]
|
||||
fn typed_bad_quality_consumes_sensor_watermark_not_health() {
|
||||
let (mut ep, mut out, now) = ready();
|
||||
// The previous complete acquisition ended 1 ms before the service tick.
|
||||
let mut bad = imu(now + 9_000);
|
||||
bad.context.quality = 0;
|
||||
assert_eq!(
|
||||
ep.observe(
|
||||
ep.status().epoch,
|
||||
SensorRecord::Imu(bad),
|
||||
time(now + 10_000),
|
||||
&mut out
|
||||
)
|
||||
.unwrap(),
|
||||
ObservationAdmission::Rejected(ObservationRejection::InvalidQuality)
|
||||
);
|
||||
bad.context.quality = 255;
|
||||
assert_eq!(
|
||||
ep.observe(
|
||||
ep.status().epoch,
|
||||
SensorRecord::Imu(bad),
|
||||
time(now + 10_000),
|
||||
&mut out
|
||||
)
|
||||
.unwrap(),
|
||||
ObservationAdmission::Rejected(ObservationRejection::Replay)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_terminate_does_not_require_current_navigation() {
|
||||
let (mut ep, mut out, now) = armed();
|
||||
let now = now + 50_000;
|
||||
let result = ep
|
||||
.command(
|
||||
request(&ep, 2, FlightCommand::Terminate, now),
|
||||
time(now),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.lifecycle, CommandLifecycle::Completed);
|
||||
assert_eq!(ep.status().phase, VehiclePhase::Terminated);
|
||||
assert_eq!(ep.status().terminal_reason, Some(TerminalReason::Requested));
|
||||
assert!(!out.outputs.last().unwrap().permitted());
|
||||
let sources = ep.status().source_status;
|
||||
for t in (now + 10_000..=now + 500_000).step_by(10_000) {
|
||||
for record in records(t) {
|
||||
assert_eq!(
|
||||
ep.observe(ep.status().epoch, record, time(t), &mut out)
|
||||
.unwrap(),
|
||||
ObservationAdmission::Rejected(ObservationRejection::Terminated)
|
||||
);
|
||||
}
|
||||
ep.tick(time(t), &mut out).unwrap();
|
||||
}
|
||||
assert_eq!(ep.status().source_status, sources);
|
||||
assert_eq!(ep.status().terminal_reason, Some(TerminalReason::Requested));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_source_isolation_is_seen_before_the_next_control_tick() {
|
||||
let (mut ep, mut out, now) = ready();
|
||||
for step in 1..=15 {
|
||||
let t = now + step * 10_000;
|
||||
for mut record in records(t) {
|
||||
let bad = if let SensorRecord::Magnetic(ref mut magnetic) = record {
|
||||
magnetic.context.quality = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let outcome = ep
|
||||
.observe(ep.status().epoch, record, time(t), &mut out)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
outcome,
|
||||
if bad {
|
||||
ObservationAdmission::Rejected(ObservationRejection::InvalidQuality)
|
||||
} else {
|
||||
ObservationAdmission::Queued
|
||||
}
|
||||
);
|
||||
}
|
||||
if step == 15 {
|
||||
let arm = ep
|
||||
.command(request(&ep, 1, FlightCommand::Arm, t), time(t), &mut out)
|
||||
.unwrap();
|
||||
assert_eq!(arm.reason, CommandReason::NotReady);
|
||||
assert_eq!(ep.status().phase, VehiclePhase::Disarmed);
|
||||
}
|
||||
ep.tick(time(t), &mut out).unwrap();
|
||||
}
|
||||
for t in (now + 160_000..=now + 1_400_000).step_by(10_000) {
|
||||
feed(&mut ep, &mut out, t);
|
||||
}
|
||||
assert_eq!(ep.status().phase, VehiclePhase::Disarmed);
|
||||
assert_eq!(ep.status().ground_state, GroundState::Grounded);
|
||||
assert!(ep.status().estimate.unwrap().capabilities.heading.available);
|
||||
let t = now + 1_400_000;
|
||||
assert_eq!(
|
||||
ep.command(request(&ep, 2, FlightCommand::Arm, t), time(t), &mut out)
|
||||
.unwrap()
|
||||
.lifecycle,
|
||||
CommandLifecycle::Accepted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensed_motion_during_prearm_consumes_the_arm_transaction() {
|
||||
let (mut ep, mut out, now) = ready();
|
||||
ep.command(
|
||||
request(&ep, 1, FlightCommand::Arm, now),
|
||||
time(now),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap();
|
||||
for step in 1..=3 {
|
||||
let t = now + step * 10_000;
|
||||
for mut record in records(t) {
|
||||
if let SensorRecord::Imu(ref mut imu) = record {
|
||||
imu.acceleration = kaptan_navigation::AccelerationFrd::metres_per_second_squared(
|
||||
0.0,
|
||||
0.0,
|
||||
-kaptan_navigation::GRAVITY_MPS2 - 5.0,
|
||||
);
|
||||
}
|
||||
ep.observe(ep.status().epoch, record, time(t), &mut out)
|
||||
.unwrap();
|
||||
}
|
||||
ep.tick(time(t), &mut out).unwrap();
|
||||
}
|
||||
assert_eq!(ep.status().phase, VehiclePhase::Disarmed);
|
||||
assert_eq!(ep.status().arm, None);
|
||||
assert_eq!(ep.status().terminal_reason, None);
|
||||
assert_eq!(
|
||||
ep.status().last_terminal_command.unwrap().lifecycle,
|
||||
CommandLifecycle::Failed
|
||||
);
|
||||
assert!(out.outputs.iter().all(|record| !record.permitted()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_notification_sink_observes_both_cancelled_arm_and_ground_disarm() {
|
||||
let (mut ep, mut out, now) = ready();
|
||||
ep.command(
|
||||
request(&ep, 1, FlightCommand::Arm, now),
|
||||
time(now),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap();
|
||||
out.fail_notification = true;
|
||||
let error = ep
|
||||
.command(
|
||||
request(&ep, 2, FlightCommand::GroundDisarm, now),
|
||||
time(now),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.primary, EndpointFailure::Notification("notification"));
|
||||
assert_eq!(ep.status().phase, VehiclePhase::Disarmed);
|
||||
assert!(!out.outputs.last().unwrap().permitted());
|
||||
let outcomes = ep.command_outcomes();
|
||||
let cancelled = outcomes[0].unwrap();
|
||||
let disarmed = outcomes[1].unwrap();
|
||||
assert_eq!(cancelled.status.sequence, RequestSequence(1));
|
||||
assert_eq!(cancelled.status.lifecycle, CommandLifecycle::Cancelled);
|
||||
assert_eq!(disarmed.status.sequence, RequestSequence(2));
|
||||
assert_eq!(disarmed.status.lifecycle, CommandLifecycle::Completed);
|
||||
assert!(!cancelled.published);
|
||||
assert!(!disarmed.published);
|
||||
assert_eq!(
|
||||
&out.commands[out.commands.len() - 2..],
|
||||
&[cancelled.status, disarmed.status]
|
||||
);
|
||||
assert_eq!(ep.status().last_terminal_command, Some(disarmed.status));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asynchronous_tick_and_observe_faults_deliver_failed_command_outcomes() {
|
||||
for observe_fault in [false, true] {
|
||||
let (mut ep, mut out, now) = armed();
|
||||
ep.command(
|
||||
request(
|
||||
&ep,
|
||||
2,
|
||||
FlightCommand::Takeoff {
|
||||
height_above_launch_metres: 2.0,
|
||||
},
|
||||
now,
|
||||
),
|
||||
time(now),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap();
|
||||
out.fail_notification = true;
|
||||
let error = if observe_fault {
|
||||
let sample = imu(now + 30_000);
|
||||
ep.observe(
|
||||
ep.status().epoch,
|
||||
SensorRecord::Imu(sample),
|
||||
time(now + 30_000),
|
||||
&mut out,
|
||||
)
|
||||
.unwrap_err()
|
||||
} else {
|
||||
ep.tick(time(now + 30_000), &mut out).unwrap_err()
|
||||
};
|
||||
assert!(!matches!(error.primary, EndpointFailure::Notification(_)));
|
||||
let outcome = ep.command_outcomes()[0].unwrap();
|
||||
assert_eq!(outcome.status.sequence, RequestSequence(2));
|
||||
assert_eq!(outcome.status.lifecycle, CommandLifecycle::Failed);
|
||||
assert!(!outcome.published);
|
||||
assert_eq!(out.commands.last(), Some(&outcome.status));
|
||||
assert_eq!(ep.status().phase, VehiclePhase::Terminated);
|
||||
assert!(!out.outputs.last().unwrap().permitted());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user