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:
Onur Özkan committed 2026-11-07 18:12:11 +03:00
1 parent fb1b8af2d1
commit f9a61a086f
211 files changed
+45657 -12740

No files matched your search

@@ -0,0 +1,391 @@
//! Level-triggered interrupt scheduling around the real USB transport operations.
extern crate std;
use super::*;
use crate::f722::usb;
use kaptan_mavlink::{encoder::MavlinkEncoder, generated::kaptan::FILE_DATA_DATA};
use kaptan_tx::FrameClass;
use std::{vec, vec::Vec};
use usb::test_bus as usb_bus;
use usb_bus::{Host, PacketBus, with_link};
use usb_device::bus::PollResult;
fn poll_irq(host: &Host, usb: &mut usb::UsbMavlink<'_, PacketBus>) -> bool {
usb.with_rx_interrupt_gate(
|usb| usb.poll(),
|enabled| host.set_rx_interrupt_enabled(enabled),
)
}
fn exchange(
host: &Host,
usb: &mut usb::UsbMavlink<'_, PacketBus>,
frame: Option<MavlinkFrame>,
) -> IoBatch {
exchange_usb(
usb,
frame.map(|frame| PendingFrame {
class: FrameClass::Critical,
frame,
}),
|enabled| host.set_rx_interrupt_enabled(enabled),
)
}
fn file_frame(length: usize) -> MavlinkFrame {
let mut data = FILE_DATA_DATA::DEFAULT;
data.data[..length - 21].fill(0x5a);
let frame = MavlinkEncoder::new(1, 1).encode(&data);
assert_eq!(frame.raw_bytes().len(), length);
frame
}
#[test]
fn register_rmw_changes_only_rxflvlm_and_preserves_adjacent_registers() {
for bit in 0..32 {
for initial in [1u32 << bit, !(1u32 << bit)] {
let mut registers = [0xa5a5_5a5a, initial, 0x5a5a_a5a5];
for enabled in [false, false, true, true, false] {
// SAFETY: this aligned RAM word substitutes for the RW register.
// No concurrent access exists and RAM needs no device barrier.
unsafe { set_rx_fifo_interrupt_enabled(&mut registers[1], enabled) };
assert_eq!(registers[1] & !(1 << 4), initial & !(1 << 4));
assert_eq!(registers[1] & (1 << 4) != 0, enabled);
assert_eq!(registers[0], 0xa5a5_5a5a);
assert_eq!(registers[2], 0x5a5a_a5a5);
}
}
}
}
// The asserted, enabled priority-8 vector wins over the priority-7 consumer.
fn step(
host: &Host,
usb: &mut usb::UsbMavlink<'_, PacketBus>,
ready: &mut bool,
frames: &mut Vec<MavlinkFrame>,
) -> bool {
if host.enter_irq() {
*ready |= poll_irq(host, usb);
false
} else if core::mem::take(ready) {
let batch = exchange(host, usb, None);
*ready |= batch.pending;
if let Some(frame) = batch.usb {
frames.push(*frame.raw());
}
true
} else {
false
}
}
#[test]
fn level_triggered_burst_starves_legacy_consumer_but_gated_consumer_recovers_exact_frames() {
for gate in [false, true] {
with_link(|host, usb| {
let mut encoder = MavlinkEncoder::new(42, 7);
let expected = core::array::from_fn::<_, 3, _>(|_| encoder.heartbeat());
for frame in expected {
host.write_packet(frame.raw_bytes());
}
let mut ready = false;
let mut frames = Vec::new();
let mut workers = 0;
for _ in 0..32 {
if gate {
workers += usize::from(step(host, usb, &mut ready, &mut frames));
} else if host.enter_irq() {
ready |= usb.poll(); // Original handler: no RX source gate.
} else if core::mem::take(&mut ready) {
workers += 1;
let batch = exchange(host, usb, None);
ready |= batch.pending;
if let Some(frame) = batch.usb {
frames.push(*frame.raw());
}
}
}
if gate {
assert_eq!(frames, expected);
assert!(workers > 0);
assert_eq!(host.receive_backlog(), (0, false));
assert!(host.rx_interrupt_enabled());
assert!(host.nvic_enabled());
} else {
assert_eq!(
workers, 0,
"level IRQ prevents the only consumer from running"
);
assert!(frames.is_empty());
assert_eq!(
host.receive_backlog(),
(1, true),
"hardware FIFO and driver buffer are both occupied"
);
assert!(
host.enter_irq(),
"source is still asserted after repeated polls"
);
}
});
}
}
#[test]
fn split_coalesced_bad_crc_and_zlp_packets_preserve_rx_and_live_tx_boundaries() {
with_link(|host, usb| {
let incoming = file_frame(130);
let mut encoder = MavlinkEncoder::new(42, 7);
let first = encoder.heartbeat();
let mut corrupt = encoder.heartbeat().raw_bytes().to_vec();
*corrupt.last_mut().unwrap() ^= 1;
let last = encoder.heartbeat();
host.write_packet(&incoming.raw_bytes()[..64]);
host.write_packet(&[]); // An OUT ZLP must not mask a partial MAVLink frame.
host.write_packet(&incoming.raw_bytes()[64..128]);
host.write_packet(
&[
&incoming.raw_bytes()[128..],
first.raw_bytes(),
&corrupt,
last.raw_bytes(),
]
.concat(),
);
let outgoing = file_frame(128);
let after = encoder.heartbeat();
assert!(exchange(host, usb, Some(outgoing)).accepted);
assert!(host.enter_irq());
assert!(poll_irq(host, usb));
assert!(!host.rx_interrupt_enabled());
assert!(host.nvic_enabled());
let mut packets = vec![host.take_packet().unwrap()];
assert!(
host.enter_irq(),
"IN completion interrupts while RX is masked"
);
assert!(poll_irq(host, usb));
packets.push(host.take_packet().unwrap());
assert_eq!(packets.iter().map(Vec::len).collect::<Vec<_>>(), [64, 64]);
assert!(host.enter_irq());
assert!(poll_irq(host, usb));
packets.push(host.take_packet().unwrap());
assert_eq!(packets[2], [], "TX ZLP progresses without the RX consumer");
let sent = exchange(host, usb, Some(after));
assert!(sent.accepted);
assert!(
!sent.pending,
"TX admission's IoBatch flag is not the IRQ policy"
);
assert!(
!host.rx_interrupt_enabled(),
"TX must preserve the actual unread RX gate"
);
if let Some(packet) = host.take_packet() {
packets.push(packet);
}
let partial = exchange(host, usb, None);
assert!(partial.usb.is_none());
assert!(!partial.pending);
assert!(
host.rx_interrupt_enabled(),
"the partial framer is waiting for more bytes, not the worker"
);
let mut ready = false;
let mut frames = Vec::new();
for _ in 0..64 {
step(host, usb, &mut ready, &mut frames);
if let Some(packet) = host.take_packet() {
packets.push(packet);
}
}
assert_eq!(frames, [incoming, first, last]);
assert_eq!(
packets.iter().map(Vec::len).collect::<Vec<_>>(),
[64, 64, 0, 13]
);
assert_eq!(
packets.concat(),
[outgoing.raw_bytes(), after.raw_bytes()].concat()
);
assert!(host.nvic_enabled());
});
}
#[test]
fn final_poll_refill_stays_masked_and_rearm_preserves_already_pending_irq() {
with_link(|host, usb| {
let frame = MavlinkEncoder::new(42, 7).heartbeat();
host.write_packet(&frame.raw_bytes()[..8]);
assert!(host.enter_irq());
usb.with_rx_interrupt_gate(
|usb| {
assert!(usb.poll());
// Latch NVIC pending just before masking the peripheral source.
host.write_packet(&frame.raw_bytes()[8..]);
},
|enabled| host.set_rx_interrupt_enabled(enabled),
);
let partial = exchange(host, usb, None);
assert!(partial.usb.is_none());
assert!(partial.pending, "the final poll refilled rx_remaining");
assert!(!host.rx_interrupt_enabled());
assert!(host.nvic_enabled());
let complete = exchange(host, usb, None);
assert_eq!(complete.usb.unwrap().raw(), &frame);
assert!(!complete.pending);
assert_eq!(host.receive_backlog(), (0, false));
assert!(
host.enter_irq(),
"RX rearm preserves latched NVIC pending even after polling consumed the source"
);
assert!(!poll_irq(host, usb));
});
}
#[test]
fn arrivals_on_either_side_of_rearm_are_not_lost() {
for before in [true, false] {
with_link(|host, usb| {
let mut encoder = MavlinkEncoder::new(42, 7);
let first = encoder.heartbeat();
let next = encoder.heartbeat();
host.write_packet(first.raw_bytes());
assert!(host.enter_irq());
assert!(poll_irq(host, usb));
let batch = exchange_usb(usb, None, |enabled| {
assert!(enabled);
if before {
host.write_packet(next.raw_bytes());
}
host.set_rx_interrupt_enabled(enabled);
if !before {
host.write_packet(next.raw_bytes());
}
});
assert_eq!(batch.usb.unwrap().raw(), &first);
assert!(!batch.pending);
assert!(host.enter_irq());
assert!(poll_irq(host, usb));
assert_eq!(exchange(host, usb, None).usb.unwrap().raw(), &next);
});
}
}
#[test]
fn backpressure_keeps_reset_suspend_and_resume_interrupts_serviceable() {
for reset in [true, false] {
with_link(|host, usb| {
let frame = file_frame(130);
assert!(exchange(host, usb, Some(frame)).accepted);
for _ in 0..3 {
host.write_packet(&frame.raw_bytes()[..8]);
}
assert!(host.enter_irq());
assert!(poll_irq(host, usb));
assert!(host.enter_irq(), "one already-pending exception is allowed");
assert!(poll_irq(host, usb));
assert_eq!(host.receive_backlog(), (1, true));
assert!(!host.enter_irq());
let (_, original_mask) = host.interrupt_status();
host.event(if reset {
PollResult::Reset
} else {
PollResult::Suspend
});
assert!(host.enter_irq());
poll_irq(host, usb);
assert!(!usb.is_open());
assert!(host.events_empty());
assert!(host.nvic_enabled());
assert_eq!(
host.interrupt_status().1 & !(1 << 4),
original_mask & !(1 << 4)
);
if reset {
assert!(host.rx_interrupt_enabled());
assert_eq!(host.receive_backlog(), (0, false));
host.configure(usb, true);
host.dtr(usb, true);
assert!(
host.collect_packets(usb).is_empty(),
"reset discarded the old TX queue"
);
} else {
assert!(!host.rx_interrupt_enabled(), "suspend retains unread RX");
assert_eq!(host.receive_backlog(), (1, true));
host.event(PollResult::Resume);
assert!(host.enter_irq());
poll_irq(host, usb);
assert!(usb.is_open());
assert_eq!(host.collect_packets(usb).concat(), frame.raw_bytes());
}
});
}
}
#[test]
fn ep0_setup_cannot_bypass_a_blocked_bulk_fifo_head() {
with_link(|host, usb| {
let frame = MavlinkEncoder::new(42, 7).heartbeat();
assert!(exchange(host, usb, Some(frame)).accepted);
for _ in 0..3 {
host.write_packet(frame.raw_bytes());
}
assert!(host.enter_irq());
poll_irq(host, usb);
assert!(host.enter_irq());
poll_irq(host, usb);
assert_eq!(host.receive_backlog(), (1, true));
assert!(!host.enter_irq());
host.queue_control([0x21, 0x22, 0, 0, 0, 0, 0, 0]); // DTR close behind bulk
assert_eq!(host.receive_backlog(), (2, true));
assert!(
!host.enter_irq(),
"EP0 arrival also uses the masked RX source"
);
let (raw, mask) = host.interrupt_status();
assert_ne!(raw & (1 << 4), 0);
assert_eq!(raw & mask, 0);
host.take_packet().unwrap(); // IN completion can interrupt and poll raw RX.
assert!(host.enter_irq());
poll_irq(host, usb);
assert_eq!(host.receive_backlog(), (2, true));
assert!(
usb.is_open(),
"SETUP cannot jump over the blocked FIFO head"
);
assert_eq!(exchange(host, usb, None).usb.unwrap().raw(), &frame);
exchange(host, usb, None);
assert!(!usb.is_open(), "consumer progress finally allows DTR close");
assert!(host.rx_interrupt_enabled());
assert!(host.nvic_enabled());
});
}
#[test]
fn full_fifo_backpressures_host_then_consumer_progress_allows_retry() {
with_link(|host, usb| {
let frame = file_frame(64);
for _ in 0..3 {
assert!(host.try_write_packet(frame.raw_bytes()));
}
assert!(!host.try_write_packet(frame.raw_bytes()));
let mut ready = false;
let mut frames = Vec::new();
for _ in 0..32 {
step(host, usb, &mut ready, &mut frames);
}
assert_eq!(frames, [frame; 3]);
assert!(host.try_write_packet(frame.raw_bytes()));
for _ in 0..16 {
step(host, usb, &mut ready, &mut frames);
}
assert_eq!(frames, [frame; 4]);
assert_eq!(host.receive_backlog(), (0, false));
assert!(host.rx_interrupt_enabled());
assert!(host.nvic_enabled());
});
}