refactor(firmware): centralize configuration and diagnostics
This commit is contained in:
1 parent
7048ed89d0
commit
387fdaad23
140 files changed
+6891
-4541
No files matched your search
@@ -1,14 +1,8 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use kaptan_board::{
|
||||
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
|
||||
TelemetryProfile, VerticalSensingKind,
|
||||
};
|
||||
use kaptan_log::{ErrorAdditionalInformation, ErrorPath, LogFlags, LogRecord, LogText, Severity};
|
||||
use kaptan_log_mavlink::{DiagnosticPayload, encode_diagnostic};
|
||||
use kaptan_mavlink::encoder::MavlinkEncoder;
|
||||
use platform_stm32f7::{
|
||||
BoardError, F7BoardBuilder, Mavlink, Mpu6000, Pa4, VerticalSensor, validate_board_config,
|
||||
AccelerometerRange, DigitalLowPassFilter, F722Hardware, F722ImmutableConfig, GyroscopeRange,
|
||||
ImuMount, Mavlink, Mpu6000, Mpu6000Config, Pa4, Pb0Pb1Pb4Pb5Low, Pd2ActiveLow, VerticalSensor,
|
||||
};
|
||||
|
||||
fn platform_source(relative: &str) -> String {
|
||||
@@ -20,7 +14,7 @@ fn platform_source(relative: &str) -> String {
|
||||
fn former_motor_pins_are_inhibited_before_all_other_initialization() {
|
||||
let module = platform_source("src/f722/mod.rs");
|
||||
let interlock = module
|
||||
.find("motor_inhibit::inhibit_motor_pins")
|
||||
.find("motor_inhibit::inhibit_pb0_pb1_pb4_pb5")
|
||||
.expect("F722 initialization must claim the former motor pins first");
|
||||
let validation = module
|
||||
.find("validate_board_config")
|
||||
@@ -36,11 +30,14 @@ fn former_motor_pins_are_inhibited_before_all_other_initialization() {
|
||||
let reset = interlock
|
||||
.find("port.bsrr()")
|
||||
.expect("interlock must reset the former motor-pin output latches");
|
||||
let clock_delay = interlock
|
||||
.find("rcc.ahb1enr().read()")
|
||||
.expect("GPIO access must follow the documented RCC synchronization delay");
|
||||
let output_mode = interlock
|
||||
.find("port.moder()")
|
||||
.expect("interlock must configure the former motor pins as GPIO outputs");
|
||||
assert!(
|
||||
reset < output_mode,
|
||||
clock_delay < reset && reset < output_mode,
|
||||
"output latches must be low before GPIO output mode is selected"
|
||||
);
|
||||
for pin in ["br0", "br1", "br4", "br5"] {
|
||||
@@ -66,85 +63,32 @@ fn former_motor_pins_are_inhibited_before_all_other_initialization() {
|
||||
}
|
||||
}
|
||||
|
||||
fn mpu_config() -> BoardConfig {
|
||||
BoardConfig {
|
||||
name: "test",
|
||||
mavlink: MavlinkEndpoint {
|
||||
system_id: 1,
|
||||
component_id: 1,
|
||||
port: MavlinkPort::Serial1,
|
||||
baud: 115_200,
|
||||
},
|
||||
profile: BoardProfile::TelemetryOnly(TelemetryProfile {
|
||||
imu: ImuDevice::Mpu6000,
|
||||
imu_mount: ImuMount::BodyAligned,
|
||||
vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow,
|
||||
data_channel: DataChannelRates {
|
||||
service_hz: 100,
|
||||
raw_imu_hz: 20,
|
||||
range_hz: Some(20),
|
||||
optical_flow_hz: Some(20),
|
||||
heartbeat_hz: 2,
|
||||
},
|
||||
maximum_log_level: Severity::Info,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immutable_board_policy_must_match_the_executed_route_graph() {
|
||||
let routes = F7BoardBuilder::new()
|
||||
.imu(Mpu6000::on_spi1().with_drdy(Pa4))
|
||||
.mavlink(Mavlink::on_usart1())
|
||||
.vertical_sensor(VerticalSensor::mtf01_on_uart4())
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(validate_board_config(mpu_config(), routes), Ok(()));
|
||||
|
||||
let mut wrong_imu = mpu_config();
|
||||
let BoardProfile::TelemetryOnly(profile) = &mut wrong_imu.profile else {
|
||||
unreachable!();
|
||||
fn immutable_board_policy_is_one_complete_literal_manifest() {
|
||||
const IMMUTABLE: F722ImmutableConfig = F722ImmutableConfig {
|
||||
name: "test",
|
||||
service_hz: 100,
|
||||
hardware: F722Hardware::new()
|
||||
.output_inhibit(Pb0Pb1Pb4Pb5Low)
|
||||
.imu(
|
||||
Mpu6000::on_spi1()
|
||||
.with_drdy(Pa4)
|
||||
.mounted(ImuMount::BodyAligned)
|
||||
.configured(Mpu6000Config {
|
||||
accelerometer: AccelerometerRange::PlusMinus4G,
|
||||
gyroscope: GyroscopeRange::PlusMinus500DegreesPerSecond,
|
||||
digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz,
|
||||
sample_rate_divider: 9,
|
||||
}),
|
||||
)
|
||||
.mavlink(Mavlink::on_usart1().baud(115_200))
|
||||
.vertical_sensor(VerticalSensor::mtf01_on_uart4().baud(115_200))
|
||||
.status_led(Pd2ActiveLow)
|
||||
.telemetry_only(),
|
||||
};
|
||||
profile.imu = ImuDevice::Icm42688;
|
||||
assert_eq!(
|
||||
validate_board_config(wrong_imu, routes),
|
||||
Err(BoardError::ImuRouteMismatch)
|
||||
);
|
||||
|
||||
let mut wrong_mount = mpu_config();
|
||||
let BoardProfile::TelemetryOnly(profile) = &mut wrong_mount.profile else {
|
||||
unreachable!();
|
||||
};
|
||||
profile.imu_mount = ImuMount::Yaw180;
|
||||
assert_eq!(
|
||||
validate_board_config(wrong_mount, routes),
|
||||
Err(BoardError::ImuMountMismatch)
|
||||
);
|
||||
|
||||
let mut wrong_vertical = mpu_config();
|
||||
let BoardProfile::TelemetryOnly(profile) = &mut wrong_vertical.profile else {
|
||||
unreachable!();
|
||||
};
|
||||
profile.vertical_sensing = VerticalSensingKind::Range;
|
||||
profile.data_channel.optical_flow_hz = None;
|
||||
assert_eq!(
|
||||
validate_board_config(wrong_vertical, routes),
|
||||
Err(BoardError::VerticalSensorCapabilityMismatch)
|
||||
);
|
||||
|
||||
let mut wrong_port = mpu_config();
|
||||
wrong_port.mavlink.port = MavlinkPort::Serial2;
|
||||
assert_eq!(
|
||||
validate_board_config(wrong_port, routes),
|
||||
Err(BoardError::MavlinkRouteMismatch)
|
||||
);
|
||||
|
||||
let mut overcommitted_link = mpu_config();
|
||||
overcommitted_link.mavlink.baud = 9_600;
|
||||
assert_eq!(
|
||||
validate_board_config(overcommitted_link, routes),
|
||||
Err(BoardError::TelemetryLinkOvercommitted)
|
||||
);
|
||||
assert_eq!(IMMUTABLE.name, "test");
|
||||
assert_eq!(IMMUTABLE.service_hz, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -184,20 +128,47 @@ fn telemetry_platform_has_no_physical_actuator_peripheral_path() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telemetry_has_no_inbound_command_authority() {
|
||||
fn ordinary_f722_image_has_direct_configuration_after_output_inhibition() {
|
||||
let board = platform_source("src/f722/board.rs");
|
||||
let firmware = platform_source("src/f722/firmware.rs");
|
||||
let routes = platform_source("src/f722/routes.rs");
|
||||
let manifest = platform_source("Cargo.toml");
|
||||
|
||||
assert!(
|
||||
!board.contains("MavlinkReceiver"),
|
||||
"telemetry must not retain an inbound MAVLink dispatch path"
|
||||
!manifest.contains("development-config")
|
||||
&& !manifest.contains("development-authority")
|
||||
&& !manifest.contains("development-unsigned"),
|
||||
"ordinary board images must not hide configuration behind a development feature"
|
||||
);
|
||||
for required in [
|
||||
"configuration: F722ConfigManager",
|
||||
"config_protocol: ConfigProtocol",
|
||||
"receiver: IntegrityMavlinkReceiver<2>",
|
||||
"config_lease_ticks: u16",
|
||||
"wire::decode_command",
|
||||
"self.config_protocol.dispatch_from_composition",
|
||||
"visit_response(response, &mut sender)",
|
||||
] {
|
||||
assert!(
|
||||
board.contains(required),
|
||||
"ordinary image must retain configuration composition: {required}"
|
||||
);
|
||||
}
|
||||
assert!(!board.contains("DirectConfigAdmission"));
|
||||
assert!(
|
||||
routes.contains("pub(crate) struct Mpu6000Spi1Route {\n spi: Spi1,\n}")
|
||||
&& routes.contains("stm32f722::GPIOB::ptr()")
|
||||
&& routes.contains("write.br2().set_bit()")
|
||||
&& routes.contains("write.bs2().set_bit()"),
|
||||
"the MPU runtime may drive only PB2 atomically, not retain GPIOB ownership"
|
||||
);
|
||||
assert!(!board.contains("development-config"));
|
||||
assert!(
|
||||
!board.contains("ArmRequest"),
|
||||
"telemetry must not decode arm commands"
|
||||
);
|
||||
assert!(
|
||||
!board.contains("MAV_RESULT_ACCEPTED"),
|
||||
!board.contains("MAV_CMD_COMPONENT_ARM_DISARM") && !board.contains("decode_arm_request"),
|
||||
"telemetry must not accept an arm command"
|
||||
);
|
||||
assert!(
|
||||
@@ -205,49 +176,195 @@ fn telemetry_has_no_inbound_command_authority() {
|
||||
"the MAVLink worker must not share a motor resource"
|
||||
);
|
||||
assert!(
|
||||
!firmware.contains("MAVLINK_EVENT_RECEIVED"),
|
||||
"telemetry must not schedule a worker from an inbound command"
|
||||
firmware.contains("const MAVLINK_EVENT_RECEIVED")
|
||||
&& firmware.contains("mavlink.receive_config_interrupt(event)")
|
||||
&& !firmware.contains("development-config"),
|
||||
"ordinary images must always dispatch received configuration frames"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f722_link_budget_uses_both_maximum_generated_diagnostic_frames() {
|
||||
let text = LogText::from_message(&"x".repeat(160));
|
||||
let log_record = LogRecord::new(Severity::Info, LogFlags::NONE, text);
|
||||
let error_record = LogRecord::error(
|
||||
LogFlags::NONE,
|
||||
ErrorPath::from_message(&"p".repeat(96)),
|
||||
ErrorAdditionalInformation::from_message(&"i".repeat(64)),
|
||||
);
|
||||
let mut encoder = MavlinkEncoder::new(1, 1);
|
||||
let DiagnosticPayload::Log(log_payload) = encode_diagnostic(&log_record) else {
|
||||
panic!("info record must use KAPTAN_LOG");
|
||||
};
|
||||
let log_frame = encoder.custom(&log_payload);
|
||||
let DiagnosticPayload::Error(error_payload) = encode_diagnostic(&error_record) else {
|
||||
panic!("error record must use KAPTAN_ERROR");
|
||||
};
|
||||
let error_frame = encoder.custom(&error_payload);
|
||||
fn usart_tx_dma_arms_each_stream_before_its_request() {
|
||||
let usart = platform_source("src/f722/peripherals/usart.rs");
|
||||
assert!(usart.contains("disable_dma_transmit_from_interrupt"));
|
||||
|
||||
assert_eq!(log_frame.raw_bytes().len(), 175);
|
||||
assert_eq!(
|
||||
kaptan_mavlink::message::kaptan::KAPTAN_LOG_DATA::ENCODED_LEN,
|
||||
163
|
||||
for (name, path) in [
|
||||
("USART1", "src/f722/peripherals/usart1_tx_dma2_stream7.rs"),
|
||||
("USART2", "src/f722/peripherals/usart2_tx_dma1_stream6.rs"),
|
||||
] {
|
||||
let route = platform_source(path);
|
||||
let constructor_start = route.find("pub fn new").expect("DMA constructor");
|
||||
let transfer_start = route.find("pub fn start").expect("DMA start operation");
|
||||
let acknowledge_start = route
|
||||
.find("pub fn acknowledge_interrupt")
|
||||
.expect("DMA interrupt acknowledgement");
|
||||
|
||||
assert!(
|
||||
!route[constructor_start..transfer_start].contains("enable_dma_transmit"),
|
||||
"{name} must not leave its DMA request armed between transfers"
|
||||
);
|
||||
|
||||
assert!(
|
||||
route.contains("write.dmdis().enabled().feie().disabled()"),
|
||||
"{name} must explicitly select direct mode without FE interrupts"
|
||||
);
|
||||
let start = &route[transfer_start..acknowledge_start];
|
||||
let stream_enable = start
|
||||
.find("write.en().enabled()")
|
||||
.expect("DMA stream must be enabled");
|
||||
let request_enable = start
|
||||
.find("self.usart.enable_dma_transmit()")
|
||||
.expect("USART DMA request must be enabled");
|
||||
assert!(
|
||||
stream_enable < request_enable,
|
||||
"{name} must enable its stream before the peripheral request"
|
||||
);
|
||||
|
||||
let acknowledge = &route[acknowledge_start..];
|
||||
let stream_disable = acknowledge
|
||||
.find("write.en().disabled()")
|
||||
.expect("DMA stream must be disabled");
|
||||
let request_disable = acknowledge
|
||||
.find("disable_dma_transmit_from_interrupt()")
|
||||
.expect("USART DMA request must be disabled");
|
||||
assert!(
|
||||
stream_disable < request_disable,
|
||||
"{name} must disable its stream before the peripheral request"
|
||||
);
|
||||
|
||||
let classifier = acknowledge
|
||||
.find("impl DmaTerminalStatus")
|
||||
.map(|index| &acknowledge[index..])
|
||||
.expect("DMA terminal classifier");
|
||||
assert!(classifier.contains("self.transfer_error || self.direct_mode_error"));
|
||||
assert!(classifier.contains("TxCompletion::Complete"));
|
||||
assert!(
|
||||
!classifier.contains("fifo_error"),
|
||||
"{name} direct-mode FEIF must not manufacture a terminal fault"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_schema_is_generated_without_board_forwarding_code() {
|
||||
let platform = platform_source("src/lib.rs");
|
||||
let platform_config = platform_source("src/f722/config.rs");
|
||||
let platform_firmware = platform_source("src/f722/firmware.rs");
|
||||
let kakute = platform_source("../../boards/kakute-f7-mini-v3/src/board.rs");
|
||||
let geprc = platform_source("../../boards/geprc-f722-v2/src/board.rs");
|
||||
|
||||
assert!(
|
||||
platform.contains("pub struct F722ImmutableConfig")
|
||||
&& platform.contains("pub hardware: F722Hardware")
|
||||
&& platform.contains("type UserConfig: Clone + Config")
|
||||
&& platform_config.contains("struct UserConfigModel")
|
||||
&& platform_config.contains("fn project_runtime<C, I>")
|
||||
&& platform_config.contains("candidate.field_value(field)"),
|
||||
"the platform must retain exactly one immutable manifest and one generic user adapter"
|
||||
);
|
||||
assert_eq!(error_frame.raw_bytes().len(), 175);
|
||||
assert_eq!(
|
||||
kaptan_mavlink::message::kaptan::KAPTAN_ERROR_DATA::ENCODED_LEN,
|
||||
163
|
||||
for semantic_id in [
|
||||
"MAVLINK_SYSTEM_ID",
|
||||
"MAVLINK_COMPONENT_ID",
|
||||
"RAW_IMU_HZ",
|
||||
"HEARTBEAT_HZ",
|
||||
"MAVLINK_MAXIMUM_LOG_SEVERITY",
|
||||
"RANGE_HZ",
|
||||
"OPTICAL_FLOW_HZ",
|
||||
"MAVLINK_LOGGING_ENABLED",
|
||||
] {
|
||||
assert!(
|
||||
platform_config.contains(&format!("const {semantic_id}: FieldId")),
|
||||
"missing stable platform-consumed semantic ID {semantic_id}"
|
||||
);
|
||||
}
|
||||
for (name, board) in [("Kakute", kakute), ("GEPRC", geprc)] {
|
||||
assert!(
|
||||
board.contains("type UserConfig = UserConfig;")
|
||||
&& board.contains("type ImmutableConfig = ImmutableConfig;")
|
||||
&& board.contains("const FACTORY_USER_CONFIG: UserConfig = UserConfig {")
|
||||
&& board.contains("const IMMUTABLE_CONFIG: ImmutableConfig = ImmutableConfig {")
|
||||
&& board.contains("hardware: F722Hardware::new()")
|
||||
&& board.contains(".telemetry_only()"),
|
||||
"{name} must visibly declare the two literal roots"
|
||||
);
|
||||
for redundant in [
|
||||
"impl F7ImmutableConfig for",
|
||||
"impl F7UserConfig for",
|
||||
"impl_f722_user_config!",
|
||||
] {
|
||||
assert!(!board.contains(redundant), "{name} retains {redundant}");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
platform_firmware.contains("<$board as $crate::F7Board>::UserConfig")
|
||||
&& platform_firmware.contains("<$board as $crate::F7Board>::ImmutableConfig")
|
||||
&& platform_firmware.contains("config_snapshot_length::<"),
|
||||
"the common RTIC graph must specialize storage for the two board roots"
|
||||
);
|
||||
assert_eq!(error_payload.path_length, 96);
|
||||
assert_eq!(error_payload.additional_information_length, 64);
|
||||
assert_eq!(error_payload.flags.bits(), LogFlags::NONE.bits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f722_link_budget_reserves_only_deterministic_telemetry() {
|
||||
let board = platform_source("src/f722/board.rs");
|
||||
assert!(board.contains("SCHEDULED_TELEMETRY_BYTES_PER_SECOND"));
|
||||
assert!(board.contains("SERVICE_HZ * SCHEDULED_FRAME_MAXIMUM_BYTES"));
|
||||
assert_eq!(
|
||||
62 * 48 + 10 * error_frame.raw_bytes().len() + 5 * log_frame.raw_bytes().len(),
|
||||
5_601
|
||||
board
|
||||
.matches("self.drain_one_mavlink_log_record();")
|
||||
.count(),
|
||||
1,
|
||||
"each service turn must make exactly one bounded log admission attempt"
|
||||
);
|
||||
assert!(board.contains("if runtime.mavlink_logging_enabled"));
|
||||
assert!(board.contains("LogFilter::up_to(runtime.mavlink_maximum_log_severity)"));
|
||||
assert!(board.contains("LogFilter::disabled()"));
|
||||
for periodic_diagnostic_policy in [
|
||||
"ERROR_RECORDS_PER_SECOND",
|
||||
"LOG_RECORDS_PER_SECOND",
|
||||
"WORST_CASE_OBSERVABILITY_BYTES_PER_SECOND",
|
||||
"KAPTAN_ERROR_FRAME_MAXIMUM_BYTES",
|
||||
"KAPTAN_LOG_FRAME_MAXIMUM_BYTES",
|
||||
"budget_ticks",
|
||||
"error_budget",
|
||||
"log_budget",
|
||||
] {
|
||||
assert!(
|
||||
!board.contains(periodic_diagnostic_policy),
|
||||
"diagnostics must not have a periodic policy: {periodic_diagnostic_policy}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mavlink_transport_faults_do_not_recurse_or_retain_dead_accounting() {
|
||||
let board = platform_source("src/f722/board.rs");
|
||||
assert!(board.contains("KAPTAN_ERROR_FRAME_MAXIMUM_BYTES"));
|
||||
assert!(board.contains("KAPTAN_LOG_FRAME_MAXIMUM_BYTES"));
|
||||
assert!(board.contains("5,601 bytes/s"));
|
||||
let transport = platform_source("src/f722/data_channel.rs");
|
||||
let portable_channel = platform_source("../../libs/kaptan-data-channel/src/lib.rs");
|
||||
|
||||
for recursive_path in [
|
||||
"drain_transport_faults",
|
||||
"take_transport_fault",
|
||||
"TransmitMemory",
|
||||
"TransmitEngine",
|
||||
"TransmitTerminal",
|
||||
"pending_faults",
|
||||
] {
|
||||
assert!(
|
||||
!board.contains(recursive_path)
|
||||
&& !transport.contains(recursive_path)
|
||||
&& !portable_channel.contains(recursive_path),
|
||||
"MAVLink transport must not feed {recursive_path} back into itself"
|
||||
);
|
||||
}
|
||||
for dead_counter in [
|
||||
"dma_fault_count",
|
||||
"memory_fault_count",
|
||||
"engine_fault_count",
|
||||
"terminal_fault_count",
|
||||
] {
|
||||
assert!(
|
||||
!transport.contains(dead_counter),
|
||||
"unused transport accounting must stay absent: {dead_counter}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user