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

@@ -1,162 +1,212 @@
//! F722 transport and vertical-route adapters for the portable data channel.
use super::peripherals;
use kaptan_dma::TxCompletion;
use kaptan_mavlink::{
MavlinkFrame, receiver::IntegrityCheckedFrame, transmitter::MavlinkTransmitter,
//! Independent per-link scheduling, encoding, framing and bounded transmission.
use super::{
peripherals::{self, F722UsbMavlink, OwnedUart, ReceiveInterrupt},
serial::{ReceiveEvent, RxChunk, SerialDevice, SerialIo},
};
use crate::SerialInventory;
use kaptan_data_channel::{DataChannel, DataChannelRates};
use kaptan_mavlink::{
MavlinkFrame,
receiver::{IntegrityCheckedFrame, IntegrityMavlinkReceiver},
};
use kaptan_ports::PortId;
use kaptan_tx::PendingFrame;
use peripherals::F722UsbMavlink;
// Baud, sensor topology, and mounting remain immutable build-time policy. The
// output-inhibited board image may recover endpoint identity and selected
// operational rates before this channel is constructed.
pub(crate) const UART1_CLOCK_HZ: u32 = 108_000_000;
pub(crate) const UART2_CLOCK_HZ: u32 = 54_000_000;
pub(crate) const SERVICE_HZ: usize = 100;
/// Fixed service-timer configuration for the validated Pll216 profile.
pub(crate) const SERVICE_TIMER_CONFIG: peripherals::Tim2PeriodicConfig =
peripherals::Tim2PeriodicConfig {
prescaler: 10_799,
auto_reload: 99,
};
/// The two F722 DMA engines behind the portable data-channel transport.
enum F722MavlinkTransport {
Usart1(MavlinkTransmitter<peripherals::DcacheDisabledDmaMemory, peripherals::Usart1TxDma, 4>),
Usart2(MavlinkTransmitter<peripherals::DcacheDisabledDmaMemory, peripherals::Usart2TxDma, 4>),
pub(crate) struct Link {
pub(crate) channel: DataChannel<SERVICE_HZ>,
pub(crate) receiver: IntegrityMavlinkReceiver<2>,
}
impl Link {
pub(crate) fn new(
system: u8,
component: u8,
rates: DataChannelRates,
) -> Result<Self, crate::BoardError> {
Ok(Self {
channel: DataChannel::new(system, component, rates, 100_000)
.map_err(|_| crate::BoardError::Initialization)?,
receiver: IntegrityMavlinkReceiver::new(),
})
}
}
/// Physical link on which an inbound frame arrived.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum MavlinkIngress {
Uart,
Usb,
impl SerialDevice for OwnedUart {
fn receive(&mut self) -> ReceiveEvent {
match OwnedUart::receive(self) {
ReceiveInterrupt::Byte(byte) => ReceiveEvent::Byte(byte),
ReceiveInterrupt::Fault => ReceiveEvent::Fault,
ReceiveInterrupt::NotPending => ReceiveEvent::Idle,
}
}
}
/// Concrete F722 MAVLink link ownership.
pub(crate) struct F722MavlinkLink {
uart: F722MavlinkTransport,
/// Bounded data exchanged between the worker and I/O owners. Decoding,
/// scheduling and persistence cannot borrow the physical owners through this API.
#[derive(Default)]
pub struct IoBatch {
pub(crate) serial: RxChunk,
pub(crate) usb: Option<IntegrityCheckedFrame>,
pub(crate) accepted: bool,
pub(crate) pending: bool,
}
impl<I: SerialDevice> SerialIo<I> {
pub fn exchange(&mut self, frame: Option<PendingFrame<MavlinkFrame>>) -> IoBatch {
match frame {
Some(frame) => IoBatch {
accepted: self.send(frame),
..IoBatch::default()
},
None => {
let serial = self.take_chunk();
IoBatch {
pending: serial.pending,
serial,
..IoBatch::default()
}
}
}
}
}
/// One short-lock RTIC resource for the board's declared UARTs. All UART IRQs
/// have priority 14. Protocol work stays outside this bounded I/O handoff.
pub struct PortIo<const N: usize> {
inventory: SerialInventory,
pub(crate) ports: [SerialIo<OwnedUart>; N],
}
impl<const N: usize> PortIo<N> {
pub(crate) fn new(inventory: SerialInventory) -> Self {
assert_eq!(inventory.len(), N, "validated board port count");
Self {
inventory,
ports: core::array::from_fn(|_| SerialIo::new(None, false)),
}
}
pub fn on_interrupt(&mut self, id: PortId, now: kaptan_time::Timestamp) -> bool {
self.inventory
.index(id)
.is_some_and(|index| self.ports[index].on_interrupt(now))
}
pub fn exchange(&mut self, id: PortId, frame: Option<PendingFrame<MavlinkFrame>>) -> IoBatch {
self.inventory
.index(id)
.map_or_else(IoBatch::default, |index| self.ports[index].exchange(frame))
}
}
pub struct UsbIo {
usb: F722UsbMavlink,
rx_interrupt: RxFifoInterruptGate,
}
impl UsbIo {
pub(crate) fn new(usb: F722UsbMavlink, rx_interrupt: RxFifoInterruptGate) -> Self {
Self { usb, rx_interrupt }
}
pub fn on_interrupt(&mut self) -> bool {
self.usb.with_rx_interrupt_gate(
|usb| usb.poll(),
|enabled| self.rx_interrupt.set_enabled(enabled),
)
}
pub fn exchange(&mut self, frame: Option<PendingFrame<MavlinkFrame>>) -> IoBatch {
exchange_usb(&mut self.usb, frame, |enabled| {
self.rx_interrupt.set_enabled(enabled)
})
}
}
impl F722MavlinkLink {
pub(crate) fn usart1(
rcc: &stm32f7::stm32f722::RCC,
uart: stm32f7::stm32f722::USART1,
baud: u32,
usb: F722UsbMavlink,
) -> Result<Self, ()> {
let uart = peripherals::Usart1Tx::new(rcc, uart, UART1_CLOCK_HZ, baud).map_err(|_| ())?;
// SAFETY: platform initialization disables D-cache before this object
// is created, and the active MAVLink frame remains queue-owned until
// this platform receives a terminal DMA completion notification.
let memory = unsafe { peripherals::DcacheDisabledDmaMemory::new() };
Ok(Self {
uart: F722MavlinkTransport::Usart1(MavlinkTransmitter::new(
memory,
peripherals::Usart1TxDma::new(rcc, uart),
)),
usb,
})
/// Private F722 capability delegated alongside the UsbPeripheral in its factory.
/// The USB I/O owner retains it at runtime. No register-block reference is made.
pub(crate) struct RxFifoInterruptGate {
_private: (),
}
impl RxFifoInterruptGate {
pub(crate) fn new(_global: &stm32f7::stm32f722::OTG_FS_GLOBAL) -> Self {
Self { _private: () }
}
pub(crate) fn usart2(
rcc: &stm32f7::stm32f722::RCC,
uart: stm32f7::stm32f722::USART2,
baud: u32,
usb: F722UsbMavlink,
) -> Result<Self, ()> {
let uart = peripherals::Usart2Tx::new(rcc, uart, UART2_CLOCK_HZ, baud).map_err(|_| ())?;
// SAFETY: identical DMA-memory invariant to the USART1 route above;
// only the fixed platform DMA endpoint differs.
let memory = unsafe { peripherals::DcacheDisabledDmaMemory::new() };
Ok(Self {
uart: F722MavlinkTransport::Usart2(MavlinkTransmitter::new(
memory,
peripherals::Usart2TxDma::new(rcc, uart),
)),
usb,
})
/// Call after bus enable in init, or inside the USB RTIC resource lock.
/// Defers RXFLVL only. IN, reset and suspend can still interrupt. EP0 shares
/// the RX FIFO and cannot bypass a bulk packet blocked at its head.
pub(crate) fn set_enabled(&mut self, enabled: bool) {
// RM0431: OTG_FS_GINTMSK is an aligned, 32-bit RW register at +0x18.
// Form only its raw address, without borrowing the driver's register block.
let gintmsk = (stm32f7::stm32f722::OTG_FS_GLOBAL::ptr() as usize + 0x18) as *mut u32;
// SAFETY: the factory consumes the PAC tokens and enables the bus before
// use. This single-core device keeps its USB clock on. UsbIo's RTIC lock
// excludes every runtime USB caller through this RMW. The pinned 0.4.0
// driver writes GINTMSK only in enable(), before this capability is used.
unsafe { set_rx_fifo_interrupt_enabled(gintmsk, enabled) };
// Complete the device write before unlocking or returning from the IRQ.
cortex_m::asm::dsb();
}
}
/// Sends publications on the open USB link, or on UART when USB is closed.
pub(crate) fn send_active(&mut self, pending: PendingFrame<MavlinkFrame>) -> bool {
if self.usb.is_open() {
self.usb.send(pending)
/// # Safety
/// `gintmsk` must permit aligned 32-bit volatile reads/writes, with no concurrent
/// writer. The caller must complete the device write before releasing exclusion.
unsafe fn set_rx_fifo_interrupt_enabled(gintmsk: *mut u32, enabled: bool) {
const RXFLVLM: u32 = 1 << 4;
// SAFETY: the caller provides exclusive access to this RW register. GINTMSK
// has no read/clear or write-one-to-clear fields. Preserve all other bits.
unsafe {
let mask = gintmsk.read_volatile();
gintmsk.write_volatile(if enabled {
mask | RXFLVLM
} else {
self.send_uart(pending)
}
}
/// Sends a protocol reply only on the link that supplied its request.
pub(crate) fn send_to(
&mut self,
ingress: MavlinkIngress,
pending: PendingFrame<MavlinkFrame>,
) -> bool {
match ingress {
MavlinkIngress::Uart => self.send_uart(pending),
MavlinkIngress::Usb => self.usb.send(pending),
}
}
fn send_uart(&mut self, pending: PendingFrame<MavlinkFrame>) -> bool {
match &mut self.uart {
F722MavlinkTransport::Usart1(transmitter) => transmitter
.admit_and_start(pending.class, pending.frame)
.admission
.is_retained(),
F722MavlinkTransport::Usart2(transmitter) => transmitter
.admit_and_start(pending.class, pending.frame)
.admission
.is_retained(),
}
}
pub(crate) fn on_dma_complete(&mut self, completion: TxCompletion) {
match &mut self.uart {
F722MavlinkTransport::Usart1(transmitter) => {
let _ = transmitter.on_dma_complete(completion);
}
F722MavlinkTransport::Usart2(transmitter) => {
let _ = transmitter.on_dma_complete(completion);
}
}
}
/// Services USB and reports whether the protocol worker has bytes to parse.
pub(crate) fn poll_usb(&mut self) -> bool {
self.usb.poll()
}
pub(crate) fn take_usb_received(&mut self) -> Option<IntegrityCheckedFrame> {
self.usb.take_received()
mask & !RXFLVLM
});
}
}
/// Board-route status light; it is not part of a board declaration API.
pub(crate) fn exchange_usb<B: super::usb::UsbBusHealth>(
usb: &mut super::usb::UsbMavlink<'_, B>,
frame: Option<PendingFrame<MavlinkFrame>>,
set_rx_interrupt_enabled: impl FnOnce(bool),
) -> IoBatch {
usb.with_rx_interrupt_gate(
|usb| match frame {
Some(frame) => IoBatch {
accepted: usb.send(frame),
..IoBatch::default()
},
None => {
usb.poll();
// The existing USB adapter consumes at most one 64-byte packet
// here, including bounded framing. Message decode/dispatch stays
// worker-local. Bus polling has separate hardware wait constraints.
let frame = usb.take_received();
let pending = usb.poll();
IoBatch {
usb: frame,
pending,
..IoBatch::default()
}
}
},
set_rx_interrupt_enabled,
)
}
pub(crate) enum F722StatusLed {
Kakute(peripherals::ActiveLowLedPd2),
Geprc(GeprcStatusLed),
Pd2(peripherals::ActiveLowLedPd2),
Pa13(GeprcStatusLed),
}
impl F722StatusLed {
pub(crate) fn toggle(&mut self) {
match self {
Self::Kakute(led) => led.toggle(),
Self::Geprc(led) => led.toggle(),
Self::Pd2(led) => led.toggle(),
Self::Pa13(led) => led.toggle(),
}
}
}
/// GEPRC GEP-F722-HD V2 blue status LED on PA13.
pub(crate) struct GeprcStatusLed {
port: stm32f7::stm32f722::GPIOA,
is_high: bool,
}
impl GeprcStatusLed {
pub(crate) fn new(rcc: &stm32f7::stm32f722::RCC, port: stm32f7::stm32f722::GPIOA) -> Self {
rcc.ahb1enr().modify(|_, write| write.gpioaen().set_bit());
@@ -171,7 +221,6 @@ impl GeprcStatusLed {
is_high: false,
}
}
fn toggle(&mut self) {
self.is_high = !self.is_high;
self.port.bsrr().write(|write| {
@@ -183,3 +232,7 @@ impl GeprcStatusLed {
});
}
}
#[cfg(test)]
#[path = "../../tests/support/usb_irq.rs"]
mod usb_irq;