refactor(firmware): centralize configuration and diagnostics

This commit is contained in:
Onur Özkan committed 2026-08-22 18:37:18 +03:00
1 parent 7048ed89d0
commit 387fdaad23
140 files changed
+6891 -4541

No files matched your search

+14 -18
View File
@@ -1,4 +1,4 @@
//! Bounded CBOR import/export for one explicit current record.
//! Bounded CBOR persistence codec for one explicit current record.
use kaptan_config::{
ConfigModel, ConfigValue, ConfigViolation, FieldWriteError, ValidationRule, ValueKind,
@@ -13,7 +13,7 @@ pub const MAXIMUM_CANDIDATE_BYTES: usize = 4096;
const FORMAT_KEY: u16 = 0;
const SCHEMA_KEY: u16 = u16::MAX;
/// CBOR import failure.
/// CBOR snapshot decoding failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum CborError {
/// The supplied payload exceeded the bounded configuration storage budget.
@@ -126,17 +126,14 @@ fn decode_current(bytes: &[u8]) -> Result<CandidateProfile, CborError> {
}
}
_ => {
let index = CandidateProfile::descriptors()
.iter()
.position(|descriptor| descriptor.id.get() == key)
let (index, descriptor) = CandidateProfile::descriptors()
.enumerate()
.find(|(_, descriptor)| descriptor.id.get() == key)
.ok_or(CborError::Malformed)?;
if values[index].is_some() {
return Err(CborError::Malformed);
}
values[index] = Some(decode_value(
&mut decoder,
CandidateProfile::descriptors()[index].kind,
)?);
values[index] = Some(decode_value(&mut decoder, descriptor.kind)?);
}
}
}
@@ -151,10 +148,10 @@ fn decode_current(bytes: &[u8]) -> Result<CandidateProfile, CborError> {
if values.iter().any(Option::is_none) {
return Err(CborError::Malformed);
}
let mut candidate = CandidateProfile::default();
let mut candidate = CandidateProfile::factory();
for (index, value) in values.into_iter().enumerate() {
let descriptor = CandidateProfile::descriptors()
.get(index)
.nth(index)
.ok_or(CborError::Malformed)?;
candidate
.set_field(descriptor.id, value.ok_or(CborError::Malformed)?)
@@ -210,7 +207,7 @@ mod tests {
fn round_trip_and_trailing_rejection_are_exact() {
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
let capabilities = VehicleCapabilities::quad_x();
encode_candidate(CandidateProfile::default(), &capabilities, &mut writer).unwrap();
encode_candidate(CandidateProfile::factory(), &capabilities, &mut writer).unwrap();
let length = writer.position();
let bytes = writer.into_inner();
assert!(decode_candidate(&bytes[..length], &capabilities).is_ok());
@@ -225,7 +222,7 @@ mod tests {
#[test]
fn encoding_rejects_a_cross_field_invalid_transaction_before_writing() {
let capabilities = VehicleCapabilities::quad_x();
let mut candidate = CandidateProfile::default();
let mut candidate = CandidateProfile::factory();
candidate
.set_field(kaptan_config::FieldId::new(2), ConfigValue::U64(50_001))
.expect("the scalar is locally valid");
@@ -295,8 +292,8 @@ mod tests {
.u16(FORMAT_VERSION)
.unwrap();
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
let candidate = CandidateProfile::default();
for descriptor in CandidateProfile::descriptors().iter().skip(1) {
let candidate = CandidateProfile::factory();
for descriptor in CandidateProfile::descriptors().skip(1) {
encoder.u16(descriptor.id.get()).unwrap();
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap();
}
@@ -312,7 +309,6 @@ mod tests {
fn cbor_scalar_write_preserves_the_generated_field_and_rule() {
let capabilities = VehicleCapabilities::quad_x();
let mass = CandidateProfile::descriptors()
.iter()
.find(|descriptor| descriptor.display_key == "thrust.mass_kg")
.expect("declared mass descriptor");
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
@@ -326,7 +322,7 @@ mod tests {
.u16(FORMAT_VERSION)
.unwrap();
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
let candidate = CandidateProfile::default();
let candidate = CandidateProfile::factory();
for descriptor in CandidateProfile::descriptors() {
encoder.u16(descriptor.id.get()).unwrap();
let value = if descriptor.id == mass.id {
@@ -367,7 +363,7 @@ mod tests {
.u16(format_version)
.unwrap();
encoder.u16(SCHEMA_KEY).unwrap().bytes(&schema_id).unwrap();
let candidate = CandidateProfile::default();
let candidate = CandidateProfile::factory();
for descriptor in CandidateProfile::descriptors() {
encoder.u16(remap_key(descriptor.id.get())).unwrap();
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap();
@@ -10,11 +10,8 @@ pub use cbor::{
};
pub use profile::{CandidateProfile, FORMAT_VERSION, VehicleCapabilities, VehicleProfile};
/// Exact identifier of the one unreleased Quad-X profile schema.
///
/// This is an identifier, not a cryptographic fingerprint. A GCS includes it
/// with an import so a profile prepared for another schema cannot be applied.
pub const SCHEMA_ID: [u8; 16] = *b"KAPTANPROFILE-01";
/// Generated fingerprint of the complete vehicle configuration schema.
pub const SCHEMA_ID: [u8; 16] = <CandidateProfile as kaptan_config::Config>::SCHEMA_ID;
#[cfg(test)]
extern crate std;
@@ -201,7 +201,7 @@ pub struct VehicleProfile {
pub(crate) thrust: ThrustSettings,
pub(crate) rate_pid: PidSettings,
pub(crate) actuator_limits: ActuatorLimits,
pub(crate) observability: super::ObservabilitySettings,
pub(crate) mavlink_logging: super::MavlinkLoggingSettings,
pub(crate) mixer: [[f32; 4]; 4],
}
@@ -227,9 +227,9 @@ impl VehicleProfile {
pub const fn actuator_limits(self) -> ActuatorLimits {
self.actuator_limits
}
/// Immutable boot-active best-effort diagnostic policy.
pub const fn observability(self) -> super::ObservabilitySettings {
self.observability
/// Immutable boot-active policy for the MAVLink diagnostic sink.
pub const fn mavlink_logging(self) -> super::MavlinkLoggingSettings {
self.mavlink_logging
}
pub const fn mixer(self) -> [[f32; 4]; 4] {
self.mixer
@@ -1,60 +1,100 @@
//! Private vehicle candidate and whole-profile validation boundary.
use kaptan_config::{
ConfigAggregate, ConfigModel, ConfigValidate, ConfigValue, ConfigViolation, FieldDescriptor,
FieldId, FieldWriteError, ValidateFields, ValidationRule,
Config, ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
ValidationRule,
};
use kaptan_log::Severity;
use super::{
ActuatorLimits, AidingSettings, ControlSettings, ObservabilitySettings, PidSettings,
ActuatorLimits, AidingSettings, ControlSettings, MavlinkLoggingSettings, PidSettings,
SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile,
control::{
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
},
observability::ObservabilityCandidate,
registry,
mavlink_logging::MavlinkLoggingCandidate,
safety::SafetyCandidate,
timing::TimingCandidate,
};
/// Current persisted CBOR schema for the vehicle candidate.
pub const FORMAT_VERSION: u16 = 1;
pub const FORMAT_VERSION: u16 = 2;
/// 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)]
/// 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 {
#[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")
}
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 const fn descriptors() -> &'static [FieldDescriptor; Self::FIELD_COUNT] {
registry::descriptors()
pub fn descriptors() -> impl ExactSizeIterator<Item = &'static FieldDescriptor> + Clone {
(0..Self::FIELD_COUNT).map(|index| {
<Self as Config>::descriptor(index)
.expect("derived aggregate descriptor count must be exact")
})
}
fn from_active(active: &VehicleProfile) -> Self {
@@ -65,7 +105,7 @@ impl CandidateProfile {
let thrust = active.thrust();
let rate_pid = active.rate_pid();
let actuator_limits = active.actuator_limits();
let observability = active.observability();
let mavlink_logging = active.mavlink_logging();
Self {
safety: SafetyCandidate {
prearm_dwell_microseconds: safety.prearm_dwell_microseconds(),
@@ -106,8 +146,9 @@ impl CandidateProfile {
maximum: actuator_limits.maximum(),
slew_per_second: actuator_limits.slew_per_second(),
},
observability: ObservabilityCandidate {
maximum_log_level: observability.maximum_log_level(),
mavlink_logging: MavlinkLoggingCandidate {
enabled: mavlink_logging.enabled(),
maximum_severity: mavlink_logging.maximum_severity(),
},
}
}
@@ -116,7 +157,7 @@ impl CandidateProfile {
&self,
capabilities: &VehicleCapabilities,
) -> Result<VehicleProfile, ConfigViolation> {
self.validate_fields()?;
<Self as Config>::validate(self)?;
self.validate_capabilities(capabilities)?;
Ok(VehicleProfile {
safety: SafetySettings {
@@ -162,7 +203,10 @@ impl CandidateProfile {
maximum: self.actuator_limits.maximum,
slew_per_second: self.actuator_limits.slew_per_second,
},
observability: ObservabilitySettings::new(self.observability.maximum_log_level),
mavlink_logging: MavlinkLoggingSettings::new(
self.mavlink_logging.enabled,
self.mavlink_logging.maximum_severity,
),
mixer: capabilities.mixer(),
})
}
@@ -183,10 +227,10 @@ impl CandidateProfile {
impl ConfigModel for CandidateProfile {
type Active = VehicleProfile;
type Context = VehicleCapabilities;
const SCHEMA_ID: [u8; 16] = crate::SCHEMA_ID;
const SCHEMA_ID: [u8; 16] = <Self as Config>::SCHEMA_ID;
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
registry::descriptor(index)
<Self as Config>::descriptor(usize::from(index))
}
fn field_count() -> u16 {
@@ -194,15 +238,15 @@ impl ConfigModel for CandidateProfile {
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
<Self as ConfigAggregate>::field_value(self, id)
<Self as Config>::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)
<Self as Config>::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)
<Self as Config>::set_field(self, id, value)
}
fn validate(&self, context: &Self::Context) -> Result<Self::Active, ConfigViolation> {
@@ -214,36 +258,43 @@ impl ConfigModel for CandidateProfile {
mod tests {
use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule};
use super::{CandidateProfile, VehicleCapabilities};
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()
.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();
fn every_descriptor_has_a_readable_factory_value() {
let candidate = CandidateProfile::factory();
for descriptor in CandidateProfile::descriptors() {
assert_eq!(
candidate.field_value(descriptor.id),
Some(descriptor.default)
);
assert!(candidate.field_value(descriptor.id).is_some());
}
}
#[test]
fn aggregated_descriptor_ids_are_unique() {
for (index, descriptor) in CandidateProfile::descriptors().iter().enumerate() {
for (index, descriptor) in CandidateProfile::descriptors().enumerate() {
assert!(
CandidateProfile::descriptors()[index + 1..]
.iter()
CandidateProfile::descriptors()
.skip(index + 1)
.all(|other| other.id != descriptor.id)
);
}
@@ -251,7 +302,7 @@ mod tests {
#[test]
fn cross_field_invalid_edit_is_retained_only_in_the_candidate() {
let mut candidate = CandidateProfile::default();
let mut candidate = CandidateProfile::factory();
candidate
.set_field(
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS,
@@ -266,7 +317,7 @@ mod tests {
#[test]
fn local_write_error_retains_exact_field_and_rule() {
let mut candidate = CandidateProfile::default();
let mut candidate = CandidateProfile::factory();
assert_eq!(
candidate.set_field(ids::MASS_KG, ConfigValue::F32(f32::NAN)),
Err(FieldWriteError::non_finite(ids::MASS_KG))
@@ -280,7 +331,7 @@ mod tests {
.into_iter()
.flatten()
{
let mut candidate = CandidateProfile::default();
let mut candidate = CandidateProfile::factory();
candidate
.set_field(descriptor.id, bound)
.expect("descriptor bound must pass the descriptor-backed setter");
@@ -297,20 +348,26 @@ mod tests {
}
#[test]
fn observability_filter_is_persisted_restart_only_and_cannot_hide_errors() {
let mut candidate = CandidateProfile::default();
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("observability.maximum_log_level"),
ConfigValue::U32(3),
descriptor_id("mavlink.logging.enabled"),
ConfigValue::Bool(false),
)
.unwrap();
let active = candidate.validate(&CAPABILITIES).unwrap();
assert_eq!(active.observability().maximum_log_level(), 3);
assert_eq!(active.observability().maximum_log_level(), 3);
assert!(!active.mavlink_logging().enabled());
candidate
.set_field(
descriptor_id("observability.maximum_log_level"),
descriptor_id("mavlink.logging.maximum_severity"),
ConfigValue::U32(5),
)
.unwrap();
@@ -318,27 +375,27 @@ mod tests {
candidate
.validate(&CAPABILITIES)
.unwrap()
.observability()
.maximum_log_level(),
5
.mavlink_logging()
.maximum_severity(),
Severity::Notice
);
assert!(matches!(
candidate.set_field(
descriptor_id("observability.maximum_log_level"),
ConfigValue::U32(2),
descriptor_id("mavlink.logging.maximum_severity"),
ConfigValue::U32(8),
),
Err(FieldWriteError::Violation(issue))
if issue.rule() == ValidationRule::BelowMinimum
if issue.rule() == ValidationRule::InvalidEnumValue
));
}
#[test]
fn every_persisted_log_severity_is_retained_exactly() {
for raw in [3, 4, 5, 6, 7] {
let mut candidate = CandidateProfile::default();
for raw in 0..=7 {
let mut candidate = CandidateProfile::factory();
candidate
.set_field(
descriptor_id("observability.maximum_log_level"),
descriptor_id("mavlink.logging.maximum_severity"),
ConfigValue::U32(raw),
)
.expect("descriptor range accepts the stable severity");
@@ -346,9 +403,9 @@ mod tests {
candidate
.validate(&CAPABILITIES)
.expect("valid complete profile")
.observability()
.maximum_log_level(),
raw
.mavlink_logging()
.maximum_severity(),
Severity::try_from_wire(u8::try_from(raw).unwrap()).unwrap()
);
}
}
@@ -1,14 +1,13 @@
use kaptan_config::{ConfigValidate, ConfigViolation, ValidationRule};
use kaptan_config::{Config, ConfigViolation, ValidationRule};
use super::ids;
#[derive(Clone, Copy, ConfigValidate)]
#[derive(Clone, Copy, Config)]
pub(super) struct AidingCandidate {
#[config(
id = 6,
key = "aiding.position_variance_m2",
unit = "m2",
default = 0.04,
finite,
range(min = 0.000_001, max = 10_000.0)
)]
@@ -17,20 +16,18 @@ pub(super) struct AidingCandidate {
id = 7,
key = "aiding.velocity_variance_m2ps2",
unit = "m2/s2",
default = 0.04,
finite,
range(min = 0.000_001, max = 10_000.0)
)]
pub(super) velocity_variance_m2_per_second_squared: f32,
}
#[derive(Clone, Copy, ConfigValidate)]
#[derive(Clone, Copy, Config)]
pub(super) struct ControlCandidate {
#[config(
id = 8,
key = "control.position_gain_per_s2",
unit = "1/s2",
default = 0.8,
finite,
range(min = 0.0, max = 100.0)
)]
@@ -39,7 +36,6 @@ pub(super) struct ControlCandidate {
id = 9,
key = "control.velocity_gain_per_s",
unit = "1/s",
default = 1.2,
finite,
range(min = 0.0, max = 100.0)
)]
@@ -48,7 +44,6 @@ pub(super) struct ControlCandidate {
id = 10,
key = "control.maximum_horizontal_acceleration_mps2",
unit = "m/s2",
default = 3.0,
finite,
range(min = 0.001, max = 100.0)
)]
@@ -57,7 +52,6 @@ pub(super) struct ControlCandidate {
id = 11,
key = "control.maximum_upward_acceleration_mps2",
unit = "m/s2",
default = 2.0,
finite,
range(min = 0.001, max = 100.0)
)]
@@ -66,20 +60,18 @@ pub(super) struct ControlCandidate {
id = 12,
key = "control.maximum_downward_acceleration_mps2",
unit = "m/s2",
default = 2.0,
finite,
range(min = 0.001, max = 100.0)
)]
pub(super) maximum_downward_acceleration_mps2: f32,
}
#[derive(Clone, Copy, ConfigValidate)]
#[derive(Clone, Copy, Config)]
#[config(post = Self::validate_thrust)]
pub(super) struct ThrustCandidate {
#[config(
id = 13,
key = "thrust.mass_kg",
unit = "kg",
default = 1.2,
finite,
range(min = 0.01, max = 100.0)
)]
@@ -88,7 +80,6 @@ pub(super) struct ThrustCandidate {
id = 14,
key = "thrust.maximum_collective_thrust_n",
unit = "N",
default = 18.0,
finite,
range(min = 0.1, max = 10_000.0)
)]
@@ -97,7 +88,6 @@ pub(super) struct ThrustCandidate {
id = 15,
key = "thrust.maximum_tilt_tangent",
unit = "",
default = 0.700_207_53,
finite,
range(min = 0.001, max = 10.0)
)]
@@ -116,14 +106,13 @@ impl ThrustCandidate {
}
}
#[derive(Clone, Copy, ConfigValidate)]
#[derive(Clone, Copy, Config)]
#[config(post = Self::validate_pid)]
pub(super) struct PidCandidate {
#[config(
id = 16,
key = "control.rate_pid.proportional",
unit = "",
default = 0.035,
finite,
range(min = 0.0, max = 10.0)
)]
@@ -132,7 +121,6 @@ pub(super) struct PidCandidate {
id = 17,
key = "control.rate_pid.integral",
unit = "",
default = 0.006,
finite,
range(min = 0.0, max = 10.0)
)]
@@ -141,7 +129,6 @@ pub(super) struct PidCandidate {
id = 18,
key = "control.rate_pid.derivative",
unit = "",
default = 0.0,
finite,
range(min = 0.0, max = 10.0)
)]
@@ -150,7 +137,6 @@ pub(super) struct PidCandidate {
id = 19,
key = "control.rate_pid.output_limit",
unit = "",
default = 0.10,
finite,
range(min = 0.001, max = 1.0)
)]
@@ -159,7 +145,6 @@ pub(super) struct PidCandidate {
id = 20,
key = "control.rate_pid.integral_limit",
unit = "",
default = 0.025,
finite,
range(min = 0.0, max = 1.0)
)]
@@ -178,14 +163,13 @@ impl PidCandidate {
}
}
#[derive(Clone, Copy, ConfigValidate)]
#[derive(Clone, Copy, Config)]
#[config(post = Self::validate_actuator_limits)]
pub(super) struct ActuatorLimitsCandidate {
#[config(
id = 21,
key = "actuator.minimum",
unit = "",
default = 0.0,
finite,
range(min = 0.0, max = 1.0)
)]
@@ -194,7 +178,6 @@ pub(super) struct ActuatorLimitsCandidate {
id = 22,
key = "actuator.maximum",
unit = "",
default = 1.0,
finite,
range(min = 0.0, max = 1.0)
)]
@@ -203,7 +186,6 @@ pub(super) struct ActuatorLimitsCandidate {
id = 23,
key = "actuator.slew_per_second",
unit = "1/s",
default = 8.0,
finite,
range(min = 0.001, max = 100.0)
)]
@@ -0,0 +1,44 @@
//! Persisted, restart-only MAVLink diagnostic-sink policy.
use kaptan_config::Config;
use kaptan_log::Severity;
/// Candidate policy for the MAVLink diagnostic sink.
#[derive(Clone, Copy, Config)]
pub(super) struct MavlinkLoggingCandidate {
#[config(id = 24, key = "mavlink.logging.enabled", unit = "bool")]
pub(super) enabled: bool,
#[config(
enum,
id = 25,
key = "mavlink.logging.maximum_severity",
unit = "mav_severity"
)]
pub(super) maximum_severity: Severity,
}
/// Immutable boot-active policy for the MAVLink diagnostic sink.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MavlinkLoggingSettings {
enabled: bool,
maximum_severity: Severity,
}
impl MavlinkLoggingSettings {
pub(crate) const fn new(enabled: bool, maximum_severity: Severity) -> Self {
Self {
enabled,
maximum_severity,
}
}
/// Whether the MAVLink diagnostic sink accepts records.
pub const fn enabled(self) -> bool {
self.enabled
}
/// Maximum accepted MAVLink severity, from Emergency (0) through Debug (7).
pub const fn maximum_severity(self) -> Severity {
self.maximum_severity
}
}
@@ -4,8 +4,7 @@ mod active;
mod candidate;
mod control;
mod ids;
mod observability;
mod registry;
mod mavlink_logging;
mod safety;
mod timing;
@@ -14,4 +13,4 @@ pub use active::{
TimingSettings, VehicleCapabilities, VehicleProfile,
};
pub use candidate::{CandidateProfile, FORMAT_VERSION};
pub use observability::ObservabilitySettings;
pub use mavlink_logging::MavlinkLoggingSettings;
@@ -1,35 +0,0 @@
//! Persisted, restart-only runtime-observability policy.
use kaptan_config::ConfigValidate;
/// Candidate-only observability policy. It deliberately controls only optional
/// detail; error-level records are never suppressible configuration fields.
#[derive(Clone, Copy, ConfigValidate)]
pub(super) struct ObservabilityCandidate {
#[config(
id = 24,
key = "observability.maximum_log_level",
unit = "mav_severity",
default = 6,
range(min = 3, max = 7)
)]
pub(super) maximum_log_level: u32,
}
/// Immutable boot-active best-effort observability policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ObservabilitySettings {
maximum_log_level: u32,
}
impl ObservabilitySettings {
pub(crate) const fn new(maximum_log_level: u32) -> Self {
Self { maximum_log_level }
}
/// Maximum best-effort MAVLink severity: Error (3) through Debug (7).
/// This never filters error-level records.
pub const fn maximum_log_level(self) -> u32 {
self.maximum_log_level
}
}
@@ -1,80 +0,0 @@
//! Aggregate descriptor registry for the generated vehicle sections.
//!
//! Every descriptor is generated from its owning scalar field. This ordered
//! projection is intentionally exhaustive: `FIELD_COUNT` comes from the root
//! aggregate, so adding an attributed field without extending this view fails
//! at compile time instead of silently omitting it from CBOR or transport.
use kaptan_config::FieldDescriptor;
use super::{
candidate::CandidateProfile,
control::{
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
},
observability::ObservabilityCandidate,
safety::SafetyCandidate,
timing::TimingCandidate,
};
/// Number of operator-editable fields in the current vehicle profile.
///
/// Allocation topology remains immutable board capability data, not an
/// operator parameter.
pub(super) const FIELD_COUNT: usize = CandidateProfile::FIELD_COUNT;
/// Ordered view of metadata generated from field attributes.
///
/// This has no independently-maintained defaults, bounds, values, or setter
/// logic. The length is tied to `CandidateProfile::FIELD_COUNT` as a
/// compile-time completeness guard.
const FIELDS: [FieldDescriptor; FIELD_COUNT] = [
SafetyCandidate::DESCRIPTORS[0],
TimingCandidate::DESCRIPTORS[0],
TimingCandidate::DESCRIPTORS[1],
TimingCandidate::DESCRIPTORS[2],
TimingCandidate::DESCRIPTORS[3],
AidingCandidate::DESCRIPTORS[0],
AidingCandidate::DESCRIPTORS[1],
ControlCandidate::DESCRIPTORS[0],
ControlCandidate::DESCRIPTORS[1],
ControlCandidate::DESCRIPTORS[2],
ControlCandidate::DESCRIPTORS[3],
ControlCandidate::DESCRIPTORS[4],
ThrustCandidate::DESCRIPTORS[0],
ThrustCandidate::DESCRIPTORS[1],
ThrustCandidate::DESCRIPTORS[2],
PidCandidate::DESCRIPTORS[0],
PidCandidate::DESCRIPTORS[1],
PidCandidate::DESCRIPTORS[2],
PidCandidate::DESCRIPTORS[3],
PidCandidate::DESCRIPTORS[4],
ActuatorLimitsCandidate::DESCRIPTORS[0],
ActuatorLimitsCandidate::DESCRIPTORS[1],
ActuatorLimitsCandidate::DESCRIPTORS[2],
ObservabilityCandidate::DESCRIPTORS[0],
];
const _: () = assert_unique_ids(&FIELDS);
const fn assert_unique_ids(fields: &[FieldDescriptor]) {
let mut index = 0;
while index < fields.len() {
let mut other = index + 1;
while other < fields.len() {
if fields[index].id.get() == fields[other].id.get() {
panic!("duplicate field id in the aggregated vehicle registry");
}
other += 1;
}
index += 1;
}
}
pub(super) const fn descriptors() -> &'static [FieldDescriptor; FIELD_COUNT] {
&FIELDS
}
pub(super) fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
FIELDS.get(usize::from(index))
}
@@ -1,12 +1,11 @@
use kaptan_config::ConfigValidate;
use kaptan_config::Config;
#[derive(Clone, Copy, ConfigValidate)]
#[derive(Clone, Copy, Config)]
pub(super) struct SafetyCandidate {
#[config(
id = 1,
key = "safety.prearm_dwell_us",
unit = "us",
default = 100_000,
nonzero,
range(min = 1, max = 10_000_000)
)]
@@ -1,15 +1,14 @@
use kaptan_config::{ConfigValidate, ConfigViolation, ValidationRule};
use kaptan_config::{Config, ConfigViolation, ValidationRule};
use super::ids;
#[derive(Clone, Copy, ConfigValidate)]
#[derive(Clone, Copy, Config)]
#[config(post = Self::validate_timing)]
pub(super) struct TimingCandidate {
#[config(
id = 2,
key = "timing.imu_max_interval_us",
unit = "us",
default = 50_000,
nonzero,
range(min = 1, max = 1_000_000)
)]
@@ -18,7 +17,6 @@ pub(super) struct TimingCandidate {
id = 3,
key = "timing.imu_freshness_us",
unit = "us",
default = 50_000,
nonzero,
range(min = 1, max = 1_000_000)
)]
@@ -27,7 +25,6 @@ pub(super) struct TimingCandidate {
id = 4,
key = "timing.local_state_freshness_us",
unit = "us",
default = 150_000,
nonzero,
range(min = 1, max = 10_000_000)
)]
@@ -36,7 +33,6 @@ pub(super) struct TimingCandidate {
id = 5,
key = "timing.link_freshness_us",
unit = "us",
default = 500_000,
nonzero,
range(min = 1, max = 10_000_000)
)]