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

+23 -27
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
@@ -151,13 +151,6 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
name = "kaptan-actuator"
version = "0.1.0"
[[package]]
name = "kaptan-board"
version = "0.1.0"
dependencies = [
"kaptan-log",
]
[[package]]
name = "kaptan-config"
version = "0.1.0"
@@ -198,7 +191,6 @@ dependencies = [
name = "kaptan-data-channel"
version = "0.1.0"
dependencies = [
"kaptan-board",
"kaptan-dma",
"kaptan-log",
"kaptan-mavlink",
@@ -243,8 +235,9 @@ dependencies = [
name = "kaptan-geprc-f722-v2"
version = "0.1.0"
dependencies = [
"kaptan-board",
"kaptan-config",
"platform-stm32f7",
"rtic",
]
[[package]]
@@ -255,14 +248,16 @@ version = "0.1.0"
name = "kaptan-kakute-f7-mini-v3"
version = "0.1.0"
dependencies = [
"kaptan-board",
"kaptan-config",
"platform-stm32f7",
"rtic",
]
[[package]]
name = "kaptan-log"
version = "0.1.0"
dependencies = [
"kaptan-config-derive",
"kaptan-log-derive",
"log",
]
@@ -486,7 +481,8 @@ version = "0.1.0"
dependencies = [
"cortex-m",
"cortex-m-rt",
"kaptan-board",
"kaptan-config",
"kaptan-config-mavlink",
"kaptan-data-channel",
"kaptan-dma",
"kaptan-drivers",
@@ -510,9 +506,9 @@ checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]]
name = "proc-macro2"
version = "1.0.106"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
@@ -528,9 +524,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.46"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
@@ -549,9 +545,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.16"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
@@ -645,7 +641,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.0",
"syn 3.0.3",
]
[[package]]
@@ -694,9 +690,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.0"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
@@ -720,22 +716,22 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.0",
"syn 3.0.3",
]
[[package]]
-1
View File
@@ -3,7 +3,6 @@ members = [
"boards/geprc-f722-v2",
"boards/kakute-f7-mini-v3",
"libs/kaptan-actuator",
"libs/kaptan-board",
"libs/kaptan-control",
"libs/kaptan-config",
"libs/kaptan-config-derive",
+4 -1
View File
@@ -10,8 +10,11 @@ build = "build.rs"
workspace = true
[dependencies]
kaptan-board = { path = "../../libs/kaptan-board" }
kaptan-config = { path = "../../libs/kaptan-config" }
platform-stm32f7 = { path = "../../platforms/stm32f7" }
# RTIC's attribute expansion emits absolute `rtic` support paths in the final
# board crate; the task graph and all RTIC source remain platform-owned.
rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] }
[[bin]]
name = "kaptan-geprc-f722-v2"
+77 -30
View File
@@ -1,45 +1,92 @@
//! Declarative support for the GEPRC TAKER F722 BLS 60A V2 stack.
use kaptan_board::{
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
Severity, TelemetryProfile, VerticalSensingKind,
};
use platform_stm32f7::{
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Icm42688, Mavlink, Pc3, VerticalSensor,
F7Board, F722Hardware, F722ImmutableConfig as ImmutableConfig, Icm42688, ImuMount, Mavlink,
Pa13ActiveHigh, Pb0Pb1Pb4Pb5Low, Pc3, Severity, VerticalSensor,
};
/// User-writable MAVLink endpoint policy.
#[derive(Clone, kaptan_config::Config)]
struct MavlinkConfig {
#[config(
id = 101,
key = "mavlink.system_id",
unit = "id",
range(min = 1, max = 255)
)]
system_id: u8,
#[config(
id = 102,
key = "mavlink.component_id",
unit = "id",
range(min = 1, max = 255)
)]
component_id: u8,
#[config(id = 108, key = "mavlink.logging.enabled", unit = "bool")]
logging_enabled: bool,
#[config(
enum,
id = 105,
key = "mavlink.logging.maximum_severity",
unit = "mav_severity"
)]
maximum_log_severity: Severity,
}
/// User-writable telemetry publication policy.
#[derive(Clone, kaptan_config::Config)]
struct TelemetryConfig {
#[config(
id = 103,
key = "telemetry.raw_imu_hz",
unit = "Hz",
range(min = 1, max = 100)
)]
raw_imu_hz: u8,
#[config(
id = 104,
key = "telemetry.heartbeat_hz",
unit = "Hz",
range(min = 1, max = 10)
)]
heartbeat_hz: u8,
}
/// Complete user-writable configuration advertised by this board.
#[derive(Clone, kaptan_config::Config)]
pub struct UserConfig {
mavlink: MavlinkConfig,
telemetry: TelemetryConfig,
}
/// ICM-42688-P-based GEP-F722-HD V2 paired with the 60 A stack.
pub struct GeprcF722V2;
impl F7Board for GeprcF722V2 {
const CONFIG: BoardConfig = BoardConfig {
name: "GEPRC TAKER F722 V2 (ICM-42688-P)",
mavlink: MavlinkEndpoint {
type UserConfig = UserConfig;
type ImmutableConfig = ImmutableConfig;
const FACTORY_USER_CONFIG: UserConfig = UserConfig {
mavlink: MavlinkConfig {
system_id: 1,
component_id: 1,
port: MavlinkPort::Serial2,
baud: 115_200,
logging_enabled: true,
maximum_log_severity: Severity::Info,
},
telemetry: TelemetryConfig {
raw_imu_hz: 20,
heartbeat_hz: 2,
},
profile: BoardProfile::TelemetryOnly(TelemetryProfile {
imu: ImuDevice::Icm42688,
imu_mount: ImuMount::Yaw180,
vertical_sensing: VerticalSensingKind::None,
data_channel: DataChannelRates {
service_hz: 100,
raw_imu_hz: 20,
range_hz: None,
optical_flow_hz: None,
heartbeat_hz: 2,
},
maximum_log_level: Severity::Info,
}),
};
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> {
platform
.imu(Icm42688::on_spi3().with_drdy(Pc3))
.mavlink(Mavlink::on_usart2())
const IMMUTABLE_CONFIG: ImmutableConfig = ImmutableConfig {
name: "GEPRC TAKER F722 V2 (ICM-42688-P)",
service_hz: 100,
hardware: F722Hardware::new()
.output_inhibit(Pb0Pb1Pb4Pb5Low)
.imu(Icm42688::on_spi3().with_drdy(Pc3).mounted(ImuMount::Yaw180))
.mavlink(Mavlink::on_usart2().baud(115_200))
.vertical_sensor(VerticalSensor::none())
.build()
}
.status_led(Pa13ActiveHigh)
.telemetry_only(),
};
}
+1 -1
View File
@@ -3,4 +3,4 @@
mod board;
platform_stm32f7::flight_firmware!(board::GeprcF722V2);
platform_stm32f7::flight_firmware!(crate::board::GeprcF722V2);
+4 -4
View File
@@ -8,12 +8,12 @@ version.workspace = true
[lints]
workspace = true
[features]
lidar-lite-v3 = []
[dependencies]
kaptan-board = { path = "../../libs/kaptan-board" }
kaptan-config = { path = "../../libs/kaptan-config" }
platform-stm32f7 = { path = "../../platforms/stm32f7" }
# RTIC's attribute expansion emits absolute `rtic` support paths in the final
# board crate; the task graph and all RTIC source remain platform-owned.
rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] }
[[bin]]
name = "kaptan-kakute-f7-mini-v3"
+108 -36
View File
@@ -4,48 +4,120 @@
//! code, interrupt names, DMA handling, or task scheduling. Those concerns
//! belong to the STM32F7 platform crate.
use kaptan_board::{
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
Severity, TelemetryProfile, VerticalSensingKind,
};
use platform_stm32f7::{
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Mavlink, Mpu6000, Pa4, VerticalSensor,
AccelerometerRange, DigitalLowPassFilter, F7Board, F722Hardware,
F722ImmutableConfig as ImmutableConfig, GyroscopeRange, ImuMount, Mavlink, Mpu6000,
Mpu6000Config, Pa4, Pb0Pb1Pb4Pb5Low, Pd2ActiveLow, Severity, VerticalSensor,
};
/// User-writable MAVLink endpoint policy.
#[derive(Clone, kaptan_config::Config)]
struct MavlinkConfig {
#[config(
id = 101,
key = "mavlink.system_id",
unit = "id",
range(min = 1, max = 255)
)]
system_id: u8,
#[config(
id = 102,
key = "mavlink.component_id",
unit = "id",
range(min = 1, max = 255)
)]
component_id: u8,
#[config(id = 108, key = "mavlink.logging.enabled", unit = "bool")]
logging_enabled: bool,
#[config(
enum,
id = 105,
key = "mavlink.logging.maximum_severity",
unit = "mav_severity"
)]
maximum_log_severity: Severity,
}
/// User-writable telemetry publication policy.
#[derive(Clone, kaptan_config::Config)]
struct TelemetryConfig {
#[config(
id = 103,
key = "telemetry.raw_imu_hz",
unit = "Hz",
range(min = 1, max = 100)
)]
raw_imu_hz: u8,
#[config(
id = 104,
key = "telemetry.heartbeat_hz",
unit = "Hz",
range(min = 1, max = 10)
)]
heartbeat_hz: u8,
#[config(
id = 106,
key = "telemetry.range_hz",
unit = "Hz",
range(min = 1, max = 100)
)]
range_hz: u8,
#[config(
id = 107,
key = "telemetry.optical_flow_hz",
unit = "Hz",
range(min = 1, max = 100)
)]
optical_flow_hz: u8,
}
/// Complete user-writable configuration advertised by this board.
#[derive(Clone, kaptan_config::Config)]
pub struct UserConfig {
mavlink: MavlinkConfig,
telemetry: TelemetryConfig,
}
/// Holybro Kakute F7 Mini V3.
pub struct KakuteF7MiniV3;
const CONFIG: BoardConfig = BoardConfig {
name: "Kakute F7 Mini V3",
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,
}),
};
impl F7Board for KakuteF7MiniV3 {
const CONFIG: BoardConfig = CONFIG;
type UserConfig = UserConfig;
type ImmutableConfig = ImmutableConfig;
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> {
platform
.imu(Mpu6000::on_spi1().with_drdy(Pa4))
.mavlink(Mavlink::on_usart1())
.vertical_sensor(VerticalSensor::mtf01_on_uart4())
.build()
}
const FACTORY_USER_CONFIG: UserConfig = UserConfig {
mavlink: MavlinkConfig {
system_id: 1,
component_id: 1,
logging_enabled: true,
maximum_log_severity: Severity::Info,
},
telemetry: TelemetryConfig {
raw_imu_hz: 20,
heartbeat_hz: 2,
range_hz: 20,
optical_flow_hz: 20,
},
};
const IMMUTABLE_CONFIG: ImmutableConfig = ImmutableConfig {
name: "Kakute F7 Mini V3",
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(),
};
}
@@ -2,4 +2,4 @@
#![no_main]
mod board;
platform_stm32f7::flight_firmware!(board::KakuteF7MiniV3);
platform_stm32f7::flight_firmware!(crate::board::KakuteF7MiniV3);
-12
View File
@@ -1,12 +0,0 @@
[package]
name = "kaptan-board"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
kaptan-log = { path = "../kaptan-log" }
-339
View File
@@ -1,339 +0,0 @@
//! Portable board-support contract for Kaptan.
//!
//! This crate describes logical board identity, sensing capability and
//! outbound data-channel policy. It intentionally has no dependency on an MCU
//! family, peripheral access crate, RTIC, or a runtime framework.
#![no_std]
use kaptan_log::KaptanError;
pub use kaptan_log::Severity;
/// Physical IMU device selected by a board declaration.
///
/// This is immutable board topology, not a runtime-selectable sensor driver.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ImuDevice {
/// InvenSense MPU-6000 on the board's selected SPI route.
Mpu6000,
/// TDK ICM-42688-P on the board's selected SPI route.
Icm42688,
}
/// Fixed board-to-body IMU mounting transform.
///
/// Calibration remains a separate, safety-gated workflow. This declaration
/// only selects the compiled transform for the physical board route.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ImuMount {
/// Sensor axes already match the controller body axes.
BodyAligned,
/// Sensor is yawed 180° relative to the controller body axes.
Yaw180,
}
/// Logical outbound MAVLink serial endpoint selected by a board declaration.
///
/// The platform maps these logical names to its checked pin/DMA routes.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MavlinkPort {
/// First supported board serial endpoint.
Serial1,
/// Second supported board serial endpoint.
Serial2,
}
/// The vertical-sensing data a board support package makes available.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum VerticalSensingKind {
/// No range or optical-flow data is supported.
None,
/// Range data is supported.
Range,
/// Coherent range and optical-flow data are supported.
RangeAndOpticalFlow,
}
/// MAVLink endpoint identity selected by one board support package.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MavlinkEndpoint {
/// MAVLink system identifier.
pub system_id: u8,
/// MAVLink component identifier.
pub component_id: u8,
/// Immutable physical serial endpoint selected for this board image.
pub port: MavlinkPort,
/// UART line rate used by that physical endpoint.
pub baud: u32,
}
/// Fixed outbound data-channel rates driven by a platform service timer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DataChannelRates {
/// Frequency of the timer that calls the data-channel scheduler.
pub service_hz: usize,
/// Raw-IMU publication rate.
pub raw_imu_hz: usize,
/// Range publication rate. `None` means range is not published.
pub range_hz: Option<usize>,
/// Optical-flow publication rate. `None` means flow is not published.
pub optical_flow_hz: Option<usize>,
/// Link heartbeat publication rate.
pub heartbeat_hz: usize,
}
/// Telemetry-only physical-sensor and outbound-data policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TelemetryProfile {
/// Immutable physical IMU driver selected by this board image.
pub imu: ImuDevice,
/// Immutable board-to-body transform applied to that IMU's samples.
pub imu_mount: ImuMount,
/// Supported physical vertical-sensing capability.
pub vertical_sensing: VerticalSensingKind,
/// Data-channel timer and publication rates.
pub data_channel: DataChannelRates,
/// Highest optional diagnostic severity forwarded to MAVLink.
///
/// Errors remain observable even when optional debug/info diagnostics are
/// filtered. This setting is compile-time board policy until the target
/// has the reviewed configuration activation path.
pub maximum_log_level: Severity,
}
/// Exclusive build-time image selection.
///
/// `ExternalControlOnly` remains representable for an explicit validation
/// rejection rather than silently falling back to telemetry.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BoardProfile {
/// Physical-sensor telemetry image.
TelemetryOnly(TelemetryProfile),
/// Reserved and deliberately unimplemented production authority profile.
ExternalControlOnly,
}
/// Static board identity and exclusive build-time image configuration.
///
/// The selected platform trait determines the MCU family. Keeping a free-form
/// MCU string here would duplicate that type-level fact without validating it.
///
/// Board policy is compiled and non-deserializable. The current F722 platform
/// validates it against typestate-selected electrical routes before it touches
/// hardware. Target-side runtime configuration remains disabled until its
/// separate authenticated, journalled, disarmed activation contract exists.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BoardConfig {
/// Stable board name used in build and diagnostic tooling.
pub name: &'static str,
/// MAVLink endpoint identity.
pub mavlink: MavlinkEndpoint,
/// Exactly one immutable image profile.
pub profile: BoardProfile,
}
impl BoardConfig {
/// Returns physical telemetry configuration only for a telemetry image.
pub const fn telemetry(self) -> Option<TelemetryProfile> {
match self.profile {
BoardProfile::TelemetryOnly(profile) => Some(profile),
BoardProfile::ExternalControlOnly => None,
}
}
/// Validates the selected exclusive profile.
pub const fn validate(self) -> Result<(), BoardConfigError> {
if self.mavlink.system_id == 0 || self.mavlink.component_id == 0 {
return Err(BoardConfigError::InvalidMavlinkEndpoint);
}
if self.mavlink.baud == 0 {
return Err(BoardConfigError::ZeroMavlinkBaud);
}
match self.profile {
BoardProfile::TelemetryOnly(profile) => Self::validate_telemetry(profile),
BoardProfile::ExternalControlOnly => {
// TODO(flight-config): replace this hard rejection only with a
// complete runtime and atomic configuration activation. Required
// inputs are vehicle geometry, calibrated sensors, actuator
// limits, authenticated MAVLink-v2 authority, persistent replay
// state, estimator/controller/allocator readiness, and a
// verified output watchdog.
Err(BoardConfigError::UnsupportedFlightProfile)
}
}
}
const fn validate_telemetry(profile: TelemetryProfile) -> Result<(), BoardConfigError> {
let rates = profile.data_channel;
if rates.service_hz == 0 {
return Err(BoardConfigError::ZeroServiceFrequency);
}
if rates.raw_imu_hz == 0 || rates.heartbeat_hz == 0 {
return Err(BoardConfigError::MissingRequiredDataChannel);
}
if rates.raw_imu_hz > rates.service_hz || rates.heartbeat_hz > rates.service_hz {
return Err(BoardConfigError::RateExceedsServiceFrequency);
}
let Some(mut scheduled_hz) = rates.raw_imu_hz.checked_add(rates.heartbeat_hz) else {
return Err(BoardConfigError::DataChannelOvercommitted);
};
if let Some(range_hz) = rates.range_hz {
if range_hz == 0 || range_hz > rates.service_hz {
return Err(BoardConfigError::RateExceedsServiceFrequency);
}
let Some(total) = scheduled_hz.checked_add(range_hz) else {
return Err(BoardConfigError::DataChannelOvercommitted);
};
scheduled_hz = total;
}
if let Some(flow_hz) = rates.optical_flow_hz {
if flow_hz == 0 || flow_hz > rates.service_hz {
return Err(BoardConfigError::RateExceedsServiceFrequency);
}
let Some(total) = scheduled_hz.checked_add(flow_hz) else {
return Err(BoardConfigError::DataChannelOvercommitted);
};
scheduled_hz = total;
}
if scheduled_hz > rates.service_hz {
return Err(BoardConfigError::DataChannelOvercommitted);
}
let vertical_schedule_is_valid = match profile.vertical_sensing {
VerticalSensingKind::None
if rates.range_hz.is_none() && rates.optical_flow_hz.is_none() =>
{
true
}
VerticalSensingKind::Range
if rates.range_hz.is_some() && rates.optical_flow_hz.is_none() =>
{
true
}
VerticalSensingKind::RangeAndOpticalFlow
if rates.range_hz.is_some() && rates.optical_flow_hz.is_some() =>
{
true
}
_ => false,
};
if !vertical_schedule_is_valid {
return Err(BoardConfigError::CapabilityDataChannelMismatch);
}
Ok(())
}
}
/// Invalid board-support configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum BoardConfigError {
/// MAVLink system/component identifiers must be exact nonzero values.
InvalidMavlinkEndpoint,
/// A physical MAVLink endpoint needs a nonzero UART rate.
ZeroMavlinkBaud,
/// A data-channel schedule needs a nonzero service frequency.
ZeroServiceFrequency,
/// Raw IMU and heartbeat are mandatory on every flight board.
MissingRequiredDataChannel,
/// A configured stream is faster than its service timer.
RateExceedsServiceFrequency,
/// The schedule requests more than one frame per service tick.
DataChannelOvercommitted,
/// Declared vertical sensing and published streams disagree.
CapabilityDataChannelMismatch,
/// The selected operation profile has no complete runtime implementation.
UnsupportedFlightProfile,
}
#[cfg(test)]
mod tests {
use super::{
BoardConfig, BoardConfigError, BoardProfile, DataChannelRates, ImuDevice, ImuMount,
MavlinkEndpoint, MavlinkPort, TelemetryProfile, VerticalSensingKind,
};
use kaptan_log::Severity;
const BASE: 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 accepts_a_schedulable_range_and_flow_board() {
assert_eq!(BASE.validate(), Ok(()));
}
#[test]
fn rejects_streams_that_exceed_the_service_budget() {
let mut config = BASE;
let BoardProfile::TelemetryOnly(profile) = &mut config.profile else {
unreachable!();
};
profile.data_channel.optical_flow_hz = Some(80);
assert_eq!(
config.validate(),
Err(BoardConfigError::DataChannelOvercommitted)
);
}
#[test]
fn rejects_capability_and_data_channel_mismatch() {
let mut config = BASE;
let BoardProfile::TelemetryOnly(profile) = &mut config.profile else {
unreachable!();
};
profile.vertical_sensing = VerticalSensingKind::Range;
assert_eq!(
config.validate(),
Err(BoardConfigError::CapabilityDataChannelMismatch)
);
}
#[test]
fn rejects_external_control_until_its_runtime_exists() {
let mut config = BASE;
config.profile = BoardProfile::ExternalControlOnly;
assert_eq!(
config.validate(),
Err(BoardConfigError::UnsupportedFlightProfile)
);
}
#[test]
fn rejects_invalid_immutable_link_policy() {
let mut config = BASE;
config.mavlink.baud = 0;
assert_eq!(config.validate(), Err(BoardConfigError::ZeroMavlinkBaud));
let mut config = BASE;
config.mavlink.component_id = 0;
assert_eq!(
config.validate(),
Err(BoardConfigError::InvalidMavlinkEndpoint)
);
}
}
@@ -0,0 +1,186 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::{
Attribute, Data, DeriveInput, Expr, Fields, Ident, Lit, LitStr, Result, Token,
punctuated::Punctuated, spanned::Spanned,
};
const MAX_CONFIG_ENUM_LABEL_BYTES: usize = 32;
pub(crate) fn expand(input: DeriveInput) -> Result<TokenStream> {
if !input.generics.params.is_empty() {
return Err(syn::Error::new(
input.generics.span(),
"ConfigEnum does not support generic enums",
));
}
require_u8_representation(&input.attrs, input.ident.span())?;
if input
.attrs
.iter()
.any(|attribute| attribute.path().is_ident("config_enum"))
{
return Err(syn::Error::new(
input.ident.span(),
"config_enum options belong on enum variants",
));
}
let Data::Enum(data) = input.data else {
return Err(syn::Error::new(
input.ident.span(),
"ConfigEnum can only be derived for enums",
));
};
if data.variants.is_empty() {
return Err(syn::Error::new(
input.ident.span(),
"ConfigEnum requires at least one variant",
));
}
if data.variants.len() > usize::from(u8::MAX) {
return Err(syn::Error::new(
input.ident.span(),
"ConfigEnum supports at most 255 variants",
));
}
let mut variants = Vec::with_capacity(data.variants.len());
let mut values = Vec::with_capacity(data.variants.len());
let mut labels = Vec::with_capacity(data.variants.len());
for variant in data.variants {
if !matches!(variant.fields, Fields::Unit) {
return Err(syn::Error::new(
variant.fields.span(),
"ConfigEnum variants cannot contain fields",
));
}
let Some((_, discriminant)) = variant.discriminant else {
return Err(syn::Error::new(
variant.ident.span(),
"ConfigEnum variants require explicit u8 discriminants",
));
};
let value = discriminant_value(&discriminant)?;
if values.contains(&value) {
return Err(syn::Error::new(
discriminant.span(),
"duplicate ConfigEnum discriminant",
));
}
let label = variant_label(&variant.attrs, &variant.ident)?;
if labels
.iter()
.any(|previous: &LitStr| previous.value() == label.value())
{
return Err(syn::Error::new(
label.span(),
"duplicate ConfigEnum display label",
));
}
variants.push(variant.ident);
values.push(value);
labels.push(label);
}
let ident = input.ident;
Ok(quote! {
impl #ident {
/// Canonical stable values and display labels for generic
/// configuration discovery.
pub const CONFIG_ENUM_OPTIONS: &'static [(u8, &'static str)] = &[
#((#values, #labels)),*
];
/// Returns this variant's stable compact representation.
pub const fn wire_value(self) -> u8 {
match self {
#(Self::#variants => #values),*
}
}
/// Decodes one stable compact representation.
pub const fn try_from_wire(value: u8) -> Option<Self> {
match value {
#(#values => Some(Self::#variants),)*
_ => None,
}
}
}
})
}
fn require_u8_representation(attributes: &[Attribute], span: proc_macro2::Span) -> Result<()> {
let representations = attributes
.iter()
.filter(|attribute| attribute.path().is_ident("repr"))
.collect::<Vec<_>>();
if representations.len() != 1 {
return Err(syn::Error::new(
span,
"ConfigEnum requires exactly #[repr(u8)]",
));
}
let arguments =
representations[0].parse_args_with(Punctuated::<Ident, Token![,]>::parse_terminated)?;
if arguments.len() != 1 || !arguments[0].eq("u8") {
return Err(syn::Error::new(
representations[0].span(),
"ConfigEnum requires exactly #[repr(u8)]",
));
}
Ok(())
}
fn discriminant_value(expression: &Expr) -> Result<u8> {
let Expr::Lit(expression) = expression else {
return Err(syn::Error::new(
expression.span(),
"ConfigEnum discriminants must be u8 integer literals",
));
};
let Lit::Int(literal) = &expression.lit else {
return Err(syn::Error::new(
expression.span(),
"ConfigEnum discriminants must be u8 integer literals",
));
};
literal.base10_parse::<u8>().map_err(|_| {
syn::Error::new(
literal.span(),
"ConfigEnum discriminant is outside the u8 domain",
)
})
}
fn variant_label(attributes: &[Attribute], ident: &Ident) -> Result<LitStr> {
let mut label = None;
for attribute in attributes
.iter()
.filter(|attribute| attribute.path().is_ident("config_enum"))
{
attribute.parse_nested_meta(|meta| {
if meta.path.is_ident("label") {
if label.is_some() {
return Err(meta.error("duplicate ConfigEnum display label"));
}
label = Some(meta.value()?.parse::<LitStr>()?);
return Ok(());
}
Err(meta.error("the only variant config_enum option is label = \"...\""))
})?;
}
let label = label.unwrap_or_else(|| LitStr::new(&ident.to_string(), ident.span()));
if label.value().is_empty() || label.value().contains('\0') {
return Err(syn::Error::new(
label.span(),
"ConfigEnum display labels must be non-empty text without NUL bytes",
));
}
if label.value().len() > MAX_CONFIG_ENUM_LABEL_BYTES {
return Err(syn::Error::new(
label.span(),
"ConfigEnum display label exceeds the 32-byte wire limit",
));
}
Ok(label)
}
+150 -138
View File
@@ -15,7 +15,7 @@ pub(crate) fn expand(model: Model) -> Result<TokenStream> {
} = model;
match kind {
ModelKind::Scalars => expand_scalars(ident, generics, crate_path, post_validate, fields),
ModelKind::Sections => expand_sections(ident, generics, crate_path, post_validate, fields),
ModelKind::Sections => expand_children(ident, generics, crate_path, post_validate, fields),
}
}
@@ -29,7 +29,6 @@ fn expand_scalars(
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
let mut checks = Vec::new();
let mut descriptors = Vec::new();
let mut defaults = Vec::new();
let mut reads = Vec::new();
let mut writes = Vec::new();
for field in &fields {
@@ -37,38 +36,62 @@ fn expand_scalars(
descriptor,
checks: field_checks,
} = expand_field(&crate_path, field)?;
if let Some(descriptor) = descriptor {
descriptors.push(descriptor);
}
descriptors.push(descriptor);
checks.extend(field_checks);
let id = field.id.expect("ConfigValidate requires configured fields");
let default = field
.default
.as_ref()
.expect("ConfigValidate requires defaults");
let (kind, value) = config_value_tokens(&crate_path, &field.ty)?;
let id = field.id.expect("scalar configuration fields have IDs");
let ty = &field.ty;
let field_ident = &field.ident;
defaults.push(quote! { #field_ident: #default });
let (kind, read, decode, out_of_domain_rule) = if !field.enum_backed {
(
quote! { <#ty as #crate_path::ConfigScalar>::KIND },
quote! { #crate_path::ConfigScalar::into_config_value(self.#field_ident) },
quote! { <#ty as #crate_path::ConfigScalar>::try_from_config_value(value) },
quote! { #crate_path::ValidationRule::AboveMaximum },
)
} else {
(
quote! { #crate_path::ValueKind::U32 },
quote! {
#crate_path::ConfigValue::U32(u32::from(self.#field_ident.wire_value()))
},
quote! {
match value {
#crate_path::ConfigValue::U32(value) => u8::try_from(value)
.ok()
.and_then(<#ty>::try_from_wire)
.ok_or(#crate_path::ScalarValueError::OutOfDomain),
value => Err(#crate_path::ScalarValueError::WrongKind(value.kind())),
}
},
quote! { #crate_path::ValidationRule::InvalidEnumValue },
)
};
reads.push(quote! {
#id => Some(#value(self.#field_ident)),
#id => Some(#read),
});
writes.push(quote! {
#id => {
let next = match value {
#value(next) => next,
actual => {
let next = match #decode {
Ok(next) => next,
Err(#crate_path::ScalarValueError::WrongKind(actual)) => {
return Err(#crate_path::FieldWriteError::WrongType {
field: #crate_path::FieldId::new(#id),
expected: #kind,
actual: actual.kind(),
actual,
});
}
Err(#crate_path::ScalarValueError::OutOfDomain) => {
return Err(#crate_path::FieldWriteError::Violation(
#crate_path::ConfigViolation::field(
#crate_path::FieldId::new(#id),
#out_of_domain_rule,
),
));
}
};
let previous = self.#field_ident;
self.#field_ident = next;
if let Err(violation) =
<Self as #crate_path::ConfigSection>::validate_local_fields(self)
{
if let Err(violation) = self.__validate_local_configuration() {
self.#field_ident = previous;
return Err(#crate_path::FieldWriteError::Violation(violation));
}
@@ -78,30 +101,32 @@ fn expand_scalars(
}
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
Ok(quote! {
impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause {
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
<Self as #crate_path::ConfigSection>::validate_local_fields(self)?;
#post_validate
impl #impl_generics #ident #type_generics #where_clause {
/// Field metadata generated from the same declarations as validation.
pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[
#(#descriptors),*
];
fn __validate_local_configuration(
&self,
) -> Result<(), #crate_path::ConfigViolation> {
if !#crate_path::validation::__valid_enum_options(Self::DESCRIPTORS) {
return Err(#crate_path::ConfigViolation::global(
#crate_path::ValidationRule::Inconsistent,
));
}
#(#checks)*
Ok(())
}
}
impl #impl_generics #ident #type_generics #where_clause {
/// Field metadata generated from the same attributes as validation.
pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[
#(#descriptors),*
];
}
impl #impl_generics #crate_path::ConfigSection for #ident #type_generics #where_clause {
impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause {
const FIELD_COUNT: usize = Self::DESCRIPTORS.len();
const SCHEMA_ID: [u8; 16] =
#crate_path::validation::__schema_id(Self::DESCRIPTORS);
fn default_section() -> Result<Self, #crate_path::ConfigViolation> {
let section = Self {
#(#defaults),*
};
<Self as #crate_path::ValidateFields>::validate_fields(&section)?;
Ok(section)
fn descriptor(index: usize) -> Option<&'static #crate_path::FieldDescriptor> {
Self::DESCRIPTORS.get(index)
}
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
@@ -122,15 +147,16 @@ fn expand_scalars(
}
}
fn validate_local_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
#(#checks)*
fn validate(&self) -> Result<(), #crate_path::ConfigViolation> {
self.__validate_local_configuration()?;
#post_validate
Ok(())
}
}
})
}
fn expand_sections(
fn expand_children(
ident: syn::Ident,
generics: syn::Generics,
crate_path: syn::Path,
@@ -138,71 +164,55 @@ fn expand_sections(
fields: Vec<crate::parse::FieldModel>,
) -> Result<TokenStream> {
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
let validations = fields.iter().map(|field| {
let mut validations = Vec::new();
let mut reads = Vec::new();
let mut writes = Vec::new();
let mut counts = Vec::new();
let mut schema_ids = Vec::new();
let mut descriptor_lookups = Vec::new();
for field in &fields {
let field_ident = &field.ident;
let ty = &field.ty;
quote! {
<#ty as #crate_path::ValidateFields>::validate_fields(&self.#field_ident)?;
}
});
let defaults = fields.iter().map(|field| {
let field_ident = &field.ident;
let ty = &field.ty;
quote! {
#field_ident: <#ty as #crate_path::ConfigSection>::default_section()?
}
});
let reads = fields.iter().map(|field| {
let field_ident = &field.ident;
let ty = &field.ty;
quote! {
if let Some(value) = <#ty as #crate_path::ConfigSection>::field_value(
validations.push(quote! {
<#ty as #crate_path::Config>::validate(&self.#field_ident)?;
});
reads.push(quote! {
if let Some(value) = <#ty as #crate_path::Config>::field_value(
&self.#field_ident,
id,
) {
return Some(value);
}
}
});
let writes = fields.iter().map(|field| {
let field_ident = &field.ident;
let ty = &field.ty;
quote! {
if <#ty as #crate_path::ConfigSection>::field_value(&self.#field_ident, id).is_some() {
return <#ty as #crate_path::ConfigSection>::set_field(
});
writes.push(quote! {
if <#ty as #crate_path::Config>::field_value(&self.#field_ident, id).is_some() {
return <#ty as #crate_path::Config>::set_field(
&mut self.#field_ident,
id,
value,
);
}
}
});
let counts = fields.iter().map(|field| {
let ty = &field.ty;
quote! { <#ty as #crate_path::ConfigSection>::FIELD_COUNT }
});
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
Ok(quote! {
impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause {
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
#(#validations)*
#post_validate
Ok(())
});
counts.push(quote! { <#ty as #crate_path::Config>::FIELD_COUNT });
schema_ids.push(quote! { <#ty as #crate_path::Config>::SCHEMA_ID });
descriptor_lookups.push(quote! {
if index < <#ty as #crate_path::Config>::FIELD_COUNT {
return <#ty as #crate_path::Config>::descriptor(index);
}
}
index -= <#ty as #crate_path::Config>::FIELD_COUNT;
});
}
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
impl #impl_generics #ident #type_generics #where_clause {
/// Number of fields aggregated from derived nested sections.
pub const FIELD_COUNT: usize = 0 #(+ #counts)*;
}
Ok(quote! {
impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause {
const FIELD_COUNT: usize = 0 #(+ #counts)*;
const SCHEMA_ID: [u8; 16] =
#crate_path::validation::__combine_schema_ids(&[#(#schema_ids),*]);
impl #impl_generics #crate_path::ConfigAggregate for #ident #type_generics #where_clause {
fn default_aggregate() -> Result<Self, #crate_path::ConfigViolation> {
let aggregate = Self {
#(#defaults),*
};
<Self as #crate_path::ValidateFields>::validate_fields(&aggregate)?;
Ok(aggregate)
fn descriptor(mut index: usize) -> Option<&'static #crate_path::FieldDescriptor> {
#(#descriptor_lookups)*
None
}
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
@@ -218,23 +228,39 @@ fn expand_sections(
#(#writes)*
Err(#crate_path::FieldWriteError::UnknownField(id))
}
fn validate(&self) -> Result<(), #crate_path::ConfigViolation> {
if !#crate_path::validation::__unique_field_ids::<Self>() {
return Err(#crate_path::ConfigViolation::global(
#crate_path::ValidationRule::Inconsistent,
));
}
if !#crate_path::validation::__unique_display_keys::<Self>() {
return Err(#crate_path::ConfigViolation::global(
#crate_path::ValidationRule::Inconsistent,
));
}
#(#validations)*
#post_validate
Ok(())
}
}
impl #impl_generics #ident #type_generics #where_clause {
/// Number of fields contributed by the complete generated tree.
pub const FIELD_COUNT: usize = <Self as #crate_path::Config>::FIELD_COUNT;
}
})
}
struct FieldMetadata {
descriptor: Option<TokenStream>,
descriptor: TokenStream,
checks: Vec<TokenStream>,
}
fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result<FieldMetadata> {
let mut checks = Vec::new();
let Some(id) = field.id else {
return Ok(FieldMetadata {
descriptor: None,
checks,
});
};
let id = field.id.expect("scalar configuration fields have IDs");
let minimum = field.checks.iter().find_map(|check| match check {
Check::Minimum(value) => Some(value),
_ => None,
@@ -248,23 +274,33 @@ fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Res
}
let key = field.display_key.as_ref().expect("id fields have a key");
let unit = field.unit.as_ref().expect("id fields have a unit");
let default = field.default.as_ref().expect("id fields have a default");
let (kind, value) = config_value_tokens(crate_path, &field.ty)?;
let ty = &field.ty;
let (kind, enum_options) = if !field.enum_backed {
(
quote! { <#ty as #crate_path::ConfigScalar>::KIND },
quote! { &[] },
)
} else {
(
quote! { #crate_path::ValueKind::U32 },
quote! { <#ty>::CONFIG_ENUM_OPTIONS },
)
};
let value = config_value_constructor_tokens(crate_path, &field.ty)?;
let minimum = option_value_tokens(value.clone(), minimum);
let maximum = option_value_tokens(value.clone(), maximum);
Ok(FieldMetadata {
descriptor: Some(quote! {
descriptor: quote! {
#crate_path::FieldDescriptor {
id: #crate_path::FieldId::new(#id),
display_key: #key,
kind: #kind,
minimum: #minimum,
maximum: #maximum,
default: #value(#default),
enum_options: #enum_options,
unit: #unit,
apply_policy: #crate_path::ApplyPolicy::RestartRequired,
}
}),
},
checks,
})
}
@@ -276,10 +312,7 @@ fn option_value_tokens(value: TokenStream, expression: Option<&syn::Expr>) -> To
}
}
fn config_value_tokens(
crate_path: &syn::Path,
ty: &syn::Type,
) -> Result<(TokenStream, TokenStream)> {
fn config_value_constructor_tokens(crate_path: &syn::Path, ty: &syn::Type) -> Result<TokenStream> {
let syn::Type::Path(path) = ty else {
return Err(syn::Error::new_spanned(
ty,
@@ -292,39 +325,18 @@ fn config_value_tokens(
"configuration metadata requires a scalar type",
));
};
let (kind, value) = match segment.ident.to_string().as_str() {
"bool" => (
quote! { #crate_path::ValueKind::Bool },
quote! { #crate_path::ConfigValue::Bool },
),
"i32" => (
quote! { #crate_path::ValueKind::I32 },
quote! { #crate_path::ConfigValue::I32 },
),
"u32" => (
quote! { #crate_path::ValueKind::U32 },
quote! { #crate_path::ConfigValue::U32 },
),
"u64" => (
quote! { #crate_path::ValueKind::U64 },
quote! { #crate_path::ConfigValue::U64 },
),
"f32" => (
quote! { #crate_path::ValueKind::F32 },
quote! { #crate_path::ConfigValue::F32 },
),
"f64" => (
quote! { #crate_path::ValueKind::F64 },
quote! { #crate_path::ConfigValue::F64 },
),
let value = match segment.ident.to_string().as_str() {
"bool" => quote! { #crate_path::ConfigValue::Bool },
"i32" => quote! { #crate_path::ConfigValue::I32 },
"u8" | "u16" | "u32" => quote! { #crate_path::ConfigValue::U32 },
"u64" => quote! { #crate_path::ConfigValue::U64 },
"f32" => quote! { #crate_path::ConfigValue::F32 },
"f64" => quote! { #crate_path::ConfigValue::F64 },
_ => {
return Err(syn::Error::new_spanned(
ty,
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
));
return Ok(quote! { #crate_path::ConfigValue::U32 });
}
};
Ok((kind, value))
Ok(value)
}
fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Check) -> TokenStream {
+23 -4
View File
@@ -1,17 +1,36 @@
//! Procedural validation derive for Kaptan configuration records.
//! Procedural derive for recursively composable Kaptan configuration records.
mod config_enum;
mod expand;
mod parse;
use proc_macro::TokenStream;
use syn::parse_macro_input;
/// Implements `kaptan_config::ValidateFields` from `#[config(...)]` helpers.
#[proc_macro_derive(ConfigValidate, attributes(config))]
pub fn derive_config_validate(input: TokenStream) -> TokenStream {
/// Generates validation, metadata, routing, and schema identity.
///
/// Primitive fields retain their native scalar type. Enum-backed fields must
/// opt in with `#[config(enum)]` and derive [`ConfigEnum`].
#[proc_macro_derive(Config, attributes(config))]
pub fn derive_config(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as syn::DeriveInput);
match parse::Model::parse(input).and_then(expand::expand) {
Ok(tokens) => tokens.into(),
Err(error) => error.into_compile_error().into(),
}
}
/// Generates the canonical table and conversions for a field configured as a
/// compact enum.
///
/// The enum must use `#[repr(u8)]`, have explicit integer discriminants, and
/// contain only fieldless variants. Variant identifiers are the default GCS
/// labels; `#[config_enum(label = "...")]` overrides one label.
#[proc_macro_derive(ConfigEnum, attributes(config_enum))]
pub fn derive_config_enum(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as syn::DeriveInput);
match config_enum::expand(input) {
Ok(tokens) => tokens.into(),
Err(error) => error.into_compile_error().into(),
}
}
+110 -157
View File
@@ -3,6 +3,9 @@ use syn::{
spanned::Spanned,
};
const MAX_DISPLAY_KEY_BYTES: usize = 48;
const MAX_UNIT_BYTES: usize = 16;
pub(crate) struct Model {
pub(crate) ident: Ident,
pub(crate) generics: syn::Generics,
@@ -24,9 +27,8 @@ pub(crate) struct FieldModel {
pub(crate) id: Option<u16>,
pub(crate) display_key: Option<syn::LitStr>,
pub(crate) unit: Option<syn::LitStr>,
pub(crate) default: Option<Expr>,
pub(crate) checks: Vec<Check>,
pub(crate) section: bool,
pub(crate) enum_backed: bool,
}
#[derive(Clone)]
@@ -43,19 +45,19 @@ impl Model {
let Data::Struct(data) = input.data else {
return Err(syn::Error::new(
input.ident.span(),
"ConfigValidate can only be derived for structs",
"Config can only be derived for structs",
));
};
let Fields::Named(fields) = data.fields else {
return Err(syn::Error::new(
input.ident.span(),
"ConfigValidate requires named struct fields",
"Config requires named struct fields",
));
};
if fields.named.is_empty() {
return Err(syn::Error::new(
input.ident.span(),
"ConfigValidate requires at least one configured field or section",
"Config requires at least one field",
));
}
let mut models = Vec::with_capacity(fields.named.len());
@@ -73,38 +75,63 @@ impl Model {
}
models.push(model);
}
let kind = if models.iter().all(|field| field.section) {
ModelKind::Sections
} else if models.iter().all(|field| field.id.is_some()) {
let kind = if models.iter().all(|field| field.id.is_some()) {
ModelKind::Scalars
} else if models.iter().all(|field| field.id.is_none()) {
ModelKind::Sections
} else {
let field = models
.iter()
.find(|field| field.section || field.id.is_none())
.find(|field| field.id.is_none())
.expect("mixed configuration model has one invalid field");
return Err(syn::Error::new(
field.ident.span(),
"ConfigValidate records must contain either scalar config fields or config(section) fields exclusively",
"Config records must contain either scalar fields or child configuration records exclusively",
));
};
if kind == ModelKind::Scalars {
let mut display_keys = Vec::with_capacity(models.len());
for field in &models {
if field.display_key.is_none() {
let Some(display_key) = &field.display_key else {
return Err(syn::Error::new(
field.ident.span(),
"configuration fields require config(key = \"...\")",
));
};
validate_metadata_text(
display_key,
MAX_DISPLAY_KEY_BYTES,
"configuration display key",
false,
)?;
if display_keys
.iter()
.any(|previous: &&syn::LitStr| previous.value() == display_key.value())
{
return Err(syn::Error::new(
display_key.span(),
"duplicate configuration display key",
));
}
if field.unit.is_none() {
display_keys.push(display_key);
let Some(unit) = &field.unit else {
return Err(syn::Error::new(
field.ident.span(),
"configuration fields require config(unit = \"...\")",
));
}
if field.default.is_none() {
};
validate_metadata_text(unit, MAX_UNIT_BYTES, "configuration unit", true)?;
let primitive = scalar_type_name(&field.ty).is_some();
if field.enum_backed && primitive {
return Err(syn::Error::new(
field.ident.span(),
"configuration fields require config(default = ...)",
field.ty.span(),
"config(enum) requires a ConfigEnum-derived enum type",
));
}
if !field.enum_backed && !primitive {
return Err(syn::Error::new(
field.ty.span(),
"non-primitive configuration fields require config(enum)",
));
}
validate_static_constraints(field)?;
@@ -121,6 +148,34 @@ impl Model {
}
}
fn validate_metadata_text(
literal: &syn::LitStr,
maximum_bytes: usize,
name: &str,
allow_empty: bool,
) -> Result<()> {
let value = literal.value();
if !allow_empty && value.is_empty() {
return Err(syn::Error::new(
literal.span(),
format!("{name} must not be empty"),
));
}
if value.contains('\0') {
return Err(syn::Error::new(
literal.span(),
format!("{name} cannot contain NUL bytes"),
));
}
if value.len() > maximum_bytes {
return Err(syn::Error::new(
literal.span(),
format!("{name} exceeds the {maximum_bytes}-byte wire limit"),
));
}
Ok(())
}
fn parse_container_attributes(attributes: &[Attribute]) -> Result<(Path, Option<Path>)> {
let mut crate_path = syn::parse_quote!(::kaptan_config);
let mut has_crate_path = false;
@@ -160,40 +215,17 @@ fn parse_field(field: Field) -> Result<FieldModel> {
let mut id = None;
let mut display_key = None;
let mut unit = None;
let mut default = None;
let mut finite = false;
let mut nonzero = false;
let mut range_minimum = false;
let mut range_maximum = false;
let mut section = false;
let mut enum_backed = false;
for attribute in field
.attrs
.iter()
.filter(|attribute| attribute.path().is_ident("config"))
{
attribute.parse_nested_meta(|meta| {
if section && !meta.path.is_ident("section") {
return Err(meta.error(
"config(section) cannot be combined with scalar id, metadata, or validation rules",
));
}
if meta.path.is_ident("section") {
if section {
return Err(meta.error("duplicate config(section)"));
}
if id.is_some()
|| display_key.is_some()
|| unit.is_some()
|| default.is_some()
|| !checks.is_empty()
{
return Err(meta.error(
"config(section) cannot be combined with scalar id, metadata, or validation rules",
));
}
section = true;
return Ok(());
}
if meta.path.is_ident("id") {
if id.is_some() {
return Err(meta.error("duplicate configuration field id"));
@@ -220,13 +252,11 @@ fn parse_field(field: Field) -> Result<FieldModel> {
unit = Some(meta.value()?.parse()?);
return Ok(());
}
if meta.path.is_ident("default") {
if default.is_some() {
return Err(meta.error("duplicate configuration default"));
if meta.path.is_ident("enum") {
if enum_backed {
return Err(meta.error("duplicate enum configuration marker"));
}
let expression = meta.value()?.parse()?;
validate_default_literal(&expression, &field.ty)?;
default = Some(expression);
enum_backed = true;
return Ok(());
}
if meta.path.is_ident("finite") {
@@ -273,26 +303,16 @@ fn parse_field(field: Field) -> Result<FieldModel> {
return Ok(());
}
Err(meta.error(
"supported field config rules are id, key, unit, default, finite, nonzero, and range",
"supported field config rules are id, key, unit, enum, finite, nonzero, and range",
))
})?;
}
if !section && !checks.is_empty() && id.is_none() {
return Err(syn::Error::new(
ident.span(),
"validated configuration fields require config(id = N)",
));
}
if section
&& (id.is_some()
|| display_key.is_some()
|| unit.is_some()
|| default.is_some()
|| !checks.is_empty())
if id.is_none()
&& (display_key.is_some() || unit.is_some() || enum_backed || !checks.is_empty())
{
return Err(syn::Error::new(
ident.span(),
"config(section) cannot be combined with scalar id, metadata, or validation rules",
"scalar configuration fields require config(id = N)",
));
}
Ok(FieldModel {
@@ -301,9 +321,8 @@ fn parse_field(field: Field) -> Result<FieldModel> {
id,
display_key,
unit,
default,
checks,
section,
enum_backed,
})
}
@@ -353,33 +372,19 @@ fn validate_numeric_literal(expression: &Expr) -> Result<()> {
}
}
fn validate_default_literal(expression: &Expr, ty: &Type) -> Result<()> {
if scalar_type_name(ty) == Some("bool") {
return match expression {
Expr::Lit(expression) if matches!(expression.lit, Lit::Bool(_)) => Ok(()),
_ => Err(syn::Error::new(
expression.span(),
"bool configuration defaults must be bool literals",
)),
};
}
validate_numeric_literal(expression)
}
fn validate_static_constraints(field: &FieldModel) -> Result<()> {
let ty = scalar_type_name(&field.ty).ok_or_else(|| {
syn::Error::new(
let Some(ty) = scalar_type_name(&field.ty) else {
if field.checks.is_empty() {
return Ok(());
}
return Err(syn::Error::new(
field.ty.span(),
"configuration metadata requires a supported scalar type",
)
})?;
"validation rules require a supported primitive scalar type",
));
};
if ty == "bool" {
return Ok(());
}
let default = scalar_literal(
field.default.as_ref().expect("scalar fields have defaults"),
ty,
)?;
let minimum = field.checks.iter().find_map(|check| match check {
Check::Minimum(value) => Some(value),
_ => None,
@@ -406,70 +411,6 @@ fn validate_static_constraints(field: &FieldModel) -> Result<()> {
"configuration range minimum exceeds maximum",
));
}
if field
.checks
.iter()
.any(|check| matches!(check, Check::Finite))
&& !default.is_finite()
{
return Err(syn::Error::new(
field
.default
.as_ref()
.expect("scalar fields have defaults")
.span(),
"configuration default violates finite",
));
}
if !default.is_finite() {
return Err(syn::Error::new(
field
.default
.as_ref()
.expect("scalar fields have defaults")
.span(),
"configuration default is not finite for its field type",
));
}
if field
.checks
.iter()
.any(|check| matches!(check, Check::Nonzero))
&& default.is_zero()
{
return Err(syn::Error::new(
field
.default
.as_ref()
.expect("scalar fields have defaults")
.span(),
"configuration default violates nonzero",
));
}
if let Some(minimum) = minimum
&& compare_scalar(default, minimum).is_lt()
{
return Err(syn::Error::new(
field
.default
.as_ref()
.expect("scalar fields have defaults")
.span(),
"configuration default is below its declared minimum",
));
}
if let Some(maximum) = maximum
&& compare_scalar(default, maximum).is_gt()
{
return Err(syn::Error::new(
field
.default
.as_ref()
.expect("scalar fields have defaults")
.span(),
"configuration default is above its declared maximum",
));
}
Ok(())
}
@@ -487,14 +428,6 @@ impl ScalarLiteral {
Self::Float(value) => value.is_finite(),
}
}
fn is_zero(self) -> bool {
match self {
Self::Signed(value) => value == 0,
Self::Unsigned(value) => value == 0,
Self::Float(value) => value == 0.0,
}
}
}
fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
@@ -519,6 +452,26 @@ fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
})?;
Ok(ScalarLiteral::Unsigned(value))
}
"u8" => {
let value = unsigned_literal(expression)?;
u8::try_from(value).map_err(|_| {
syn::Error::new(
expression.span(),
"configuration literal is outside u8 range",
)
})?;
Ok(ScalarLiteral::Unsigned(value))
}
"u16" => {
let value = unsigned_literal(expression)?;
u16::try_from(value).map_err(|_| {
syn::Error::new(
expression.span(),
"configuration literal is outside u16 range",
)
})?;
Ok(ScalarLiteral::Unsigned(value))
}
"u64" => {
let value = unsigned_literal(expression)?;
u64::try_from(value).map_err(|_| {
@@ -535,7 +488,7 @@ fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
"f64" => float_literal(expression).map(ScalarLiteral::Float),
_ => Err(syn::Error::new(
expression.span(),
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
"configuration metadata supports bool, i32, u8, u16, u32, u64, f32, and f64",
)),
}
}
@@ -1,27 +1,27 @@
use kaptan_config::{ConfigAggregate, ConfigSection, ConfigViolation, ValidateFields};
use kaptan_config_derive::ConfigValidate;
use kaptan_config::{Config, ConfigViolation};
#[derive(ConfigValidate)]
#[derive(Config)]
struct Child {
#[config(id = 1, key = "child.value", unit = "count", default = 1, range(min = 0, max = 2))]
#[config(id = 1, key = "child.value", unit = "count", range(min = 0, max = 2))]
value: u32,
}
#[derive(ConfigValidate)]
#[derive(Config)]
#[config(post = Self::validate_complete)]
struct Root {
#[config(section)]
child: Child,
}
impl Root {
fn validate_complete(&self) -> Result<(), ConfigViolation> {
self.child.validate_fields()
self.child.validate()
}
}
fn main() {
let root = Root::default_aggregate().unwrap();
let root = Root {
child: Child { value: 1 },
};
assert_eq!(Root::FIELD_COUNT, 1);
assert_eq!(root.child.field_value(kaptan_config::FieldId::new(1)), Some(kaptan_config::ConfigValue::U32(1)));
}
@@ -0,0 +1,25 @@
use kaptan_config_derive::{Config, ConfigEnum};
#[derive(Clone, Copy, ConfigEnum)]
#[repr(u8)]
enum Mode {
Safe = 1,
#[config_enum(label = "Full authority")]
FullAuthority = 7,
}
#[derive(Config)]
struct EnumConfig {
#[config(enum, id = 1, key = "mode", unit = "mode")]
mode: Mode,
}
fn main() {
assert_eq!(Mode::Safe.wire_value(), 1);
assert!(matches!(Mode::try_from_wire(7), Some(Mode::FullAuthority)));
assert_eq!(Mode::CONFIG_ENUM_OPTIONS[1], (7, "Full authority"));
assert_eq!(
EnumConfig::DESCRIPTORS[0].enum_options,
Mode::CONFIG_ENUM_OPTIONS
);
}
@@ -1,8 +1,8 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct RootBooleanConfig {
#[config(id = 1, key = "enabled", unit = "bool", default = true)]
#[config(id = 1, key = "enabled", unit = "bool")]
enabled: bool,
}
@@ -1,37 +1,22 @@
use kaptan_config::{
ConfigAggregate, ConfigSection, ConfigValue, ConfigViolation, FieldId, FieldWriteError,
ValidateFields, ValidationRule,
Config, ConfigEnum, ConfigValue, ConfigViolation, FieldId, FieldWriteError, ValidationRule,
};
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
#[derive(Config)]
struct LocalSection {
#[config(
id = 1,
key = "local.first",
unit = "count",
default = 2,
range(min = 1, max = 9)
)]
#[config(id = 1, key = "local.first", unit = "count", range(min = 1, max = 9))]
first: u32,
#[config(
id = 2,
key = "local.second",
unit = "count",
default = 4,
range(min = 1, max = 9)
)]
#[config(id = 2, key = "local.second", unit = "count", range(min = 1, max = 9))]
second: u32,
}
#[derive(ConfigValidate)]
#[derive(Config)]
#[config(post = Self::validate_order)]
struct OrderedSection {
#[config(
id = 3,
key = "ordered.minimum",
unit = "count",
default = 1,
range(min = 0, max = 9)
)]
minimum: u32,
@@ -39,7 +24,6 @@ struct OrderedSection {
id = 4,
key = "ordered.maximum",
unit = "count",
default = 2,
range(min = 0, max = 9)
)]
maximum: u32,
@@ -58,17 +42,54 @@ impl OrderedSection {
}
}
#[derive(ConfigValidate)]
#[derive(Config)]
struct Aggregate {
#[config(section)]
local: LocalSection,
#[config(section)]
ordered: OrderedSection,
}
#[derive(Config)]
struct BoardOwnedSection {
#[config(
id = 10,
key = "board.endpoint",
unit = "id",
range(min = 1, max = 255)
)]
endpoint: u32,
}
#[derive(Config)]
struct BoardOwnedAggregate {
endpoint: BoardOwnedSection,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ConfigEnum)]
#[repr(u8)]
enum TestMode {
Quiet = 1,
Loud = 3,
}
#[derive(Config)]
struct CompactSection {
#[config(
id = 20,
key = "compact.endpoint",
unit = "id",
range(min = 1, max = 255)
)]
endpoint: u8,
#[config(enum, id = 21, key = "compact.mode", unit = "mode")]
mode: TestMode,
}
#[test]
fn generated_section_owns_every_declared_scalar_path() {
let mut section = LocalSection::default_section().unwrap();
let mut section = LocalSection {
first: 2,
second: 4,
};
assert_eq!(LocalSection::FIELD_COUNT, 2);
assert_eq!(
section.field_value(FieldId::new(2)),
@@ -94,9 +115,52 @@ fn generated_section_owns_every_declared_scalar_path() {
);
}
#[test]
fn narrow_scalars_and_compact_enums_keep_native_types_and_generic_metadata() {
let mut section = CompactSection {
endpoint: 7,
mode: TestMode::Quiet,
};
assert_eq!(
section.field_value(FieldId::new(20)),
Some(ConfigValue::U32(7))
);
assert_eq!(
CompactSection::DESCRIPTORS[1].enum_options,
TestMode::CONFIG_ENUM_OPTIONS
);
assert_eq!(TestMode::Quiet.wire_value(), 1);
assert_eq!(TestMode::try_from_wire(3), Some(TestMode::Loud));
assert_eq!(TestMode::try_from_wire(2), None);
section
.set_field(FieldId::new(21), ConfigValue::U32(3))
.unwrap();
assert_eq!(section.mode, TestMode::Loud);
assert!(matches!(
section.set_field(FieldId::new(21), ConfigValue::U32(2)),
Err(FieldWriteError::Violation(issue))
if issue.rule() == ValidationRule::InvalidEnumValue
));
assert!(matches!(
section.set_field(FieldId::new(20), ConfigValue::U32(256)),
Err(FieldWriteError::Violation(issue))
if issue.rule() == ValidationRule::AboveMaximum
));
}
#[test]
fn aggregate_routes_writes_and_defers_declared_post_validation() {
let mut aggregate = Aggregate::default_aggregate().unwrap();
let mut aggregate = Aggregate {
local: LocalSection {
first: 2,
second: 4,
},
ordered: OrderedSection {
minimum: 1,
maximum: 2,
},
};
assert_eq!(Aggregate::FIELD_COUNT, 4);
assert_eq!(
aggregate.field_value(FieldId::new(2)),
@@ -106,7 +170,7 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() {
.set_field(FieldId::new(3), ConfigValue::U32(3))
.unwrap();
assert!(matches!(
aggregate.validate_fields(),
aggregate.validate(),
Err(issue)
if issue.primary() == Some(FieldId::new(3))
&& issue.related_field() == Some(FieldId::new(4))
@@ -115,5 +179,101 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() {
aggregate
.set_field(FieldId::new(4), ConfigValue::U32(3))
.unwrap();
aggregate.validate_fields().unwrap();
aggregate.validate().unwrap();
}
#[test]
fn aggregate_derives_its_flattened_descriptor_order() {
let descriptors: Vec<_> = (0..Aggregate::FIELD_COUNT)
.map(|index| Aggregate::descriptor(index).unwrap())
.collect();
assert_eq!(
descriptors
.iter()
.map(|descriptor| descriptor.id.get())
.collect::<Vec<_>>(),
[1, 2, 3, 4]
);
assert!(Aggregate::descriptor(Aggregate::FIELD_COUNT).is_none());
}
#[test]
fn factory_values_are_owned_outside_the_generated_schema() {
let mut aggregate = BoardOwnedAggregate {
endpoint: BoardOwnedSection { endpoint: 42 },
};
assert_eq!(BoardOwnedAggregate::FIELD_COUNT, 1);
assert_eq!(
aggregate.field_value(FieldId::new(10)),
Some(ConfigValue::U32(42))
);
aggregate
.set_field(FieldId::new(10), ConfigValue::U32(43))
.unwrap();
aggregate.validate().unwrap();
}
#[derive(Config)]
struct DuplicateChildren {
first: LocalSection,
second: LocalSection,
}
#[derive(Config)]
struct TransparentRoot {
local: LocalSection,
}
#[derive(Config)]
struct FirstKeyOwner {
#[config(id = 30, key = "shared.key", unit = "count")]
value: u32,
}
#[derive(Config)]
struct SecondKeyOwner {
#[config(id = 31, key = "shared.key", unit = "count")]
value: u32,
}
#[derive(Config)]
struct DuplicateKeysAcrossChildren {
first: FirstKeyOwner,
second: SecondKeyOwner,
}
#[test]
fn schema_fingerprint_is_generated_and_one_child_wrappers_are_transparent() {
assert_eq!(TransparentRoot::SCHEMA_ID, LocalSection::SCHEMA_ID);
assert_ne!(Aggregate::SCHEMA_ID, LocalSection::SCHEMA_ID);
}
#[test]
fn recursive_tree_rejects_duplicate_field_ids() {
let duplicate = DuplicateChildren {
first: LocalSection {
first: 2,
second: 4,
},
second: LocalSection {
first: 2,
second: 4,
},
};
assert!(matches!(
duplicate.validate(),
Err(issue) if issue.rule() == ValidationRule::Inconsistent
));
}
#[test]
fn recursive_tree_rejects_duplicate_display_keys() {
let duplicate = DuplicateKeysAcrossChildren {
first: FirstKeyOwner { value: 1 },
second: SecondKeyOwner { value: 2 },
};
assert!(matches!(
duplicate.validate(),
Err(issue) if issue.rule() == ValidationRule::Inconsistent
));
}
@@ -0,0 +1,10 @@
use kaptan_config_derive::ConfigEnum;
#[derive(ConfigEnum)]
#[repr(u8)]
enum ImplicitDiscriminant {
First = 1,
Second,
}
fn main() {}
@@ -0,0 +1,5 @@
error: ConfigEnum variants require explicit u8 discriminants
--> tests/ui/config_enum_implicit_discriminant.rs:7:5
|
7 | Second,
| ^^^^^^
@@ -0,0 +1,10 @@
use kaptan_config_derive::ConfigEnum;
#[derive(ConfigEnum)]
#[repr(u8)]
enum LabelWithNul {
#[config_enum(label = "invalid\0label")]
Value = 1,
}
fn main() {}
@@ -0,0 +1,5 @@
error: ConfigEnum display labels must be non-empty text without NUL bytes
--> tests/ui/config_enum_label_nul.rs:6:27
|
6 | #[config_enum(label = "invalid\0label")]
| ^^^^^^^^^^^^^^^^
@@ -0,0 +1,10 @@
use kaptan_config_derive::ConfigEnum;
#[derive(ConfigEnum)]
#[repr(u8)]
enum LabelTooManyBytes {
#[config_enum(label = "şşşşşşşşşşşşşşşşş")]
Value = 1,
}
fn main() {}
@@ -0,0 +1,5 @@
error: ConfigEnum display label exceeds the 32-byte wire limit
--> tests/ui/config_enum_label_too_many_bytes.rs:6:27
|
6 | #[config_enum(label = "şşşşşşşşşşşşşşşşş")]
| ^^^^^^^^^^^^^^^^^^^
@@ -0,0 +1,8 @@
use kaptan_config_derive::ConfigEnum;
#[derive(ConfigEnum)]
enum MissingRepresentation {
First = 1,
}
fn main() {}
@@ -0,0 +1,5 @@
error: ConfigEnum requires exactly #[repr(u8)]
--> tests/ui/config_enum_missing_repr.rs:4:6
|
4 | enum MissingRepresentation {
| ^^^^^^^^^^^^^^^^^^^^^
@@ -0,0 +1,9 @@
use kaptan_config_derive::ConfigEnum;
#[derive(ConfigEnum)]
#[repr(u8)]
enum Payload {
Value(u8) = 1,
}
fn main() {}
@@ -0,0 +1,5 @@
error: ConfigEnum variants cannot contain fields
--> tests/ui/config_enum_payload.rs:6:10
|
6 | Value(u8) = 1,
| ^^^^
@@ -1,9 +0,0 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct DefaultBelowMinimum {
#[config(id = 1, key = "value", unit = "count", default = 1, range(min = 2, max = 3))]
value: u32,
}
fn main() {}
@@ -1,5 +0,0 @@
error: configuration default is below its declared minimum
--> tests/ui/default_below_minimum.rs:5:63
|
5 | #[config(id = 1, key = "value", unit = "count", default = 1, range(min = 2, max = 3))]
| ^
@@ -1,9 +0,0 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct NonFiniteDefault {
#[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
value: f32,
}
fn main() {}
@@ -1,5 +0,0 @@
error: configuration default violates finite
--> tests/ui/default_violates_finite.rs:5:63
|
5 | #[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
| ^^^^^
@@ -1,9 +0,0 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct ZeroDefault {
#[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
value: u32,
}
fn main() {}
@@ -1,5 +0,0 @@
error: configuration default violates nonzero
--> tests/ui/default_violates_nonzero.rs:5:63
|
5 | #[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
| ^
@@ -0,0 +1,13 @@
use kaptan_config_derive::Config;
#[derive(Config)]
struct DisplayKeyTooLong {
#[config(
id = 1,
key = "configuration.display.key.with.more.than.forty.eight.bytes",
unit = "count"
)]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: configuration display key exceeds the 48-byte wire limit
--> tests/ui/display_key_too_long.rs:7:15
|
7 | key = "configuration.display.key.with.more.than.forty.eight.bytes",
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -0,0 +1,11 @@
use kaptan_config_derive::Config;
#[derive(Config)]
struct DuplicateDisplayKey {
#[config(id = 1, key = "duplicate.key", unit = "count")]
first: u32,
#[config(id = 2, key = "duplicate.key", unit = "count")]
second: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: duplicate configuration display key
--> tests/ui/duplicate_display_key.rs:7:28
|
7 | #[config(id = 2, key = "duplicate.key", unit = "count")]
| ^^^^^^^^^^^^^^^
@@ -1,6 +1,6 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct DuplicateId {
#[config(id = 7, nonzero)]
first: u32,
@@ -1,6 +1,6 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct DuplicateRange {
#[config(id = 7, range(min = 1), range(min = 2))]
value: u32,
@@ -1,6 +1,6 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct Empty {}
fn main() {}
@@ -1,4 +1,4 @@
error: ConfigValidate requires at least one configured field or section
error: Config requires at least one field
--> tests/ui/empty.rs:4:8
|
4 | struct Empty {}
@@ -0,0 +1,9 @@
use kaptan_config_derive::Config;
#[derive(Config)]
struct PrimitiveEnumMarker {
#[config(enum, id = 1, key = "mode", unit = "mode")]
mode: u8,
}
fn main() {}
@@ -0,0 +1,5 @@
error: config(enum) requires a ConfigEnum-derived enum type
--> tests/ui/enum_marker_on_primitive.rs:6:11
|
6 | mode: u8,
| ^^
@@ -0,0 +1,15 @@
use kaptan_config_derive::{Config, ConfigEnum};
#[derive(Clone, Copy, ConfigEnum)]
#[repr(u8)]
enum Mode {
Safe = 1,
}
#[derive(Config)]
struct MissingEnumMarker {
#[config(id = 1, key = "mode", unit = "mode")]
mode: Mode,
}
fn main() {}
@@ -0,0 +1,5 @@
error: non-primitive configuration fields require config(enum)
--> tests/ui/missing_enum_marker.rs:12:11
|
12 | mode: Mode,
| ^^^^
@@ -1,6 +1,6 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct MissingId {
#[config(nonzero)]
value: u32,
@@ -1,4 +1,4 @@
error: validated configuration fields require config(id = N)
error: scalar configuration fields require config(id = N)
--> tests/ui/missing_id.rs:6:5
|
6 | value: u32,
@@ -1,8 +1,8 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct MissingMetadata {
#[config(id = 7, unit = "count", default = 1, range(min = 0, max = 10))]
#[config(id = 7, unit = "count", range(min = 0, max = 10))]
value: u32,
}
@@ -1,10 +1,9 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct MixedSectionAndScalar {
#[config(section)]
child: u32,
#[config(id = 1, key = "scalar", unit = "count", default = 1, range(min = 0, max = 2))]
#[config(id = 1, key = "scalar", unit = "count", range(min = 0, max = 2))]
scalar: u32,
}
@@ -1,5 +1,5 @@
error: ConfigValidate records must contain either scalar config fields or config(section) fields exclusively
--> tests/ui/mixed_section_scalar.rs:6:5
error: Config records must contain either scalar fields or child configuration records exclusively
--> tests/ui/mixed_section_scalar.rs:5:5
|
6 | child: u32,
5 | child: u32,
| ^^^^^
@@ -1,8 +1,8 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct InvalidRange {
#[config(id = 1, key = "value", unit = "count", default = 2, range(min = 3, max = 1))]
#[config(id = 1, key = "value", unit = "count", range(min = 3, max = 1))]
value: u32,
}
@@ -0,0 +1,9 @@
use kaptan_config_derive::Config;
#[derive(Config)]
struct UnitTooLong {
#[config(id = 1, key = "value", unit = "seventeen_bytes___")]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: configuration unit exceeds the 16-byte wire limit
--> tests/ui/unit_too_long.rs:5:44
|
5 | #[config(id = 1, key = "value", unit = "seventeen_bytes___")]
| ^^^^^^^^^^^^^^^^^^^^
@@ -0,0 +1,9 @@
use kaptan_config_derive::Config;
#[derive(Config)]
struct UnsupportedDefault {
#[config(id = 1, key = "value", unit = "count", default = 1)]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: supported field config rules are id, key, unit, enum, finite, nonzero, and range
--> tests/ui/unsupported_default.rs:5:53
|
5 | #[config(id = 1, key = "value", unit = "count", default = 1)]
| ^^^^^^^
@@ -0,0 +1,10 @@
use kaptan_config_derive::Config;
#[derive(Config)]
#[config(external_defaults)]
struct UnsupportedExternalDefaults {
#[config(id = 1, key = "value", unit = "count")]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: struct config options are crate = path and post = path
--> tests/ui/unsupported_external_defaults.rs:4:10
|
4 | #[config(external_defaults)]
| ^^^^^^^^^^^^^^^^^
@@ -8,11 +8,6 @@ version.workspace = true
[lints]
workspace = true
[features]
# Explicitly opt-in host/SITL test authority. Production cannot construct an
# authorization token from a CRC-valid raw MAVLink frame.
host-authority = []
[dependencies]
kaptan-config = { path = "../kaptan-config" }
kaptan-log = { path = "../kaptan-log" }
@@ -1,4 +1,4 @@
//! Authorized, transport-neutral configuration commands.
//! Transport-neutral configuration commands.
use kaptan_config::{ConfigValue, FieldId};
@@ -11,6 +11,30 @@ pub struct Target {
component: u8,
}
/// Exact MAVLink endpoint that owns a configuration transaction lease.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Principal(u16);
impl Principal {
/// Creates a principal from one exact source system/component pair.
pub const fn new(system: u8, component: u8) -> Self {
assert!(
system != 0 && component != 0,
"configuration principal must be exact"
);
Self(u16::from_be_bytes([system, component]))
}
/// Parses an exact source principal from an untrusted transport.
pub const fn try_new(system: u8, component: u8) -> Option<Self> {
if system == 0 || component == 0 {
None
} else {
Some(Self(u16::from_be_bytes([system, component])))
}
}
}
/// Selects the configuration snapshot read by a value request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ValueSource {
@@ -45,21 +69,30 @@ impl Target {
/// One command accepted by the Kaptan configuration microservice.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ConfigCommand {
/// Reads protocol/schema metadata before any descriptor or upload request.
/// Reads protocol/schema metadata before descriptor and value requests.
InfoRequest {
/// Client correlation identity.
request_id: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Enumerates a bounded page of schema descriptors.
/// Reads one schema descriptor by stable index.
DescriptorRequest {
/// Client correlation identity.
request_id: u32,
/// First stable descriptor index.
first: u16,
/// Requested number of descriptors, clamped to the protocol bound.
count: u16,
/// Stable descriptor index.
index: u16,
/// Exact addressed endpoint.
target: Target,
},
/// Reads one named choice for one descriptor.
EnumOptionRequest {
/// Client correlation identity.
request_id: u32,
/// Stable index of the owning descriptor.
descriptor_index: u16,
/// Stable enum-option index.
option_index: u8,
/// Exact addressed endpoint.
target: Target,
},
@@ -76,59 +109,10 @@ pub enum ConfigCommand {
/// Exact addressed endpoint.
target: Target,
},
/// Starts one bounded CBOR snapshot import into the caller's transaction.
ImportBegin {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Optimistic transaction revision.
expected_revision: u32,
/// Complete CBOR byte length.
length: u16,
/// Exact schema identifier advertised by `KAPTAN_CONFIG_INFO`.
schema_id: [u8; 16],
/// CRC-32C over exactly `length` CBOR bytes.
crc32c: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Supplies one fixed-bounded CBOR import chunk.
ImportChunk {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Server-issued ID returned by the matching import-begin result.
transfer_id: u32,
/// Byte offset into the complete import.
offset: u16,
/// Used bytes of `data`.
length: u8,
/// Fixed MAVLink payload data.
data: [u8; 192],
/// Exact addressed endpoint.
target: Target,
},
/// Checks the completed import and replaces only the RAM transaction.
ImportFinish {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Server-issued ID returned by the matching import-begin result.
transfer_id: u32,
/// Optimistic transaction revision.
expected_revision: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Starts an edit transaction from the expected committed generation.
/// Starts an edit transaction from the latest committed configuration.
Begin {
/// Client correlation identity.
request_id: u32,
/// Optimistic concurrency generation.
expected_generation: u64,
/// Exact addressed endpoint.
target: Target,
},
@@ -164,8 +148,6 @@ pub enum ConfigCommand {
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Optimistic committed generation.
expected_generation: u64,
/// Optimistic transaction revision.
expected_revision: u32,
/// Exact addressed endpoint.
@@ -188,10 +170,8 @@ impl ConfigCommand {
match self {
Self::InfoRequest { request_id, .. }
| Self::DescriptorRequest { request_id, .. }
| Self::EnumOptionRequest { request_id, .. }
| Self::ValueRequest { request_id, .. }
| Self::ImportBegin { request_id, .. }
| Self::ImportChunk { request_id, .. }
| Self::ImportFinish { request_id, .. }
| Self::Begin { request_id, .. }
| Self::Set { request_id, .. }
| Self::Validate { request_id, .. }
@@ -205,10 +185,8 @@ impl ConfigCommand {
match self {
Self::InfoRequest { target, .. }
| Self::DescriptorRequest { target, .. }
| Self::EnumOptionRequest { target, .. }
| Self::ValueRequest { target, .. }
| Self::ImportBegin { target, .. }
| Self::ImportChunk { target, .. }
| Self::ImportFinish { target, .. }
| Self::Begin { target, .. }
| Self::Set { target, .. }
| Self::Validate { target, .. }
@@ -218,54 +196,25 @@ impl ConfigCommand {
}
}
/// A capability token produced only by the future signing/replay authority.
///
/// The token carries a stable principal identity so the service can bind a
/// transaction lease to one authenticated source. It is intentionally opaque:
/// an unverified raw MAVLink frame cannot create one.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VerifiedAuthority {
principal: u64,
_private: (),
}
impl VerifiedAuthority {
/// Returns the authenticated principal identity for ownership checks.
pub const fn principal(self) -> u64 {
self.principal
}
#[cfg(any(test, feature = "host-authority"))]
/// Creates an explicit host/SITL authority token.
///
/// This exists only for host integration and protocol tests. Flight code
/// must receive the equivalent token from the reviewed signing/replay path.
pub const fn host(principal: u64) -> Self {
Self {
principal,
_private: (),
}
}
}
/// A command whose authority was verified before it reached this adapter.
/// Test command paired with its lease principal.
#[cfg(test)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Authorized<T> {
authority: VerifiedAuthority,
principal: Principal,
command: T,
}
#[cfg(test)]
impl<T> Authorized<T> {
#[cfg(any(test, feature = "host-authority"))]
/// Wraps a host/SITL command in an explicit test-only authority token.
pub const fn host(principal: u64, command: T) -> Self {
/// Pairs a test command with component `principal` in test system 1.
pub const fn host(principal: u8, command: T) -> Self {
Self {
authority: VerifiedAuthority::host(principal),
principal: Principal::new(1, principal),
command,
}
}
pub(crate) fn into_parts(self) -> (VerifiedAuthority, T) {
(self.authority, self.command)
pub(crate) fn into_parts(self) -> (Principal, T) {
(self.principal, self.command)
}
}
+19 -4
View File
@@ -2,19 +2,34 @@
//!
//! This crate translates generated `kaptan.xml` records to the transport-
//! neutral configuration lifecycle. It does not parse raw frames, authenticate
//! peers, deserialize CBOR, access storage directly, or mutate active flight
//! peers, access storage directly, or mutate active flight
//! consumers.
#![no_std]
#[cfg(test)]
extern crate std;
mod command;
mod protocol;
pub mod service;
pub mod wire;
pub use command::{Authorized, ConfigCommand, Target, ValueSource, VerifiedAuthority};
#[cfg(test)]
pub use command::Authorized;
pub use command::{ConfigCommand, Principal, Target, ValueSource};
pub use protocol::{ConfigReply, Operation, ProtocolError, ResultKind};
pub use service::{
CAPABILITY_CBOR_IMPORT, ConfigInfo, ConfigProtocol, DescriptorPage, NoSnapshotImporter,
ServiceResponse, SnapshotImporter, ValueResponse,
ConfigInfo, ConfigProtocol, DescriptorResponse, EnumOptionResponse, ServiceResponse,
ValueResponse,
};
pub use wire::{ResponseVisitor, visit_response};
/// Wire protocol implemented by the descriptor/value configuration service.
pub const PROTOCOL_MAJOR: u8 = 2;
pub const PROTOCOL_MINOR: u8 = 0;
/// `KAPTAN_CONFIG_DESCRIPTOR.flags` bit: `minimum_bits` is present.
pub const DESCRIPTOR_HAS_MINIMUM: u8 = 1 << 0;
/// `KAPTAN_CONFIG_DESCRIPTOR.flags` bit: `maximum_bits` is present.
pub const DESCRIPTOR_HAS_MAXIMUM: u8 = 1 << 1;
@@ -1,7 +1,8 @@
//! Stable typed outcomes for the Kaptan configuration microservice.
use kaptan_config::{ConfigState, ConfigViolation, FieldId};
use kaptan_log::{EventHub, KaptanError, LogFlags, PublishOutcome};
use kaptan_log::KaptanError;
/// Operation reported in an authoritative result record.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Operation {
@@ -17,8 +18,6 @@ pub enum Operation {
Commit,
/// Transaction abort.
Abort,
/// CBOR snapshot import into the isolated transaction.
Import,
}
/// Stable result classification.
@@ -38,11 +37,7 @@ pub enum ResultKind {
pub enum ProtocolError {
/// The request target did not match this component.
BadTarget,
/// Authorization was absent or insufficient.
AuthorizationDenied,
/// The optimistic durable generation did not match.
GenerationConflict,
/// The single transaction slot is occupied.
/// The configuration lease or single transaction slot is occupied.
SessionBusy,
/// The session is unknown, expired, or belongs to a different principal.
InvalidSession,
@@ -54,22 +49,14 @@ pub enum ProtocolError {
BadState,
/// The field is not present in this schema.
UnknownField(FieldId),
/// A query requested an empty or out-of-range descriptor span.
InvalidQuery,
/// The typed setter does not match the field descriptor.
TypeMismatch(FieldId),
/// A local or complete validation issue.
Violation(ConfigViolation),
/// Persistence failed after a complete validation pass.
StorageFailure,
/// A CBOR snapshot could not be decoded into this schema.
CborMalformed,
/// A CBOR snapshot exceeded the fixed import budget.
CborTooLarge,
/// The complete CBOR snapshot did not match the supplied CRC-32C.
BlobCrc,
/// The uploaded CBOR blob was prepared for a different schema.
SchemaMismatch,
/// This deployment does not expose bounded CBOR transaction import.
ImportUnavailable,
/// The adapter reached an impossible internal state.
Internal,
}
@@ -81,12 +68,8 @@ pub struct ConfigReply {
pub request_id: u32,
/// The current session, or zero when none exists.
pub session_id: u32,
/// Server-assigned import transfer identity, or zero when not importing.
pub transfer_id: u32,
/// The transaction revision, or zero when no transaction exists.
pub revision: u32,
/// Latest committed generation, or zero for the factory profile.
pub generation: u64,
/// Completed operation.
pub operation: Operation,
/// High-level result.
@@ -130,66 +113,4 @@ impl ConfigReply {
_ => 0,
}
}
/// Enqueues a critical diagnostic companion for a rejected structured
/// result. The `KAPTAN_CONFIG_RESULT` remains the correlated,
/// field/rule-precise authority; this sends only the derive-generated
/// high-level `ProtocolError` display identity through `KAPTAN_ERROR`.
///
/// The helper deliberately carries no request/session/generation fields
/// into the error channel. A composition owner may call it after it has
/// emitted or retained the authoritative result.
pub fn publish_rejection<const CRITICAL: usize, const BEST_EFFORT: usize>(
self,
hub: &mut EventHub<CRITICAL, BEST_EFFORT>,
) -> Option<PublishOutcome> {
if self.result != ResultKind::Rejected {
return None;
}
self.error
.as_ref()
.map(|error| hub.publish_error(LogFlags::NONE, error))
}
}
#[cfg(test)]
mod tests {
use kaptan_log::{EventHub, LogFilter, PublishOutcome, RecordChannel};
use super::{ConfigReply, Operation, ProtocolError, ResultKind};
use kaptan_config::ConfigState;
#[test]
fn rejected_result_enqueues_only_its_high_level_error_identity() {
let reply = ConfigReply {
request_id: 42,
session_id: 7,
transfer_id: 0,
revision: 3,
generation: 9,
operation: Operation::Set,
result: ResultKind::Rejected,
error: Some(ProtocolError::BadState),
state: ConfigState::Editing,
};
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert_eq!(
reply.publish_rejection(&mut hub),
Some(PublishOutcome::Queued)
);
let record = hub.peek().expect("rejection is retained");
assert_eq!(record.channel(), RecordChannel::Error);
assert_eq!(
record.error_path().unwrap().as_str(),
"kaptan_config_mavlink::protocol::ProtocolError::BadState"
);
assert!(record.additional_information().unwrap().is_empty());
let accepted = ConfigReply {
result: ResultKind::Accepted,
error: None,
..reply
};
assert_eq!(accepted.publish_rejection(&mut hub), None);
}
}
@@ -1,14 +1,14 @@
//! Transport-neutral façade for the custom configuration service.
//!
//! Public callers see one small protocol API here. Its bounded query,
//! session, replay, and CBOR-import mechanics stay in private sibling modules
//! session and replay mechanics stay in private sibling modules
//! so no one concern becomes a second service owner.
mod import;
mod query;
mod replay;
mod session;
pub use import::{CAPABILITY_CBOR_IMPORT, NoSnapshotImporter, SnapshotImporter};
pub use query::{ConfigInfo, DescriptorPage, ServiceResponse, ValueResponse};
pub use query::{
ConfigInfo, DescriptorResponse, EnumOptionResponse, ServiceResponse, ValueResponse,
};
pub use session::ConfigProtocol;
@@ -1,248 +0,0 @@
//! Bounded CBOR snapshot transfer state.
use kaptan_config::{CommitError, ConfigManager, ConfigModel, ProfileStore};
use super::session::{ConfigProtocol, Session};
use crate::{ConfigCommand, ConfigReply, Operation, ProtocolError, ResultKind};
/// Maximum complete CBOR snapshot accepted by the protocol.
pub(super) const IMPORT_MAXIMUM_BYTES: usize = 4096;
/// Payload bytes carried by each aligned import chunk.
pub(super) const IMPORT_CHUNK_BYTES: usize = 192;
pub(super) const IMPORT_CHUNK_COUNT: usize = IMPORT_MAXIMUM_BYTES.div_ceil(IMPORT_CHUNK_BYTES);
/// `KAPTAN_CONFIG_INFO.capability_flags`: bounded CBOR transaction import.
pub const CAPABILITY_CBOR_IMPORT: u8 = 1;
/// Decodes a complete bounded CBOR snapshot into this service's candidate.
///
/// Vehicle-specific implementations live above this transport adapter so the
/// generic MAVLink layer never owns a profile schema or board capabilities.
pub trait SnapshotImporter<C> {
/// Whether this concrete deployment exposes the bounded import path.
const IMPORT_SUPPORTED: bool;
/// Current format carried inside the deployment's raw CBOR profile blob.
const PROFILE_FORMAT: u16;
/// Converts one complete CBOR payload to a candidate. The service performs
/// the subsequent complete-context validation before replacing the RAM
/// transaction.
fn decode(&self, bytes: &[u8]) -> Result<C, ProtocolError>;
}
/// Default importer used when a deployment deliberately does not expose CBOR
/// import yet. It keeps the service type usable for descriptor-only tests.
#[derive(Clone, Copy, Debug, Default)]
pub struct NoSnapshotImporter;
impl<C> SnapshotImporter<C> for NoSnapshotImporter {
const IMPORT_SUPPORTED: bool = false;
const PROFILE_FORMAT: u16 = 0;
fn decode(&self, _: &[u8]) -> Result<C, ProtocolError> {
Err(ProtocolError::CborMalformed)
}
}
#[derive(Clone, Copy)]
pub(super) struct ImportBuffer {
owner: u64,
session_id: u32,
transfer_id: u32,
revision: u32,
length: u16,
crc32c: u32,
received: [bool; IMPORT_CHUNK_COUNT],
data: [u8; IMPORT_MAXIMUM_BYTES],
}
impl<I> ConfigProtocol<I> {
pub(super) fn begin_import<C>(
&mut self,
owner: u64,
command: ConfigCommand,
session: Session,
) -> ConfigReply
where
C: ConfigModel,
I: SnapshotImporter<C>,
{
let ConfigCommand::ImportBegin {
request_id,
length,
schema_id,
crc32c,
..
} = command
else {
return self.rejected(command, ProtocolError::Internal);
};
if !I::IMPORT_SUPPORTED {
return self.rejected(command, ProtocolError::ImportUnavailable);
}
if schema_id != C::SCHEMA_ID {
return self.rejected(command, ProtocolError::SchemaMismatch);
}
if length == 0 || usize::from(length) > IMPORT_MAXIMUM_BYTES {
return self.rejected(command, ProtocolError::CborTooLarge);
}
let Some(transfer_id) = self.allocate_transfer_id() else {
return self.rejected(command, ProtocolError::Internal);
};
self.import = Some(ImportBuffer {
owner,
session_id: session.id,
transfer_id,
revision: session.revision,
length,
crc32c,
received: [false; IMPORT_CHUNK_COUNT],
data: [0; IMPORT_MAXIMUM_BYTES],
});
let mut reply = self.reply(
request_id,
session.id,
session.revision,
Operation::Import,
ResultKind::Accepted,
None,
);
reply.transfer_id = transfer_id;
reply
}
pub(super) fn import_chunk(&mut self, command: ConfigCommand, session: Session) -> ConfigReply {
let ConfigCommand::ImportChunk {
request_id,
transfer_id,
offset,
length,
data,
..
} = command
else {
return self.rejected(command, ProtocolError::Internal);
};
let offset = usize::from(offset);
let length = usize::from(length);
let Some(import) = self.import.as_mut() else {
return self.rejected(command, ProtocolError::BadState);
};
if import.owner != session.owner
|| import.session_id != session.id
|| import.transfer_id != transfer_id
{
return self.rejected(command, ProtocolError::InvalidSession);
}
if import.revision != session.revision {
return self.rejected(command, ProtocolError::StaleRevision);
}
let total = usize::from(import.length);
let Some(end) = offset.checked_add(length) else {
return self.rejected(command, ProtocolError::CborMalformed);
};
if length == 0
|| offset % IMPORT_CHUNK_BYTES != 0
|| end > total
|| (length != IMPORT_CHUNK_BYTES && end != total)
{
return self.rejected(command, ProtocolError::CborMalformed);
}
let chunk = offset / IMPORT_CHUNK_BYTES;
if import.received[chunk] {
if import.data[offset..end] != data[..length] {
return self.rejected(command, ProtocolError::RequestConflict);
}
} else {
import.data[offset..end].copy_from_slice(&data[..length]);
import.received[chunk] = true;
}
self.reply(
request_id,
session.id,
session.revision,
Operation::Import,
ResultKind::Accepted,
None,
)
}
pub(super) fn finish_import<C, S>(
&mut self,
manager: &mut ConfigManager<C, S>,
command: ConfigCommand,
session: Session,
request_id: u32,
transfer_id: u32,
) -> ConfigReply
where
C: ConfigModel,
S: ProfileStore<C>,
I: SnapshotImporter<C>,
{
let Some(import) = self.import else {
return self.rejected(command, ProtocolError::BadState);
};
if import.owner != session.owner
|| import.session_id != session.id
|| import.transfer_id != transfer_id
{
return self.rejected(command, ProtocolError::InvalidSession);
}
if import.revision != session.revision {
return self.rejected(command, ProtocolError::StaleRevision);
}
let total = usize::from(import.length);
let chunks = total.div_ceil(IMPORT_CHUNK_BYTES);
if import.received[..chunks].iter().any(|received| !received) {
return self.rejected(command, ProtocolError::CborMalformed);
}
if crc32c(&import.data[..total]) != import.crc32c {
return self.rejected(command, ProtocolError::BlobCrc);
}
let candidate = match self.importer.decode(&import.data[..total]) {
Ok(candidate) => candidate,
Err(error) => return self.rejected(command, error),
};
let Some(next_revision) = session.revision.checked_add(1) else {
return self.rejected(command, ProtocolError::Internal);
};
match manager.replace_transaction(candidate) {
Ok(()) => {
let Some(session) = self.session.as_mut() else {
return self.rejected(command, ProtocolError::Internal);
};
session.revision = next_revision;
let (session_id, revision) = (session.id, session.revision);
self.import = None;
self.reply(
request_id,
session_id,
revision,
Operation::Import,
ResultKind::Accepted,
None,
)
}
Err(CommitError::NoTransaction) => self.rejected(command, ProtocolError::BadState),
Err(CommitError::InvalidCandidate(violation)) => {
self.rejected(command, ProtocolError::Violation(violation))
}
Err(CommitError::Store(_)) => self.rejected(command, ProtocolError::Internal),
}
}
}
pub(super) fn crc32c(bytes: &[u8]) -> u32 {
let mut crc = !0_u32;
for byte in bytes {
crc ^= u32::from(*byte);
for _ in 0..8 {
crc = if crc & 1 == 0 {
crc >> 1
} else {
(crc >> 1) ^ 0x82f6_3b78
};
}
}
!crc
}
@@ -1,24 +1,39 @@
//! Read-only protocol discovery and value-query responses.
use kaptan_config::{
ConfigManager, ConfigModel, ConfigState, ConfigValue, FieldDescriptor, FieldId, Generation,
ProfileStore,
ConfigManager, ConfigModel, ConfigState, ConfigStore, ConfigValue, EnumOption, FieldDescriptor,
FieldId,
};
use super::{import::SnapshotImporter, session::ConfigProtocol};
use crate::{ProtocolError, ValueSource};
use super::session::ConfigProtocol;
use crate::{Principal, ProtocolError, ValueSource};
/// Bounded descriptor page returned by a schema discovery request.
/// One descriptor returned by a schema discovery request.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DescriptorPage {
pub struct DescriptorResponse {
/// Request being answered.
pub request_id: u32,
/// First requested descriptor index.
pub first: u16,
/// Number of descriptors present in `descriptors`.
pub len: u8,
/// Fixed-capacity response page; `None` slots are unused.
pub descriptors: [Option<&'static FieldDescriptor>; 4],
/// Stable index of this descriptor in the advertised schema.
pub index: u16,
/// Self-describing field metadata.
pub descriptor: FieldDescriptor,
/// Factory value selected by the concrete board/image composition.
pub default: ConfigValue,
}
/// One named enum choice associated with a descriptor response.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EnumOptionResponse {
/// Request being answered.
pub request_id: u32,
/// Stable schema index of the owning descriptor.
pub descriptor_index: u16,
/// Stable field identity of the owning descriptor.
pub field: FieldId,
/// Stable zero-based order within the descriptor's choices.
pub option_index: u8,
/// Self-describing named choice.
pub option: EnumOption,
}
/// One typed value read from an explicitly selected configuration snapshot.
@@ -41,20 +56,12 @@ pub struct ValueResponse {
pub struct ConfigInfo {
/// Request being answered.
pub request_id: u32,
/// Boot-active generation, or `None` for the factory profile.
pub active_generation: Option<Generation>,
/// Last committed generation, or `None` for the factory profile.
pub committed_generation: Option<Generation>,
/// Exact schema identifier expected by profile import.
/// Stable schema identity for this target-described field registry.
pub schema_id: [u8; 16],
/// Version carried inside the one raw CBOR profile blob.
pub profile_format: u16,
/// Number of editable fields in the registry.
pub field_count: u16,
/// Current configuration lifecycle state.
pub state: ConfigState,
/// Deployment capabilities such as bounded CBOR import.
pub capability_flags: u8,
}
/// Exact response produced by the custom configuration service.
@@ -64,13 +71,15 @@ pub enum ServiceResponse {
Info(ConfigInfo),
/// Completion for a mutating operation or rejected query.
Result(crate::ConfigReply),
/// Bounded self-describing schema page.
Descriptors(DescriptorPage),
/// One self-describing schema field.
Descriptor(DescriptorResponse),
/// One named choice for its explicitly identified enum descriptor.
EnumOption(EnumOptionResponse),
/// One typed scalar value.
Value(ValueResponse),
}
impl<I> ConfigProtocol<I> {
impl ConfigProtocol {
/// Returns static descriptor metadata by stable schema index.
pub fn descriptor<C, S>(
&self,
@@ -79,7 +88,7 @@ impl<I> ConfigProtocol<I> {
) -> Option<&'static FieldDescriptor>
where
C: ConfigModel,
S: ProfileStore<C>,
S: ConfigStore<C>,
{
manager.descriptor(index)
}
@@ -93,7 +102,7 @@ impl<I> ConfigProtocol<I> {
) -> Option<ConfigValue>
where
C: ConfigModel,
S: ProfileStore<C>,
S: ConfigStore<C>,
{
match source {
ValueSource::Active => manager.active_value(field),
@@ -102,60 +111,65 @@ impl<I> ConfigProtocol<I> {
}
}
pub(super) fn descriptors<C, S>(
pub(super) fn descriptor_response<C, S>(
&self,
manager: &ConfigManager<C, S>,
request_id: u32,
first: u16,
requested: u16,
) -> DescriptorPage
index: u16,
) -> Option<DescriptorResponse>
where
C: ConfigModel,
S: ProfileStore<C>,
S: ConfigStore<C>,
{
let mut descriptors = [None; 4];
let count = requested.min(descriptors.len() as u16);
let mut len = 0;
for offset in 0..count {
let Some(index) = first.checked_add(offset) else {
break;
};
let Some(descriptor) = manager.descriptor(index) else {
break;
};
descriptors[usize::from(len)] = Some(descriptor);
len += 1;
}
DescriptorPage {
let descriptor = *manager.descriptor(index)?;
let default = manager.factory_value(descriptor.id)?;
Some(DescriptorResponse {
request_id,
first,
len,
descriptors,
}
index,
descriptor,
default,
})
}
pub(super) fn enum_option_response<C, S>(
&self,
manager: &ConfigManager<C, S>,
request_id: u32,
descriptor_index: u16,
option_index: u8,
) -> Option<EnumOptionResponse>
where
C: ConfigModel,
S: ConfigStore<C>,
{
let descriptor = manager.descriptor(descriptor_index)?;
let option = *descriptor.enum_options.get(usize::from(option_index))?;
Some(EnumOptionResponse {
request_id,
descriptor_index,
field: descriptor.id,
option_index,
option,
})
}
pub(super) fn info<C, S>(&self, manager: &ConfigManager<C, S>, request_id: u32) -> ConfigInfo
where
C: ConfigModel,
S: ProfileStore<C>,
I: SnapshotImporter<C>,
S: ConfigStore<C>,
{
ConfigInfo {
request_id,
active_generation: manager.active_generation(),
committed_generation: manager.committed_generation(),
schema_id: C::SCHEMA_ID,
profile_format: I::PROFILE_FORMAT,
field_count: manager.field_count(),
state: self.state,
capability_flags: self.capability_flags_for::<C>(),
}
}
pub(super) fn value_response<C, S>(
&self,
manager: &ConfigManager<C, S>,
owner: u64,
owner: Principal,
request_id: u32,
session_id: u32,
field: FieldId,
@@ -163,7 +177,7 @@ impl<I> ConfigProtocol<I> {
) -> ServiceResponse
where
C: ConfigModel,
S: ProfileStore<C>,
S: ConfigStore<C>,
{
let (value, reply_session, revision) = match source {
ValueSource::Active => (manager.active_value(field), 0, 0),
@@ -176,7 +190,7 @@ impl<I> ConfigProtocol<I> {
ProtocolError::InvalidSession,
));
};
if session.id != session_id || session.owner != owner {
if session.id != session_id || self.principal != Some(owner) {
return ServiceResponse::Result(self.rejected_for(
request_id,
session_id,
@@ -3,34 +3,42 @@
use kaptan_config::ConfigValue;
use super::session::ConfigProtocol;
use crate::{ConfigCommand, ConfigReply, ProtocolError};
pub(super) const REQUEST_CACHE_CAPACITY: usize = 8;
use crate::{ConfigCommand, ConfigReply, Operation, Principal, ProtocolError};
#[derive(Clone, Copy)]
pub(super) struct RememberedResult {
pub(super) owner: u64,
pub(super) command: ConfigCommand,
pub(super) reply: ConfigReply,
pub(super) owner: Principal,
}
impl<I> ConfigProtocol<I> {
pub(super) fn cached(&self, owner: u64, command: ConfigCommand) -> Option<ConfigReply> {
for cached in self.remembered.iter().flatten() {
if cached.owner != owner || cached.command.request_id() != command.request_id() {
continue;
}
if same_command(cached.command, command) {
return Some(cached.reply);
}
return Some(self.rejected(command, ProtocolError::RequestConflict));
impl ConfigProtocol {
pub(super) fn cached(&self, owner: Principal, command: ConfigCommand) -> Option<ConfigReply> {
let cached = self.remembered?;
if cached.owner != owner || cached.command.request_id() != command.request_id() {
return None;
}
if same_command(cached.command, command) {
Some(cached.reply)
} else {
Some(self.rejected(command, ProtocolError::RequestConflict))
}
None
}
pub(super) fn remember(&mut self, result: RememberedResult) {
self.remembered[self.next_remembered] = Some(result);
self.next_remembered = (self.next_remembered + 1) % REQUEST_CACHE_CAPACITY;
pub(super) fn remember(
&mut self,
owner: Principal,
command: ConfigCommand,
reply: ConfigReply,
) {
if reply.operation == Operation::Query {
return;
}
self.remembered = Some(RememberedResult {
command,
reply,
owner,
});
}
}
@@ -49,20 +57,36 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
(
ConfigCommand::DescriptorRequest {
request_id: left_request,
first: left_first,
count: left_count,
index: left_index,
target: left_target,
},
ConfigCommand::DescriptorRequest {
request_id: right_request,
first: right_first,
count: right_count,
index: right_index,
target: right_target,
},
) => {
left_request == right_request
&& left_first == right_first
&& left_count == right_count
&& left_index == right_index
&& left_target == right_target
}
(
ConfigCommand::EnumOptionRequest {
request_id: left_request,
descriptor_index: left_descriptor,
option_index: left_option,
target: left_target,
},
ConfigCommand::EnumOptionRequest {
request_id: right_request,
descriptor_index: right_descriptor,
option_index: right_option,
target: right_target,
},
) => {
left_request == right_request
&& left_descriptor == right_descriptor
&& left_option == right_option
&& left_target == right_target
}
(
@@ -90,19 +114,13 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
(
ConfigCommand::Begin {
request_id: left_request,
expected_generation: left_generation,
target: left_target,
},
ConfigCommand::Begin {
request_id: right_request,
expected_generation: right_generation,
target: right_target,
},
) => {
left_request == right_request
&& left_generation == right_generation
&& left_target == right_target
}
) => left_request == right_request && left_target == right_target,
(
ConfigCommand::Set {
request_id: left_request,
@@ -151,21 +169,18 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
ConfigCommand::Commit {
request_id: left_request,
session_id: left_session,
expected_generation: left_generation,
expected_revision: left_revision,
target: left_target,
},
ConfigCommand::Commit {
request_id: right_request,
session_id: right_session,
expected_generation: right_generation,
expected_revision: right_revision,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_generation == right_generation
&& left_revision == right_revision
&& left_target == right_target
}
@@ -185,84 +200,6 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
&& left_session == right_session
&& left_target == right_target
}
(
ConfigCommand::ImportBegin {
request_id: left_request,
session_id: left_session,
expected_revision: left_revision,
length: left_length,
schema_id: left_schema,
crc32c: left_crc,
target: left_target,
},
ConfigCommand::ImportBegin {
request_id: right_request,
session_id: right_session,
expected_revision: right_revision,
length: right_length,
schema_id: right_schema,
crc32c: right_crc,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_revision == right_revision
&& left_length == right_length
&& left_schema == right_schema
&& left_crc == right_crc
&& left_target == right_target
}
(
ConfigCommand::ImportChunk {
request_id: left_request,
session_id: left_session,
transfer_id: left_transfer,
offset: left_offset,
length: left_length,
data: left_data,
target: left_target,
},
ConfigCommand::ImportChunk {
request_id: right_request,
session_id: right_session,
transfer_id: right_transfer,
offset: right_offset,
length: right_length,
data: right_data,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_transfer == right_transfer
&& left_offset == right_offset
&& left_length == right_length
&& left_data == right_data
&& left_target == right_target
}
(
ConfigCommand::ImportFinish {
request_id: left_request,
session_id: left_session,
transfer_id: left_transfer,
expected_revision: left_revision,
target: left_target,
},
ConfigCommand::ImportFinish {
request_id: right_request,
session_id: right_session,
transfer_id: right_transfer,
expected_revision: right_revision,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_transfer == right_transfer
&& left_revision == right_revision
&& left_target == right_target
}
_ => false,
}
}
File diff suppressed because it is too large. Load diff
+277 -104
View File
@@ -1,23 +1,68 @@
//! Generated Kaptan-dialect message translation.
use kaptan_config::{
ApplyPolicy, ConfigState, ConfigValue, FieldDescriptor, FieldId, Generation, ValidationRule,
ValueKind,
ConfigState, ConfigValue, FieldDescriptor, FieldId, ValidationRule, ValueKind,
};
use kaptan_log::KaptanError;
use kaptan_mavlink::message::Message;
use kaptan_mavlink::message::kaptan::{
KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_INFO_DATA, KAPTAN_CONFIG_RESULT_DATA,
KAPTAN_CONFIG_VALUE_DATA, KaptanConfigError, KaptanConfigOperation, KaptanConfigPolicy,
KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_ENUM_OPTION_DATA, KAPTAN_CONFIG_INFO_DATA,
KAPTAN_CONFIG_RESULT_DATA, KAPTAN_CONFIG_VALUE_DATA, KaptanConfigError, KaptanConfigOperation,
KaptanConfigResult, KaptanConfigState, KaptanConfigValueKind, KaptanConfigValueSource,
MavMessage,
};
use crate::{
ConfigCommand, ConfigReply, Operation, ProtocolError, ResultKind, Target, ValueSource,
service::{ConfigInfo, ValueResponse},
ConfigCommand, ConfigReply, DESCRIPTOR_HAS_MAXIMUM, DESCRIPTOR_HAS_MINIMUM, Operation,
PROTOCOL_MAJOR, PROTOCOL_MINOR, ProtocolError, ResultKind, Target, ValueSource,
service::{ConfigInfo, EnumOptionResponse, ServiceResponse, ValueResponse},
};
/// Transport-owned consumer of generated configuration response payloads.
///
/// Implementors retain ownership of MAVLink framing, sender sequence, queue
/// classification, and physical transmission.
pub trait ResponseVisitor {
/// Transport-specific admission or serialization failure.
type Error;
/// Visits one protocol/schema information response.
fn info(&mut self, response: &KAPTAN_CONFIG_INFO_DATA) -> Result<(), Self::Error>;
/// Visits one authoritative operation result.
fn result(&mut self, response: &KAPTAN_CONFIG_RESULT_DATA) -> Result<(), Self::Error>;
/// Visits one field descriptor.
fn descriptor(&mut self, response: &KAPTAN_CONFIG_DESCRIPTOR_DATA) -> Result<(), Self::Error>;
/// Visits one named choice for an enum-backed field descriptor.
fn enum_option(&mut self, response: &KAPTAN_CONFIG_ENUM_OPTION_DATA)
-> Result<(), Self::Error>;
/// Visits one typed configuration value.
fn value(&mut self, response: &KAPTAN_CONFIG_VALUE_DATA) -> Result<(), Self::Error>;
}
/// Projects one transport-neutral service response into generated MAVLink
/// payloads without allocating or assuming a board, schema, or field set.
pub fn visit_response<V>(response: ServiceResponse, visitor: &mut V) -> Result<(), V::Error>
where
V: ResponseVisitor,
{
match response {
ServiceResponse::Info(info) => visitor.info(&encode_info(info)),
ServiceResponse::Result(result) => visitor.result(&encode_result(result)),
ServiceResponse::Descriptor(response) => visitor.descriptor(&encode_descriptor(
response.request_id,
response.index,
response.descriptor,
response.default,
)),
ServiceResponse::EnumOption(response) => visitor.enum_option(&encode_enum_option(response)),
ServiceResponse::Value(value) => visitor.value(&encode_value(value)),
}
}
/// Rejects a generated message that is not a Kaptan configuration command.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum DecodeError {
@@ -35,8 +80,9 @@ pub enum DecodeError {
/// Converts one generated custom request into a transport-neutral command.
///
/// This only decodes a validated MAVLink payload. An authenticated ingress must
/// still wrap the result in [`crate::Authorized`] before dispatch.
/// This only decodes a validated MAVLink payload. CRC validity is not peer
/// authentication; the platform composition must separately decide whether
/// the ingress may dispatch the command and bind its transaction principal.
pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError> {
if matches!(message.target_system_id(), Some(0))
|| matches!(message.target_component_id(), Some(0))
@@ -51,8 +97,15 @@ pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError
MavMessage::KAPTAN_CONFIG_DESCRIPTOR_REQUEST(data) => {
Ok(ConfigCommand::DescriptorRequest {
request_id: data.request_id,
first: data.first,
count: data.count,
index: data.descriptor_index,
target: Target::new(data.target_system, data.target_component),
})
}
MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(data) => {
Ok(ConfigCommand::EnumOptionRequest {
request_id: data.request_id,
descriptor_index: data.descriptor_index,
option_index: data.option_index,
target: Target::new(data.target_system, data.target_component),
})
}
@@ -63,34 +116,8 @@ pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError
source: value_source(data.source)?,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_IMPORT_BEGIN(data) => Ok(ConfigCommand::ImportBegin {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
length: data.length,
schema_id: data.schema_id,
crc32c: data.crc32c,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_IMPORT_CHUNK(data) => Ok(ConfigCommand::ImportChunk {
request_id: data.request_id,
session_id: data.session_id,
transfer_id: data.transfer_id,
offset: data.offset,
length: data.length,
data: data.data,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_IMPORT_FINISH(data) => Ok(ConfigCommand::ImportFinish {
request_id: data.request_id,
session_id: data.session_id,
transfer_id: data.transfer_id,
expected_revision: data.expected_revision,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_BEGIN(data) => Ok(ConfigCommand::Begin {
request_id: data.request_id,
expected_generation: data.expected_generation,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_F32(data) => Ok(ConfigCommand::Set {
@@ -150,7 +177,6 @@ pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError
MavMessage::KAPTAN_CONFIG_COMMIT(data) => Ok(ConfigCommand::Commit {
request_id: data.request_id,
session_id: data.session_id,
expected_generation: data.expected_generation,
expected_revision: data.expected_revision,
target: Target::new(data.target_system, data.target_component),
}),
@@ -191,10 +217,8 @@ const fn boolean(value: u8) -> Result<bool, DecodeError> {
/// Encodes one authoritative service completion.
pub fn encode_result(reply: ConfigReply) -> KAPTAN_CONFIG_RESULT_DATA {
KAPTAN_CONFIG_RESULT_DATA {
generation: reply.generation,
request_id: reply.request_id,
session_id: reply.session_id,
transfer_id: reply.transfer_id,
revision: reply.revision,
primary_field: reply.primary_field(),
related_field: reply.related_field(),
@@ -209,16 +233,12 @@ pub fn encode_result(reply: ConfigReply) -> KAPTAN_CONFIG_RESULT_DATA {
/// Encodes configuration protocol/schema status for a Kaptan control station.
pub fn encode_info(info: ConfigInfo) -> KAPTAN_CONFIG_INFO_DATA {
KAPTAN_CONFIG_INFO_DATA {
active_generation: info.active_generation.map_or(0, Generation::get),
committed_generation: info.committed_generation.map_or(0, Generation::get),
request_id: info.request_id,
schema_id: info.schema_id,
profile_format: info.profile_format,
field_count: info.field_count,
protocol_major: 1,
protocol_minor: 0,
protocol_major: PROTOCOL_MAJOR,
protocol_minor: PROTOCOL_MINOR,
config_state: state(info.state),
capability_flags: info.capability_flags,
}
}
@@ -227,19 +247,34 @@ pub fn encode_descriptor(
request_id: u32,
descriptor_index: u16,
descriptor: FieldDescriptor,
default: ConfigValue,
) -> KAPTAN_CONFIG_DESCRIPTOR_DATA {
KAPTAN_CONFIG_DESCRIPTOR_DATA {
minimum_bits: descriptor.minimum.map_or(0, ConfigValue::raw_bits),
maximum_bits: descriptor.maximum.map_or(0, ConfigValue::raw_bits),
default_bits: descriptor.default.raw_bits(),
default_bits: default.raw_bits(),
request_id,
descriptor_index,
field_id: descriptor.id.get(),
enum_option_count: u8::try_from(descriptor.enum_options.len())
.expect("validated enum option count fits u8"),
display_key: descriptor.display_key.into(),
unit: descriptor.unit.into(),
value_kind: value_kind(descriptor.kind),
apply_policy: policy(descriptor.apply_policy),
flags: 0,
flags: descriptor.minimum.map_or(0, |_| DESCRIPTOR_HAS_MINIMUM)
| descriptor.maximum.map_or(0, |_| DESCRIPTOR_HAS_MAXIMUM),
}
}
/// Encodes one generic named enum choice.
pub fn encode_enum_option(response: EnumOptionResponse) -> KAPTAN_CONFIG_ENUM_OPTION_DATA {
KAPTAN_CONFIG_ENUM_OPTION_DATA {
value: response.option.0,
request_id: response.request_id,
descriptor_index: response.descriptor_index,
field_id: response.field.get(),
option_index: response.option_index,
label: response.option.1.into(),
}
}
@@ -266,14 +301,6 @@ const fn value_kind(kind: ValueKind) -> KaptanConfigValueKind {
}
}
const fn policy(policy: ApplyPolicy) -> KaptanConfigPolicy {
match policy {
ApplyPolicy::Live => KaptanConfigPolicy::KAPTAN_CONFIG_POLICY_LIVE,
ApplyPolicy::DisarmedReset => KaptanConfigPolicy::KAPTAN_CONFIG_POLICY_DISARMED_RESET,
ApplyPolicy::RestartRequired => KaptanConfigPolicy::KAPTAN_CONFIG_POLICY_RESTART_REQUIRED,
}
}
const fn operation(operation: Operation) -> KaptanConfigOperation {
match operation {
Operation::Query => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_QUERY,
@@ -282,7 +309,6 @@ const fn operation(operation: Operation) -> KaptanConfigOperation {
Operation::Validate => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE,
Operation::Commit => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT,
Operation::Abort => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT,
Operation::Import => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_IMPORT,
}
}
@@ -308,12 +334,6 @@ const fn error(error: Option<ProtocolError>) -> KaptanConfigError {
match error {
None => KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE,
Some(ProtocolError::BadTarget) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BAD_TARGET,
Some(ProtocolError::AuthorizationDenied) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_AUTHORIZATION_DENIED
}
Some(ProtocolError::GenerationConflict) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_GENERATION_CONFLICT
}
Some(ProtocolError::SessionBusy) => KaptanConfigError::KAPTAN_CONFIG_ERROR_SESSION_BUSY,
Some(ProtocolError::InvalidSession) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_SESSION
@@ -326,6 +346,7 @@ const fn error(error: Option<ProtocolError>) -> KaptanConfigError {
Some(ProtocolError::UnknownField(_)) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNKNOWN_FIELD
}
Some(ProtocolError::InvalidQuery) => KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_QUERY,
Some(ProtocolError::TypeMismatch(_)) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_TYPE_MISMATCH
}
@@ -339,36 +360,88 @@ const fn error(error: Option<ProtocolError>) -> KaptanConfigError {
ValidationRule::UnsupportedCapability => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_CAPABILITY
}
ValidationRule::UnsupportedFormat => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_FORMAT
ValidationRule::UnsupportedFormat => KaptanConfigError::KAPTAN_CONFIG_ERROR_INTERNAL,
ValidationRule::InvalidEnumValue => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_ENUM_VALUE
}
},
Some(ProtocolError::StorageFailure) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_STORAGE_FAILURE
}
Some(ProtocolError::CborMalformed) => KaptanConfigError::KAPTAN_CONFIG_ERROR_CBOR_MALFORMED,
Some(ProtocolError::CborTooLarge) => KaptanConfigError::KAPTAN_CONFIG_ERROR_CBOR_TOO_LARGE,
Some(ProtocolError::BlobCrc) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BLOB_CRC,
Some(ProtocolError::SchemaMismatch) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_SCHEMA_MISMATCH
}
Some(ProtocolError::ImportUnavailable) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_IMPORT_UNAVAILABLE
}
Some(ProtocolError::Internal) => KaptanConfigError::KAPTAN_CONFIG_ERROR_INTERNAL,
}
}
#[cfg(test)]
mod tests {
use kaptan_config::{ConfigState, ConfigViolation, ValidationRule};
use kaptan_config::{
ConfigState, ConfigValue, ConfigViolation, FieldDescriptor, ValidationRule, ValueKind,
};
use kaptan_mavlink::message::kaptan::{
KAPTAN_CONFIG_INFO_REQUEST_DATA, KAPTAN_CONFIG_SET_F32_DATA, MavMessage,
KAPTAN_CONFIG_ENUM_OPTION_REQUEST_DATA, KAPTAN_CONFIG_INFO_REQUEST_DATA,
KAPTAN_CONFIG_SET_F32_DATA, MavMessage,
};
use super::*;
use crate::CAPABILITY_CBOR_IMPORT;
#[derive(Debug, PartialEq)]
enum Emitted {
Info(KAPTAN_CONFIG_INFO_DATA),
Result(KAPTAN_CONFIG_RESULT_DATA),
Descriptor(KAPTAN_CONFIG_DESCRIPTOR_DATA),
EnumOption(KAPTAN_CONFIG_ENUM_OPTION_DATA),
Value(KAPTAN_CONFIG_VALUE_DATA),
}
#[derive(Default)]
struct RecordingVisitor {
emitted: std::vec::Vec<Emitted>,
}
impl ResponseVisitor for RecordingVisitor {
type Error = core::convert::Infallible;
fn info(&mut self, response: &KAPTAN_CONFIG_INFO_DATA) -> Result<(), Self::Error> {
self.emitted.push(Emitted::Info(response.clone()));
Ok(())
}
fn result(&mut self, response: &KAPTAN_CONFIG_RESULT_DATA) -> Result<(), Self::Error> {
self.emitted.push(Emitted::Result(response.clone()));
Ok(())
}
fn descriptor(
&mut self,
response: &KAPTAN_CONFIG_DESCRIPTOR_DATA,
) -> Result<(), Self::Error> {
self.emitted.push(Emitted::Descriptor(response.clone()));
Ok(())
}
fn enum_option(
&mut self,
response: &KAPTAN_CONFIG_ENUM_OPTION_DATA,
) -> Result<(), Self::Error> {
self.emitted.push(Emitted::EnumOption(response.clone()));
Ok(())
}
fn value(&mut self, response: &KAPTAN_CONFIG_VALUE_DATA) -> Result<(), Self::Error> {
self.emitted.push(Emitted::Value(response.clone()));
Ok(())
}
}
const FIRST_DESCRIPTOR: FieldDescriptor = FieldDescriptor {
id: FieldId::new(17),
display_key: "future.sensor.rate_hz",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(200)),
enum_options: &[],
unit: "Hz",
};
#[test]
fn generated_set_message_preserves_typed_value_and_identifiers() {
let message = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
@@ -409,6 +482,27 @@ mod tests {
);
}
#[test]
fn generated_enum_option_request_preserves_the_explicit_index() {
let message =
MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(KAPTAN_CONFIG_ENUM_OPTION_REQUEST_DATA {
request_id: 41,
descriptor_index: 3,
option_index: 6,
target_system: 1,
target_component: 2,
});
assert_eq!(
decode_command(&message),
Ok(ConfigCommand::EnumOptionRequest {
request_id: 41,
descriptor_index: 3,
option_index: 6,
target: Target::new(1, 2),
})
);
}
#[test]
fn generated_config_requests_reject_broadcast_targets() {
let zero_system = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
@@ -444,9 +538,7 @@ mod tests {
let reply = ConfigReply {
request_id: 1,
session_id: 2,
transfer_id: 0,
revision: 3,
generation: 4,
operation: Operation::Set,
result: ResultKind::Rejected,
error: Some(ProtocolError::Violation(ConfigViolation::field(
@@ -465,37 +557,118 @@ mod tests {
}
#[test]
fn unavailable_import_has_its_own_structured_error_code() {
let reply = ConfigReply {
request_id: 1,
session_id: 0,
transfer_id: 0,
revision: 0,
generation: 0,
operation: Operation::Import,
result: ResultKind::Rejected,
error: Some(ProtocolError::ImportUnavailable),
fn info_preserves_schema_and_protocol_identity() {
let info = encode_info(ConfigInfo {
request_id: 7,
schema_id: [0; 16],
field_count: 23,
state: ConfigState::Active,
};
});
assert_eq!(info.request_id, 7);
assert_eq!(info.schema_id, [0; 16]);
assert_eq!(
encode_result(reply).error,
KaptanConfigError::KAPTAN_CONFIG_ERROR_IMPORT_UNAVAILABLE
(info.protocol_major, info.protocol_minor),
(PROTOCOL_MAJOR, PROTOCOL_MINOR)
);
}
#[test]
fn info_preserves_service_capabilities() {
let info = encode_info(ConfigInfo {
fn descriptor_flags_identify_present_bounds() {
let descriptor = FieldDescriptor {
id: FieldId::new(9),
display_key: "test.value",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
enum_options: &[],
unit: "count",
};
assert_eq!(
encode_descriptor(1, 0, descriptor, ConfigValue::U32(2)).flags,
DESCRIPTOR_HAS_MINIMUM | DESCRIPTOR_HAS_MAXIMUM
);
assert_eq!(
encode_descriptor(1, 0, descriptor, ConfigValue::U32(2)).enum_option_count,
0
);
}
#[test]
fn response_visitor_projects_every_singleton_response_variant() {
let info = ConfigInfo {
request_id: 7,
active_generation: None,
committed_generation: None,
schema_id: [0; 16],
profile_format: 1,
field_count: 23,
state: ConfigState::Active,
capability_flags: CAPABILITY_CBOR_IMPORT,
});
assert_eq!(info.request_id, 7);
assert_eq!(info.capability_flags, CAPABILITY_CBOR_IMPORT);
schema_id: *b"UNKNOWN-SCHEMA01",
field_count: 2,
state: ConfigState::RestartRequired,
};
let result = ConfigReply {
request_id: 8,
session_id: 9,
revision: 2,
operation: Operation::Commit,
result: ResultKind::CommittedRestartRequired,
error: None,
state: ConfigState::RestartRequired,
};
let value = ValueResponse {
request_id: 10,
session_id: 0,
revision: 0,
field: FieldId::new(401),
value: ConfigValue::Bool(true),
};
let enum_option = crate::EnumOptionResponse {
request_id: 9,
descriptor_index: 12,
field: FieldId::new(17),
option_index: 1,
option: (3, "Error"),
};
let mut visitor = RecordingVisitor::default();
assert_eq!(
visit_response(ServiceResponse::Info(info), &mut visitor),
Ok(())
);
assert_eq!(
visit_response(ServiceResponse::Result(result), &mut visitor),
Ok(())
);
assert_eq!(
visit_response(
ServiceResponse::Descriptor(crate::DescriptorResponse {
request_id: 9,
index: 12,
descriptor: FIRST_DESCRIPTOR,
default: ConfigValue::U32(20),
}),
&mut visitor,
),
Ok(())
);
assert_eq!(
visit_response(ServiceResponse::EnumOption(enum_option), &mut visitor),
Ok(())
);
assert_eq!(
visit_response(ServiceResponse::Value(value), &mut visitor),
Ok(())
);
assert_eq!(
visitor.emitted,
std::vec![
Emitted::Info(encode_info(info)),
Emitted::Result(encode_result(result)),
Emitted::Descriptor(encode_descriptor(
9,
12,
FIRST_DESCRIPTOR,
ConfigValue::U32(20),
)),
Emitted::EnumOption(encode_enum_option(enum_option)),
Emitted::Value(encode_value(value)),
]
);
}
}
+4 -2
View File
@@ -2,7 +2,7 @@
use crate::FieldId;
/// The invariant violated by a configuration field or complete profile.
/// The invariant violated by a configuration field or complete candidate.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u16)]
pub enum ValidationRule {
@@ -20,6 +20,8 @@ pub enum ValidationRule {
UnsupportedCapability = 6,
/// The encoded snapshot format cannot be used by this firmware.
UnsupportedFormat = 7,
/// A scalar does not name a declared variant of its typed enum field.
InvalidEnumValue = 8,
}
impl ValidationRule {
@@ -59,7 +61,7 @@ impl ConfigViolation {
}
}
/// Constructs a profile-level violation with no scalar identity.
/// Constructs a configuration-wide violation with no scalar identity.
pub const fn global(rule: ValidationRule) -> Self {
Self {
primary: None,
+115 -18
View File
@@ -3,9 +3,18 @@
use crate::{ConfigViolation, ValidationRule};
use kaptan_log::KaptanError;
/// Maximum choices representable by one enum-backed field.
///
/// Enum values and indexed discovery use their native `u8` domain. Options
/// are immutable flash metadata, so no smaller runtime resource limit is
/// necessary.
pub const MAX_ENUM_OPTIONS_PER_FIELD: usize = u8::MAX as usize;
/// Maximum UTF-8 bytes advertised for one enum variant name.
pub const MAX_ENUM_OPTION_LABEL_BYTES: usize = 32;
/// A stable, schema-local identity for one editable configuration field.
///
/// IDs are never reused within a schema generation. They are compact wire
/// IDs are never reused within a schema version. They are compact wire
/// identities; display strings belong to metadata and diagnostics only.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)]
#[repr(transparent)]
@@ -56,6 +65,88 @@ pub enum ConfigValue {
F64(f64),
}
/// One named value advertised for an enum-backed configuration field.
///
/// Enum metadata belongs to the compiled schema. Generic transports can
/// therefore render choices without knowing field keys or firmware-specific
/// enum types. The tuple is `(stable u8 representation, display label)`.
pub type EnumOption = (u8, &'static str);
/// Failure to project a generic configuration scalar into its native type.
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ScalarValueError {
/// The generic scalar kind does not match the native field type.
WrongKind(ValueKind),
/// The representation has the correct scalar kind but no native value.
OutOfDomain,
}
/// Native representation used by generated configuration fields.
///
/// The generic manager and transports retain their compact fixed scalar
/// protocol while board and runtime structures keep the narrow or enum type
/// that actually models the value.
#[doc(hidden)]
pub trait ConfigScalar: Copy {
/// Generic scalar kind used at schema and transport boundaries.
const KIND: ValueKind;
/// Projects the native value into the generic scalar boundary.
fn into_config_value(self) -> ConfigValue;
/// Validates and projects one generic scalar into the native type.
fn try_from_config_value(value: ConfigValue) -> Result<Self, ScalarValueError>;
}
macro_rules! direct_config_scalar {
($type:ty, $kind:ident, $variant:ident) => {
impl ConfigScalar for $type {
const KIND: ValueKind = ValueKind::$kind;
fn into_config_value(self) -> ConfigValue {
ConfigValue::$variant(self)
}
fn try_from_config_value(value: ConfigValue) -> Result<Self, ScalarValueError> {
match value {
ConfigValue::$variant(value) => Ok(value),
value => Err(ScalarValueError::WrongKind(value.kind())),
}
}
}
};
}
direct_config_scalar!(bool, Bool, Bool);
direct_config_scalar!(i32, I32, I32);
direct_config_scalar!(u32, U32, U32);
direct_config_scalar!(u64, U64, U64);
direct_config_scalar!(f32, F32, F32);
direct_config_scalar!(f64, F64, F64);
macro_rules! narrow_unsigned_config_scalar {
($type:ty) => {
impl ConfigScalar for $type {
const KIND: ValueKind = ValueKind::U32;
fn into_config_value(self) -> ConfigValue {
ConfigValue::U32(u32::from(self))
}
fn try_from_config_value(value: ConfigValue) -> Result<Self, ScalarValueError> {
match value {
ConfigValue::U32(value) => {
Self::try_from(value).map_err(|_| ScalarValueError::OutOfDomain)
}
value => Err(ScalarValueError::WrongKind(value.kind())),
}
}
}
};
}
narrow_unsigned_config_scalar!(u8);
narrow_unsigned_config_scalar!(u16);
impl ConfigValue {
/// The declared value kind.
pub const fn kind(self) -> ValueKind {
@@ -80,6 +171,26 @@ impl ConfigValue {
Self::F64(value) => value.to_bits(),
}
}
/// Decodes the canonical scalar representation used by snapshots and the
/// configuration wire protocol.
pub const fn try_from_raw_bits(kind: ValueKind, bits: u64) -> Option<Self> {
match kind {
ValueKind::Bool => match bits {
0 => Some(Self::Bool(false)),
1 => Some(Self::Bool(true)),
_ => None,
},
ValueKind::I32 if bits <= u32::MAX as u64 => Some(Self::I32(bits as u32 as i32)),
ValueKind::U32 if bits <= u32::MAX as u64 => Some(Self::U32(bits as u32)),
ValueKind::U64 => Some(Self::U64(bits)),
ValueKind::F32 if bits <= u32::MAX as u64 => {
Some(Self::F32(f32::from_bits(bits as u32)))
}
ValueKind::F64 => Some(Self::F64(f64::from_bits(bits))),
ValueKind::I32 | ValueKind::U32 | ValueKind::F32 => None,
}
}
}
/// The scalar kind carried in a descriptor or typed protocol message.
@@ -100,18 +211,6 @@ pub enum ValueKind {
F64 = 6,
}
/// When a committed field may affect the active runtime.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum ApplyPolicy {
/// Reserved for individually proven live-safe changes.
Live = 1,
/// Reserved for controlled output-inhibited reconfiguration.
DisarmedReset = 2,
/// P1 policy: the field takes effect only at the next boot.
RestartRequired = 3,
}
/// Static metadata for one configuration field.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FieldDescriptor {
@@ -125,12 +224,10 @@ pub struct FieldDescriptor {
pub minimum: Option<ConfigValue>,
/// Inclusive upper bound when the value is ordered.
pub maximum: Option<ConfigValue>,
/// Firmware-build default.
pub default: ConfigValue,
/// Named choices for an enum-backed field, otherwise empty.
pub enum_options: &'static [EnumOption],
/// Human-readable unit for schema metadata.
pub unit: &'static str,
/// Activation policy advertised to a control station.
pub apply_policy: ApplyPolicy,
}
/// Failure while changing one transaction field.
@@ -168,7 +265,7 @@ impl FieldWriteError {
/// Vehicle-specific editable candidate model.
///
/// Implementations expose every operator-editable scalar through the static
/// Implementations expose every user-editable scalar through the static
/// descriptor registry. A field write performs only local validation; complete
/// relational and capability validation happens through [`Self::validate`].
pub trait ConfigModel: Clone {
+524
View File
@@ -0,0 +1,524 @@
//! Power-loss-safe two-slot journal over an erase-before-write NOR device.
//!
//! The journal is board- and schema-agnostic. A platform supplies exactly two
//! independently erasable slots; the generic scalar snapshot codec supplies
//! the payload. The previous committed slot is never erased until the complete
//! replacement is programmed, verified, and marked committed.
use core::marker::PhantomData;
use crate::{ConfigModel, ConfigStore, decode_scalar_snapshot, encode_scalar_snapshot};
const MAGIC: u32 = u32::from_le_bytes(*b"KJNL");
const RECORD_VERSION: u16 = 1;
const HEADER_LENGTH: usize = 32;
const CRC_OFFSET: usize = 24;
const COMMIT_OFFSET: usize = 28;
const COMMIT_MARKER: u32 = u32::from_le_bytes(*b"DONE");
/// Exactly two independently erasable NOR slots used by [`DualSlotJournal`].
pub trait DualSlotNor {
/// Device-specific read, erase, or programming failure.
type Error;
/// Bytes available in each of the two slots.
fn slot_size(&self) -> usize;
/// Copies bytes from one slot into caller-owned RAM.
fn read(&mut self, slot: usize, offset: usize, output: &mut [u8]) -> Result<(), Self::Error>;
/// Erases one whole slot without affecting the other slot.
fn erase_slot(&mut self, slot: usize) -> Result<(), Self::Error>;
/// Programs one aligned erased NOR word. Implementations must reject any
/// attempted zero-to-one bit transition.
fn program_word(&mut self, slot: usize, offset: usize, word: u32) -> Result<(), Self::Error>;
}
/// Two-slot journal failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum JournalError<E> {
/// The platform slot cannot contain the bounded snapshot record.
InvalidGeometry,
/// The compiled model could not produce a canonical bounded snapshot.
InvalidModel,
/// The private durable sequence domain is exhausted.
SequenceExhausted,
/// The NOR device rejected a read, erase, or program operation.
Storage(E),
/// Read-back did not match the bytes just programmed.
Verification,
}
/// Generic persistent store backed by two independently erasable NOR slots.
pub struct DualSlotJournal<C, F, const MAX_PAYLOAD: usize> {
flash: F,
factory: C,
_payload: PhantomData<[u8; MAX_PAYLOAD]>,
}
impl<C, F, const MAX_PAYLOAD: usize> DualSlotJournal<C, F, MAX_PAYLOAD> {
/// Creates a journal whose erased-flash decode base is the board factory
/// candidate. Construction performs no flash access.
pub const fn new(flash: F, factory: C) -> Self {
Self {
flash,
factory,
_payload: PhantomData,
}
}
/// Releases the underlying platform flash owner.
pub fn into_flash(self) -> F {
self.flash
}
}
#[derive(Clone)]
struct Record<C> {
slot: usize,
sequence: u64,
candidate: C,
}
impl<C, F, const MAX_PAYLOAD: usize> DualSlotJournal<C, F, MAX_PAYLOAD>
where
C: ConfigModel,
F: DualSlotNor,
{
fn geometry(&self) -> Result<(), JournalError<F::Error>> {
let padded_payload = MAX_PAYLOAD
.checked_add(3)
.ok_or(JournalError::InvalidGeometry)?
& !3;
if MAX_PAYLOAD == 0 || self.flash.slot_size() < HEADER_LENGTH + padded_payload {
return Err(JournalError::InvalidGeometry);
}
Ok(())
}
fn record(&mut self, slot: usize) -> Result<Option<Record<C>>, JournalError<F::Error>> {
let mut header = [0_u8; HEADER_LENGTH];
self.flash
.read(slot, 0, &mut header)
.map_err(JournalError::Storage)?;
if header[..4] == [0xff; 4] {
return Ok(None);
}
if read_u32(&header[0..4]) != MAGIC
|| read_u16(&header[4..6]) != RECORD_VERSION
|| usize::from(read_u16(&header[6..8])) != HEADER_LENGTH
|| read_u32(&header[COMMIT_OFFSET..COMMIT_OFFSET + 4]) != COMMIT_MARKER
{
return Ok(None);
}
let sequence = read_u64(&header[8..16]);
if sequence == 0 {
return Ok(None);
}
let payload_length = read_u32(&header[16..20]) as usize;
if payload_length == 0
|| payload_length > MAX_PAYLOAD
|| HEADER_LENGTH + payload_length > self.flash.slot_size()
{
return Ok(None);
}
let mut payload = [0_u8; MAX_PAYLOAD];
self.flash
.read(slot, HEADER_LENGTH, &mut payload[..payload_length])
.map_err(JournalError::Storage)?;
if record_crc(&header, &payload[..payload_length])
!= read_u32(&header[CRC_OFFSET..COMMIT_OFFSET])
{
return Ok(None);
}
let Ok(candidate) = decode_scalar_snapshot(&payload[..payload_length], &self.factory)
else {
return Ok(None);
};
Ok(Some(Record {
slot,
sequence,
candidate,
}))
}
fn newest(&mut self) -> Result<Option<Record<C>>, JournalError<F::Error>> {
let left = self.record(0)?;
let right = self.record(1)?;
Ok(match (left, right) {
(Some(left), Some(right)) if right.sequence > left.sequence => Some(right),
(Some(left), Some(_)) | (Some(left), None) => Some(left),
(None, Some(right)) => Some(right),
(None, None) => None,
})
}
fn program_bytes(
&mut self,
slot: usize,
offset: usize,
bytes: &[u8],
) -> Result<(), JournalError<F::Error>> {
if !offset.is_multiple_of(4) {
return Err(JournalError::InvalidGeometry);
}
for (index, chunk) in bytes.chunks(4).enumerate() {
let mut word = [0xff; 4];
word[..chunk.len()].copy_from_slice(chunk);
self.flash
.program_word(slot, offset + index * 4, u32::from_le_bytes(word))
.map_err(JournalError::Storage)?;
}
Ok(())
}
}
impl<C, F, const MAX_PAYLOAD: usize> ConfigStore<C> for DualSlotJournal<C, F, MAX_PAYLOAD>
where
C: ConfigModel,
F: DualSlotNor,
{
type Error = JournalError<F::Error>;
fn load(&mut self) -> Result<Option<C>, Self::Error> {
self.geometry()?;
Ok(self.newest()?.map(|record| record.candidate))
}
fn load_matching<P>(&mut self, mut compatible: P) -> Result<Option<C>, Self::Error>
where
P: FnMut(&C) -> bool,
{
self.geometry()?;
let left = self.record(0)?;
let right = self.record(1)?;
let left = left.filter(|record| compatible(&record.candidate));
let right = right.filter(|record| compatible(&record.candidate));
let selected = match (left, right) {
(Some(left), Some(right)) if right.sequence > left.sequence => Some(right),
(Some(left), Some(_)) | (Some(left), None) => Some(left),
(None, Some(right)) => Some(right),
(None, None) => None,
};
Ok(selected.map(|record| record.candidate))
}
fn persist(&mut self, candidate: &C) -> Result<(), Self::Error> {
self.geometry()?;
let current = self.newest()?;
let sequence = current
.as_ref()
.map_or(1, |record| record.sequence.saturating_add(1));
if sequence == 0
|| current
.as_ref()
.is_some_and(|record| sequence == record.sequence)
{
return Err(JournalError::SequenceExhausted);
}
let target_slot = current.as_ref().map_or(0, |record| 1 - record.slot);
let mut payload = [0_u8; MAX_PAYLOAD];
let payload = encode_scalar_snapshot(candidate, &mut payload)
.map_err(|_| JournalError::InvalidModel)?;
let mut header = [0xff; HEADER_LENGTH];
header[0..4].copy_from_slice(&MAGIC.to_le_bytes());
header[4..6].copy_from_slice(&RECORD_VERSION.to_le_bytes());
header[6..8].copy_from_slice(&(HEADER_LENGTH as u16).to_le_bytes());
header[8..16].copy_from_slice(&sequence.to_le_bytes());
header[16..20].copy_from_slice(&(payload.len() as u32).to_le_bytes());
header[20..24].fill(0);
let crc = record_crc(&header, payload);
header[CRC_OFFSET..COMMIT_OFFSET].copy_from_slice(&crc.to_le_bytes());
self.flash
.erase_slot(target_slot)
.map_err(JournalError::Storage)?;
self.program_bytes(target_slot, 0, &header[..COMMIT_OFFSET])?;
self.program_bytes(target_slot, HEADER_LENGTH, payload)?;
let mut verify_header = [0_u8; COMMIT_OFFSET];
self.flash
.read(target_slot, 0, &mut verify_header)
.map_err(JournalError::Storage)?;
let mut verify_payload = [0_u8; MAX_PAYLOAD];
self.flash
.read(
target_slot,
HEADER_LENGTH,
&mut verify_payload[..payload.len()],
)
.map_err(JournalError::Storage)?;
if verify_header != header[..COMMIT_OFFSET] || verify_payload[..payload.len()] != *payload {
return Err(JournalError::Verification);
}
self.flash
.program_word(target_slot, COMMIT_OFFSET, COMMIT_MARKER)
.map_err(JournalError::Storage)?;
let Some(record) = self.record(target_slot)? else {
return Err(JournalError::Verification);
};
if record.sequence != sequence {
return Err(JournalError::Verification);
}
Ok(())
}
}
fn record_crc(header: &[u8; HEADER_LENGTH], payload: &[u8]) -> u32 {
let mut crc = !0_u32;
for byte in header[..CRC_OFFSET].iter().chain(payload) {
crc ^= u32::from(*byte);
for _ in 0..8 {
crc = (crc >> 1) ^ (0x82f6_3b78 & 0_u32.wrapping_sub(crc & 1));
}
}
!crc
}
fn read_u16(bytes: &[u8]) -> u16 {
u16::from_le_bytes([bytes[0], bytes[1]])
}
fn read_u32(bytes: &[u8]) -> u32 {
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
}
fn read_u64(bytes: &[u8]) -> u64 {
u64::from_le_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, ValueKind,
};
const VALUE: FieldId = FieldId::new(401);
const DESCRIPTOR: FieldDescriptor = FieldDescriptor {
id: VALUE,
display_key: "unseen.value",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(100)),
enum_options: &[],
unit: "ticks",
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Candidate(u32);
impl ConfigModel for Candidate {
type Active = Self;
type Context = ();
const SCHEMA_ID: [u8; 16] = *b"JOURNAL-UNKNOWN1";
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
(index == 0).then_some(&DESCRIPTOR)
}
fn field_count() -> u16 {
1
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
(id == VALUE).then_some(ConfigValue::U32(self.0))
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
active.field_value(id)
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
if id != VALUE {
return Err(FieldWriteError::UnknownField(id));
}
let ConfigValue::U32(value) = value else {
return Err(FieldWriteError::WrongType {
field: id,
expected: ValueKind::U32,
actual: value.kind(),
});
};
if !(1..=100).contains(&value) {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
crate::ValidationRule::AboveMaximum,
)));
}
self.0 = value;
Ok(())
}
fn validate(&self, _: &Self::Context) -> Result<Self::Active, ConfigViolation> {
Ok(*self)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FakeError {
PowerLoss,
Bounds,
ZeroToOne,
}
#[derive(Clone)]
struct FakeNor {
slots: [[u8; 256]; 2],
operations: usize,
fail_at: Option<usize>,
}
impl FakeNor {
fn erased() -> Self {
Self {
slots: [[0xff; 256]; 2],
operations: 0,
fail_at: None,
}
}
fn before_mutation(&mut self) -> Result<(), FakeError> {
if self.fail_at == Some(self.operations) {
return Err(FakeError::PowerLoss);
}
self.operations += 1;
Ok(())
}
}
impl DualSlotNor for FakeNor {
type Error = FakeError;
fn slot_size(&self) -> usize {
self.slots[0].len()
}
fn read(
&mut self,
slot: usize,
offset: usize,
output: &mut [u8],
) -> Result<(), Self::Error> {
let source = self
.slots
.get(slot)
.and_then(|slot| slot.get(offset..offset + output.len()))
.ok_or(FakeError::Bounds)?;
output.copy_from_slice(source);
Ok(())
}
fn erase_slot(&mut self, slot: usize) -> Result<(), Self::Error> {
self.before_mutation()?;
self.slots
.get_mut(slot)
.ok_or(FakeError::Bounds)?
.fill(0xff);
Ok(())
}
fn program_word(
&mut self,
slot: usize,
offset: usize,
word: u32,
) -> Result<(), Self::Error> {
self.before_mutation()?;
let destination = self
.slots
.get_mut(slot)
.and_then(|slot| slot.get_mut(offset..offset + 4))
.ok_or(FakeError::Bounds)?;
let bytes = word.to_le_bytes();
if destination
.iter()
.zip(bytes)
.any(|(old, new)| old & new != new)
{
return Err(FakeError::ZeroToOne);
}
destination.copy_from_slice(&bytes);
Ok(())
}
}
type Journal = DualSlotJournal<Candidate, FakeNor, 64>;
#[test]
fn alternates_slots_and_recovers_the_newest_record() {
let mut journal = Journal::new(FakeNor::erased(), Candidate(20));
assert_eq!(journal.load().unwrap(), None);
journal.persist(&Candidate(30)).unwrap();
journal.persist(&Candidate(40)).unwrap();
let flash = journal.into_flash();
let mut recovered = Journal::new(flash, Candidate(20));
assert_eq!(recovered.load().unwrap(), Some(Candidate(40)));
}
#[test]
fn every_interrupted_erase_or_word_program_preserves_the_old_record() {
let mut initial = Journal::new(FakeNor::erased(), Candidate(20));
initial.persist(&Candidate(30)).unwrap();
let baseline = initial.into_flash();
let mut successful = Journal::new(baseline.clone(), Candidate(20));
let start = successful.flash.operations;
successful.persist(&Candidate(40)).unwrap();
let operation_count = successful.flash.operations - start;
for interrupted_at in 0..operation_count {
let mut flash = baseline.clone();
flash.fail_at = Some(flash.operations + interrupted_at);
let mut interrupted = Journal::new(flash, Candidate(20));
assert!(matches!(
interrupted.persist(&Candidate(40)),
Err(JournalError::Storage(FakeError::PowerLoss))
));
let flash = interrupted.into_flash();
let mut recovered = Journal::new(flash, Candidate(20));
assert_eq!(
recovered.load().unwrap(),
Some(Candidate(30)),
"mutation {interrupted_at} exposed a partial replacement"
);
}
}
#[test]
fn corrupt_newest_record_falls_back_to_the_previous_slot() {
let mut journal = Journal::new(FakeNor::erased(), Candidate(20));
journal.persist(&Candidate(30)).unwrap();
journal.persist(&Candidate(40)).unwrap();
let mut flash = journal.into_flash();
flash.slots[1][HEADER_LENGTH + 2] ^= 1;
let mut recovered = Journal::new(flash, Candidate(20));
assert_eq!(recovered.load().unwrap(), Some(Candidate(30)));
}
#[test]
fn compatibility_filter_can_select_the_older_structurally_valid_slot() {
let mut journal = Journal::new(FakeNor::erased(), Candidate(20));
journal.persist(&Candidate(30)).unwrap();
journal.persist(&Candidate(40)).unwrap();
assert_eq!(
journal
.load_matching(|candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30))
);
assert_eq!(
journal.load_matching(|candidate| candidate.0 < 10).unwrap(),
None
);
}
}
+16 -5
View File
@@ -3,7 +3,9 @@
//! This crate has no knowledge of MAVLink, filesystems, board routing, motors,
//! or a particular airframe. It owns the staged-versus-active lifecycle and
//! parameter metadata; vehicle crates provide the typed schema and complete
//! validation. CBOR storage and MAVLink transport are separate adapters.
//! validation. Persistence framing and transports remain separate adapters;
//! this crate provides only the bounded scalar snapshot payload shared by
//! storage backends.
#![no_std]
@@ -11,15 +13,24 @@ extern crate self as kaptan_config;
pub mod error;
pub mod field;
pub mod journal;
pub mod lifecycle;
pub mod snapshot;
pub mod storage;
pub mod validation;
pub use error::{ConfigViolation, ValidationRule};
pub use field::{
ApplyPolicy, ConfigModel, ConfigValue, FieldDescriptor, FieldId, FieldWriteError, ValueKind,
ConfigModel, ConfigScalar, ConfigValue, EnumOption, FieldDescriptor, FieldId, FieldWriteError,
MAX_ENUM_OPTION_LABEL_BYTES, MAX_ENUM_OPTIONS_PER_FIELD, ScalarValueError, ValueKind,
};
pub use kaptan_config_derive::ConfigValidate;
pub use journal::{DualSlotJournal, DualSlotNor, JournalError};
pub use kaptan_config_derive::{Config, ConfigEnum};
pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError};
pub use storage::{Generation, ProfileStore};
pub use validation::{ConfigAggregate, ConfigSection, ValidateFields};
pub use snapshot::{
SCALAR_SNAPSHOT_ENTRY_LENGTH, SCALAR_SNAPSHOT_HEADER_LENGTH, SCALAR_SNAPSHOT_VERSION,
ScalarSnapshotDecodeError, ScalarSnapshotEncodeError, SnapshotModelError,
decode_scalar_snapshot, encode_scalar_snapshot, scalar_snapshot_length,
};
pub use storage::ConfigStore;
pub use validation::Config;
+83 -67
View File
@@ -1,8 +1,8 @@
//! Transactional committed-versus-active configuration lifecycle.
use crate::{
ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
Generation, ProfileStore,
ConfigModel, ConfigStore, ConfigValue, ConfigViolation, FieldDescriptor, FieldId,
FieldWriteError,
};
use kaptan_log::KaptanError;
@@ -13,7 +13,7 @@ pub enum ConfigState {
Active,
/// One isolated RAM transaction is open.
Editing,
/// A newer committed snapshot will take effect after reboot.
/// A newer committed snapshot will take effect after restart.
RestartRequired,
}
@@ -53,7 +53,7 @@ pub enum CommitError<E> {
Store(E),
}
/// Owns the immutable boot-active profile, the latest durable candidate, and
/// Owns the immutable boot-active configuration, the latest durable candidate, and
/// exactly one RAM edit transaction.
///
/// P1 deliberately has no activation operation: [`Self::active`] never
@@ -61,11 +61,10 @@ pub enum CommitError<E> {
/// candidate, then reports [`ConfigState::RestartRequired`].
pub struct ConfigManager<C: ConfigModel, S> {
active: C::Active,
factory: C,
committed: C,
transaction: Option<C>,
context: C::Context,
active_generation: Option<Generation>,
committed_generation: Option<Generation>,
store: S,
restart_required: bool,
}
@@ -73,7 +72,7 @@ pub struct ConfigManager<C: ConfigModel, S> {
impl<C, S> ConfigManager<C, S>
where
C: ConfigModel,
S: ProfileStore<C>,
S: ConfigStore<C>,
{
/// Starts from a validated factory candidate when no persistent record is
/// available. Flight callers separately decide whether setup/calibration
@@ -82,11 +81,10 @@ where
let active = factory.validate(&context)?;
Ok(Self {
active,
factory: factory.clone(),
committed: factory,
transaction: None,
context,
active_generation: None,
committed_generation: None,
store,
restart_required: false,
})
@@ -99,7 +97,7 @@ where
context: C::Context,
mut store: S,
) -> Result<Self, StartupError<S::Error>> {
let Some((generation, committed)) = store.load().map_err(StartupError::Store)? else {
let Some(committed) = store.load().map_err(StartupError::Store)? else {
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
};
let active = committed
@@ -107,11 +105,41 @@ where
.map_err(StartupError::InvalidPersisted)?;
Ok(Self {
active,
factory,
committed,
transaction: None,
context,
store,
restart_required: false,
})
}
/// Recovers the newest candidate compatible with the current context,
/// falling back to the supplied factory when no compatible record exists.
///
/// This is an explicit recovery policy for output-inhibited setup images.
/// Safety-critical compositions should use [`Self::recover`] if an
/// incompatible durable record must prevent initialization.
pub fn recover_compatible_or_factory(
factory: C,
context: C::Context,
mut store: S,
) -> Result<Self, StartupError<S::Error>> {
let Some(committed) = store
.load_matching(|candidate| candidate.validate(&context).is_ok())
.map_err(StartupError::Store)?
else {
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
};
let active = committed
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
Ok(Self {
active,
factory,
committed,
transaction: None,
context,
active_generation: Some(generation),
committed_generation: Some(generation),
store,
restart_required: false,
})
@@ -127,6 +155,11 @@ where
self.committed.field_value(id)
}
/// Returns the selected board/image factory value for descriptor metadata.
pub fn factory_value(&self, id: FieldId) -> Option<ConfigValue> {
self.factory.field_value(id)
}
/// Returns a value from the immutable boot-active snapshot.
pub fn active_value(&self, id: FieldId) -> Option<ConfigValue> {
C::active_field_value(&self.active, id)
@@ -158,23 +191,6 @@ where
}
}
/// Returns the boot-active snapshot generation.
pub const fn active_generation(&self) -> Option<Generation> {
self.active_generation
}
/// Returns the most recently recovered or committed generation.
pub const fn committed_generation(&self) -> Option<Generation> {
self.committed_generation
}
/// Returns the most recently recovered or committed generation.
///
/// Retained as a concise alias for optimistic commit callers.
pub const fn generation(&self) -> Option<Generation> {
self.committed_generation
}
/// Opens the only transaction slot from the last durably committed state.
pub fn begin(&mut self) -> Result<(), BeginError> {
if self.transaction.is_some() {
@@ -209,25 +225,8 @@ where
.map_err(CommitError::InvalidCandidate)
}
/// Replaces the open RAM transaction with one complete imported candidate.
///
/// Unlike scalar edits, imports are accepted only after complete
/// relational and capability validation. Neither the boot-active nor the
/// committed snapshot is changed here.
pub fn replace_transaction(&mut self, candidate: C) -> Result<(), CommitError<S::Error>> {
candidate
.validate(&self.context)
.map_err(CommitError::InvalidCandidate)?;
let transaction = self
.transaction
.as_mut()
.ok_or(CommitError::NoTransaction)?;
*transaction = candidate;
Ok(())
}
/// Validates and durably commits the complete open transaction.
pub fn commit(&mut self) -> Result<Generation, CommitError<S::Error>> {
pub fn commit(&mut self) -> Result<(), CommitError<S::Error>> {
let candidate = self
.transaction
.as_ref()
@@ -235,29 +234,23 @@ where
candidate
.validate(&self.context)
.map_err(CommitError::InvalidCandidate)?;
let generation = self.store.persist(candidate).map_err(CommitError::Store)?;
self.store.persist(candidate).map_err(CommitError::Store)?;
self.committed = candidate.clone();
self.transaction = None;
self.committed_generation = Some(generation);
self.restart_required = true;
Ok(generation)
Ok(())
}
/// Drops the RAM transaction without changing active or committed state.
pub fn abort(&mut self) -> bool {
self.transaction.take().is_some()
}
/// Releases the owned store when the configuration owner shuts down.
pub fn into_store(self) -> S {
self.store
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ApplyPolicy, ConfigValue, ValidationRule, ValueKind};
use crate::{ConfigValue, ValidationRule, ValueKind};
const VALUE: FieldId = FieldId::new(1);
const OTHER: FieldId = FieldId::new(2);
@@ -268,9 +261,8 @@ mod tests {
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
default: ConfigValue::U32(2),
enum_options: &[],
unit: "count",
apply_policy: ApplyPolicy::RestartRequired,
},
FieldDescriptor {
id: OTHER,
@@ -278,9 +270,8 @@ mod tests {
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
default: ConfigValue::U32(4),
enum_options: &[],
unit: "count",
apply_policy: ApplyPolicy::RestartRequired,
},
];
@@ -364,15 +355,29 @@ mod tests {
struct MemoryStore;
impl ProfileStore<Candidate> for MemoryStore {
impl ConfigStore<Candidate> for MemoryStore {
type Error = ();
fn load(&mut self) -> Result<Option<(Generation, Candidate)>, Self::Error> {
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
Ok(None)
}
fn persist(&mut self, _: &Candidate) -> Result<Generation, Self::Error> {
Ok(Generation::new(7))
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
Ok(())
}
}
struct IncompatibleStore;
impl ConfigStore<Candidate> for IncompatibleStore {
type Error = ();
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
Ok(Some(Candidate { value: 9, other: 1 }))
}
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
Ok(())
}
}
@@ -405,11 +410,9 @@ mod tests {
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(3)).unwrap();
assert_eq!(manager.commit(), Ok(Generation::new(7)));
assert_eq!(manager.commit(), Ok(()));
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.active_generation(), None);
assert_eq!(manager.committed_generation(), Some(Generation::new(7)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(4)).unwrap();
@@ -417,4 +420,17 @@ mod tests {
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
}
#[test]
fn explicit_recovery_policy_uses_factory_when_no_record_matches_context() {
let factory = Candidate { value: 2, other: 4 };
assert!(matches!(
ConfigManager::recover(factory.clone(), (), IncompatibleStore),
Err(StartupError::InvalidPersisted(_))
));
let manager =
ConfigManager::recover_compatible_or_factory(factory, (), IncompatibleStore).unwrap();
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
}
}
+781
View File
@@ -0,0 +1,781 @@
//! Allocation-free scalar snapshot payload codec.
//!
//! A snapshot is a canonical storage payload, not a flash journal record. A
//! storage backend remains responsible for private ordering metadata, integrity,
//! power-loss-safe commit markers, and selecting the newest valid record.
//!
//! The fixed-width little-endian layout is:
//!
//! ```text
//! magic[4] | version:u8 | schema_id[16] | field_count:u16
//! repeated field_count times:
//! field_id:u16 | value_kind:u8 | value_bits:u64
//! ```
//!
//! Field entries are emitted in descriptor order. Decoding uses only stable
//! field IDs and therefore also accepts a complete, uniquely identified set in
//! another order.
use kaptan_log::KaptanError;
use crate::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValueKind};
const MAGIC: [u8; 4] = *b"KCFG";
const MAGIC_OFFSET: usize = 0;
const VERSION_OFFSET: usize = 4;
const SCHEMA_OFFSET: usize = 5;
const FIELD_COUNT_OFFSET: usize = 21;
const ENTRIES_OFFSET: usize = 23;
/// Current format of the generic scalar snapshot payload.
pub const SCALAR_SNAPSHOT_VERSION: u8 = 1;
/// Bytes preceding the first scalar entry.
pub const SCALAR_SNAPSHOT_HEADER_LENGTH: usize = ENTRIES_OFFSET;
/// Fixed bytes occupied by one scalar entry.
pub const SCALAR_SNAPSHOT_ENTRY_LENGTH: usize = 11;
/// Returns the exact encoded length for a declared number of scalar fields.
///
/// The `u16` field-count domain makes this arithmetic safe on all targets
/// supported by this crate, including 32-bit microcontrollers.
pub const fn scalar_snapshot_length(field_count: u16) -> usize {
SCALAR_SNAPSHOT_HEADER_LENGTH + field_count as usize * SCALAR_SNAPSHOT_ENTRY_LENGTH
}
/// A broken [`ConfigModel`] registry detected before encoding or decoding.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum SnapshotModelError {
/// `field_count` includes an index with no descriptor.
MissingDescriptor(u16),
/// A descriptor exists at the first index after `field_count`.
ExtraDescriptor(u16),
/// Two descriptor indexes expose the same stable field identity.
DuplicateDescriptor(FieldId),
/// A descriptor has no readable value in the supplied candidate.
MissingValue(FieldId),
/// A candidate returned a scalar type different from its descriptor.
WrongValueKind {
/// Descriptor identity.
field: FieldId,
/// Type declared by the descriptor.
expected: ValueKind,
/// Type returned by the candidate.
actual: ValueKind,
},
}
/// Failure while encoding a candidate into caller-owned storage.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum ScalarSnapshotEncodeError {
/// The destination cannot hold the complete canonical payload.
BufferTooSmall {
/// Exact number of bytes required.
required: usize,
/// Number of bytes supplied by the caller.
available: usize,
},
/// The model's descriptor/value contract is internally inconsistent.
InvalidModel(SnapshotModelError),
}
/// Failure while decoding an untrusted scalar snapshot.
#[derive(Clone, Copy, Debug, PartialEq, KaptanError)]
pub enum ScalarSnapshotDecodeError {
/// The input ends before the complete header or declared field set.
Truncated {
/// Exact number of bytes required once it is known.
required: usize,
/// Number of bytes supplied by the caller.
available: usize,
},
/// Bytes remain after the exact declared field set.
TrailingData {
/// Exact number of bytes required.
expected: usize,
/// Number of bytes supplied by the caller.
actual: usize,
},
/// The payload does not carry the scalar-snapshot magic.
InvalidMagic,
/// The payload uses an unsupported scalar-snapshot format.
UnsupportedVersion(u8),
/// The payload belongs to another configuration schema.
WrongSchema {
/// Schema compiled into the target model.
expected: [u8; 16],
/// Schema carried by the payload.
actual: [u8; 16],
},
/// The payload is not a complete snapshot for this schema.
WrongFieldCount {
/// Exact number of fields compiled into the target model.
expected: u16,
/// Number declared by the payload.
actual: u16,
},
/// The target model's descriptor registry is internally inconsistent.
InvalidModel(SnapshotModelError),
/// An entry used a reserved stable field identity.
InvalidFieldId(u16),
/// An entry names no field in the target schema.
UnknownField(FieldId),
/// The complete payload identifies one field more than once.
DuplicateField(FieldId),
/// The complete payload does not identify a required schema field.
MissingField(FieldId),
/// An entry contains no supported scalar-kind discriminator.
InvalidValueKind(u8),
/// An entry's scalar kind differs from the target descriptor.
WrongValueKind {
/// Entry identity.
field: FieldId,
/// Type declared by the target descriptor.
expected: ValueKind,
/// Type carried by the payload.
actual: ValueKind,
},
/// Scalar bits are not canonical for the entry's declared kind.
InvalidValueBits {
/// Entry identity.
field: FieldId,
/// Scalar kind whose representation was invalid.
kind: ValueKind,
},
/// The model rejected an otherwise structurally valid scalar write.
InvalidWrite(FieldWriteError),
}
/// Encodes a complete [`ConfigModel`] candidate into a caller-owned buffer.
///
/// The returned slice covers only the initialized payload; a larger caller
/// buffer is permitted. No partial payload is returned on failure.
pub fn encode_scalar_snapshot<'a, C: ConfigModel>(
candidate: &C,
output: &'a mut [u8],
) -> Result<&'a [u8], ScalarSnapshotEncodeError> {
validate_model::<C>().map_err(ScalarSnapshotEncodeError::InvalidModel)?;
let field_count = C::field_count();
let required = scalar_snapshot_length(field_count);
if output.len() < required {
return Err(ScalarSnapshotEncodeError::BufferTooSmall {
required,
available: output.len(),
});
}
output[MAGIC_OFFSET..VERSION_OFFSET].copy_from_slice(&MAGIC);
output[VERSION_OFFSET] = SCALAR_SNAPSHOT_VERSION;
output[SCHEMA_OFFSET..FIELD_COUNT_OFFSET].copy_from_slice(&C::SCHEMA_ID);
output[FIELD_COUNT_OFFSET..ENTRIES_OFFSET].copy_from_slice(&field_count.to_le_bytes());
for index in 0..field_count {
let descriptor = C::descriptor(index).ok_or(ScalarSnapshotEncodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(index),
))?;
let value =
candidate
.field_value(descriptor.id)
.ok_or(ScalarSnapshotEncodeError::InvalidModel(
SnapshotModelError::MissingValue(descriptor.id),
))?;
if value.kind() != descriptor.kind {
return Err(ScalarSnapshotEncodeError::InvalidModel(
SnapshotModelError::WrongValueKind {
field: descriptor.id,
expected: descriptor.kind,
actual: value.kind(),
},
));
}
let entry = entry_offset(index);
output[entry..entry + 2].copy_from_slice(&descriptor.id.get().to_le_bytes());
output[entry + 2] = descriptor.kind as u8;
output[entry + 3..entry + SCALAR_SNAPSHOT_ENTRY_LENGTH]
.copy_from_slice(&value.raw_bits().to_le_bytes());
}
Ok(&output[..required])
}
/// Decodes a complete scalar snapshot into a clone of `factory`.
///
/// Structural validation completes before any field setter runs. Each write
/// then passes through [`ConfigModel::set_field`], preserving the model's local
/// validation. Whole-candidate capability validation remains the composition
/// owner's responsibility because its context is deliberately not part of a
/// storage codec.
pub fn decode_scalar_snapshot<C: ConfigModel>(
input: &[u8],
factory: &C,
) -> Result<C, ScalarSnapshotDecodeError> {
validate_model::<C>().map_err(ScalarSnapshotDecodeError::InvalidModel)?;
if input.len() < SCALAR_SNAPSHOT_HEADER_LENGTH {
return Err(ScalarSnapshotDecodeError::Truncated {
required: SCALAR_SNAPSHOT_HEADER_LENGTH,
available: input.len(),
});
}
if input[MAGIC_OFFSET..VERSION_OFFSET] != MAGIC {
return Err(ScalarSnapshotDecodeError::InvalidMagic);
}
if input[VERSION_OFFSET] != SCALAR_SNAPSHOT_VERSION {
return Err(ScalarSnapshotDecodeError::UnsupportedVersion(
input[VERSION_OFFSET],
));
}
let mut actual_schema = [0_u8; 16];
actual_schema.copy_from_slice(&input[SCHEMA_OFFSET..FIELD_COUNT_OFFSET]);
if actual_schema != C::SCHEMA_ID {
return Err(ScalarSnapshotDecodeError::WrongSchema {
expected: C::SCHEMA_ID,
actual: actual_schema,
});
}
let actual_count = read_u16(&input[FIELD_COUNT_OFFSET..ENTRIES_OFFSET]);
let expected_count = C::field_count();
if actual_count != expected_count {
return Err(ScalarSnapshotDecodeError::WrongFieldCount {
expected: expected_count,
actual: actual_count,
});
}
let expected_length = scalar_snapshot_length(actual_count);
if input.len() < expected_length {
return Err(ScalarSnapshotDecodeError::Truncated {
required: expected_length,
available: input.len(),
});
}
if input.len() > expected_length {
return Err(ScalarSnapshotDecodeError::TrailingData {
expected: expected_length,
actual: input.len(),
});
}
validate_entries::<C>(input, actual_count)?;
let mut candidate = factory.clone();
for index in 0..actual_count {
let entry = entry_offset(index);
let id = FieldId::try_new(read_u16(&input[entry..entry + 2]))
.expect("entry identities were structurally validated");
let kind = decode_kind(input[entry + 2]).expect("entry kinds were structurally validated");
let bits = read_u64(&input[entry + 3..entry + SCALAR_SNAPSHOT_ENTRY_LENGTH]);
let value = decode_value(id, kind, bits)?;
candidate
.set_field(id, value)
.map_err(ScalarSnapshotDecodeError::InvalidWrite)?;
}
Ok(candidate)
}
fn validate_model<C: ConfigModel>() -> Result<(), SnapshotModelError> {
let count = C::field_count();
for index in 0..count {
let descriptor =
C::descriptor(index).ok_or(SnapshotModelError::MissingDescriptor(index))?;
for previous in 0..index {
let previous =
C::descriptor(previous).ok_or(SnapshotModelError::MissingDescriptor(previous))?;
if previous.id == descriptor.id {
return Err(SnapshotModelError::DuplicateDescriptor(descriptor.id));
}
}
}
if C::descriptor(count).is_some() {
return Err(SnapshotModelError::ExtraDescriptor(count));
}
Ok(())
}
fn validate_entries<C: ConfigModel>(
input: &[u8],
count: u16,
) -> Result<(), ScalarSnapshotDecodeError> {
for index in 0..count {
let entry = entry_offset(index);
let raw_id = read_u16(&input[entry..entry + 2]);
let id =
FieldId::try_new(raw_id).ok_or(ScalarSnapshotDecodeError::InvalidFieldId(raw_id))?;
let descriptor =
descriptor_by_id::<C>(id).ok_or(ScalarSnapshotDecodeError::UnknownField(id))?;
let raw_kind = input[entry + 2];
let kind =
decode_kind(raw_kind).ok_or(ScalarSnapshotDecodeError::InvalidValueKind(raw_kind))?;
if kind != descriptor.kind {
return Err(ScalarSnapshotDecodeError::WrongValueKind {
field: id,
expected: descriptor.kind,
actual: kind,
});
}
let bits = read_u64(&input[entry + 3..entry + SCALAR_SNAPSHOT_ENTRY_LENGTH]);
let _ = decode_value(id, kind, bits)?;
for previous in 0..index {
let previous_entry = entry_offset(previous);
if read_u16(&input[previous_entry..previous_entry + 2]) == raw_id {
return Err(ScalarSnapshotDecodeError::DuplicateField(id));
}
}
}
for descriptor_index in 0..count {
let descriptor =
C::descriptor(descriptor_index).ok_or(ScalarSnapshotDecodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(descriptor_index),
))?;
let mut present = false;
for entry_index in 0..count {
let entry = entry_offset(entry_index);
present |= read_u16(&input[entry..entry + 2]) == descriptor.id.get();
}
if !present {
return Err(ScalarSnapshotDecodeError::MissingField(descriptor.id));
}
}
Ok(())
}
fn descriptor_by_id<C: ConfigModel>(id: FieldId) -> Option<&'static crate::FieldDescriptor> {
for index in 0..C::field_count() {
let descriptor = C::descriptor(index)?;
if descriptor.id == id {
return Some(descriptor);
}
}
None
}
const fn entry_offset(index: u16) -> usize {
ENTRIES_OFFSET + index as usize * SCALAR_SNAPSHOT_ENTRY_LENGTH
}
const fn decode_kind(value: u8) -> Option<ValueKind> {
match value {
1 => Some(ValueKind::Bool),
2 => Some(ValueKind::I32),
3 => Some(ValueKind::U32),
4 => Some(ValueKind::U64),
5 => Some(ValueKind::F32),
6 => Some(ValueKind::F64),
_ => None,
}
}
fn decode_value(
field: FieldId,
kind: ValueKind,
bits: u64,
) -> Result<ConfigValue, ScalarSnapshotDecodeError> {
let invalid = || ScalarSnapshotDecodeError::InvalidValueBits { field, kind };
ConfigValue::try_from_raw_bits(kind, bits).ok_or_else(invalid)
}
fn read_u16(bytes: &[u8]) -> u16 {
u16::from_le_bytes([bytes[0], bytes[1]])
}
fn read_u64(bytes: &[u8]) -> u64 {
u64::from_le_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ConfigViolation, FieldDescriptor, ValidationRule};
const BOOL: FieldId = FieldId::new(1);
const I32: FieldId = FieldId::new(2);
const U32: FieldId = FieldId::new(3);
const U64: FieldId = FieldId::new(4);
const F32: FieldId = FieldId::new(5);
const F64: FieldId = FieldId::new(6);
const SCHEMA: [u8; 16] = *b"SCALAR-SNAPSHOT1";
const DESCRIPTORS: [FieldDescriptor; 6] = [
descriptor(BOOL, ValueKind::Bool),
descriptor(I32, ValueKind::I32),
descriptor(U32, ValueKind::U32),
descriptor(U64, ValueKind::U64),
descriptor(F32, ValueKind::F32),
descriptor(F64, ValueKind::F64),
];
const fn descriptor(id: FieldId, kind: ValueKind) -> FieldDescriptor {
FieldDescriptor {
id,
display_key: "test",
kind,
minimum: None,
maximum: None,
enum_options: &[],
unit: "",
}
}
#[derive(Clone, Debug, PartialEq)]
struct Candidate {
boolean: bool,
signed: i32,
unsigned: u32,
wide: u64,
float: f32,
double: f64,
}
impl Default for Candidate {
fn default() -> Self {
Self {
boolean: false,
signed: -1,
unsigned: 1,
wide: 1,
float: 1.0,
double: 1.0,
}
}
}
impl ConfigModel for Candidate {
type Active = Self;
type Context = ();
const SCHEMA_ID: [u8; 16] = SCHEMA;
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
DESCRIPTORS.get(usize::from(index))
}
fn field_count() -> u16 {
DESCRIPTORS.len() as u16
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
match id {
BOOL => Some(ConfigValue::Bool(self.boolean)),
I32 => Some(ConfigValue::I32(self.signed)),
U32 => Some(ConfigValue::U32(self.unsigned)),
U64 => Some(ConfigValue::U64(self.wide)),
F32 => Some(ConfigValue::F32(self.float)),
F64 => Some(ConfigValue::F64(self.double)),
_ => None,
}
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
active.field_value(id)
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
match (id, value) {
(BOOL, ConfigValue::Bool(value)) => self.boolean = value,
(I32, ConfigValue::I32(value)) => self.signed = value,
(U32, ConfigValue::U32(value)) if value <= 100 => self.unsigned = value,
(U64, ConfigValue::U64(value)) => self.wide = value,
(F32, ConfigValue::F32(value)) if value.is_finite() => self.float = value,
(F64, ConfigValue::F64(value)) if value.is_finite() => self.double = value,
(U32, ConfigValue::U32(_)) => {
return Err(FieldWriteError::Violation(ConfigViolation::field(
U32,
ValidationRule::AboveMaximum,
)));
}
(F32, ConfigValue::F32(_)) => return Err(FieldWriteError::non_finite(F32)),
(F64, ConfigValue::F64(_)) => return Err(FieldWriteError::non_finite(F64)),
(_, value) if self.field_value(id).is_some() => {
return Err(FieldWriteError::WrongType {
field: id,
expected: self.field_value(id).expect("known field").kind(),
actual: value.kind(),
});
}
_ => return Err(FieldWriteError::UnknownField(id)),
}
Ok(())
}
fn validate(&self, _: &Self::Context) -> Result<Self::Active, ConfigViolation> {
Ok(self.clone())
}
}
fn encoded(candidate: &Candidate) -> ([u8; scalar_snapshot_length(6)], usize) {
let mut bytes = [0xa5; scalar_snapshot_length(6)];
let length = encode_scalar_snapshot(candidate, &mut bytes)
.expect("candidate encodes")
.len();
(bytes, length)
}
fn entry(index: u16) -> usize {
entry_offset(index)
}
#[test]
fn round_trips_every_scalar_kind_and_uses_exact_length() {
let candidate = Candidate {
boolean: true,
signed: i32::MIN,
unsigned: 99,
wide: u64::MAX,
float: -0.0,
double: f64::MIN_POSITIVE,
};
let mut storage = [0xa5; scalar_snapshot_length(6) + 7];
let payload = encode_scalar_snapshot(&candidate, &mut storage).unwrap();
assert_eq!(payload.len(), scalar_snapshot_length(6));
assert_eq!(&payload[..4], b"KCFG");
assert_eq!(payload[4], SCALAR_SNAPSHOT_VERSION);
assert_eq!(&payload[5..21], &SCHEMA);
assert_eq!(read_u16(&payload[21..23]), 6);
assert_eq!(
decode_scalar_snapshot(payload, &Candidate::default()),
Ok(candidate)
);
}
#[test]
fn encoder_rejects_a_short_destination_without_writing_it() {
let required = scalar_snapshot_length(6);
let mut output = [0xa5; scalar_snapshot_length(6) - 1];
assert_eq!(
encode_scalar_snapshot(&Candidate::default(), &mut output),
Err(ScalarSnapshotEncodeError::BufferTooSmall {
required,
available: required - 1,
})
);
assert!(output.iter().all(|byte| *byte == 0xa5));
}
#[test]
fn decoder_rejects_every_truncation_and_trailing_data() {
let (bytes, length) = encoded(&Candidate::default());
for end in 0..length {
assert!(decode_scalar_snapshot(&bytes[..end], &Candidate::default()).is_err());
}
let mut trailing = [0_u8; scalar_snapshot_length(6) + 1];
trailing[..length].copy_from_slice(&bytes);
assert_eq!(
decode_scalar_snapshot(&trailing, &Candidate::default()),
Err(ScalarSnapshotDecodeError::TrailingData {
expected: length,
actual: length + 1,
})
);
}
#[test]
fn decoder_rejects_wrong_magic_version_schema_and_count() {
let (source, _) = encoded(&Candidate::default());
let mut bytes = source;
bytes[0] ^= 1;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::InvalidMagic)
);
let mut bytes = source;
bytes[VERSION_OFFSET] += 1;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::UnsupportedVersion(2))
);
let mut bytes = source;
bytes[SCHEMA_OFFSET] ^= 1;
let mut actual = SCHEMA;
actual[0] ^= 1;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::WrongSchema {
expected: SCHEMA,
actual,
})
);
let mut bytes = source;
bytes[FIELD_COUNT_OFFSET..ENTRIES_OFFSET].copy_from_slice(&5_u16.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::WrongFieldCount {
expected: 6,
actual: 5,
})
);
}
#[test]
fn decoder_rejects_invalid_unknown_duplicate_and_missing_ids() {
let (source, _) = encoded(&Candidate::default());
let mut bytes = source;
bytes[entry(0)..entry(0) + 2].copy_from_slice(&0_u16.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::InvalidFieldId(0))
);
let mut bytes = source;
bytes[entry(0)..entry(0) + 2].copy_from_slice(&100_u16.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::UnknownField(FieldId::new(100)))
);
let mut bytes = source;
let first_id = bytes[entry(0)..entry(0) + 2].to_vec();
bytes[entry(1)..entry(1) + 2].copy_from_slice(&first_id);
bytes[entry(1) + 2] = ValueKind::Bool as u8;
bytes[entry(1) + 3..entry(1) + 11].copy_from_slice(&0_u64.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::DuplicateField(BOOL))
);
// With an exact count and only known IDs, a duplicate necessarily also
// omits a schema field. Rejecting it proves incomplete sets cannot pass.
}
#[test]
fn decoder_rejects_invalid_and_wrong_kinds_and_noncanonical_bits() {
let (source, _) = encoded(&Candidate::default());
let mut bytes = source;
bytes[entry(0) + 2] = 0xff;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::InvalidValueKind(0xff))
);
let mut bytes = source;
bytes[entry(0) + 2] = ValueKind::U32 as u8;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::WrongValueKind {
field: BOOL,
expected: ValueKind::Bool,
actual: ValueKind::U32,
})
);
let mut bytes = source;
bytes[entry(0) + 3..entry(0) + 11].copy_from_slice(&2_u64.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::InvalidValueBits {
field: BOOL,
kind: ValueKind::Bool,
})
);
let mut bytes = source;
bytes[entry(1) + 3..entry(1) + 11]
.copy_from_slice(&(u64::from(u32::MAX) + 1).to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::InvalidValueBits {
field: I32,
kind: ValueKind::I32,
})
);
}
#[test]
fn decoder_accepts_reordered_entries_because_ids_are_authoritative() {
let candidate = Candidate {
boolean: true,
signed: -72,
unsigned: 50,
wide: 987_654_321,
float: 0.25,
double: -19.5,
};
let (mut bytes, _) = encoded(&candidate);
for offset in 0..SCALAR_SNAPSHOT_ENTRY_LENGTH {
bytes.swap(entry(0) + offset, entry(5) + offset);
}
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Ok(candidate)
);
}
#[test]
fn decoder_routes_values_through_local_model_validation() {
let (mut bytes, _) = encoded(&Candidate::default());
bytes[entry(2) + 3..entry(2) + 11].copy_from_slice(&101_u64.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::InvalidWrite(
FieldWriteError::Violation(ConfigViolation::field(
U32,
ValidationRule::AboveMaximum,
))
))
);
}
struct MissingDescriptor;
impl Clone for MissingDescriptor {
fn clone(&self) -> Self {
Self
}
}
impl ConfigModel for MissingDescriptor {
type Active = ();
type Context = ();
const SCHEMA_ID: [u8; 16] = SCHEMA;
fn descriptor(_: u16) -> Option<&'static FieldDescriptor> {
None
}
fn field_count() -> u16 {
1
}
fn field_value(&self, _: FieldId) -> Option<ConfigValue> {
None
}
fn active_field_value(_: &Self::Active, _: FieldId) -> Option<ConfigValue> {
None
}
fn set_field(&mut self, id: FieldId, _: ConfigValue) -> Result<(), FieldWriteError> {
Err(FieldWriteError::UnknownField(id))
}
fn validate(&self, _: &Self::Context) -> Result<Self::Active, ConfigViolation> {
Ok(())
}
}
#[test]
fn encoder_and_decoder_reject_a_broken_model_registry() {
let mut output = [0_u8; scalar_snapshot_length(1)];
assert_eq!(
encode_scalar_snapshot(&MissingDescriptor, &mut output),
Err(ScalarSnapshotEncodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(0)
))
);
let (bytes, _) = encoded(&Candidate::default());
assert!(matches!(
decode_scalar_snapshot(&bytes, &MissingDescriptor),
Err(ScalarSnapshotDecodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(0)
))
));
}
}
+16 -33
View File
@@ -1,48 +1,31 @@
//! Persistence interface for complete validated candidate snapshots.
use core::num::NonZeroU64;
/// Storage-owned monotonically increasing committed snapshot generation.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Generation(NonZeroU64);
impl Generation {
/// Creates a generation supplied only by a persistence backend.
pub const fn new(value: u64) -> Self {
match NonZeroU64::new(value) {
Some(value) => Self(value),
None => panic!("configuration generation must be nonzero"),
}
}
/// Parses an on-disk or wire generation, rejecting the factory sentinel.
pub const fn try_new(value: u64) -> Option<Self> {
match NonZeroU64::new(value) {
Some(value) => Some(Self(value)),
None => None,
}
}
/// Returns the committed generation value.
pub const fn get(self) -> u64 {
self.0.get()
}
}
/// Durable storage of complete configuration snapshots.
///
/// Flight implementations use a two-slot journal: a record contains a header,
/// bounded CBOR payload, integrity check, and a commit marker written last.
/// bounded payload, integrity check, and a commit marker written last.
/// A backend must retain its previous valid record until the replacement is
/// fully verified. Host file stores may implement the same interface without
/// claiming flash power-loss semantics.
pub trait ProfileStore<C> {
pub trait ConfigStore<C> {
/// Backend-specific I/O or integrity failure.
type Error;
/// Loads the newest valid committed candidate, if one exists.
fn load(&mut self) -> Result<Option<(Generation, C)>, Self::Error>;
fn load(&mut self) -> Result<Option<C>, Self::Error>;
/// Loads the newest structurally valid candidate accepted by `compatible`.
///
/// Single-record stores inherit this implementation. Journals should
/// override it so an incompatible newest record can fall back to an older
/// compatible slot without weakening structural integrity checks.
fn load_matching<F>(&mut self, mut compatible: F) -> Result<Option<C>, Self::Error>
where
F: FnMut(&C) -> bool,
{
Ok(self.load()?.filter(|candidate| compatible(candidate)))
}
/// Persists one complete already-validated candidate atomically.
fn persist(&mut self, candidate: &C) -> Result<Generation, Self::Error>;
fn persist(&mut self, candidate: &C) -> Result<(), Self::Error>;
}
+184 -48
View File
@@ -1,63 +1,199 @@
//! Local scalar configuration sections implemented by the derive macro.
//! Generated configuration trees.
use crate::{ConfigValue, ConfigViolation, FieldId, FieldWriteError};
use crate::{ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError};
/// Validates local scalar invariants of one record.
/// One recursively composable configuration tree.
///
/// The derive macro handles finite, nonzero, and inclusive range checks.
/// Cross-field, capability, calibration, and lifecycle invariants belong in a
/// handwritten post-validation hook on the owning vehicle schema.
pub trait ValidateFields {
/// Rejects the first violated scalar or declared post-validation rule.
fn validate_fields(&self) -> Result<(), ConfigViolation>;
}
/// A complete scalar configuration section generated from `#[config(...)]`
/// field attributes.
///
/// Its metadata, default construction, reads, local writes, and scalar
/// validation are all produced from the same field declarations. A section
/// setter never commits a locally invalid value. Cross-section and capability
/// invariants deliberately remain the aggregate candidate's responsibility.
pub trait ConfigSection: ValidateFields {
/// Number of scalar descriptors generated for this section.
/// `#[derive(kaptan_config::Config)]` implements this contract for both scalar
/// records and records composed from other configuration trees. Validation,
/// metadata, field routing, and schema identity therefore come from one source.
pub trait Config {
/// Number of scalar fields in the flattened tree.
const FIELD_COUNT: usize;
/// Builds the section from derive-generated defaults after validating it.
fn default_section() -> Result<Self, ConfigViolation>
where
Self: Sized;
/// Fingerprint generated from the complete declared schema.
const SCHEMA_ID: [u8; 16];
/// Returns a descriptor from the flattened declaration order.
fn descriptor(index: usize) -> Option<&'static FieldDescriptor>;
/// Returns one scalar by stable field identity.
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
/// Applies one scalar only when this section owns the ID and its local
/// generated validation accepts the new value.
/// Applies one scalar only when its owning record accepts the local value.
/// Whole-tree relational validation is intentionally deferred until the
/// candidate is validated or committed.
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
/// Validates only generated scalar invariants. This intentionally omits
/// a derive `post` hook so staged multi-field edits can remain temporarily
/// cross-field-invalid until aggregate validation/commit.
fn validate_local_fields(&self) -> Result<(), ConfigViolation>;
/// Validates every generated rule and declared post-validation hook.
fn validate(&self) -> Result<(), ConfigViolation>;
}
/// An ordered aggregate of generated configuration sections.
/// Detects duplicate flattened field identities in a generated tree.
#[doc(hidden)]
pub fn __unique_field_ids<C: Config>() -> bool {
let mut index = 0;
while index < C::FIELD_COUNT {
let Some(descriptor) = C::descriptor(index) else {
return false;
};
let mut other = index + 1;
while other < C::FIELD_COUNT {
let Some(other_descriptor) = C::descriptor(other) else {
return false;
};
if descriptor.id == other_descriptor.id {
return false;
}
other += 1;
}
index += 1;
}
C::descriptor(C::FIELD_COUNT).is_none()
}
/// Checks display-key uniqueness across one recursively composed schema.
#[doc(hidden)]
pub fn __unique_display_keys<C: Config>() -> bool {
let mut index = 0;
while index < C::FIELD_COUNT {
let Some(descriptor) = C::descriptor(index) else {
return false;
};
let mut other = index + 1;
while other < C::FIELD_COUNT {
let Some(other_descriptor) = C::descriptor(other) else {
return false;
};
if descriptor.display_key == other_descriptor.display_key {
return false;
}
other += 1;
}
index += 1;
}
true
}
/// Validates the bounded, self-describing enum portion of generated metadata.
#[doc(hidden)]
pub fn __valid_enum_options(descriptors: &[FieldDescriptor]) -> bool {
for descriptor in descriptors {
if descriptor.enum_options.len() > crate::MAX_ENUM_OPTIONS_PER_FIELD {
return false;
}
for (index, option) in descriptor.enum_options.iter().enumerate() {
if option.1.is_empty()
|| option.1.len() > crate::MAX_ENUM_OPTION_LABEL_BYTES
|| descriptor.kind != crate::ValueKind::U32
{
return false;
}
if descriptor.enum_options[..index]
.iter()
.any(|previous| previous.0 == option.0)
{
return false;
}
}
}
true
}
const FINGERPRINT_LEFT_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const FINGERPRINT_RIGHT_OFFSET: u64 = 0x6c62_272e_07bb_0142;
const FINGERPRINT_LEFT_PRIME: u64 = 0x0000_0100_0000_01b3;
const FINGERPRINT_RIGHT_PRIME: u64 = 0x9e37_79b1_85eb_ca87;
const fn hash_byte(mut state: [u64; 2], byte: u8) -> [u64; 2] {
state[0] = (state[0] ^ byte as u64).wrapping_mul(FINGERPRINT_LEFT_PRIME);
state[1] = (state[1] ^ byte as u64).wrapping_mul(FINGERPRINT_RIGHT_PRIME);
state
}
const fn hash_u64(mut state: [u64; 2], value: u64) -> [u64; 2] {
let bytes = value.to_le_bytes();
let mut index = 0;
while index < bytes.len() {
state = hash_byte(state, bytes[index]);
index += 1;
}
state
}
const fn hash_str(mut state: [u64; 2], value: &str) -> [u64; 2] {
state = hash_u64(state, value.len() as u64);
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
state = hash_byte(state, bytes[index]);
index += 1;
}
state
}
const fn hash_value(mut state: [u64; 2], value: Option<ConfigValue>) -> [u64; 2] {
let Some(value) = value else {
return hash_byte(state, 0);
};
state = hash_byte(state, value.kind() as u8);
hash_u64(state, value.raw_bits())
}
const fn finish_fingerprint(state: [u64; 2]) -> [u8; 16] {
let left = state[0].to_le_bytes();
let right = state[1].to_le_bytes();
[
left[0], left[1], left[2], left[3], left[4], left[5], left[6], left[7], right[0], right[1],
right[2], right[3], right[4], right[5], right[6], right[7],
]
}
/// Produces the stable schema fingerprint for one scalar record.
#[doc(hidden)]
pub const fn __schema_id(descriptors: &[FieldDescriptor]) -> [u8; 16] {
let mut state = [FINGERPRINT_LEFT_OFFSET, FINGERPRINT_RIGHT_OFFSET];
state = hash_u64(state, descriptors.len() as u64);
let mut index = 0;
while index < descriptors.len() {
let descriptor = descriptors[index];
state = hash_u64(state, descriptor.id.get() as u64);
state = hash_str(state, descriptor.display_key);
state = hash_byte(state, descriptor.kind as u8);
state = hash_value(state, descriptor.minimum);
state = hash_value(state, descriptor.maximum);
state = hash_u64(state, descriptor.enum_options.len() as u64);
let mut option = 0;
while option < descriptor.enum_options.len() {
state = hash_u64(state, descriptor.enum_options[option].0 as u64);
state = hash_str(state, descriptor.enum_options[option].1);
option += 1;
}
state = hash_str(state, descriptor.unit);
index += 1;
}
finish_fingerprint(state)
}
/// Produces a parent fingerprint from ordered child fingerprints.
///
/// The derive owns routing/default construction so adding a field to an
/// existing section cannot silently bypass local validation. The aggregate
/// owner still defines capability checks and immutable active construction.
pub trait ConfigAggregate: ValidateFields {
/// Builds and fully validates all nested sections from their derived
/// defaults.
fn default_aggregate() -> Result<Self, ConfigViolation>
where
Self: Sized;
/// Returns one nested scalar by stable field identity.
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
/// Routes one scalar write to its owning generated section. The setter
/// applies local validation only, so a transaction may temporarily hold a
/// cross-field-invalid multi-edit candidate.
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
/// A one-child wrapper is deliberately transparent, so adding or removing a
/// redundant structural wrapper does not invalidate persisted values.
#[doc(hidden)]
pub const fn __combine_schema_ids(children: &[[u8; 16]]) -> [u8; 16] {
if children.len() == 1 {
return children[0];
}
let mut state = [FINGERPRINT_LEFT_OFFSET, FINGERPRINT_RIGHT_OFFSET];
state = hash_u64(state, children.len() as u64);
let mut child = 0;
while child < children.len() {
let mut byte = 0;
while byte < children[child].len() {
state = hash_byte(state, children[child][byte]);
byte += 1;
}
child += 1;
}
finish_fingerprint(state)
}
+1 -1
View File
@@ -292,7 +292,7 @@ impl RatePid {
/// Position, velocity, and acceleration reference in the local NED frame.
///
/// The reference is generated continuously. A caller must not replace it with
/// a position step merely because an operator requested a distant goal.
/// a position step merely because a user requested a distant goal.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinematicReference {
/// Local NED position in metres.
@@ -9,7 +9,6 @@ version.workspace = true
workspace = true
[dependencies]
kaptan-board = { path = "../kaptan-board" }
kaptan-log = { path = "../kaptan-log" }
kaptan-dma = { path = "../kaptan-dma" }
kaptan-mavlink = { path = "../kaptan-mavlink" }
+52 -155
View File
@@ -7,16 +7,14 @@
#![no_std]
use kaptan_board::{BoardConfig, DataChannelRates};
use kaptan_dma::TxCompletion;
use kaptan_log::KaptanError;
use kaptan_mavlink::message::MessageData;
use kaptan_mavlink::{
MavlinkFrame,
encoder::{DistanceSensorConfig, MavlinkEncoder},
message::{MavCmd, MavResult, MavState},
transmitter::FrameClass,
transmitter::TransmitFaultKind,
message::MavState,
transmitter::{EnqueueResult, FrameClass},
};
use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
@@ -24,84 +22,36 @@ mod mailbox;
pub use mailbox::{LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox};
/// Validated publication schedule consumed by the outbound data channel.
///
/// This is a derived runtime input, not a persisted configuration root. Board
/// packages own the factory values and the platform constructs this view only
/// after validating the active user configuration against immutable hardware.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DataChannelRates {
/// Frequency of the platform service tick.
pub service_hz: usize,
/// Raw-IMU publication rate.
pub raw_imu_hz: usize,
/// Range publication rate; `None` disables the stream.
pub range_hz: Option<usize>,
/// Optical-flow publication rate; `None` disables the stream.
pub optical_flow_hz: Option<usize>,
/// Heartbeat publication rate.
pub heartbeat_hz: usize,
}
/// Platform transport used by a data channel.
///
/// The channel decides exactly which encoded frame is admitted and with which
/// delivery class. The platform retains only DMA endpoint ownership.
pub trait FrameSink {
/// Admits one fully encoded frame to the platform's bounded transport.
fn send(&mut self, class: FrameClass, frame: MavlinkFrame) -> FrameAdmission;
fn send(&mut self, class: FrameClass, frame: MavlinkFrame) -> EnqueueResult;
/// Releases or faults the active DMA frame after a terminal interrupt.
fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion;
/// Returns unconfigurable sole-link loss counters.
fn health(&self) -> TxHealth;
/// Removes one exact terminal transport-fault category for the central
/// observability owner. It is never encoded from an ISR or transport
/// driver directly.
fn take_transport_fault(&mut self) -> Option<TransmitFaultKind>;
}
/// Outcome returned to the one platform telemetry owner for each attempted
/// frame admission. It never triggers logging recursively.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FrameAdmission {
/// The queue retained this encoded frame, possibly after replacing a
/// pending lower-priority frame.
Retained(kaptan_mavlink::transmitter::EnqueueResult),
}
impl FrameAdmission {
/// Whether the frame is represented by the lower transport and the log
/// hub may acknowledge its source record.
pub const fn is_retained(self) -> bool {
matches!(
self,
Self::Retained(
kaptan_mavlink::transmitter::EnqueueResult::Enqueued
| kaptan_mavlink::transmitter::EnqueueResult::ReplacedBestEffort
| kaptan_mavlink::transmitter::EnqueueResult::ReplacedCritical
)
)
}
/// Whether no pending queue slot accepted the source frame.
pub const fn is_queue_full(self) -> bool {
matches!(
self,
Self::Retained(
kaptan_mavlink::transmitter::EnqueueResult::VitalQueueFull
| kaptan_mavlink::transmitter::EnqueueResult::CriticalQueueFull
| kaptan_mavlink::transmitter::EnqueueResult::BestEffortDropped
)
)
}
}
/// Terminal outcome from the sole DMA owner.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DmaCompletion {
/// No new transport fault was observed.
Complete,
/// A DMA terminal or restart fault was counted by the platform.
Fault,
}
/// Unconfigurable loss counters from sole-link admission/DMA ownership.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TxHealth {
/// Dropped or displaced pending frames, including vital admission loss.
pub admission_loss_count: u32,
/// DMA preparation/start/terminal transfer faults.
pub dma_fault_count: u32,
/// DMA-visible memory preparation faults.
pub memory_fault_count: u32,
/// DMA-engine transfer-start faults.
pub engine_fault_count: u32,
/// Active-DMA terminal faults or halts.
pub terminal_fault_count: u32,
/// Applies one terminal DMA interrupt and starts the next retained frame
/// when possible.
fn on_dma_complete(&mut self, completion: TxCompletion);
}
/// Mailboxes read by one data-channel worker.
@@ -129,8 +79,6 @@ pub struct VerticalDataConfig {
/// Data-channel construction failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum DataChannelError {
/// Rejected profiles must not construct a physical telemetry channel.
NotTelemetryProfile,
/// A configured channel must be due at least once per second.
ZeroRate,
/// A configured channel exceeds the service frequency.
@@ -159,20 +107,19 @@ impl<'a, Sink, const SERVICE_HZ: usize> DataChannel<'a, Sink, SERVICE_HZ>
where
Sink: FrameSink,
{
/// Creates an outbound data flow from validated portable board metadata.
/// Creates an outbound data flow from already-validated runtime inputs.
pub fn new(
sink: Sink,
config: BoardConfig,
system_id: u8,
component_id: u8,
rates: DataChannelRates,
vertical: VerticalDataConfig,
sources: DataSources<'a>,
) -> Result<Self, DataChannelError> {
let telemetry = config
.telemetry()
.ok_or(DataChannelError::NotTelemetryProfile)?;
let schedule = Schedule::from_rates(telemetry.data_channel)?;
let schedule = Schedule::from_rates(rates)?;
Ok(Self {
sink,
encoder: MavlinkEncoder::new(config.mavlink.system_id, config.mavlink.component_id),
encoder: MavlinkEncoder::new(system_id, component_id),
schedule,
sources,
vertical,
@@ -234,41 +181,16 @@ where
}
/// Completes the active DMA lifecycle for this channel's transport.
pub fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion {
pub fn on_dma_complete(&mut self, completion: TxCompletion) {
self.sink.on_dma_complete(completion)
}
/// Emits a critical acknowledgement for a received MAVLink command.
pub fn acknowledge_command(&mut self, command: MavCmd, result: MavResult) {
let _ = self.sink.send(
FrameClass::Critical,
self.encoder.command_ack(command, result),
);
}
/// Encodes an adapter-owned generated MAVLink data record through the
/// channel's sole encoder and bounded transport sink.
pub fn send_custom<M: MessageData>(
&mut self,
class: FrameClass,
message: &M,
) -> FrameAdmission {
pub fn send_custom<M: MessageData>(&mut self, class: FrameClass, message: &M) -> EnqueueResult {
self.sink.send(class, self.encoder.custom(message))
}
/// Returns local sole-link accounting for platform diagnostics. It is not
/// serialized through Kaptan's bounded diagnostic messages.
pub fn transport_health(&self) -> TxHealth {
self.sink.health()
}
/// Removes one exact sole-link transport fault category after the platform
/// has counted it. The platform maps it to a typed log record on its
/// bounded telemetry worker.
pub fn take_transport_fault(&mut self) -> Option<TransmitFaultKind> {
self.sink.take_transport_fault()
}
fn refresh_vertical_data(&mut self) {
if let Some(range) = self.sources.range.take_newer(&mut self.observed_range) {
self.last_range = Some(range);
@@ -371,17 +293,15 @@ impl<const SERVICE_HZ: usize> Schedule<SERVICE_HZ> {
#[cfg(test)]
mod tests {
use super::{
DataChannel, DataSources, DmaCompletion, FrameAdmission, FrameSink, LatestImuMailbox,
LatestOpticalFlowMailbox, LatestRangeMailbox, TransmitFaultKind, TxHealth,
VerticalDataConfig,
};
use kaptan_board::{
BoardConfig, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
VerticalSensingKind,
DataChannel, DataChannelRates, DataSources, FrameSink, LatestImuMailbox,
LatestOpticalFlowMailbox, LatestRangeMailbox, VerticalDataConfig,
};
use kaptan_dma::TxCompletion;
use kaptan_log::Severity;
use kaptan_mavlink::{MavlinkFrame, message::MavState, transmitter::FrameClass};
use kaptan_mavlink::{
MavlinkFrame,
message::MavState,
transmitter::{EnqueueResult, FrameClass},
};
use kaptan_sensor::{
imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot,
};
@@ -394,50 +314,26 @@ mod tests {
}
impl FrameSink for Sink {
fn send(&mut self, class: FrameClass, _: MavlinkFrame) -> FrameAdmission {
fn send(&mut self, class: FrameClass, _: MavlinkFrame) -> EnqueueResult {
match class {
FrameClass::Vital => self.critical += 1,
FrameClass::BestEffort => self.best_effort += 1,
FrameClass::Critical => self.critical += 1,
}
FrameAdmission::Retained(kaptan_mavlink::transmitter::EnqueueResult::Enqueued)
EnqueueResult::Enqueued
}
fn on_dma_complete(&mut self, _: TxCompletion) -> DmaCompletion {
fn on_dma_complete(&mut self, _: TxCompletion) {
self.completions += 1;
DmaCompletion::Complete
}
fn health(&self) -> TxHealth {
TxHealth::default()
}
fn take_transport_fault(&mut self) -> Option<TransmitFaultKind> {
None
}
}
const CONFIG: BoardConfig = BoardConfig {
name: "test",
mavlink: MavlinkEndpoint {
system_id: 1,
component_id: 1,
port: MavlinkPort::Serial1,
baud: 115_200,
},
profile: kaptan_board::BoardProfile::TelemetryOnly(kaptan_board::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,
}),
const RATES: DataChannelRates = DataChannelRates {
service_hz: 100,
raw_imu_hz: 20,
range_hz: Some(20),
optical_flow_hz: Some(20),
heartbeat_hz: 2,
};
#[test]
@@ -451,7 +347,9 @@ mod tests {
critical: 0,
completions: 0,
},
CONFIG,
1,
1,
RATES,
VerticalDataConfig {
distance: Some(kaptan_mavlink::encoder::DistanceSensorConfig {
sensor_id: 0,
@@ -488,7 +386,6 @@ mod tests {
channel.on_service_tick(MavState::MAV_STATE_STANDBY, false);
}
channel.on_dma_complete(TxCompletion::Complete);
let sink = channel.sink;
assert_eq!(sink.best_effort, 60);
assert_eq!(sink.critical, 2);
@@ -13,17 +13,6 @@ pub struct Mpu6000Config {
pub sample_rate_divider: u8,
}
impl Default for Mpu6000Config {
fn default() -> Self {
Self {
accelerometer: AccelerometerRange::PlusMinus4G,
gyroscope: GyroscopeRange::PlusMinus1000DegreesPerSecond,
digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz,
sample_rate_divider: 9,
}
}
}
/// MPU6000 digital low-pass filter bandwidth selection.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DigitalLowPassFilter {
@@ -232,10 +232,20 @@ impl<Bus: SpiDevice> Mpu6000<Bus> {
#[cfg(test)]
mod tests {
use super::{Error, Mpu6000, Mpu6000Config, Mpu6000Event, TRANSFER_CAPACITY};
use super::{
AccelerometerRange, DigitalLowPassFilter, Error, GyroscopeRange, Mpu6000, Mpu6000Config,
Mpu6000Event, TRANSFER_CAPACITY,
};
use kaptan_spi::{SpiCompletion, SpiDevice};
use kaptan_time::Timestamp;
const TEST_CONFIG: Mpu6000Config = Mpu6000Config {
accelerometer: AccelerometerRange::PlusMinus4G,
gyroscope: GyroscopeRange::PlusMinus1000DegreesPerSecond,
digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz,
sample_rate_divider: 9,
};
#[derive(Debug, Eq, PartialEq)]
enum FakeError {
Start,
@@ -277,7 +287,7 @@ mod tests {
fn configures_a_verified_device_without_waiting() {
let mut bus = FakeBus::ready();
bus.receive[1] = 0x68;
let mut driver = Mpu6000::new(bus, Mpu6000Config::default());
let mut driver = Mpu6000::new(bus, TEST_CONFIG);
driver.begin_initialization().unwrap();
assert_eq!(
@@ -302,7 +312,7 @@ mod tests {
fn rejects_an_unexpected_identity() {
let mut bus = FakeBus::ready();
bus.receive[1] = 0x70;
let mut driver = Mpu6000::new(bus, Mpu6000Config::default());
let mut driver = Mpu6000::new(bus, TEST_CONFIG);
driver.begin_initialization().unwrap();
let _ = driver.on_transfer_complete(SpiCompletion::Complete);
@@ -322,7 +332,7 @@ mod tests {
];
let mut bus = FakeBus::ready();
bus.receive[1] = 0x68;
let mut driver = Mpu6000::new(bus, Mpu6000Config::default());
let mut driver = Mpu6000::new(bus, TEST_CONFIG);
driver.begin_initialization().unwrap();
let _ = driver.on_transfer_complete(SpiCompletion::Complete);
@@ -352,7 +362,7 @@ mod tests {
fn reports_a_route_transfer_fault() {
let mut bus = FakeBus::ready();
bus.receive[1] = 0x68;
let mut driver = Mpu6000::new(bus, Mpu6000Config::default());
let mut driver = Mpu6000::new(bus, TEST_CONFIG);
driver.begin_initialization().unwrap();
assert!(matches!(
@@ -89,10 +89,19 @@ where
#[cfg(test)]
mod tests {
use super::{Mpu6000Service, Mpu6000ServiceEvent};
use crate::mpu6000::{Mpu6000, Mpu6000Config};
use crate::mpu6000::{
AccelerometerRange, DigitalLowPassFilter, GyroscopeRange, Mpu6000, Mpu6000Config,
};
use kaptan_spi::{SpiCompletion, SpiDevice};
use kaptan_time::{MonotonicClock, Timestamp};
const TEST_CONFIG: Mpu6000Config = Mpu6000Config {
accelerometer: AccelerometerRange::PlusMinus4G,
gyroscope: GyroscopeRange::PlusMinus1000DegreesPerSecond,
digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz,
sample_rate_divider: 9,
};
struct Clock;
impl MonotonicClock for Clock {
@@ -120,10 +129,7 @@ mod tests {
fn accepts_a_sample_only_after_initialization() {
let mut response = [0; 15];
response[1] = 0x68;
let mut service = Mpu6000Service::new(
Clock,
Mpu6000::new(Bus { response }, Mpu6000Config::default()),
);
let mut service = Mpu6000Service::new(Clock, Mpu6000::new(Bus { response }, TEST_CONFIG));
service.begin().unwrap();
assert!(matches!(service.on_data_ready(), Mpu6000ServiceEvent::Idle));
+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)
)]
+83 -54
View File
@@ -1,45 +1,48 @@
//! Central, transport-neutral flight-runtime composition.
//!
//! This crate owns the one configuration manager and one bounded log hub for
//! an image. MAVLink adapters receive mutable borrows for bounded dispatch or
//! draining, but neither can own configuration lifecycle or link state.
//! This crate owns the one configuration manager and the bounded queue for the
//! image's MAVLink diagnostic sink. MAVLink adapters receive mutable borrows
//! for bounded dispatch or draining, but cannot own configuration lifecycle or
//! link state.
#![no_std]
use kaptan_config::{ConfigManager, ProfileStore, StartupError};
use kaptan_config::{ConfigManager, ConfigStore, StartupError};
use kaptan_flight_config::{CandidateProfile, VehicleCapabilities};
use kaptan_log::{EventHub, LogFlags, LogRecord, LogText, PublishOutcome, Severity};
use kaptan_log::{LogRecord, LogSinkQueue};
/// Default bounded allocation for central runtime text diagnostics. Error
/// records are partitioned from optional detail.
pub type RuntimeLogHub = EventHub<8, 16>;
/// Default bounded allocation for the MAVLink diagnostic sink. Error records
/// are partitioned from optional detail.
pub type RuntimeMavlinkLogQueue = LogSinkQueue<8, 16>;
/// One boot-active vehicle runtime. It deliberately has no MAVLink dependency.
pub struct FlightRuntime<S> {
configuration: ConfigManager<CandidateProfile, S>,
logs: RuntimeLogHub,
mavlink_logs: RuntimeMavlinkLogQueue,
}
impl<S> FlightRuntime<S>
where
S: ProfileStore<CandidateProfile>,
S: ConfigStore<CandidateProfile>,
{
/// Recovers the central manager, validates its boot-active snapshot, and
/// copies the restart-only observability policy into the bounded hub.
/// copies the restart-only MAVLink sink policy into its bounded queue.
pub fn recover(
factory: CandidateProfile,
capabilities: VehicleCapabilities,
store: S,
) -> Result<Self, StartupError<S::Error>> {
let configuration =
ConfigManager::recover(CandidateProfile::default(), capabilities, store)?;
let maximum_log_level = configuration.active().observability().maximum_log_level();
let logs = RuntimeLogHub::new(kaptan_log::LogFilter::new(
Severity::try_from_wire(maximum_log_level as u8)
.expect("validated flight profile has a MAVLink log severity"),
));
let configuration = ConfigManager::recover(factory, capabilities, store)?;
let logging = configuration.active().mavlink_logging();
let filter = if logging.enabled() {
kaptan_log::LogFilter::up_to(logging.maximum_severity())
} else {
kaptan_log::LogFilter::disabled()
};
let mavlink_logs = RuntimeMavlinkLogQueue::new(filter);
Ok(Self {
configuration,
logs,
mavlink_logs,
})
}
@@ -48,82 +51,108 @@ where
&self.configuration
}
/// Borrows the manager and hub together for a state-only protocol adapter.
/// This makes adapter ownership inversion unrepresentable in composition.
pub fn configuration_and_logs_mut(
&mut self,
) -> (&mut ConfigManager<CandidateProfile, S>, &mut RuntimeLogHub) {
(&mut self.configuration, &mut self.logs)
/// Mutably borrows the sole configuration manager for protocol dispatch.
pub fn configuration_mut(&mut self) -> &mut ConfigManager<CandidateProfile, S> {
&mut self.configuration
}
/// Publishes one preformatted bounded task record through the sole hub.
pub fn log(&mut self, severity: Severity, flags: LogFlags, message: LogText) -> PublishOutcome {
self.logs.publish_text(severity, flags, message)
/// Composition-owned fan-out boundary for one preformatted event.
/// A future blackbox adds its independent sink admission here.
pub fn publish_log_event(&mut self, record: LogRecord) {
let _ = self.mavlink_logs.publish_record(record);
}
/// Peeks the highest-priority pending text log for the sole telemetry link.
pub fn pending_report(&self) -> Option<LogRecord> {
self.logs.peek()
pub fn pending_mavlink_report(&self) -> Option<LogRecord> {
self.mavlink_logs.peek()
}
/// Submits the next record and removes it only after transport admission.
pub fn submit_next<R>(
pub fn submit_next_mavlink<R>(
&mut self,
submit: impl FnOnce(&LogRecord) -> (kaptan_log::TransportAdmission, R),
) -> Option<R> {
self.logs.submit_next(submit)
self.mavlink_logs.submit_next(submit)
}
/// Immutable boot-active log policy copied from the validated profile.
pub const fn log_filter(&self) -> kaptan_log::LogFilter {
self.logs.filter()
/// Immutable boot-active MAVLink log policy copied from the validated profile.
pub const fn mavlink_log_filter(&self) -> kaptan_log::LogFilter {
self.mavlink_logs.filter()
}
}
#[cfg(test)]
mod tests {
use kaptan_config::{Generation, ProfileStore};
use kaptan_log::{LogFlags, LogText, Severity};
use kaptan_config::{ConfigModel, ConfigStore, ConfigValue};
use kaptan_log::{LogFlags, LogRecord, LogText, Severity};
use super::FlightRuntime;
struct Store;
impl ProfileStore<kaptan_flight_config::CandidateProfile> for Store {
impl ConfigStore<kaptan_flight_config::CandidateProfile> for Store {
type Error = ();
fn load(
&mut self,
) -> Result<Option<(Generation, kaptan_flight_config::CandidateProfile)>, Self::Error>
{
fn load(&mut self) -> Result<Option<kaptan_flight_config::CandidateProfile>, Self::Error> {
Ok(None)
}
fn persist(
&mut self,
_: &kaptan_flight_config::CandidateProfile,
) -> Result<Generation, Self::Error> {
Ok(Generation::new(1))
) -> Result<(), Self::Error> {
Ok(())
}
}
#[test]
fn central_runtime_uses_the_active_profile_filter_and_owns_one_hub() {
let mut runtime =
FlightRuntime::recover(kaptan_flight_config::VehicleCapabilities::quad_x(), Store)
.expect("factory profile validates");
fn runtime_uses_the_active_mavlink_policy_and_owns_one_sink_queue() {
let mut runtime = FlightRuntime::recover(
kaptan_flight_config::CandidateProfile::factory(),
kaptan_flight_config::VehicleCapabilities::quad_x(),
Store,
)
.expect("factory configuration validates");
assert_eq!(
runtime
.configuration()
.active()
.observability()
.maximum_log_level(),
runtime.log_filter().maximum_severity() as u32
.mavlink_logging()
.maximum_severity(),
runtime
.mavlink_log_filter()
.maximum_severity()
.expect("factory enables MAVLink logs")
);
let _ = runtime.log(
runtime.publish_log_event(LogRecord::new(
Severity::Info,
LogFlags::NONE,
LogText::from_message("runtime ready"),
);
assert!(runtime.pending_report().is_some());
));
assert!(runtime.pending_mavlink_report().is_some());
}
#[test]
fn disabled_mavlink_sink_filters_every_severity() {
let mut factory = kaptan_flight_config::CandidateProfile::factory();
let enabled = kaptan_flight_config::CandidateProfile::descriptors()
.find(|descriptor| descriptor.display_key == "mavlink.logging.enabled")
.expect("MAVLink logging enable descriptor");
factory
.set_field(enabled.id, ConfigValue::Bool(false))
.unwrap();
let mut runtime = FlightRuntime::recover(
factory,
kaptan_flight_config::VehicleCapabilities::quad_x(),
Store,
)
.expect("disabled MAVLink sink is valid");
assert_eq!(runtime.mavlink_log_filter().maximum_severity(), None);
runtime.publish_log_event(LogRecord::new(
Severity::Error,
LogFlags::NONE,
LogText::from_message("not routed to MAVLink"),
));
assert!(runtime.pending_mavlink_report().is_none());
}
}
+68 -47
View File
@@ -9,18 +9,21 @@
#[cfg(test)]
extern crate std;
use kaptan_log::{LogRecord, LogText, RecordChannel, ShutdownReason, ShutdownReport};
use kaptan_log::{
LogRecord, MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES, MAXIMUM_ERROR_PATH_BYTES,
MAXIMUM_LOG_TEXT_BYTES, RecordChannel, ShutdownReason, ShutdownReport,
};
use kaptan_mavlink::message::kaptan::{
KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KAPTAN_SHUTDOWN_DATA, KaptanErrorFlags, KaptanLogFlags,
KaptanShutdownReason,
};
/// Exact capacity of the fixed text field in [`KAPTAN_LOG_DATA`].
pub const LOG_MESSAGE_BYTES: usize = 160;
pub const LOG_MESSAGE_BYTES: usize = MAXIMUM_LOG_TEXT_BYTES;
/// Exact capacity of the fixed error identity path field.
pub const ERROR_PATH_BYTES: usize = 96;
pub const ERROR_PATH_BYTES: usize = MAXIMUM_ERROR_PATH_BYTES;
/// Exact capacity of the fixed error additional-information field.
pub const ERROR_ADDITIONAL_INFORMATION_BYTES: usize = 64;
pub const ERROR_ADDITIONAL_INFORMATION_BYTES: usize = MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES;
/// One generated diagnostic payload. A platform-owned link remains the sole
/// MAVLink framing, admission, DMA, and retry owner.
@@ -45,8 +48,8 @@ pub fn encode_diagnostic(record: &LogRecord) -> DiagnosticPayload {
.expect("log channel must contain ordinary message text");
debug_assert!(usize::from(message.len()) <= LOG_MESSAGE_BYTES);
DiagnosticPayload::Log(KAPTAN_LOG_DATA {
level: record.severity() as u8,
flags: KaptanLogFlags::from_bits_retain(record.flags().bits()),
level: record.severity().wire_value(),
flags: KaptanLogFlags::from_bits_retain(record.flags().log_bits()),
message_length: message.len(),
message: *message.bytes(),
})
@@ -58,47 +61,29 @@ pub fn encode_diagnostic(record: &LogRecord) -> DiagnosticPayload {
let additional_information = record
.additional_information()
.expect("error channel must contain additional-information storage");
let (path, path_length, path_clipped) = project_text::<ERROR_PATH_BYTES>(path);
let (additional_information, additional_information_length, information_clipped) =
project_text::<ERROR_ADDITIONAL_INFORMATION_BYTES>(additional_information);
let mut flags = record.flags().error_bits();
if path_clipped || information_clipped {
flags |= KaptanErrorFlags::KAPTAN_ERROR_FLAG_TRUNCATED.bits();
}
debug_assert_eq!(path.bytes().len(), ERROR_PATH_BYTES);
debug_assert_eq!(
additional_information.bytes().len(),
ERROR_ADDITIONAL_INFORMATION_BYTES
);
DiagnosticPayload::Error(KAPTAN_ERROR_DATA {
flags: KaptanErrorFlags::from_bits_retain(flags),
path_length,
additional_information_length,
path,
additional_information,
level: record.severity().wire_value(),
flags: KaptanErrorFlags::from_bits_retain(record.flags().error_bits()),
path_length: path.len(),
additional_information_length: additional_information.len(),
path: *path.bytes(),
additional_information: *additional_information.bytes(),
})
}
}
}
fn project_text<const N: usize>(text: &LogText) -> ([u8; N], u8, bool) {
let source = text.as_str();
let retained = source
.char_indices()
.take_while(|(index, character)| index + character.len_utf8() <= N)
.map(|(index, character)| index + character.len_utf8())
.last()
.unwrap_or(0);
let mut bytes = [0; N];
bytes[..retained].copy_from_slice(&source.as_bytes()[..retained]);
(
bytes,
retained as u8,
text.is_truncated() || retained != source.len(),
)
}
/// Encodes one typed shutdown event. The caller owns when a shutdown is safe
/// to report; this pure adapter cannot run a shutdown state machine.
pub const fn encode_shutdown(report: ShutdownReport) -> KAPTAN_SHUTDOWN_DATA {
KAPTAN_SHUTDOWN_DATA {
reason: match report.reason() {
ShutdownReason::Operator => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_OPERATOR,
ShutdownReason::User => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_USER,
ShutdownReason::Safety => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_SAFETY,
},
}
@@ -113,8 +98,8 @@ mod tests {
encode_diagnostic, encode_shutdown,
};
use kaptan_log::{
ErrorAdditionalInformation, ErrorPath, EventHub, FacadeIngress, GlobalLogger, LogFilter,
LogFlags, LogRecord, LogText, Severity, ShutdownReason, ShutdownReport,
ErrorAdditionalInformation, ErrorPath, FacadeIngress, GlobalLogger, LogFlags, LogRecord,
LogText, Severity, ShutdownReason, ShutdownReport,
};
use kaptan_mavlink::{
encoder::MavlinkEncoder,
@@ -124,7 +109,7 @@ mod tests {
},
};
static ERROR_INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info));
static ERROR_INGRESS: FacadeIngress<1> = FacadeIngress::new();
static ERROR_LOGGER: GlobalLogger<1> = GlobalLogger::new(&ERROR_INGRESS, never_forbidden);
static INSTALL_ERROR_LOGGER: Once = Once::new();
@@ -136,13 +121,13 @@ mod tests {
fn ordinary_record_preserves_level_flags_and_bounded_text() {
let record = LogRecord::new(
Severity::Info,
LogFlags::TRUNCATED,
LogFlags::TRUNCATED.with(LogFlags::COALESCED),
LogText::from_message("imu unavailable"),
);
let DiagnosticPayload::Log(data) = encode_diagnostic(&record) else {
panic!("info record must use KAPTAN_LOG");
};
assert_eq!(data.level, Severity::Info as u8);
assert_eq!(data.level, Severity::Info.wire_value());
assert_eq!(data.message_length, 15);
assert_eq!(&data.message[..15], b"imu unavailable");
assert!(data.message[15..].iter().all(|byte| *byte == 0));
@@ -161,9 +146,9 @@ mod tests {
kaptan_log::log::info!("optional ingress is occupied");
kaptan_log::log::error!("critical error survives");
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert!(ERROR_INGRESS.drain_one_into(&mut hub).is_some());
let record = hub.peek().expect("critical macro record retained");
let record = ERROR_INGRESS
.take()
.expect("critical macro record retained");
let DiagnosticPayload::Error(payload) = encode_diagnostic(&record) else {
panic!("log::error! must not produce KAPTAN_LOG");
};
@@ -211,6 +196,7 @@ mod tests {
panic!("error record must use KAPTAN_ERROR");
};
assert_eq!(error_data.flags.bits(), LogFlags::COALESCED.bits());
assert_eq!(error_data.level, Severity::Error.wire_value());
assert_eq!(error_data.path_length, 23);
assert_eq!(error_data.additional_information_length, 0);
assert_eq!(&error_data.path[..23], b"driver::Error::Transfer");
@@ -220,10 +206,10 @@ mod tests {
.iter()
.all(|byte| *byte == 0)
);
assert_eq!(KAPTAN_ERROR_DATA::ENCODED_LEN, 163);
assert_eq!(KAPTAN_ERROR_DATA::ENCODED_LEN, 164);
let error_frame = encoder.custom(&error_data);
assert_eq!(error_frame.message_id(), 51_021);
assert!(error_frame.raw_bytes().len() <= 175);
assert!(error_frame.raw_bytes().len() <= 176);
assert!(matches!(
MavMessage::parse(
mavlink::MavlinkVersion::V2,
@@ -235,7 +221,7 @@ mod tests {
}
#[test]
fn error_wire_projection_clips_each_component_independently() {
fn error_record_bounds_each_component_before_wire_projection() {
let error = LogRecord::error(
LogFlags::NONE,
ErrorPath::from_message(&"p".repeat(LOG_MESSAGE_BYTES)),
@@ -257,6 +243,41 @@ mod tests {
);
}
#[test]
fn ordinary_error_constructor_preserves_narrowing_as_truncation() {
let error = LogRecord::new(
Severity::Error,
LogFlags::NONE,
LogText::from_message(&"p".repeat(ERROR_PATH_BYTES + 1)),
);
let DiagnosticPayload::Error(payload) = encode_diagnostic(&error) else {
panic!("error record must use KAPTAN_ERROR");
};
assert_eq!(usize::from(payload.path_length), ERROR_PATH_BYTES);
assert_eq!(&payload.path, &[b'p'; ERROR_PATH_BYTES]);
assert_eq!(payload.flags.bits(), LogFlags::TRUNCATED.bits());
}
#[test]
fn error_wire_preserves_every_error_severity() {
for severity in [
Severity::Emergency,
Severity::Alert,
Severity::Critical,
Severity::Error,
] {
let error = LogRecord::new(
severity,
LogFlags::NONE,
LogText::from_message("flight fault"),
);
let DiagnosticPayload::Error(payload) = encode_diagnostic(&error) else {
panic!("error severity must use KAPTAN_ERROR");
};
assert_eq!(payload.level, severity.wire_value());
}
}
#[test]
fn error_path_remains_decodable_when_only_additional_information_clips() {
let error = LogRecord::error(
+1
View File
@@ -15,5 +15,6 @@ default = []
facade = ["dep:log"]
[dependencies]
kaptan-config-derive = { path = "../kaptan-config-derive" }
kaptan-log-derive = { path = "../kaptan-log-derive" }
log = { version = "0.4.33", default-features = false, optional = true }
+60 -79
View File
@@ -6,15 +6,12 @@
use core::{
cell::UnsafeCell,
sync::atomic::{AtomicBool, AtomicU8, Ordering},
sync::atomic::{AtomicBool, Ordering},
};
use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError};
use crate::{
ErrorAdditionalInformation, ErrorPath, EventHub, LogFilter, LogFlags, LogText, PublishOutcome,
Severity,
};
use crate::{ErrorAdditionalInformation, ErrorPath, LogFlags, LogRecord, LogText, Severity};
/// Saturating, process-local accounting for the optional global facade.
///
@@ -24,8 +21,6 @@ use crate::{
pub struct FacadeHealth {
/// Calls rejected before formatting because their context is forbidden.
pub forbidden_context: u32,
/// Optional records excluded by the active verbosity cap.
pub filtered: u32,
/// Calls that lost the one-attempt ingress ownership race.
pub contended: u32,
/// Calls dropped because their bounded ingress lane was full.
@@ -36,7 +31,6 @@ pub struct FacadeHealth {
struct FacadeCounters {
forbidden_context: core::sync::atomic::AtomicU32,
filtered: core::sync::atomic::AtomicU32,
contended: core::sync::atomic::AtomicU32,
full: core::sync::atomic::AtomicU32,
truncated: core::sync::atomic::AtomicU32,
@@ -46,7 +40,6 @@ impl FacadeCounters {
const fn new() -> Self {
Self {
forbidden_context: core::sync::atomic::AtomicU32::new(0),
filtered: core::sync::atomic::AtomicU32::new(0),
contended: core::sync::atomic::AtomicU32::new(0),
full: core::sync::atomic::AtomicU32::new(0),
truncated: core::sync::atomic::AtomicU32::new(0),
@@ -71,7 +64,6 @@ impl FacadeCounters {
fn snapshot(&self) -> FacadeHealth {
FacadeHealth {
forbidden_context: self.forbidden_context.load(Ordering::Relaxed),
filtered: self.filtered.load(Ordering::Relaxed),
contended: self.contended.load(Ordering::Relaxed),
full: self.full.load(Ordering::Relaxed),
truncated: self.truncated.load(Ordering::Relaxed),
@@ -146,8 +138,8 @@ impl<const N: usize> IngressQueue<N> {
}
}
/// Fixed global-facade ingress. The normal task/runtime owner drains it into
/// an [`EventHub`] after startup.
/// Fixed global-facade ingress. The normal task/runtime owner takes each
/// transport-neutral record and fans it out to independently filtered sinks.
pub struct FacadeIngress<const N: usize> {
/// One dedicated critical slot keeps error records independent from the
/// bounded ordinary FIFO. It is deliberately one slot: producers never
@@ -157,7 +149,6 @@ pub struct FacadeIngress<const N: usize> {
critical_busy: AtomicBool,
queue: UnsafeCell<IngressQueue<N>>,
queue_busy: AtomicBool,
maximum_severity: AtomicU8,
counters: FacadeCounters,
}
@@ -167,29 +158,17 @@ pub struct FacadeIngress<const N: usize> {
unsafe impl<const N: usize> Sync for FacadeIngress<N> {}
impl<const N: usize> FacadeIngress<N> {
/// Creates empty fixed ingress with the boot-active verbosity cap.
pub const fn new(filter: LogFilter) -> Self {
/// Creates an empty fixed ingress.
pub const fn new() -> Self {
Self {
critical: UnsafeCell::new(None),
critical_busy: AtomicBool::new(false),
queue: UnsafeCell::new(IngressQueue::new()),
queue_busy: AtomicBool::new(false),
maximum_severity: AtomicU8::new(filter.maximum_severity() as u8),
counters: FacadeCounters::new(),
}
}
/// Updates the runtime-selected maximum non-error verbosity.
pub fn set_filter(&self, filter: LogFilter) {
self.maximum_severity
.store(filter.maximum_severity() as u8, Ordering::Release);
}
fn allows(&self, severity: Severity) -> bool {
severity <= Severity::Error
|| (severity as u8) <= self.maximum_severity.load(Ordering::Acquire)
}
fn push(&self, record: IngressRecord) {
if record.severity <= Severity::Error {
self.push_critical(record);
@@ -222,24 +201,23 @@ impl<const N: usize> FacadeIngress<N> {
self.counters.snapshot()
}
/// Moves at most one preformatted facade record into the central hub.
pub fn drain_one_into<const CRITICAL: usize, const BEST_EFFORT: usize>(
&self,
hub: &mut EventHub<CRITICAL, BEST_EFFORT>,
) -> Option<PublishOutcome> {
/// Takes at most one preformatted event for composition-level fan-out.
///
/// The ingress deliberately applies no sink policy. A caller may submit
/// the returned copy to MAVLink, blackbox, or any later sink without one
/// sink's verbosity selection suppressing another's event.
pub fn take(&self) -> Option<LogRecord> {
let record = match self.with_critical(Option::take) {
Some(Some(record)) => record,
Some(None) => self.with_queue(IngressQueue::pop).flatten()?,
None => return None,
};
Some(match record.body {
IngressBody::Log { message } => {
hub.publish_text(record.severity, record.flags, message)
}
IngressBody::Log { message } => LogRecord::new(record.severity, record.flags, message),
IngressBody::Error {
path,
additional_information,
} => hub.publish_error_text(record.flags, path, additional_information),
} => LogRecord::error(record.flags, path, additional_information),
})
}
@@ -278,6 +256,12 @@ impl<const N: usize> FacadeIngress<N> {
}
}
impl<const N: usize> Default for FacadeIngress<N> {
fn default() -> Self {
Self::new()
}
}
/// Installed standard logger. The context probe must return true for
/// ISR/panic/hard-fault/shutdown contexts; those calls are rejected before
/// [`Record::args`] is formatted.
@@ -296,7 +280,8 @@ impl<const N: usize> GlobalLogger<N> {
}
/// Installs this logger once for the process/image and enables all Rust
/// levels compiled into the image. Kaptan's own filter applies afterward.
/// levels compiled into the image. Each sink applies its policy after it
/// receives a record from the ingress.
pub fn install(&'static self) -> Result<(), SetLoggerError> {
log::set_logger(self)?;
log::set_max_level(LevelFilter::Trace);
@@ -305,18 +290,11 @@ impl<const N: usize> GlobalLogger<N> {
}
impl<const N: usize> Log for GlobalLogger<N> {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
fn enabled(&self, _: &Metadata<'_>) -> bool {
if (self.forbidden_context)() {
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
return false;
}
let Some(severity) = severity(metadata.level()) else {
return false;
};
if !self.ingress.allows(severity) {
FacadeCounters::increment(&self.ingress.counters.filtered);
return false;
}
true
}
@@ -325,13 +303,7 @@ impl<const N: usize> Log for GlobalLogger<N> {
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
return;
}
let Some(severity) = severity(record.level()) else {
return;
};
if !self.ingress.allows(severity) {
FacadeCounters::increment(&self.ingress.counters.filtered);
return;
}
let severity = severity(record.level());
let flags = if record.level() == Level::Trace {
LogFlags::TRACE
} else {
@@ -367,12 +339,12 @@ impl<const N: usize> Log for GlobalLogger<N> {
fn flush(&self) {}
}
const fn severity(level: Level) -> Option<Severity> {
const fn severity(level: Level) -> Severity {
match level {
Level::Error => Some(Severity::Error),
Level::Warn => Some(Severity::Warning),
Level::Info => Some(Severity::Info),
Level::Debug | Level::Trace => Some(Severity::Debug),
Level::Error => Severity::Error,
Level::Warn => Severity::Warning,
Level::Info => Severity::Info,
Level::Debug | Level::Trace => Severity::Debug,
}
}
@@ -382,10 +354,10 @@ mod tests {
use std::sync::Once;
use super::{FacadeIngress, GlobalLogger};
use crate::{EventHub, LogFilter, RecordChannel, Severity};
use crate::{RecordChannel, Severity};
use log::{Level, Log, Metadata, Record};
static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new();
static GLOBAL_LOGGER: GlobalLogger<2> = GlobalLogger::new(&GLOBAL_INGRESS, never_forbidden);
static INSTALL_GLOBAL: Once = Once::new();
@@ -399,7 +371,7 @@ mod tests {
#[test]
fn rejects_forbidden_context_before_formatting() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
static INGRESS: FacadeIngress<2> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, always_forbidden);
let metadata = Metadata::builder()
.level(Level::Error)
@@ -408,19 +380,31 @@ mod tests {
assert!(!logger.enabled(&metadata));
assert_eq!(INGRESS.health().forbidden_context, 1);
static FILTERED: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Info));
let filtered_logger = GlobalLogger::new(&FILTERED, never_forbidden);
static UNFILTERED: FacadeIngress<2> = FacadeIngress::new();
let unfiltered_logger = GlobalLogger::new(&UNFILTERED, never_forbidden);
let debug = Metadata::builder()
.level(Level::Debug)
.target("test")
.build();
assert!(!filtered_logger.enabled(&debug));
assert_eq!(FILTERED.health().filtered, 1);
assert!(unfiltered_logger.enabled(&debug));
unfiltered_logger.log(
&Record::builder()
.metadata(debug)
.args(format_args!("sink decides whether to keep this"))
.build(),
);
assert_eq!(
UNFILTERED
.take()
.expect("debug event reaches the unfiltered ingress")
.severity(),
Severity::Debug
);
}
#[test]
fn formats_once_then_drains_into_priority_hub() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
fn formats_once_then_exposes_a_transport_neutral_record() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
let record = Record::builder()
.metadata(
@@ -432,9 +416,7 @@ mod tests {
.args(format_args!("link failed"))
.build();
logger.log(&record);
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert!(INGRESS.drain_one_into(&mut hub).is_some());
let pending = hub.peek().unwrap();
let pending = INGRESS.take().expect("facade event retained");
assert_eq!(pending.error_path().unwrap().as_str(), "test");
assert_eq!(
pending.additional_information().unwrap().as_str(),
@@ -456,11 +438,10 @@ mod tests {
fn standard_log_macros_enqueue_bounded_targeted_text() {
INSTALL_GLOBAL.call_once(|| GLOBAL_LOGGER.install().expect("one global test logger"));
log::info!("armed configuration loaded");
let mut hub = EventHub::<1, 2>::new(LogFilter::default());
assert!(GLOBAL_INGRESS.drain_one_into(&mut hub).is_some());
assert!(
hub.peek()
.unwrap()
GLOBAL_INGRESS
.take()
.expect("macro event retained")
.message()
.unwrap()
.as_str()
@@ -470,7 +451,7 @@ mod tests {
#[test]
fn forbidden_context_skips_argument_formatting() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
static INGRESS: FacadeIngress<2> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, always_forbidden);
logger.log(
&Record::builder()
@@ -488,7 +469,7 @@ mod tests {
#[test]
fn bounded_ingress_exposes_local_drop_and_truncation_accounting() {
static INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info));
static INGRESS: FacadeIngress<1> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
logger.log(
&Record::builder()
@@ -531,7 +512,7 @@ mod tests {
#[test]
fn full_optional_ingress_retains_an_error_for_the_error_channel() {
static INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info));
static INGRESS: FacadeIngress<1> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
logger.log(
&Record::builder()
@@ -556,9 +537,9 @@ mod tests {
.build(),
);
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert!(INGRESS.drain_one_into(&mut hub).is_some());
let error = hub.peek().expect("error retained ahead of optional text");
let error = INGRESS
.take()
.expect("error retained ahead of optional text");
assert_eq!(error.channel(), RecordChannel::Error);
assert_eq!(error.error_path().unwrap().as_str(), "test");
assert_eq!(error.additional_information().unwrap().as_str(), "critical");
+59 -19
View File
@@ -2,37 +2,77 @@
use crate::Severity;
/// Immutable policy for best-effort log admission.
/// Immutable policy for one log sink.
///
/// Error/critical/vital records are never filtered by this type; callers use
/// their dedicated delivery lanes for those records.
/// Filtering belongs to a sink rather than to event creation: MAVLink and a
/// future blackbox may therefore select different policies without either
/// sink suppressing the other's events.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LogFilter {
maximum_severity: Severity,
pub enum LogFilter {
/// The sink receives no log events, including errors.
Disabled,
/// The sink receives this level and every more severe level.
UpTo(Severity),
}
impl LogFilter {
/// Creates a best-effort verbosity cap.
pub const fn new(maximum_severity: Severity) -> Self {
Self { maximum_severity }
/// Creates a disabled sink policy.
pub const fn disabled() -> Self {
Self::Disabled
}
/// The selected MAVLink-compatible severity cap.
///
/// The value is exact on the wire: in particular, `NOTICE` does not
/// silently permit `INFO` records.
pub const fn maximum_severity(self) -> Severity {
self.maximum_severity
/// Creates a sink policy with one inclusive verbosity cap.
pub const fn up_to(maximum_severity: Severity) -> Self {
Self::UpTo(maximum_severity)
}
/// Returns true if a best-effort record should be retained.
/// The selected MAVLink-compatible severity cap, or `None` when disabled.
pub const fn maximum_severity(self) -> Option<Severity> {
match self {
Self::Disabled => None,
Self::UpTo(maximum_severity) => Some(maximum_severity),
}
}
/// Returns true if this sink accepts the event.
pub const fn allows(self, severity: Severity) -> bool {
(severity as u8) <= (self.maximum_severity as u8)
match self {
Self::Disabled => false,
Self::UpTo(maximum_severity) => severity.wire_value() <= maximum_severity.wire_value(),
}
}
}
impl Default for LogFilter {
fn default() -> Self {
Self::new(Severity::Info)
#[cfg(test)]
mod tests {
use super::LogFilter;
use crate::Severity;
#[test]
fn disabled_policy_rejects_every_severity() {
let filter = LogFilter::disabled();
for severity in [
Severity::Emergency,
Severity::Alert,
Severity::Critical,
Severity::Error,
Severity::Warning,
Severity::Notice,
Severity::Info,
Severity::Debug,
] {
assert!(!filter.allows(severity));
}
assert_eq!(filter.maximum_severity(), None);
}
#[test]
fn level_policy_is_inclusive_and_rejects_more_verbose_events() {
let filter = LogFilter::up_to(Severity::Info);
assert!(filter.allows(Severity::Emergency));
assert!(filter.allows(Severity::Error));
assert!(filter.allows(Severity::Info));
assert!(!filter.allows(Severity::Debug));
assert_eq!(filter.maximum_severity(), Some(Severity::Info));
}
}
+197 -160
View File
@@ -4,7 +4,7 @@ use core::fmt;
use crate::{
DeliveryClass, ErrorAdditionalInformation, ErrorPath, ErrorReport, LogFilter, LogFlags,
LogRecord, LogText, Severity, StaticError,
LogRecord, LogText, Severity, StaticError, StaticErrorMailbox,
};
/// Outcome from one bounded diagnostic admission attempt.
@@ -12,9 +12,7 @@ use crate::{
pub enum PublishOutcome {
/// The record was retained.
Queued,
/// An adjacent equivalent record absorbed this occurrence.
Coalesced,
/// An ordinary log record was excluded by the configured verbosity cap.
/// A record was excluded by this sink's configured policy.
Filtered,
/// A best-effort record was replaced by newer optional information.
ReplacedOldest,
@@ -26,37 +24,10 @@ pub enum PublishOutcome {
/// record to its lower transport.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransportAdmission {
/// The lower transport retained the record, so it may leave the hub.
/// The lower transport retained the record, so it may leave the queue.
Accepted,
/// The lower transport did not accept it yet; retain it for retry.
Retry,
/// The lower transport will not accept optional detail. This can only
/// discard a best-effort record; critical records remain queued.
DropBestEffort,
}
/// Saturating, process-local accounting for bounded diagnostic admission.
///
/// These counters intentionally are not part of `KAPTAN_LOG` or
/// `KAPTAN_ERROR`: neither wire message is a metrics protocol.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct HubHealth {
/// Optional records rejected by the active verbosity cap.
pub filtered: u32,
/// Adjacent records folded into an existing entry.
pub coalesced: u32,
/// Optional entries replaced because their bounded lane was full.
pub best_effort_replaced: u32,
/// Error entries rejected because their bounded critical lane was full.
pub critical_rejected: u32,
/// Optional entries the lower transport refused permanently.
pub best_effort_transport_dropped: u32,
}
impl HubHealth {
fn increment(value: &mut u32) {
*value = value.saturating_add(1);
}
}
struct Lane<const N: usize> {
@@ -76,15 +47,6 @@ impl<const N: usize> Lane<N> {
}
fn publish(&mut self, record: LogRecord, replace_oldest: bool) -> PublishOutcome {
if self.len != 0 {
let tail = (self.head + self.len - 1) % N;
if let Some(previous) = self.entries[tail].as_mut()
&& previous.is_equivalent_to(&record)
{
previous.increment_occurrences();
return PublishOutcome::Coalesced;
}
}
if self.len == N {
if replace_oldest {
self.entries[self.head] = Some(record);
@@ -99,6 +61,10 @@ impl<const N: usize> Lane<N> {
PublishOutcome::Queued
}
const fn is_full(&self) -> bool {
self.len == N
}
fn peek(&self) -> Option<LogRecord> {
self.entries.get(self.head).copied().flatten()
}
@@ -114,39 +80,78 @@ impl<const N: usize> Lane<N> {
}
}
/// One bounded priority hub. It owns no global logger, MAVLink state, or DMA.
pub struct EventHub<const CRITICAL: usize, const BEST_EFFORT: usize> {
/// One bounded priority queue owned by a log sink.
///
/// It owns no global logger, MAVLink state, DMA, filesystem, or other sink.
pub struct LogSinkQueue<const CRITICAL: usize, const BEST_EFFORT: usize> {
filter: LogFilter,
critical: Lane<CRITICAL>,
best_effort: Lane<BEST_EFFORT>,
health: HubHealth,
}
impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EFFORT> {
/// Creates one hub with its immutable boot-active verbosity cap.
impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BEST_EFFORT> {
/// Creates one sink queue with its immutable boot-active policy.
pub const fn new(filter: LogFilter) -> Self {
Self {
filter,
critical: Lane::new(),
best_effort: Lane::new(),
health: HubHealth {
filtered: 0,
coalesced: 0,
best_effort_replaced: 0,
critical_rejected: 0,
best_effort_transport_dropped: 0,
},
}
}
/// Immutable best-effort verbosity policy.
/// Immutable sink policy.
pub const fn filter(&self) -> LogFilter {
self.filter
}
/// Process-local bounded-admission accounting.
pub const fn health(&self) -> HubHealth {
self.health
/// Whether an error source may be consumed without losing an enabled
/// critical record. Filtered errors may be consumed intentionally.
pub const fn can_publish_error(&self) -> bool {
!self.filter.allows(Severity::Error) || !self.critical.is_full()
}
/// Consumes at most one source-local static-error mailbox in round-robin
/// order. An enabled source remains untouched while the critical lane is
/// full; a filtered source is consumed intentionally.
pub fn drain_next_error_mailbox<const SOURCES: usize>(
&mut self,
mailboxes: [&StaticErrorMailbox; SOURCES],
cursor: &mut u8,
) -> bool {
assert!(
SOURCES != 0 && SOURCES <= u8::MAX as usize,
"error mailbox fan-in must fit its u8 cursor"
);
if !self.can_publish_error() {
return false;
}
if usize::from(*cursor) >= SOURCES {
*cursor = 0;
}
let mut checked = 0;
while checked < SOURCES {
let index = usize::from(*cursor);
*cursor = if index + 1 == SOURCES { 0 } else { *cursor + 1 };
checked += 1;
let (error, coalesced) = mailboxes[index].take();
let Some(error) = error else {
continue;
};
let flags = if coalesced {
LogFlags::COALESCED
} else {
LogFlags::NONE
};
let outcome = self.publish_error_static(flags, error);
debug_assert!(matches!(
outcome,
PublishOutcome::Queued | PublishOutcome::Filtered
));
return true;
}
false
}
/// Formats and retains one ordinary task/worker diagnostic. Error-level
@@ -164,7 +169,7 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
/// Retains one static derived error without formatting or exposing source
/// values. The adapter maps it to `KAPTAN_ERROR`.
pub fn publish_error<E: ErrorReport>(&mut self, flags: LogFlags, error: &E) -> PublishOutcome {
self.publish_error_static(flags, error.static_error())
self.publish_record(LogRecord::from_error(flags, error))
}
/// Retains one static mailbox error. This is the ISR-safe counterpart to
@@ -174,16 +179,13 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
flags: LogFlags,
error: &'static StaticError,
) -> PublishOutcome {
self.publish_error_text(
flags,
ErrorPath::from_message(error.path()),
ErrorAdditionalInformation::from_message(error.additional_information()),
)
self.publish_record(LogRecord::from_static_error(flags, error))
}
/// Retains a preformatted error identity and optional detail. Both
/// components stay transport-neutral here; the MAVLink adapter enforces
/// its independent on-wire capacities.
/// transport-neutral components already have the exact common diagnostic
/// envelope capacities, so a sink adapter need not retain or clip them
/// again.
pub fn publish_error_text(
&mut self,
flags: LogFlags,
@@ -194,38 +196,29 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
}
/// Retains one preformatted bounded text payload. Error-or-higher
/// severities are independently retained and sent as `KAPTAN_ERROR`.
/// severities are independently encoded as `KAPTAN_ERROR`.
pub fn publish_text(
&mut self,
severity: Severity,
flags: LogFlags,
text: LogText,
) -> PublishOutcome {
let delivery = delivery_for(severity);
if delivery == DeliveryClass::BestEffort && !self.filter.allows(severity) {
HubHealth::increment(&mut self.health.filtered);
return PublishOutcome::Filtered;
}
self.publish_record(LogRecord::new(severity, flags, text))
}
fn publish_record(&mut self, record: LogRecord) -> PublishOutcome {
/// Applies this sink's policy and retains one already-constructed event.
///
/// Composition may submit the same transport-neutral record to multiple
/// sink queues without coupling event creation to any sink policy.
pub fn publish_record(&mut self, record: LogRecord) -> PublishOutcome {
if !self.filter.allows(record.severity()) {
return PublishOutcome::Filtered;
}
let delivery = record.delivery();
let outcome = match delivery {
match delivery {
DeliveryClass::Critical => self.critical.publish(record, false),
DeliveryClass::BestEffort => self.best_effort.publish(record, true),
};
match outcome {
PublishOutcome::Coalesced => HubHealth::increment(&mut self.health.coalesced),
PublishOutcome::ReplacedOldest => {
HubHealth::increment(&mut self.health.best_effort_replaced);
}
PublishOutcome::RejectedFull => {
HubHealth::increment(&mut self.health.critical_rejected);
}
PublishOutcome::Queued | PublishOutcome::Filtered => {}
}
outcome
}
/// Highest-priority record without relinquishing it.
@@ -233,9 +226,9 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
self.critical.peek().or_else(|| self.best_effort.peek())
}
/// Submits the current record through the sole telemetry owner. The hub
/// removes it only after `Accepted`, and only permits `DropBestEffort` for
/// optional detail. This keeps encoder/admission retry semantics local.
/// Submits the current record through the sink owner and removes it only
/// after `Accepted`. This keeps encoder/admission retry semantics local
/// for every severity.
pub fn submit_next<R>(
&mut self,
submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R),
@@ -246,13 +239,7 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
TransportAdmission::Accepted => {
let _ = self.pop();
}
TransportAdmission::DropBestEffort
if record.delivery() == DeliveryClass::BestEffort =>
{
let _ = self.best_effort.pop();
HubHealth::increment(&mut self.health.best_effort_transport_dropped);
}
TransportAdmission::Retry | TransportAdmission::DropBestEffort => {}
TransportAdmission::Retry => {}
}
Some(result)
}
@@ -262,27 +249,30 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
}
}
fn delivery_for(severity: Severity) -> DeliveryClass {
if severity <= Severity::Error {
DeliveryClass::Critical
} else {
DeliveryClass::BestEffort
}
}
#[cfg(test)]
mod tests {
use super::{EventHub, PublishOutcome, TransportAdmission};
use super::{LogSinkQueue, PublishOutcome, TransportAdmission};
use crate::{
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogText,
RecordChannel, Severity, StaticError,
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogRecord,
LogText, RecordChannel, Severity, StaticError,
};
#[test]
fn errors_bypass_filter_but_debug_does_not() {
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info));
fn sink_policy_applies_to_errors_and_ordinary_logs() {
let mut disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
assert_eq!(
hub.publish_text(
disabled.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("error")
),
PublishOutcome::Filtered
);
assert!(disabled.peek().is_none());
let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
assert_eq!(
queue.publish_text(
Severity::Debug,
LogFlags::NONE,
LogText::from_message("debug")
@@ -290,43 +280,110 @@ mod tests {
PublishOutcome::Filtered
);
assert_eq!(
hub.publish_text(
queue.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("error")
),
PublishOutcome::Queued
);
let record = hub.peek().unwrap();
let record = queue.peek().unwrap();
assert_eq!(record.error_path().unwrap().as_str(), "error");
assert!(record.additional_information().unwrap().is_empty());
assert_eq!(record.channel(), RecordChannel::Error);
}
#[test]
fn static_messages_and_repeats_are_bounded() {
static FAULT: StaticError = StaticError::new("driver::Fault");
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
fn full_critical_lane_backpressures_only_an_enabled_error_source() {
let mut enabled = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
assert!(enabled.can_publish_error());
assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
enabled.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("pending")
),
PublishOutcome::Queued
);
assert!(!enabled.can_publish_error());
let disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
assert!(disabled.can_publish_error());
}
#[test]
fn mailbox_fan_in_survives_backpressure_and_is_round_robin() {
static FIRST: StaticError = StaticError::new("first");
static SECOND: StaticError = StaticError::new("second");
static THIRD: StaticError = StaticError::new("third");
let first = crate::StaticErrorMailbox::new();
let second = crate::StaticErrorMailbox::new();
let third = crate::StaticErrorMailbox::new();
let mailboxes = [&first, &second, &third];
first.record(&FIRST);
second.record(&SECOND);
third.record(&THIRD);
let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
let mut cursor = 0;
assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor));
assert_eq!(
queue.peek().unwrap().error_path().unwrap().as_str(),
"first"
);
first.record(&FIRST);
assert!(!queue.drain_next_error_mailbox(mailboxes, &mut cursor));
assert_eq!(
queue.peek().unwrap().error_path().unwrap().as_str(),
"first"
);
let mut expect = |path| {
assert_eq!(queue.peek().unwrap().error_path().unwrap().as_str(), path);
assert_eq!(
queue.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
);
assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor));
};
expect("first");
expect("second");
expect("third");
assert_eq!(
queue.peek().unwrap().error_path().unwrap().as_str(),
"first"
);
}
#[test]
fn identical_events_remain_distinct_records() {
static FAULT: StaticError = StaticError::new("driver::Fault");
let mut queue = LogSinkQueue::<2, 1>::new(LogFilter::up_to(Severity::Debug));
assert_eq!(
queue.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
);
assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Coalesced
queue.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
);
let record = hub.peek().unwrap();
assert_eq!(record.occurrences, 2);
assert!(record.flags().contains(LogFlags::COALESCED));
let record = queue.peek().unwrap();
assert!(!record.flags().contains(LogFlags::COALESCED));
assert_eq!(record.error_path().unwrap().as_str(), "driver::Fault");
assert!(record.additional_information().unwrap().is_empty());
assert_eq!(
queue.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
);
assert_eq!(queue.peek(), Some(record));
}
#[test]
fn static_error_detail_needs_no_producer_formatting() {
static FAULT: StaticError =
StaticError::with_additional_information("driver::Dma::Fault", "stream 7");
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
@@ -341,7 +398,7 @@ mod tests {
#[test]
fn error_identity_and_detail_are_independent_record_components() {
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
let _ = hub.publish_error_text(
LogFlags::NONE,
ErrorPath::from_message("driver::Icm42688::Transfer"),
@@ -360,15 +417,15 @@ mod tests {
}
#[test]
fn retry_retains_critical_and_only_best_effort_can_be_discarded() {
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
fn retry_retains_every_severity() {
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
let _ = hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("fault"),
);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())),
hub.submit_next(|_| (TransportAdmission::Retry, ())),
Some(())
);
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::Critical);
@@ -384,50 +441,30 @@ mod tests {
LogText::from_message("detail"),
);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())),
hub.submit_next(|_| (TransportAdmission::Retry, ())),
Some(())
);
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::BestEffort);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
);
assert!(hub.peek().is_none());
assert_eq!(hub.health().best_effort_transport_dropped, 1);
}
#[test]
fn admission_losses_are_retained_as_local_saturating_accounting() {
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info));
let _ = hub.publish_text(
Severity::Debug,
LogFlags::NONE,
LogText::from_message("filtered"),
);
let _ = hub.publish_text(
fn independent_sink_policies_do_not_suppress_each_other() {
let record = LogRecord::new(
Severity::Info,
LogFlags::NONE,
LogText::from_message("first optional"),
);
let _ = hub.publish_text(
Severity::Info,
LogFlags::NONE,
LogText::from_message("newer optional"),
);
let _ = hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("first error"),
);
let _ = hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("second error"),
);
assert_eq!(
hub.health(),
super::HubHealth {
filtered: 1,
coalesced: 0,
best_effort_replaced: 1,
critical_rejected: 1,
best_effort_transport_dropped: 0,
}
LogText::from_message("flight ready"),
);
let mut mavlink = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
let mut blackbox = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
assert_eq!(mavlink.publish_record(record), PublishOutcome::Filtered);
assert_eq!(blackbox.publish_record(record), PublishOutcome::Queued);
assert!(mavlink.peek().is_none());
assert_eq!(blackbox.peek(), Some(record));
}
}
+10 -15
View File
@@ -2,7 +2,7 @@
/// MAVLink-compatible severity. Numeric values deliberately match
/// `MAV_SEVERITY`.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, kaptan_config_derive::ConfigEnum)]
#[repr(u8)]
pub enum Severity {
/// System is unusable.
@@ -23,19 +23,14 @@ pub enum Severity {
Debug = 7,
}
impl Severity {
/// Converts the stable MAVLink numeric representation.
pub const fn try_from_wire(value: u8) -> Option<Self> {
match value {
0 => Some(Self::Emergency),
1 => Some(Self::Alert),
2 => Some(Self::Critical),
3 => Some(Self::Error),
4 => Some(Self::Warning),
5 => Some(Self::Notice),
6 => Some(Self::Info),
7 => Some(Self::Debug),
_ => None,
}
#[cfg(test)]
mod tests {
use core::mem::size_of;
use super::Severity;
#[test]
fn severity_is_one_byte() {
assert_eq!(size_of::<Severity>(), 1);
}
}
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more