//! Private vehicle candidate and whole-profile validation boundary. use kaptan_config::{ Config, ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, ValidationRule, }; use kaptan_log::Severity; use super::{ ActuatorLimits, AidingSettings, ControlSettings, MavlinkLoggingSettings, PidSettings, SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile, control::{ ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate, }, mavlink_logging::MavlinkLoggingCandidate, safety::SafetyCandidate, timing::TimingCandidate, }; /// Current persisted CBOR schema for the vehicle candidate. pub const FORMAT_VERSION: u16 = 2; /// Complete editable vehicle candidate. /// /// Fields are private outside this crate. Local snapshot recovery and MAVLink /// scalar writes both use the same descriptor/mutation and full-validation /// path. #[derive(Clone, Copy, Config)] pub struct CandidateProfile { pub(super) safety: SafetyCandidate, pub(super) timing: TimingCandidate, pub(super) aiding: AidingCandidate, pub(super) control: ControlCandidate, pub(super) thrust: ThrustCandidate, pub(super) rate_pid: PidCandidate, pub(super) actuator_limits: ActuatorLimitsCandidate, pub(super) mavlink_logging: MavlinkLoggingCandidate, } impl CandidateProfile { /// Explicit factory candidate used by the vehicle-profile examples. /// /// Board firmware supplies its own factory [`Config`] through the board /// composition root; configuration types never synthesize values through /// [`Default`]. pub const fn factory() -> Self { Self { safety: SafetyCandidate { prearm_dwell_microseconds: 100_000, }, timing: TimingCandidate { maximum_imu_interval_microseconds: 50_000, imu_freshness_microseconds: 50_000, local_state_freshness_microseconds: 150_000, command_link_freshness_microseconds: 500_000, }, aiding: AidingCandidate { position_variance_m2: 0.04, velocity_variance_m2_per_second_squared: 0.04, }, control: ControlCandidate { position_gain_per_second_squared: 0.8, velocity_gain_per_second: 1.2, maximum_horizontal_acceleration_mps2: 3.0, maximum_upward_acceleration_mps2: 2.0, maximum_downward_acceleration_mps2: 2.0, }, thrust: ThrustCandidate { mass_kg: 1.2, maximum_collective_thrust_newtons: 18.0, maximum_tilt_tangent: 0.700_207_53, }, rate_pid: PidCandidate { proportional: 0.035, integral: 0.006, derivative: 0.0, output_limit: 0.10, integral_limit: 0.025, }, actuator_limits: ActuatorLimitsCandidate { minimum: 0.0, maximum: 1.0, slew_per_second: 8.0, }, mavlink_logging: MavlinkLoggingCandidate { enabled: true, maximum_severity: Severity::Info, }, } } /// Returns the complete transport-neutral descriptor registry. pub fn descriptors() -> impl ExactSizeIterator + Clone { (0..Self::FIELD_COUNT).map(|index| { ::descriptor(index) .expect("derived aggregate descriptor count must be exact") }) } fn from_active(active: &VehicleProfile) -> Self { let safety = active.safety(); let timing = active.timing(); let aiding = active.aiding(); let control = active.control(); let thrust = active.thrust(); let rate_pid = active.rate_pid(); let actuator_limits = active.actuator_limits(); let mavlink_logging = active.mavlink_logging(); Self { safety: SafetyCandidate { prearm_dwell_microseconds: safety.prearm_dwell_microseconds(), }, timing: TimingCandidate { maximum_imu_interval_microseconds: timing.maximum_imu_interval_microseconds(), imu_freshness_microseconds: timing.imu_freshness_microseconds(), local_state_freshness_microseconds: timing.local_state_freshness_microseconds(), command_link_freshness_microseconds: timing.command_link_freshness_microseconds(), }, aiding: AidingCandidate { position_variance_m2: aiding.position_variance_m2(), velocity_variance_m2_per_second_squared: aiding .velocity_variance_m2_per_second_squared(), }, control: ControlCandidate { position_gain_per_second_squared: control.position_gain_per_second_squared(), velocity_gain_per_second: control.velocity_gain_per_second(), maximum_horizontal_acceleration_mps2: control .maximum_horizontal_acceleration_mps2(), maximum_upward_acceleration_mps2: control.maximum_upward_acceleration_mps2(), maximum_downward_acceleration_mps2: control.maximum_downward_acceleration_mps2(), }, thrust: ThrustCandidate { mass_kg: thrust.mass_kg(), maximum_collective_thrust_newtons: thrust.maximum_collective_thrust_newtons(), maximum_tilt_tangent: thrust.maximum_tilt_tangent(), }, rate_pid: PidCandidate { proportional: rate_pid.proportional(), integral: rate_pid.integral(), derivative: rate_pid.derivative(), output_limit: rate_pid.output_limit(), integral_limit: rate_pid.integral_limit(), }, actuator_limits: ActuatorLimitsCandidate { minimum: actuator_limits.minimum(), maximum: actuator_limits.maximum(), slew_per_second: actuator_limits.slew_per_second(), }, mavlink_logging: MavlinkLoggingCandidate { enabled: mavlink_logging.enabled(), maximum_severity: mavlink_logging.maximum_severity(), }, } } fn validate_complete( &self, capabilities: &VehicleCapabilities, ) -> Result { ::validate(self)?; self.validate_capabilities(capabilities)?; Ok(VehicleProfile { safety: SafetySettings { prearm_dwell_microseconds: self.safety.prearm_dwell_microseconds, }, timing: TimingSettings { maximum_imu_interval_microseconds: self.timing.maximum_imu_interval_microseconds, imu_freshness_microseconds: self.timing.imu_freshness_microseconds, local_state_freshness_microseconds: self.timing.local_state_freshness_microseconds, command_link_freshness_microseconds: self .timing .command_link_freshness_microseconds, }, aiding: AidingSettings { position_variance_m2: self.aiding.position_variance_m2, velocity_variance_m2_per_second_squared: self .aiding .velocity_variance_m2_per_second_squared, }, control: ControlSettings { position_gain_per_second_squared: self.control.position_gain_per_second_squared, velocity_gain_per_second: self.control.velocity_gain_per_second, maximum_horizontal_acceleration_mps2: self .control .maximum_horizontal_acceleration_mps2, maximum_upward_acceleration_mps2: self.control.maximum_upward_acceleration_mps2, maximum_downward_acceleration_mps2: self.control.maximum_downward_acceleration_mps2, }, thrust: ThrustSettings { mass_kg: self.thrust.mass_kg, maximum_collective_thrust_newtons: self.thrust.maximum_collective_thrust_newtons, maximum_tilt_tangent: self.thrust.maximum_tilt_tangent, }, rate_pid: PidSettings { proportional: self.rate_pid.proportional, integral: self.rate_pid.integral, derivative: self.rate_pid.derivative, output_limit: self.rate_pid.output_limit, integral_limit: self.rate_pid.integral_limit, }, actuator_limits: ActuatorLimits { minimum: self.actuator_limits.minimum, maximum: self.actuator_limits.maximum, slew_per_second: self.actuator_limits.slew_per_second, }, mavlink_logging: MavlinkLoggingSettings::new( self.mavlink_logging.enabled, self.mavlink_logging.maximum_severity, ), mixer: capabilities.mixer(), }) } fn validate_capabilities( &self, capabilities: &VehicleCapabilities, ) -> Result<(), ConfigViolation> { if capabilities.actuator_count() != 4 { return Err(ConfigViolation::global( ValidationRule::UnsupportedCapability, )); } Ok(()) } } impl ConfigModel for CandidateProfile { type Active = VehicleProfile; type Context = VehicleCapabilities; const SCHEMA_ID: [u8; 16] = ::SCHEMA_ID; fn descriptor(index: u16) -> Option<&'static FieldDescriptor> { ::descriptor(usize::from(index)) } fn field_count() -> u16 { u16::try_from(Self::FIELD_COUNT).expect("field registry exceeds the protocol limit") } fn field_value(&self, id: FieldId) -> Option { ::field_value(self, id) } fn active_field_value(active: &Self::Active, id: FieldId) -> Option { ::field_value(&Self::from_active(active), id) } fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> { ::set_field(self, id, value) } fn validate(&self, context: &Self::Context) -> Result { self.validate_complete(context) } } #[cfg(test)] mod tests { use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule}; use super::{CandidateProfile, Severity, VehicleCapabilities}; use crate::profile::ids; const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x(); #[test] fn generated_schema_fingerprint_is_stable() { assert_eq!( crate::SCHEMA_ID, [ 0xc4, 0xf6, 0x36, 0xc0, 0x29, 0xd2, 0x3e, 0x29, 0xa3, 0x85, 0x5b, 0x36, 0xb7, 0xb3, 0x59, 0x9c, ] ); } fn descriptor_id(display_key: &str) -> FieldId { CandidateProfile::descriptors() .find(|descriptor| descriptor.display_key == display_key) .expect("declared field descriptor") .id } #[test] fn every_descriptor_has_a_readable_factory_value() { let candidate = CandidateProfile::factory(); for descriptor in CandidateProfile::descriptors() { assert!(candidate.field_value(descriptor.id).is_some()); } } #[test] fn aggregated_descriptor_ids_are_unique() { for (index, descriptor) in CandidateProfile::descriptors().enumerate() { assert!( CandidateProfile::descriptors() .skip(index + 1) .all(|other| other.id != descriptor.id) ); } } #[test] fn cross_field_invalid_edit_is_retained_only_in_the_candidate() { let mut candidate = CandidateProfile::factory(); candidate .set_field( ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS, ConfigValue::U64(51_000), ) .unwrap(); assert!(matches!( candidate.validate(&CAPABILITIES), Err(issue) if issue.rule() == ValidationRule::Inconsistent )); } #[test] fn local_write_error_retains_exact_field_and_rule() { let mut candidate = CandidateProfile::factory(); assert_eq!( candidate.set_field(ids::MASS_KG, ConfigValue::F32(f32::NAN)), Err(FieldWriteError::non_finite(ids::MASS_KG)) ); } #[test] fn descriptor_bounds_do_not_conflict_with_derived_field_validation() { for descriptor in CandidateProfile::descriptors() { for bound in [descriptor.minimum, descriptor.maximum] .into_iter() .flatten() { let mut candidate = CandidateProfile::factory(); candidate .set_field(descriptor.id, bound) .expect("descriptor bound must pass the descriptor-backed setter"); if let Err(issue) = candidate.validate(&CAPABILITIES) { assert!( issue.primary() != Some(descriptor.id) || issue.rule() == ValidationRule::Inconsistent, "descriptor bound for {} conflicts with generated field validation: {issue:?}", descriptor.display_key, ); } } } } #[test] fn mavlink_logging_policy_is_persisted_and_restart_only() { let mut candidate = CandidateProfile::factory(); let severity_descriptor = CandidateProfile::descriptors() .find(|descriptor| descriptor.display_key == "mavlink.logging.maximum_severity") .unwrap(); assert_eq!( severity_descriptor.enum_options, Severity::CONFIG_ENUM_OPTIONS ); candidate .set_field( descriptor_id("mavlink.logging.enabled"), ConfigValue::Bool(false), ) .unwrap(); let active = candidate.validate(&CAPABILITIES).unwrap(); assert!(!active.mavlink_logging().enabled()); candidate .set_field( descriptor_id("mavlink.logging.maximum_severity"), ConfigValue::U32(5), ) .unwrap(); assert_eq!( candidate .validate(&CAPABILITIES) .unwrap() .mavlink_logging() .maximum_severity(), Severity::Notice ); assert!(matches!( candidate.set_field( descriptor_id("mavlink.logging.maximum_severity"), ConfigValue::U32(8), ), Err(FieldWriteError::Violation(issue)) if issue.rule() == ValidationRule::InvalidEnumValue )); } #[test] fn every_persisted_log_severity_is_retained_exactly() { for raw in 0..=7 { let mut candidate = CandidateProfile::factory(); candidate .set_field( descriptor_id("mavlink.logging.maximum_severity"), ConfigValue::U32(raw), ) .expect("descriptor range accepts the stable severity"); assert_eq!( candidate .validate(&CAPABILITIES) .expect("valid complete profile") .mavlink_logging() .maximum_severity(), Severity::try_from_wire(u8::try_from(raw).unwrap()).unwrap() ); } } }