refactor: move firmware workspace under firmware

This commit is contained in:
Onur Özkan committed 2026-08-10 16:58:55 +03:00
1 parent 153b0bf262
commit f03fc119a4
222 files changed
+56 -13

No files matched your search

@@ -0,0 +1,355 @@
//! Private vehicle candidate and whole-profile validation boundary.
use kaptan_config::{
ConfigAggregate, ConfigModel, ConfigValidate, ConfigValue, ConfigViolation, FieldDescriptor,
FieldId, FieldWriteError, ValidateFields, ValidationRule,
};
use super::{
ActuatorLimits, AidingSettings, ControlSettings, ObservabilitySettings, PidSettings,
SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile,
control::{
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
},
observability::ObservabilityCandidate,
registry,
safety::SafetyCandidate,
timing::TimingCandidate,
};
/// Current persisted CBOR schema for the vehicle candidate.
pub const FORMAT_VERSION: u16 = 1;
/// Complete editable vehicle candidate.
///
/// Fields are private outside this crate. Operators, CBOR import, and MAVLink
/// transport all use the same descriptor/mutation and full-validation path.
#[derive(Clone, Copy, ConfigValidate)]
pub struct CandidateProfile {
#[config(section)]
pub(super) safety: SafetyCandidate,
#[config(section)]
pub(super) timing: TimingCandidate,
#[config(section)]
pub(super) aiding: AidingCandidate,
#[config(section)]
pub(super) control: ControlCandidate,
#[config(section)]
pub(super) thrust: ThrustCandidate,
#[config(section)]
pub(super) rate_pid: PidCandidate,
#[config(section)]
pub(super) actuator_limits: ActuatorLimitsCandidate,
#[config(section)]
pub(super) observability: ObservabilityCandidate,
}
impl Default for CandidateProfile {
fn default() -> Self {
<Self as ConfigAggregate>::default_aggregate()
.expect("compiled configuration defaults must validate")
}
}
impl CandidateProfile {
/// Returns the complete transport-neutral descriptor registry.
pub const fn descriptors() -> &'static [FieldDescriptor; Self::FIELD_COUNT] {
registry::descriptors()
}
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 observability = active.observability();
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(),
},
observability: ObservabilityCandidate {
maximum_log_level: observability.maximum_log_level(),
},
}
}
fn validate_complete(
&self,
capabilities: &VehicleCapabilities,
) -> Result<VehicleProfile, ConfigViolation> {
self.validate_fields()?;
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,
},
observability: ObservabilitySettings::new(self.observability.maximum_log_level),
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] = crate::SCHEMA_ID;
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
registry::descriptor(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<ConfigValue> {
<Self as ConfigAggregate>::field_value(self, id)
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
<Self as ConfigAggregate>::field_value(&Self::from_active(active), id)
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
<Self as ConfigAggregate>::set_field(self, id, value)
}
fn validate(&self, context: &Self::Context) -> Result<Self::Active, ConfigViolation> {
self.validate_complete(context)
}
}
#[cfg(test)]
mod tests {
use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule};
use super::{CandidateProfile, VehicleCapabilities};
use crate::profile::ids;
const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
fn descriptor_id(display_key: &str) -> FieldId {
CandidateProfile::descriptors()
.iter()
.find(|descriptor| descriptor.display_key == display_key)
.expect("declared field descriptor")
.id
}
#[test]
fn every_descriptor_has_a_readable_default_value() {
let candidate = CandidateProfile::default();
for descriptor in CandidateProfile::descriptors() {
assert_eq!(
candidate.field_value(descriptor.id),
Some(descriptor.default)
);
}
}
#[test]
fn aggregated_descriptor_ids_are_unique() {
for (index, descriptor) in CandidateProfile::descriptors().iter().enumerate() {
assert!(
CandidateProfile::descriptors()[index + 1..]
.iter()
.all(|other| other.id != descriptor.id)
);
}
}
#[test]
fn cross_field_invalid_edit_is_retained_only_in_the_candidate() {
let mut candidate = CandidateProfile::default();
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::default();
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::default();
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 observability_filter_is_persisted_restart_only_and_cannot_hide_errors() {
let mut candidate = CandidateProfile::default();
candidate
.set_field(
descriptor_id("observability.maximum_log_level"),
ConfigValue::U32(3),
)
.unwrap();
let active = candidate.validate(&CAPABILITIES).unwrap();
assert_eq!(active.observability().maximum_log_level(), 3);
assert_eq!(active.observability().maximum_log_level(), 3);
candidate
.set_field(
descriptor_id("observability.maximum_log_level"),
ConfigValue::U32(5),
)
.unwrap();
assert_eq!(
candidate
.validate(&CAPABILITIES)
.unwrap()
.observability()
.maximum_log_level(),
5
);
assert!(matches!(
candidate.set_field(
descriptor_id("observability.maximum_log_level"),
ConfigValue::U32(2),
),
Err(FieldWriteError::Violation(issue))
if issue.rule() == ValidationRule::BelowMinimum
));
}
#[test]
fn every_persisted_log_severity_is_retained_exactly() {
for raw in [3, 4, 5, 6, 7] {
let mut candidate = CandidateProfile::default();
candidate
.set_field(
descriptor_id("observability.maximum_log_level"),
ConfigValue::U32(raw),
)
.expect("descriptor range accepts the stable severity");
assert_eq!(
candidate
.validate(&CAPABILITIES)
.expect("valid complete profile")
.observability()
.maximum_log_level(),
raw
);
}
}
}