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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -151,13 +151,6 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
name = "kaptan-actuator" name = "kaptan-actuator"
version = "0.1.0" version = "0.1.0"
[[package]]
name = "kaptan-board"
version = "0.1.0"
dependencies = [
"kaptan-log",
]
[[package]] [[package]]
name = "kaptan-config" name = "kaptan-config"
version = "0.1.0" version = "0.1.0"
@@ -198,7 +191,6 @@ dependencies = [
name = "kaptan-data-channel" name = "kaptan-data-channel"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"kaptan-board",
"kaptan-dma", "kaptan-dma",
"kaptan-log", "kaptan-log",
"kaptan-mavlink", "kaptan-mavlink",
@@ -243,8 +235,9 @@ dependencies = [
name = "kaptan-geprc-f722-v2" name = "kaptan-geprc-f722-v2"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"kaptan-board", "kaptan-config",
"platform-stm32f7", "platform-stm32f7",
"rtic",
] ]
[[package]] [[package]]
@@ -255,14 +248,16 @@ version = "0.1.0"
name = "kaptan-kakute-f7-mini-v3" name = "kaptan-kakute-f7-mini-v3"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"kaptan-board", "kaptan-config",
"platform-stm32f7", "platform-stm32f7",
"rtic",
] ]
[[package]] [[package]]
name = "kaptan-log" name = "kaptan-log"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"kaptan-config-derive",
"kaptan-log-derive", "kaptan-log-derive",
"log", "log",
] ]
@@ -486,7 +481,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"cortex-m", "cortex-m",
"cortex-m-rt", "cortex-m-rt",
"kaptan-board", "kaptan-config",
"kaptan-config-mavlink",
"kaptan-data-channel", "kaptan-data-channel",
"kaptan-dma", "kaptan-dma",
"kaptan-drivers", "kaptan-drivers",
@@ -510,9 +506,9 @@ checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -528,9 +524,9 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.46" version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -549,9 +545,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.16" version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@@ -645,7 +641,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.0", "syn 3.0.3",
] ]
[[package]] [[package]]
@@ -694,9 +690,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.0" version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -720,22 +716,22 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.19" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.19" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.0", "syn 3.0.3",
] ]
[[package]] [[package]]
-1
View File
@@ -3,7 +3,6 @@ members = [
"boards/geprc-f722-v2", "boards/geprc-f722-v2",
"boards/kakute-f7-mini-v3", "boards/kakute-f7-mini-v3",
"libs/kaptan-actuator", "libs/kaptan-actuator",
"libs/kaptan-board",
"libs/kaptan-control", "libs/kaptan-control",
"libs/kaptan-config", "libs/kaptan-config",
"libs/kaptan-config-derive", "libs/kaptan-config-derive",
+4 -1
View File
@@ -10,8 +10,11 @@ build = "build.rs"
workspace = true workspace = true
[dependencies] [dependencies]
kaptan-board = { path = "../../libs/kaptan-board" } kaptan-config = { path = "../../libs/kaptan-config" }
platform-stm32f7 = { path = "../../platforms/stm32f7" } 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]] [[bin]]
name = "kaptan-geprc-f722-v2" name = "kaptan-geprc-f722-v2"
+77 -30
View File
@@ -1,45 +1,92 @@
//! Declarative support for the GEPRC TAKER F722 BLS 60A V2 stack. //! 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::{ 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. /// ICM-42688-P-based GEP-F722-HD V2 paired with the 60 A stack.
pub struct GeprcF722V2; pub struct GeprcF722V2;
impl F7Board for GeprcF722V2 { impl F7Board for GeprcF722V2 {
const CONFIG: BoardConfig = BoardConfig { type UserConfig = UserConfig;
name: "GEPRC TAKER F722 V2 (ICM-42688-P)", type ImmutableConfig = ImmutableConfig;
mavlink: MavlinkEndpoint {
const FACTORY_USER_CONFIG: UserConfig = UserConfig {
mavlink: MavlinkConfig {
system_id: 1, system_id: 1,
component_id: 1, component_id: 1,
port: MavlinkPort::Serial2, logging_enabled: true,
baud: 115_200, 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,
}),
}; };
const IMMUTABLE_CONFIG: ImmutableConfig = ImmutableConfig {
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> { name: "GEPRC TAKER F722 V2 (ICM-42688-P)",
platform service_hz: 100,
.imu(Icm42688::on_spi3().with_drdy(Pc3)) hardware: F722Hardware::new()
.mavlink(Mavlink::on_usart2()) .output_inhibit(Pb0Pb1Pb4Pb5Low)
.imu(Icm42688::on_spi3().with_drdy(Pc3).mounted(ImuMount::Yaw180))
.mavlink(Mavlink::on_usart2().baud(115_200))
.vertical_sensor(VerticalSensor::none()) .vertical_sensor(VerticalSensor::none())
.build() .status_led(Pa13ActiveHigh)
} .telemetry_only(),
};
} }
+1 -1
View File
@@ -3,4 +3,4 @@
mod board; 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] [lints]
workspace = true workspace = true
[features]
lidar-lite-v3 = []
[dependencies] [dependencies]
kaptan-board = { path = "../../libs/kaptan-board" } kaptan-config = { path = "../../libs/kaptan-config" }
platform-stm32f7 = { path = "../../platforms/stm32f7" } 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]] [[bin]]
name = "kaptan-kakute-f7-mini-v3" name = "kaptan-kakute-f7-mini-v3"
+108 -36
View File
@@ -4,48 +4,120 @@
//! code, interrupt names, DMA handling, or task scheduling. Those concerns //! code, interrupt names, DMA handling, or task scheduling. Those concerns
//! belong to the STM32F7 platform crate. //! belong to the STM32F7 platform crate.
use kaptan_board::{
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
Severity, TelemetryProfile, VerticalSensingKind,
};
use platform_stm32f7::{ 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. /// Holybro Kakute F7 Mini V3.
pub struct KakuteF7MiniV3; 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 { impl F7Board for KakuteF7MiniV3 {
const CONFIG: BoardConfig = CONFIG; type UserConfig = UserConfig;
type ImmutableConfig = ImmutableConfig;
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> { const FACTORY_USER_CONFIG: UserConfig = UserConfig {
platform mavlink: MavlinkConfig {
.imu(Mpu6000::on_spi1().with_drdy(Pa4)) system_id: 1,
.mavlink(Mavlink::on_usart1()) component_id: 1,
.vertical_sensor(VerticalSensor::mtf01_on_uart4()) logging_enabled: true,
.build() 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] #![no_main]
mod board; 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; } = model;
match kind { match kind {
ModelKind::Scalars => expand_scalars(ident, generics, crate_path, post_validate, fields), 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 (impl_generics, type_generics, where_clause) = generics.split_for_impl();
let mut checks = Vec::new(); let mut checks = Vec::new();
let mut descriptors = Vec::new(); let mut descriptors = Vec::new();
let mut defaults = Vec::new();
let mut reads = Vec::new(); let mut reads = Vec::new();
let mut writes = Vec::new(); let mut writes = Vec::new();
for field in &fields { for field in &fields {
@@ -37,38 +36,62 @@ fn expand_scalars(
descriptor, descriptor,
checks: field_checks, checks: field_checks,
} = expand_field(&crate_path, field)?; } = expand_field(&crate_path, field)?;
if let Some(descriptor) = descriptor { descriptors.push(descriptor);
descriptors.push(descriptor);
}
checks.extend(field_checks); checks.extend(field_checks);
let id = field.id.expect("ConfigValidate requires configured fields"); let id = field.id.expect("scalar configuration fields have IDs");
let default = field let ty = &field.ty;
.default
.as_ref()
.expect("ConfigValidate requires defaults");
let (kind, value) = config_value_tokens(&crate_path, &field.ty)?;
let field_ident = &field.ident; 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! { reads.push(quote! {
#id => Some(#value(self.#field_ident)), #id => Some(#read),
}); });
writes.push(quote! { writes.push(quote! {
#id => { #id => {
let next = match value { let next = match #decode {
#value(next) => next, Ok(next) => next,
actual => { Err(#crate_path::ScalarValueError::WrongKind(actual)) => {
return Err(#crate_path::FieldWriteError::WrongType { return Err(#crate_path::FieldWriteError::WrongType {
field: #crate_path::FieldId::new(#id), field: #crate_path::FieldId::new(#id),
expected: #kind, 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; let previous = self.#field_ident;
self.#field_ident = next; self.#field_ident = next;
if let Err(violation) = if let Err(violation) = self.__validate_local_configuration() {
<Self as #crate_path::ConfigSection>::validate_local_fields(self)
{
self.#field_ident = previous; self.#field_ident = previous;
return Err(#crate_path::FieldWriteError::Violation(violation)); return Err(#crate_path::FieldWriteError::Violation(violation));
} }
@@ -78,30 +101,32 @@ fn expand_scalars(
} }
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; }); let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
Ok(quote! { Ok(quote! {
impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause { impl #impl_generics #ident #type_generics #where_clause {
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> { /// Field metadata generated from the same declarations as validation.
<Self as #crate_path::ConfigSection>::validate_local_fields(self)?; pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[
#post_validate #(#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(()) Ok(())
} }
} }
impl #impl_generics #ident #type_generics #where_clause { impl #impl_generics #crate_path::Config for #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 {
const FIELD_COUNT: usize = Self::DESCRIPTORS.len(); 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> { fn descriptor(index: usize) -> Option<&'static #crate_path::FieldDescriptor> {
let section = Self { Self::DESCRIPTORS.get(index)
#(#defaults),*
};
<Self as #crate_path::ValidateFields>::validate_fields(&section)?;
Ok(section)
} }
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> { 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> { fn validate(&self) -> Result<(), #crate_path::ConfigViolation> {
#(#checks)* self.__validate_local_configuration()?;
#post_validate
Ok(()) Ok(())
} }
} }
}) })
} }
fn expand_sections( fn expand_children(
ident: syn::Ident, ident: syn::Ident,
generics: syn::Generics, generics: syn::Generics,
crate_path: syn::Path, crate_path: syn::Path,
@@ -138,71 +164,55 @@ fn expand_sections(
fields: Vec<crate::parse::FieldModel>, fields: Vec<crate::parse::FieldModel>,
) -> Result<TokenStream> { ) -> Result<TokenStream> {
let (impl_generics, type_generics, where_clause) = generics.split_for_impl(); 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 field_ident = &field.ident;
let ty = &field.ty; let ty = &field.ty;
quote! { validations.push(quote! {
<#ty as #crate_path::ValidateFields>::validate_fields(&self.#field_ident)?; <#ty as #crate_path::Config>::validate(&self.#field_ident)?;
} });
}); reads.push(quote! {
let defaults = fields.iter().map(|field| { if let Some(value) = <#ty as #crate_path::Config>::field_value(
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(
&self.#field_ident, &self.#field_ident,
id, id,
) { ) {
return Some(value); return Some(value);
} }
} });
}); writes.push(quote! {
let writes = fields.iter().map(|field| { if <#ty as #crate_path::Config>::field_value(&self.#field_ident, id).is_some() {
let field_ident = &field.ident; return <#ty as #crate_path::Config>::set_field(
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(
&mut self.#field_ident, &mut self.#field_ident,
id, id,
value, value,
); );
} }
} });
}); counts.push(quote! { <#ty as #crate_path::Config>::FIELD_COUNT });
let counts = fields.iter().map(|field| { schema_ids.push(quote! { <#ty as #crate_path::Config>::SCHEMA_ID });
let ty = &field.ty; descriptor_lookups.push(quote! {
quote! { <#ty as #crate_path::ConfigSection>::FIELD_COUNT } if index < <#ty as #crate_path::Config>::FIELD_COUNT {
}); return <#ty as #crate_path::Config>::descriptor(index);
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(())
} }
} 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 { Ok(quote! {
/// Number of fields aggregated from derived nested sections. impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause {
pub const FIELD_COUNT: usize = 0 #(+ #counts)*; 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 descriptor(mut index: usize) -> Option<&'static #crate_path::FieldDescriptor> {
fn default_aggregate() -> Result<Self, #crate_path::ConfigViolation> { #(#descriptor_lookups)*
let aggregate = Self { None
#(#defaults),*
};
<Self as #crate_path::ValidateFields>::validate_fields(&aggregate)?;
Ok(aggregate)
} }
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> { fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
@@ -218,23 +228,39 @@ fn expand_sections(
#(#writes)* #(#writes)*
Err(#crate_path::FieldWriteError::UnknownField(id)) 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 { struct FieldMetadata {
descriptor: Option<TokenStream>, descriptor: TokenStream,
checks: Vec<TokenStream>, checks: Vec<TokenStream>,
} }
fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result<FieldMetadata> { fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result<FieldMetadata> {
let mut checks = Vec::new(); let mut checks = Vec::new();
let Some(id) = field.id else { let id = field.id.expect("scalar configuration fields have IDs");
return Ok(FieldMetadata {
descriptor: None,
checks,
});
};
let minimum = field.checks.iter().find_map(|check| match check { let minimum = field.checks.iter().find_map(|check| match check {
Check::Minimum(value) => Some(value), Check::Minimum(value) => Some(value),
_ => None, _ => 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 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 unit = field.unit.as_ref().expect("id fields have a unit");
let default = field.default.as_ref().expect("id fields have a default"); let ty = &field.ty;
let (kind, value) = config_value_tokens(crate_path, &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 minimum = option_value_tokens(value.clone(), minimum);
let maximum = option_value_tokens(value.clone(), maximum); let maximum = option_value_tokens(value.clone(), maximum);
Ok(FieldMetadata { Ok(FieldMetadata {
descriptor: Some(quote! { descriptor: quote! {
#crate_path::FieldDescriptor { #crate_path::FieldDescriptor {
id: #crate_path::FieldId::new(#id), id: #crate_path::FieldId::new(#id),
display_key: #key, display_key: #key,
kind: #kind, kind: #kind,
minimum: #minimum, minimum: #minimum,
maximum: #maximum, maximum: #maximum,
default: #value(#default), enum_options: #enum_options,
unit: #unit, unit: #unit,
apply_policy: #crate_path::ApplyPolicy::RestartRequired,
} }
}), },
checks, checks,
}) })
} }
@@ -276,10 +312,7 @@ fn option_value_tokens(value: TokenStream, expression: Option<&syn::Expr>) -> To
} }
} }
fn config_value_tokens( fn config_value_constructor_tokens(crate_path: &syn::Path, ty: &syn::Type) -> Result<TokenStream> {
crate_path: &syn::Path,
ty: &syn::Type,
) -> Result<(TokenStream, TokenStream)> {
let syn::Type::Path(path) = ty else { let syn::Type::Path(path) = ty else {
return Err(syn::Error::new_spanned( return Err(syn::Error::new_spanned(
ty, ty,
@@ -292,39 +325,18 @@ fn config_value_tokens(
"configuration metadata requires a scalar type", "configuration metadata requires a scalar type",
)); ));
}; };
let (kind, value) = match segment.ident.to_string().as_str() { let value = match segment.ident.to_string().as_str() {
"bool" => ( "bool" => quote! { #crate_path::ConfigValue::Bool },
quote! { #crate_path::ValueKind::Bool }, "i32" => quote! { #crate_path::ConfigValue::I32 },
quote! { #crate_path::ConfigValue::Bool }, "u8" | "u16" | "u32" => quote! { #crate_path::ConfigValue::U32 },
), "u64" => quote! { #crate_path::ConfigValue::U64 },
"i32" => ( "f32" => quote! { #crate_path::ConfigValue::F32 },
quote! { #crate_path::ValueKind::I32 }, "f64" => quote! { #crate_path::ConfigValue::F64 },
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 },
),
_ => { _ => {
return Err(syn::Error::new_spanned( return Ok(quote! { #crate_path::ConfigValue::U32 });
ty,
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
));
} }
}; };
Ok((kind, value)) Ok(value)
} }
fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Check) -> TokenStream { 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 expand;
mod parse; mod parse;
use proc_macro::TokenStream; use proc_macro::TokenStream;
use syn::parse_macro_input; use syn::parse_macro_input;
/// Implements `kaptan_config::ValidateFields` from `#[config(...)]` helpers. /// Generates validation, metadata, routing, and schema identity.
#[proc_macro_derive(ConfigValidate, attributes(config))] ///
pub fn derive_config_validate(input: TokenStream) -> TokenStream { /// 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); let input = parse_macro_input!(input as syn::DeriveInput);
match parse::Model::parse(input).and_then(expand::expand) { match parse::Model::parse(input).and_then(expand::expand) {
Ok(tokens) => tokens.into(), Ok(tokens) => tokens.into(),
Err(error) => error.into_compile_error().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, spanned::Spanned,
}; };
const MAX_DISPLAY_KEY_BYTES: usize = 48;
const MAX_UNIT_BYTES: usize = 16;
pub(crate) struct Model { pub(crate) struct Model {
pub(crate) ident: Ident, pub(crate) ident: Ident,
pub(crate) generics: syn::Generics, pub(crate) generics: syn::Generics,
@@ -24,9 +27,8 @@ pub(crate) struct FieldModel {
pub(crate) id: Option<u16>, pub(crate) id: Option<u16>,
pub(crate) display_key: Option<syn::LitStr>, pub(crate) display_key: Option<syn::LitStr>,
pub(crate) unit: Option<syn::LitStr>, pub(crate) unit: Option<syn::LitStr>,
pub(crate) default: Option<Expr>,
pub(crate) checks: Vec<Check>, pub(crate) checks: Vec<Check>,
pub(crate) section: bool, pub(crate) enum_backed: bool,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -43,19 +45,19 @@ impl Model {
let Data::Struct(data) = input.data else { let Data::Struct(data) = input.data else {
return Err(syn::Error::new( return Err(syn::Error::new(
input.ident.span(), input.ident.span(),
"ConfigValidate can only be derived for structs", "Config can only be derived for structs",
)); ));
}; };
let Fields::Named(fields) = data.fields else { let Fields::Named(fields) = data.fields else {
return Err(syn::Error::new( return Err(syn::Error::new(
input.ident.span(), input.ident.span(),
"ConfigValidate requires named struct fields", "Config requires named struct fields",
)); ));
}; };
if fields.named.is_empty() { if fields.named.is_empty() {
return Err(syn::Error::new( return Err(syn::Error::new(
input.ident.span(), 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()); let mut models = Vec::with_capacity(fields.named.len());
@@ -73,38 +75,63 @@ impl Model {
} }
models.push(model); models.push(model);
} }
let kind = if models.iter().all(|field| field.section) { let kind = if models.iter().all(|field| field.id.is_some()) {
ModelKind::Sections
} else if models.iter().all(|field| field.id.is_some()) {
ModelKind::Scalars ModelKind::Scalars
} else if models.iter().all(|field| field.id.is_none()) {
ModelKind::Sections
} else { } else {
let field = models let field = models
.iter() .iter()
.find(|field| field.section || field.id.is_none()) .find(|field| field.id.is_none())
.expect("mixed configuration model has one invalid field"); .expect("mixed configuration model has one invalid field");
return Err(syn::Error::new( return Err(syn::Error::new(
field.ident.span(), 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 { if kind == ModelKind::Scalars {
let mut display_keys = Vec::with_capacity(models.len());
for field in &models { for field in &models {
if field.display_key.is_none() { let Some(display_key) = &field.display_key else {
return Err(syn::Error::new( return Err(syn::Error::new(
field.ident.span(), field.ident.span(),
"configuration fields require config(key = \"...\")", "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( return Err(syn::Error::new(
field.ident.span(), field.ident.span(),
"configuration fields require config(unit = \"...\")", "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( return Err(syn::Error::new(
field.ident.span(), field.ty.span(),
"configuration fields require config(default = ...)", "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)?; 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>)> { fn parse_container_attributes(attributes: &[Attribute]) -> Result<(Path, Option<Path>)> {
let mut crate_path = syn::parse_quote!(::kaptan_config); let mut crate_path = syn::parse_quote!(::kaptan_config);
let mut has_crate_path = false; let mut has_crate_path = false;
@@ -160,40 +215,17 @@ fn parse_field(field: Field) -> Result<FieldModel> {
let mut id = None; let mut id = None;
let mut display_key = None; let mut display_key = None;
let mut unit = None; let mut unit = None;
let mut default = None;
let mut finite = false; let mut finite = false;
let mut nonzero = false; let mut nonzero = false;
let mut range_minimum = false; let mut range_minimum = false;
let mut range_maximum = false; let mut range_maximum = false;
let mut section = false; let mut enum_backed = false;
for attribute in field for attribute in field
.attrs .attrs
.iter() .iter()
.filter(|attribute| attribute.path().is_ident("config")) .filter(|attribute| attribute.path().is_ident("config"))
{ {
attribute.parse_nested_meta(|meta| { 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 meta.path.is_ident("id") {
if id.is_some() { if id.is_some() {
return Err(meta.error("duplicate configuration field id")); return Err(meta.error("duplicate configuration field id"));
@@ -220,13 +252,11 @@ fn parse_field(field: Field) -> Result<FieldModel> {
unit = Some(meta.value()?.parse()?); unit = Some(meta.value()?.parse()?);
return Ok(()); return Ok(());
} }
if meta.path.is_ident("default") { if meta.path.is_ident("enum") {
if default.is_some() { if enum_backed {
return Err(meta.error("duplicate configuration default")); return Err(meta.error("duplicate enum configuration marker"));
} }
let expression = meta.value()?.parse()?; enum_backed = true;
validate_default_literal(&expression, &field.ty)?;
default = Some(expression);
return Ok(()); return Ok(());
} }
if meta.path.is_ident("finite") { if meta.path.is_ident("finite") {
@@ -273,26 +303,16 @@ fn parse_field(field: Field) -> Result<FieldModel> {
return Ok(()); return Ok(());
} }
Err(meta.error( 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() { if id.is_none()
return Err(syn::Error::new( && (display_key.is_some() || unit.is_some() || enum_backed || !checks.is_empty())
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())
{ {
return Err(syn::Error::new( return Err(syn::Error::new(
ident.span(), ident.span(),
"config(section) cannot be combined with scalar id, metadata, or validation rules", "scalar configuration fields require config(id = N)",
)); ));
} }
Ok(FieldModel { Ok(FieldModel {
@@ -301,9 +321,8 @@ fn parse_field(field: Field) -> Result<FieldModel> {
id, id,
display_key, display_key,
unit, unit,
default,
checks, 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<()> { fn validate_static_constraints(field: &FieldModel) -> Result<()> {
let ty = scalar_type_name(&field.ty).ok_or_else(|| { let Some(ty) = scalar_type_name(&field.ty) else {
syn::Error::new( if field.checks.is_empty() {
return Ok(());
}
return Err(syn::Error::new(
field.ty.span(), field.ty.span(),
"configuration metadata requires a supported scalar type", "validation rules require a supported primitive scalar type",
) ));
})?; };
if ty == "bool" { if ty == "bool" {
return Ok(()); 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 { let minimum = field.checks.iter().find_map(|check| match check {
Check::Minimum(value) => Some(value), Check::Minimum(value) => Some(value),
_ => None, _ => None,
@@ -406,70 +411,6 @@ fn validate_static_constraints(field: &FieldModel) -> Result<()> {
"configuration range minimum exceeds maximum", "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(()) Ok(())
} }
@@ -487,14 +428,6 @@ impl ScalarLiteral {
Self::Float(value) => value.is_finite(), 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> { 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)) 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" => { "u64" => {
let value = unsigned_literal(expression)?; let value = unsigned_literal(expression)?;
u64::try_from(value).map_err(|_| { 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), "f64" => float_literal(expression).map(ScalarLiteral::Float),
_ => Err(syn::Error::new( _ => Err(syn::Error::new(
expression.span(), 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::{Config, ConfigViolation};
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)] #[derive(Config)]
struct Child { 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, value: u32,
} }
#[derive(ConfigValidate)] #[derive(Config)]
#[config(post = Self::validate_complete)] #[config(post = Self::validate_complete)]
struct Root { struct Root {
#[config(section)]
child: Child, child: Child,
} }
impl Root { impl Root {
fn validate_complete(&self) -> Result<(), ConfigViolation> { fn validate_complete(&self) -> Result<(), ConfigViolation> {
self.child.validate_fields() self.child.validate()
} }
} }
fn main() { fn main() {
let root = Root::default_aggregate().unwrap(); let root = Root {
child: Child { value: 1 },
};
assert_eq!(Root::FIELD_COUNT, 1); assert_eq!(Root::FIELD_COUNT, 1);
assert_eq!(root.child.field_value(kaptan_config::FieldId::new(1)), Some(kaptan_config::ConfigValue::U32(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 { struct RootBooleanConfig {
#[config(id = 1, key = "enabled", unit = "bool", default = true)] #[config(id = 1, key = "enabled", unit = "bool")]
enabled: bool, enabled: bool,
} }
@@ -1,37 +1,22 @@
use kaptan_config::{ use kaptan_config::{
ConfigAggregate, ConfigSection, ConfigValue, ConfigViolation, FieldId, FieldWriteError, Config, ConfigEnum, ConfigValue, ConfigViolation, FieldId, FieldWriteError, ValidationRule,
ValidateFields, ValidationRule,
}; };
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)] #[derive(Config)]
struct LocalSection { struct LocalSection {
#[config( #[config(id = 1, key = "local.first", unit = "count", range(min = 1, max = 9))]
id = 1,
key = "local.first",
unit = "count",
default = 2,
range(min = 1, max = 9)
)]
first: u32, first: u32,
#[config( #[config(id = 2, key = "local.second", unit = "count", range(min = 1, max = 9))]
id = 2,
key = "local.second",
unit = "count",
default = 4,
range(min = 1, max = 9)
)]
second: u32, second: u32,
} }
#[derive(ConfigValidate)] #[derive(Config)]
#[config(post = Self::validate_order)] #[config(post = Self::validate_order)]
struct OrderedSection { struct OrderedSection {
#[config( #[config(
id = 3, id = 3,
key = "ordered.minimum", key = "ordered.minimum",
unit = "count", unit = "count",
default = 1,
range(min = 0, max = 9) range(min = 0, max = 9)
)] )]
minimum: u32, minimum: u32,
@@ -39,7 +24,6 @@ struct OrderedSection {
id = 4, id = 4,
key = "ordered.maximum", key = "ordered.maximum",
unit = "count", unit = "count",
default = 2,
range(min = 0, max = 9) range(min = 0, max = 9)
)] )]
maximum: u32, maximum: u32,
@@ -58,17 +42,54 @@ impl OrderedSection {
} }
} }
#[derive(ConfigValidate)] #[derive(Config)]
struct Aggregate { struct Aggregate {
#[config(section)]
local: LocalSection, local: LocalSection,
#[config(section)]
ordered: OrderedSection, 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] #[test]
fn generated_section_owns_every_declared_scalar_path() { 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!(LocalSection::FIELD_COUNT, 2);
assert_eq!( assert_eq!(
section.field_value(FieldId::new(2)), 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] #[test]
fn aggregate_routes_writes_and_defers_declared_post_validation() { 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_COUNT, 4);
assert_eq!( assert_eq!(
aggregate.field_value(FieldId::new(2)), 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)) .set_field(FieldId::new(3), ConfigValue::U32(3))
.unwrap(); .unwrap();
assert!(matches!( assert!(matches!(
aggregate.validate_fields(), aggregate.validate(),
Err(issue) Err(issue)
if issue.primary() == Some(FieldId::new(3)) if issue.primary() == Some(FieldId::new(3))
&& issue.related_field() == Some(FieldId::new(4)) && issue.related_field() == Some(FieldId::new(4))
@@ -115,5 +179,101 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() {
aggregate aggregate
.set_field(FieldId::new(4), ConfigValue::U32(3)) .set_field(FieldId::new(4), ConfigValue::U32(3))
.unwrap(); .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 { struct DuplicateId {
#[config(id = 7, nonzero)] #[config(id = 7, nonzero)]
first: u32, first: u32,
@@ -1,6 +1,6 @@
use kaptan_config_derive::ConfigValidate; use kaptan_config_derive::Config;
#[derive(ConfigValidate)] #[derive(Config)]
struct DuplicateRange { struct DuplicateRange {
#[config(id = 7, range(min = 1), range(min = 2))] #[config(id = 7, range(min = 1), range(min = 2))]
value: u32, value: u32,
@@ -1,6 +1,6 @@
use kaptan_config_derive::ConfigValidate; use kaptan_config_derive::Config;
#[derive(ConfigValidate)] #[derive(Config)]
struct Empty {} struct Empty {}
fn main() {} 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 --> tests/ui/empty.rs:4:8
| |
4 | struct Empty {} 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 { struct MissingId {
#[config(nonzero)] #[config(nonzero)]
value: u32, 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 --> tests/ui/missing_id.rs:6:5
| |
6 | value: u32, 6 | value: u32,
@@ -1,8 +1,8 @@
use kaptan_config_derive::ConfigValidate; use kaptan_config_derive::Config;
#[derive(ConfigValidate)] #[derive(Config)]
struct MissingMetadata { 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, value: u32,
} }
@@ -1,10 +1,9 @@
use kaptan_config_derive::ConfigValidate; use kaptan_config_derive::Config;
#[derive(ConfigValidate)] #[derive(Config)]
struct MixedSectionAndScalar { struct MixedSectionAndScalar {
#[config(section)]
child: u32, 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, scalar: u32,
} }
@@ -1,5 +1,5 @@
error: ConfigValidate records must contain either scalar config fields or config(section) fields exclusively error: Config records must contain either scalar fields or child configuration records exclusively
--> tests/ui/mixed_section_scalar.rs:6:5 --> 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 { 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, 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] [lints]
workspace = true 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] [dependencies]
kaptan-config = { path = "../kaptan-config" } kaptan-config = { path = "../kaptan-config" }
kaptan-log = { path = "../kaptan-log" } kaptan-log = { path = "../kaptan-log" }
@@ -1,4 +1,4 @@
//! Authorized, transport-neutral configuration commands. //! Transport-neutral configuration commands.
use kaptan_config::{ConfigValue, FieldId}; use kaptan_config::{ConfigValue, FieldId};
@@ -11,6 +11,30 @@ pub struct Target {
component: u8, 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. /// Selects the configuration snapshot read by a value request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ValueSource { pub enum ValueSource {
@@ -45,21 +69,30 @@ impl Target {
/// One command accepted by the Kaptan configuration microservice. /// One command accepted by the Kaptan configuration microservice.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub enum ConfigCommand { pub enum ConfigCommand {
/// Reads protocol/schema metadata before any descriptor or upload request. /// Reads protocol/schema metadata before descriptor and value requests.
InfoRequest { InfoRequest {
/// Client correlation identity. /// Client correlation identity.
request_id: u32, request_id: u32,
/// Exact addressed endpoint. /// Exact addressed endpoint.
target: Target, target: Target,
}, },
/// Enumerates a bounded page of schema descriptors. /// Reads one schema descriptor by stable index.
DescriptorRequest { DescriptorRequest {
/// Client correlation identity. /// Client correlation identity.
request_id: u32, request_id: u32,
/// First stable descriptor index. /// Stable descriptor index.
first: u16, index: u16,
/// Requested number of descriptors, clamped to the protocol bound. /// Exact addressed endpoint.
count: u16, 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. /// Exact addressed endpoint.
target: Target, target: Target,
}, },
@@ -76,59 +109,10 @@ pub enum ConfigCommand {
/// Exact addressed endpoint. /// Exact addressed endpoint.
target: Target, target: Target,
}, },
/// Starts one bounded CBOR snapshot import into the caller's transaction. /// Starts an edit transaction from the latest committed configuration.
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.
Begin { Begin {
/// Client correlation identity. /// Client correlation identity.
request_id: u32, request_id: u32,
/// Optimistic concurrency generation.
expected_generation: u64,
/// Exact addressed endpoint. /// Exact addressed endpoint.
target: Target, target: Target,
}, },
@@ -164,8 +148,6 @@ pub enum ConfigCommand {
request_id: u32, request_id: u32,
/// Lease-bound transaction identity. /// Lease-bound transaction identity.
session_id: u32, session_id: u32,
/// Optimistic committed generation.
expected_generation: u64,
/// Optimistic transaction revision. /// Optimistic transaction revision.
expected_revision: u32, expected_revision: u32,
/// Exact addressed endpoint. /// Exact addressed endpoint.
@@ -188,10 +170,8 @@ impl ConfigCommand {
match self { match self {
Self::InfoRequest { request_id, .. } Self::InfoRequest { request_id, .. }
| Self::DescriptorRequest { request_id, .. } | Self::DescriptorRequest { request_id, .. }
| Self::EnumOptionRequest { request_id, .. }
| Self::ValueRequest { request_id, .. } | Self::ValueRequest { request_id, .. }
| Self::ImportBegin { request_id, .. }
| Self::ImportChunk { request_id, .. }
| Self::ImportFinish { request_id, .. }
| Self::Begin { request_id, .. } | Self::Begin { request_id, .. }
| Self::Set { request_id, .. } | Self::Set { request_id, .. }
| Self::Validate { request_id, .. } | Self::Validate { request_id, .. }
@@ -205,10 +185,8 @@ impl ConfigCommand {
match self { match self {
Self::InfoRequest { target, .. } Self::InfoRequest { target, .. }
| Self::DescriptorRequest { target, .. } | Self::DescriptorRequest { target, .. }
| Self::EnumOptionRequest { target, .. }
| Self::ValueRequest { target, .. } | Self::ValueRequest { target, .. }
| Self::ImportBegin { target, .. }
| Self::ImportChunk { target, .. }
| Self::ImportFinish { target, .. }
| Self::Begin { target, .. } | Self::Begin { target, .. }
| Self::Set { target, .. } | Self::Set { target, .. }
| Self::Validate { target, .. } | Self::Validate { target, .. }
@@ -218,54 +196,25 @@ impl ConfigCommand {
} }
} }
/// A capability token produced only by the future signing/replay authority. /// Test command paired with its lease principal.
/// #[cfg(test)]
/// 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.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct Authorized<T> { pub struct Authorized<T> {
authority: VerifiedAuthority, principal: Principal,
command: T, command: T,
} }
#[cfg(test)]
impl<T> Authorized<T> { impl<T> Authorized<T> {
#[cfg(any(test, feature = "host-authority"))] /// Pairs a test command with component `principal` in test system 1.
/// Wraps a host/SITL command in an explicit test-only authority token. pub const fn host(principal: u8, command: T) -> Self {
pub const fn host(principal: u64, command: T) -> Self {
Self { Self {
authority: VerifiedAuthority::host(principal), principal: Principal::new(1, principal),
command, command,
} }
} }
pub(crate) fn into_parts(self) -> (VerifiedAuthority, T) { pub(crate) fn into_parts(self) -> (Principal, T) {
(self.authority, self.command) (self.principal, self.command)
} }
} }
+19 -4
View File
@@ -2,19 +2,34 @@
//! //!
//! This crate translates generated `kaptan.xml` records to the transport- //! This crate translates generated `kaptan.xml` records to the transport-
//! neutral configuration lifecycle. It does not parse raw frames, authenticate //! 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. //! consumers.
#![no_std] #![no_std]
#[cfg(test)]
extern crate std;
mod command; mod command;
mod protocol; mod protocol;
pub mod service; pub mod service;
pub mod wire; 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 protocol::{ConfigReply, Operation, ProtocolError, ResultKind};
pub use service::{ pub use service::{
CAPABILITY_CBOR_IMPORT, ConfigInfo, ConfigProtocol, DescriptorPage, NoSnapshotImporter, ConfigInfo, ConfigProtocol, DescriptorResponse, EnumOptionResponse, ServiceResponse,
ServiceResponse, SnapshotImporter, ValueResponse, 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. //! Stable typed outcomes for the Kaptan configuration microservice.
use kaptan_config::{ConfigState, ConfigViolation, FieldId}; use kaptan_config::{ConfigState, ConfigViolation, FieldId};
use kaptan_log::{EventHub, KaptanError, LogFlags, PublishOutcome}; use kaptan_log::KaptanError;
/// Operation reported in an authoritative result record. /// Operation reported in an authoritative result record.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Operation { pub enum Operation {
@@ -17,8 +18,6 @@ pub enum Operation {
Commit, Commit,
/// Transaction abort. /// Transaction abort.
Abort, Abort,
/// CBOR snapshot import into the isolated transaction.
Import,
} }
/// Stable result classification. /// Stable result classification.
@@ -38,11 +37,7 @@ pub enum ResultKind {
pub enum ProtocolError { pub enum ProtocolError {
/// The request target did not match this component. /// The request target did not match this component.
BadTarget, BadTarget,
/// Authorization was absent or insufficient. /// The configuration lease or single transaction slot is occupied.
AuthorizationDenied,
/// The optimistic durable generation did not match.
GenerationConflict,
/// The single transaction slot is occupied.
SessionBusy, SessionBusy,
/// The session is unknown, expired, or belongs to a different principal. /// The session is unknown, expired, or belongs to a different principal.
InvalidSession, InvalidSession,
@@ -54,22 +49,14 @@ pub enum ProtocolError {
BadState, BadState,
/// The field is not present in this schema. /// The field is not present in this schema.
UnknownField(FieldId), UnknownField(FieldId),
/// A query requested an empty or out-of-range descriptor span.
InvalidQuery,
/// The typed setter does not match the field descriptor. /// The typed setter does not match the field descriptor.
TypeMismatch(FieldId), TypeMismatch(FieldId),
/// A local or complete validation issue. /// A local or complete validation issue.
Violation(ConfigViolation), Violation(ConfigViolation),
/// Persistence failed after a complete validation pass. /// Persistence failed after a complete validation pass.
StorageFailure, 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. /// The adapter reached an impossible internal state.
Internal, Internal,
} }
@@ -81,12 +68,8 @@ pub struct ConfigReply {
pub request_id: u32, pub request_id: u32,
/// The current session, or zero when none exists. /// The current session, or zero when none exists.
pub session_id: u32, 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. /// The transaction revision, or zero when no transaction exists.
pub revision: u32, pub revision: u32,
/// Latest committed generation, or zero for the factory profile.
pub generation: u64,
/// Completed operation. /// Completed operation.
pub operation: Operation, pub operation: Operation,
/// High-level result. /// High-level result.
@@ -130,66 +113,4 @@ impl ConfigReply {
_ => 0, _ => 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. //! Transport-neutral façade for the custom configuration service.
//! //!
//! Public callers see one small protocol API here. Its bounded query, //! 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. //! so no one concern becomes a second service owner.
mod import;
mod query; mod query;
mod replay; mod replay;
mod session; mod session;
pub use import::{CAPABILITY_CBOR_IMPORT, NoSnapshotImporter, SnapshotImporter}; pub use query::{
pub use query::{ConfigInfo, DescriptorPage, ServiceResponse, ValueResponse}; ConfigInfo, DescriptorResponse, EnumOptionResponse, ServiceResponse, ValueResponse,
};
pub use session::ConfigProtocol; 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. //! Read-only protocol discovery and value-query responses.
use kaptan_config::{ use kaptan_config::{
ConfigManager, ConfigModel, ConfigState, ConfigValue, FieldDescriptor, FieldId, Generation, ConfigManager, ConfigModel, ConfigState, ConfigStore, ConfigValue, EnumOption, FieldDescriptor,
ProfileStore, FieldId,
}; };
use super::{import::SnapshotImporter, session::ConfigProtocol}; use super::session::ConfigProtocol;
use crate::{ProtocolError, ValueSource}; 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)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct DescriptorPage { pub struct DescriptorResponse {
/// Request being answered. /// Request being answered.
pub request_id: u32, pub request_id: u32,
/// First requested descriptor index. /// Stable index of this descriptor in the advertised schema.
pub first: u16, pub index: u16,
/// Number of descriptors present in `descriptors`. /// Self-describing field metadata.
pub len: u8, pub descriptor: FieldDescriptor,
/// Fixed-capacity response page; `None` slots are unused. /// Factory value selected by the concrete board/image composition.
pub descriptors: [Option<&'static FieldDescriptor>; 4], 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. /// One typed value read from an explicitly selected configuration snapshot.
@@ -41,20 +56,12 @@ pub struct ValueResponse {
pub struct ConfigInfo { pub struct ConfigInfo {
/// Request being answered. /// Request being answered.
pub request_id: u32, pub request_id: u32,
/// Boot-active generation, or `None` for the factory profile. /// Stable schema identity for this target-described field registry.
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.
pub schema_id: [u8; 16], 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. /// Number of editable fields in the registry.
pub field_count: u16, pub field_count: u16,
/// Current configuration lifecycle state. /// Current configuration lifecycle state.
pub state: ConfigState, pub state: ConfigState,
/// Deployment capabilities such as bounded CBOR import.
pub capability_flags: u8,
} }
/// Exact response produced by the custom configuration service. /// Exact response produced by the custom configuration service.
@@ -64,13 +71,15 @@ pub enum ServiceResponse {
Info(ConfigInfo), Info(ConfigInfo),
/// Completion for a mutating operation or rejected query. /// Completion for a mutating operation or rejected query.
Result(crate::ConfigReply), Result(crate::ConfigReply),
/// Bounded self-describing schema page. /// One self-describing schema field.
Descriptors(DescriptorPage), Descriptor(DescriptorResponse),
/// One named choice for its explicitly identified enum descriptor.
EnumOption(EnumOptionResponse),
/// One typed scalar value. /// One typed scalar value.
Value(ValueResponse), Value(ValueResponse),
} }
impl<I> ConfigProtocol<I> { impl ConfigProtocol {
/// Returns static descriptor metadata by stable schema index. /// Returns static descriptor metadata by stable schema index.
pub fn descriptor<C, S>( pub fn descriptor<C, S>(
&self, &self,
@@ -79,7 +88,7 @@ impl<I> ConfigProtocol<I> {
) -> Option<&'static FieldDescriptor> ) -> Option<&'static FieldDescriptor>
where where
C: ConfigModel, C: ConfigModel,
S: ProfileStore<C>, S: ConfigStore<C>,
{ {
manager.descriptor(index) manager.descriptor(index)
} }
@@ -93,7 +102,7 @@ impl<I> ConfigProtocol<I> {
) -> Option<ConfigValue> ) -> Option<ConfigValue>
where where
C: ConfigModel, C: ConfigModel,
S: ProfileStore<C>, S: ConfigStore<C>,
{ {
match source { match source {
ValueSource::Active => manager.active_value(field), 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, &self,
manager: &ConfigManager<C, S>, manager: &ConfigManager<C, S>,
request_id: u32, request_id: u32,
first: u16, index: u16,
requested: u16, ) -> Option<DescriptorResponse>
) -> DescriptorPage
where where
C: ConfigModel, C: ConfigModel,
S: ProfileStore<C>, S: ConfigStore<C>,
{ {
let mut descriptors = [None; 4]; let descriptor = *manager.descriptor(index)?;
let count = requested.min(descriptors.len() as u16); let default = manager.factory_value(descriptor.id)?;
let mut len = 0; Some(DescriptorResponse {
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 {
request_id, request_id,
first, index,
len, descriptor,
descriptors, 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 pub(super) fn info<C, S>(&self, manager: &ConfigManager<C, S>, request_id: u32) -> ConfigInfo
where where
C: ConfigModel, C: ConfigModel,
S: ProfileStore<C>, S: ConfigStore<C>,
I: SnapshotImporter<C>,
{ {
ConfigInfo { ConfigInfo {
request_id, request_id,
active_generation: manager.active_generation(),
committed_generation: manager.committed_generation(),
schema_id: C::SCHEMA_ID, schema_id: C::SCHEMA_ID,
profile_format: I::PROFILE_FORMAT,
field_count: manager.field_count(), field_count: manager.field_count(),
state: self.state, state: self.state,
capability_flags: self.capability_flags_for::<C>(),
} }
} }
pub(super) fn value_response<C, S>( pub(super) fn value_response<C, S>(
&self, &self,
manager: &ConfigManager<C, S>, manager: &ConfigManager<C, S>,
owner: u64, owner: Principal,
request_id: u32, request_id: u32,
session_id: u32, session_id: u32,
field: FieldId, field: FieldId,
@@ -163,7 +177,7 @@ impl<I> ConfigProtocol<I> {
) -> ServiceResponse ) -> ServiceResponse
where where
C: ConfigModel, C: ConfigModel,
S: ProfileStore<C>, S: ConfigStore<C>,
{ {
let (value, reply_session, revision) = match source { let (value, reply_session, revision) = match source {
ValueSource::Active => (manager.active_value(field), 0, 0), ValueSource::Active => (manager.active_value(field), 0, 0),
@@ -176,7 +190,7 @@ impl<I> ConfigProtocol<I> {
ProtocolError::InvalidSession, 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( return ServiceResponse::Result(self.rejected_for(
request_id, request_id,
session_id, session_id,
@@ -3,34 +3,42 @@
use kaptan_config::ConfigValue; use kaptan_config::ConfigValue;
use super::session::ConfigProtocol; use super::session::ConfigProtocol;
use crate::{ConfigCommand, ConfigReply, ProtocolError}; use crate::{ConfigCommand, ConfigReply, Operation, Principal, ProtocolError};
pub(super) const REQUEST_CACHE_CAPACITY: usize = 8;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub(super) struct RememberedResult { pub(super) struct RememberedResult {
pub(super) owner: u64,
pub(super) command: ConfigCommand, pub(super) command: ConfigCommand,
pub(super) reply: ConfigReply, pub(super) reply: ConfigReply,
pub(super) owner: Principal,
} }
impl<I> ConfigProtocol<I> { impl ConfigProtocol {
pub(super) fn cached(&self, owner: u64, command: ConfigCommand) -> Option<ConfigReply> { pub(super) fn cached(&self, owner: Principal, command: ConfigCommand) -> Option<ConfigReply> {
for cached in self.remembered.iter().flatten() { let cached = self.remembered?;
if cached.owner != owner || cached.command.request_id() != command.request_id() { if cached.owner != owner || cached.command.request_id() != command.request_id() {
continue; return None;
} }
if same_command(cached.command, command) { if same_command(cached.command, command) {
return Some(cached.reply); Some(cached.reply)
} } else {
return Some(self.rejected(command, ProtocolError::RequestConflict)); Some(self.rejected(command, ProtocolError::RequestConflict))
} }
None
} }
pub(super) fn remember(&mut self, result: RememberedResult) { pub(super) fn remember(
self.remembered[self.next_remembered] = Some(result); &mut self,
self.next_remembered = (self.next_remembered + 1) % REQUEST_CACHE_CAPACITY; 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 { ConfigCommand::DescriptorRequest {
request_id: left_request, request_id: left_request,
first: left_first, index: left_index,
count: left_count,
target: left_target, target: left_target,
}, },
ConfigCommand::DescriptorRequest { ConfigCommand::DescriptorRequest {
request_id: right_request, request_id: right_request,
first: right_first, index: right_index,
count: right_count,
target: right_target, target: right_target,
}, },
) => { ) => {
left_request == right_request left_request == right_request
&& left_first == right_first && left_index == right_index
&& left_count == right_count && 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 && left_target == right_target
} }
( (
@@ -90,19 +114,13 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
( (
ConfigCommand::Begin { ConfigCommand::Begin {
request_id: left_request, request_id: left_request,
expected_generation: left_generation,
target: left_target, target: left_target,
}, },
ConfigCommand::Begin { ConfigCommand::Begin {
request_id: right_request, request_id: right_request,
expected_generation: right_generation,
target: right_target, target: right_target,
}, },
) => { ) => left_request == right_request && left_target == right_target,
left_request == right_request
&& left_generation == right_generation
&& left_target == right_target
}
( (
ConfigCommand::Set { ConfigCommand::Set {
request_id: left_request, request_id: left_request,
@@ -151,21 +169,18 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
ConfigCommand::Commit { ConfigCommand::Commit {
request_id: left_request, request_id: left_request,
session_id: left_session, session_id: left_session,
expected_generation: left_generation,
expected_revision: left_revision, expected_revision: left_revision,
target: left_target, target: left_target,
}, },
ConfigCommand::Commit { ConfigCommand::Commit {
request_id: right_request, request_id: right_request,
session_id: right_session, session_id: right_session,
expected_generation: right_generation,
expected_revision: right_revision, expected_revision: right_revision,
target: right_target, target: right_target,
}, },
) => { ) => {
left_request == right_request left_request == right_request
&& left_session == right_session && left_session == right_session
&& left_generation == right_generation
&& left_revision == right_revision && left_revision == right_revision
&& left_target == right_target && left_target == right_target
} }
@@ -185,84 +200,6 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
&& left_session == right_session && left_session == right_session
&& left_target == right_target && 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, _ => false,
} }
} }
File diff suppressed because it is too large. Load diff
+277 -104
View File
@@ -1,23 +1,68 @@
//! Generated Kaptan-dialect message translation. //! Generated Kaptan-dialect message translation.
use kaptan_config::{ use kaptan_config::{
ApplyPolicy, ConfigState, ConfigValue, FieldDescriptor, FieldId, Generation, ValidationRule, ConfigState, ConfigValue, FieldDescriptor, FieldId, ValidationRule, ValueKind,
ValueKind,
}; };
use kaptan_log::KaptanError; use kaptan_log::KaptanError;
use kaptan_mavlink::message::Message; use kaptan_mavlink::message::Message;
use kaptan_mavlink::message::kaptan::{ use kaptan_mavlink::message::kaptan::{
KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_INFO_DATA, KAPTAN_CONFIG_RESULT_DATA, KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_ENUM_OPTION_DATA, KAPTAN_CONFIG_INFO_DATA,
KAPTAN_CONFIG_VALUE_DATA, KaptanConfigError, KaptanConfigOperation, KaptanConfigPolicy, KAPTAN_CONFIG_RESULT_DATA, KAPTAN_CONFIG_VALUE_DATA, KaptanConfigError, KaptanConfigOperation,
KaptanConfigResult, KaptanConfigState, KaptanConfigValueKind, KaptanConfigValueSource, KaptanConfigResult, KaptanConfigState, KaptanConfigValueKind, KaptanConfigValueSource,
MavMessage, MavMessage,
}; };
use crate::{ use crate::{
ConfigCommand, ConfigReply, Operation, ProtocolError, ResultKind, Target, ValueSource, ConfigCommand, ConfigReply, DESCRIPTOR_HAS_MAXIMUM, DESCRIPTOR_HAS_MINIMUM, Operation,
service::{ConfigInfo, ValueResponse}, 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. /// Rejects a generated message that is not a Kaptan configuration command.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum DecodeError { pub enum DecodeError {
@@ -35,8 +80,9 @@ pub enum DecodeError {
/// Converts one generated custom request into a transport-neutral command. /// Converts one generated custom request into a transport-neutral command.
/// ///
/// This only decodes a validated MAVLink payload. An authenticated ingress must /// This only decodes a validated MAVLink payload. CRC validity is not peer
/// still wrap the result in [`crate::Authorized`] before dispatch. /// 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> { pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError> {
if matches!(message.target_system_id(), Some(0)) if matches!(message.target_system_id(), Some(0))
|| matches!(message.target_component_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) => { MavMessage::KAPTAN_CONFIG_DESCRIPTOR_REQUEST(data) => {
Ok(ConfigCommand::DescriptorRequest { Ok(ConfigCommand::DescriptorRequest {
request_id: data.request_id, request_id: data.request_id,
first: data.first, index: data.descriptor_index,
count: data.count, 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), 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)?, source: value_source(data.source)?,
target: Target::new(data.target_system, data.target_component), 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 { MavMessage::KAPTAN_CONFIG_BEGIN(data) => Ok(ConfigCommand::Begin {
request_id: data.request_id, request_id: data.request_id,
expected_generation: data.expected_generation,
target: Target::new(data.target_system, data.target_component), target: Target::new(data.target_system, data.target_component),
}), }),
MavMessage::KAPTAN_CONFIG_SET_F32(data) => Ok(ConfigCommand::Set { 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 { MavMessage::KAPTAN_CONFIG_COMMIT(data) => Ok(ConfigCommand::Commit {
request_id: data.request_id, request_id: data.request_id,
session_id: data.session_id, session_id: data.session_id,
expected_generation: data.expected_generation,
expected_revision: data.expected_revision, expected_revision: data.expected_revision,
target: Target::new(data.target_system, data.target_component), 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. /// Encodes one authoritative service completion.
pub fn encode_result(reply: ConfigReply) -> KAPTAN_CONFIG_RESULT_DATA { pub fn encode_result(reply: ConfigReply) -> KAPTAN_CONFIG_RESULT_DATA {
KAPTAN_CONFIG_RESULT_DATA { KAPTAN_CONFIG_RESULT_DATA {
generation: reply.generation,
request_id: reply.request_id, request_id: reply.request_id,
session_id: reply.session_id, session_id: reply.session_id,
transfer_id: reply.transfer_id,
revision: reply.revision, revision: reply.revision,
primary_field: reply.primary_field(), primary_field: reply.primary_field(),
related_field: reply.related_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. /// Encodes configuration protocol/schema status for a Kaptan control station.
pub fn encode_info(info: ConfigInfo) -> KAPTAN_CONFIG_INFO_DATA { pub fn encode_info(info: ConfigInfo) -> KAPTAN_CONFIG_INFO_DATA {
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, request_id: info.request_id,
schema_id: info.schema_id, schema_id: info.schema_id,
profile_format: info.profile_format,
field_count: info.field_count, field_count: info.field_count,
protocol_major: 1, protocol_major: PROTOCOL_MAJOR,
protocol_minor: 0, protocol_minor: PROTOCOL_MINOR,
config_state: state(info.state), config_state: state(info.state),
capability_flags: info.capability_flags,
} }
} }
@@ -227,19 +247,34 @@ pub fn encode_descriptor(
request_id: u32, request_id: u32,
descriptor_index: u16, descriptor_index: u16,
descriptor: FieldDescriptor, descriptor: FieldDescriptor,
default: ConfigValue,
) -> KAPTAN_CONFIG_DESCRIPTOR_DATA { ) -> KAPTAN_CONFIG_DESCRIPTOR_DATA {
KAPTAN_CONFIG_DESCRIPTOR_DATA { KAPTAN_CONFIG_DESCRIPTOR_DATA {
minimum_bits: descriptor.minimum.map_or(0, ConfigValue::raw_bits), minimum_bits: descriptor.minimum.map_or(0, ConfigValue::raw_bits),
maximum_bits: descriptor.maximum.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, request_id,
descriptor_index, descriptor_index,
field_id: descriptor.id.get(), 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(), display_key: descriptor.display_key.into(),
unit: descriptor.unit.into(), unit: descriptor.unit.into(),
value_kind: value_kind(descriptor.kind), value_kind: value_kind(descriptor.kind),
apply_policy: policy(descriptor.apply_policy), flags: descriptor.minimum.map_or(0, |_| DESCRIPTOR_HAS_MINIMUM)
flags: 0, | 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 { const fn operation(operation: Operation) -> KaptanConfigOperation {
match operation { match operation {
Operation::Query => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_QUERY, 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::Validate => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE,
Operation::Commit => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT, Operation::Commit => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT,
Operation::Abort => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT, 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 { match error {
None => KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE, None => KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE,
Some(ProtocolError::BadTarget) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BAD_TARGET, 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::SessionBusy) => KaptanConfigError::KAPTAN_CONFIG_ERROR_SESSION_BUSY,
Some(ProtocolError::InvalidSession) => { Some(ProtocolError::InvalidSession) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_SESSION KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_SESSION
@@ -326,6 +346,7 @@ const fn error(error: Option<ProtocolError>) -> KaptanConfigError {
Some(ProtocolError::UnknownField(_)) => { Some(ProtocolError::UnknownField(_)) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNKNOWN_FIELD KaptanConfigError::KAPTAN_CONFIG_ERROR_UNKNOWN_FIELD
} }
Some(ProtocolError::InvalidQuery) => KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_QUERY,
Some(ProtocolError::TypeMismatch(_)) => { Some(ProtocolError::TypeMismatch(_)) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_TYPE_MISMATCH KaptanConfigError::KAPTAN_CONFIG_ERROR_TYPE_MISMATCH
} }
@@ -339,36 +360,88 @@ const fn error(error: Option<ProtocolError>) -> KaptanConfigError {
ValidationRule::UnsupportedCapability => { ValidationRule::UnsupportedCapability => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_CAPABILITY KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_CAPABILITY
} }
ValidationRule::UnsupportedFormat => { ValidationRule::UnsupportedFormat => KaptanConfigError::KAPTAN_CONFIG_ERROR_INTERNAL,
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_FORMAT ValidationRule::InvalidEnumValue => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_ENUM_VALUE
} }
}, },
Some(ProtocolError::StorageFailure) => { Some(ProtocolError::StorageFailure) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_STORAGE_FAILURE 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, Some(ProtocolError::Internal) => KaptanConfigError::KAPTAN_CONFIG_ERROR_INTERNAL,
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use kaptan_config::{ConfigState, ConfigViolation, ValidationRule}; use kaptan_config::{
ConfigState, ConfigValue, ConfigViolation, FieldDescriptor, ValidationRule, ValueKind,
};
use kaptan_mavlink::message::kaptan::{ 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 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] #[test]
fn generated_set_message_preserves_typed_value_and_identifiers() { fn generated_set_message_preserves_typed_value_and_identifiers() {
let message = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA { 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] #[test]
fn generated_config_requests_reject_broadcast_targets() { fn generated_config_requests_reject_broadcast_targets() {
let zero_system = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA { let zero_system = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
@@ -444,9 +538,7 @@ mod tests {
let reply = ConfigReply { let reply = ConfigReply {
request_id: 1, request_id: 1,
session_id: 2, session_id: 2,
transfer_id: 0,
revision: 3, revision: 3,
generation: 4,
operation: Operation::Set, operation: Operation::Set,
result: ResultKind::Rejected, result: ResultKind::Rejected,
error: Some(ProtocolError::Violation(ConfigViolation::field( error: Some(ProtocolError::Violation(ConfigViolation::field(
@@ -465,37 +557,118 @@ mod tests {
} }
#[test] #[test]
fn unavailable_import_has_its_own_structured_error_code() { fn info_preserves_schema_and_protocol_identity() {
let reply = ConfigReply { let info = encode_info(ConfigInfo {
request_id: 1, request_id: 7,
session_id: 0, schema_id: [0; 16],
transfer_id: 0, field_count: 23,
revision: 0,
generation: 0,
operation: Operation::Import,
result: ResultKind::Rejected,
error: Some(ProtocolError::ImportUnavailable),
state: ConfigState::Active, state: ConfigState::Active,
}; });
assert_eq!(info.request_id, 7);
assert_eq!(info.schema_id, [0; 16]);
assert_eq!( assert_eq!(
encode_result(reply).error, (info.protocol_major, info.protocol_minor),
KaptanConfigError::KAPTAN_CONFIG_ERROR_IMPORT_UNAVAILABLE (PROTOCOL_MAJOR, PROTOCOL_MINOR)
); );
} }
#[test] #[test]
fn info_preserves_service_capabilities() { fn descriptor_flags_identify_present_bounds() {
let info = encode_info(ConfigInfo { 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, request_id: 7,
active_generation: None, schema_id: *b"UNKNOWN-SCHEMA01",
committed_generation: None, field_count: 2,
schema_id: [0; 16], state: ConfigState::RestartRequired,
profile_format: 1, };
field_count: 23, let result = ConfigReply {
state: ConfigState::Active, request_id: 8,
capability_flags: CAPABILITY_CBOR_IMPORT, session_id: 9,
}); revision: 2,
assert_eq!(info.request_id, 7); operation: Operation::Commit,
assert_eq!(info.capability_flags, CAPABILITY_CBOR_IMPORT); 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; 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u16)] #[repr(u16)]
pub enum ValidationRule { pub enum ValidationRule {
@@ -20,6 +20,8 @@ pub enum ValidationRule {
UnsupportedCapability = 6, UnsupportedCapability = 6,
/// The encoded snapshot format cannot be used by this firmware. /// The encoded snapshot format cannot be used by this firmware.
UnsupportedFormat = 7, UnsupportedFormat = 7,
/// A scalar does not name a declared variant of its typed enum field.
InvalidEnumValue = 8,
} }
impl ValidationRule { 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 { pub const fn global(rule: ValidationRule) -> Self {
Self { Self {
primary: None, primary: None,
+115 -18
View File
@@ -3,9 +3,18 @@
use crate::{ConfigViolation, ValidationRule}; use crate::{ConfigViolation, ValidationRule};
use kaptan_log::KaptanError; 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. /// 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. /// identities; display strings belong to metadata and diagnostics only.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)] #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)]
#[repr(transparent)] #[repr(transparent)]
@@ -56,6 +65,88 @@ pub enum ConfigValue {
F64(f64), 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 { impl ConfigValue {
/// The declared value kind. /// The declared value kind.
pub const fn kind(self) -> ValueKind { pub const fn kind(self) -> ValueKind {
@@ -80,6 +171,26 @@ impl ConfigValue {
Self::F64(value) => value.to_bits(), 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. /// The scalar kind carried in a descriptor or typed protocol message.
@@ -100,18 +211,6 @@ pub enum ValueKind {
F64 = 6, 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. /// Static metadata for one configuration field.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct FieldDescriptor { pub struct FieldDescriptor {
@@ -125,12 +224,10 @@ pub struct FieldDescriptor {
pub minimum: Option<ConfigValue>, pub minimum: Option<ConfigValue>,
/// Inclusive upper bound when the value is ordered. /// Inclusive upper bound when the value is ordered.
pub maximum: Option<ConfigValue>, pub maximum: Option<ConfigValue>,
/// Firmware-build default. /// Named choices for an enum-backed field, otherwise empty.
pub default: ConfigValue, pub enum_options: &'static [EnumOption],
/// Human-readable unit for schema metadata. /// Human-readable unit for schema metadata.
pub unit: &'static str, pub unit: &'static str,
/// Activation policy advertised to a control station.
pub apply_policy: ApplyPolicy,
} }
/// Failure while changing one transaction field. /// Failure while changing one transaction field.
@@ -168,7 +265,7 @@ impl FieldWriteError {
/// Vehicle-specific editable candidate model. /// 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 /// descriptor registry. A field write performs only local validation; complete
/// relational and capability validation happens through [`Self::validate`]. /// relational and capability validation happens through [`Self::validate`].
pub trait ConfigModel: Clone { 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, //! This crate has no knowledge of MAVLink, filesystems, board routing, motors,
//! or a particular airframe. It owns the staged-versus-active lifecycle and //! or a particular airframe. It owns the staged-versus-active lifecycle and
//! parameter metadata; vehicle crates provide the typed schema and complete //! 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] #![no_std]
@@ -11,15 +13,24 @@ extern crate self as kaptan_config;
pub mod error; pub mod error;
pub mod field; pub mod field;
pub mod journal;
pub mod lifecycle; pub mod lifecycle;
pub mod snapshot;
pub mod storage; pub mod storage;
pub mod validation; pub mod validation;
pub use error::{ConfigViolation, ValidationRule}; pub use error::{ConfigViolation, ValidationRule};
pub use field::{ 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 lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError};
pub use storage::{Generation, ProfileStore}; pub use snapshot::{
pub use validation::{ConfigAggregate, ConfigSection, ValidateFields}; 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. //! Transactional committed-versus-active configuration lifecycle.
use crate::{ use crate::{
ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, ConfigModel, ConfigStore, ConfigValue, ConfigViolation, FieldDescriptor, FieldId,
Generation, ProfileStore, FieldWriteError,
}; };
use kaptan_log::KaptanError; use kaptan_log::KaptanError;
@@ -13,7 +13,7 @@ pub enum ConfigState {
Active, Active,
/// One isolated RAM transaction is open. /// One isolated RAM transaction is open.
Editing, Editing,
/// A newer committed snapshot will take effect after reboot. /// A newer committed snapshot will take effect after restart.
RestartRequired, RestartRequired,
} }
@@ -53,7 +53,7 @@ pub enum CommitError<E> {
Store(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. /// exactly one RAM edit transaction.
/// ///
/// P1 deliberately has no activation operation: [`Self::active`] never /// P1 deliberately has no activation operation: [`Self::active`] never
@@ -61,11 +61,10 @@ pub enum CommitError<E> {
/// candidate, then reports [`ConfigState::RestartRequired`]. /// candidate, then reports [`ConfigState::RestartRequired`].
pub struct ConfigManager<C: ConfigModel, S> { pub struct ConfigManager<C: ConfigModel, S> {
active: C::Active, active: C::Active,
factory: C,
committed: C, committed: C,
transaction: Option<C>, transaction: Option<C>,
context: C::Context, context: C::Context,
active_generation: Option<Generation>,
committed_generation: Option<Generation>,
store: S, store: S,
restart_required: bool, restart_required: bool,
} }
@@ -73,7 +72,7 @@ pub struct ConfigManager<C: ConfigModel, S> {
impl<C, S> ConfigManager<C, S> impl<C, S> ConfigManager<C, S>
where where
C: ConfigModel, C: ConfigModel,
S: ProfileStore<C>, S: ConfigStore<C>,
{ {
/// Starts from a validated factory candidate when no persistent record is /// Starts from a validated factory candidate when no persistent record is
/// available. Flight callers separately decide whether setup/calibration /// available. Flight callers separately decide whether setup/calibration
@@ -82,11 +81,10 @@ where
let active = factory.validate(&context)?; let active = factory.validate(&context)?;
Ok(Self { Ok(Self {
active, active,
factory: factory.clone(),
committed: factory, committed: factory,
transaction: None, transaction: None,
context, context,
active_generation: None,
committed_generation: None,
store, store,
restart_required: false, restart_required: false,
}) })
@@ -99,7 +97,7 @@ where
context: C::Context, context: C::Context,
mut store: S, mut store: S,
) -> Result<Self, StartupError<S::Error>> { ) -> 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); return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
}; };
let active = committed let active = committed
@@ -107,11 +105,41 @@ where
.map_err(StartupError::InvalidPersisted)?; .map_err(StartupError::InvalidPersisted)?;
Ok(Self { Ok(Self {
active, 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, committed,
transaction: None, transaction: None,
context, context,
active_generation: Some(generation),
committed_generation: Some(generation),
store, store,
restart_required: false, restart_required: false,
}) })
@@ -127,6 +155,11 @@ where
self.committed.field_value(id) 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. /// Returns a value from the immutable boot-active snapshot.
pub fn active_value(&self, id: FieldId) -> Option<ConfigValue> { pub fn active_value(&self, id: FieldId) -> Option<ConfigValue> {
C::active_field_value(&self.active, id) 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. /// Opens the only transaction slot from the last durably committed state.
pub fn begin(&mut self) -> Result<(), BeginError> { pub fn begin(&mut self) -> Result<(), BeginError> {
if self.transaction.is_some() { if self.transaction.is_some() {
@@ -209,25 +225,8 @@ where
.map_err(CommitError::InvalidCandidate) .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. /// 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 let candidate = self
.transaction .transaction
.as_ref() .as_ref()
@@ -235,29 +234,23 @@ where
candidate candidate
.validate(&self.context) .validate(&self.context)
.map_err(CommitError::InvalidCandidate)?; .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.committed = candidate.clone();
self.transaction = None; self.transaction = None;
self.committed_generation = Some(generation);
self.restart_required = true; self.restart_required = true;
Ok(generation) Ok(())
} }
/// Drops the RAM transaction without changing active or committed state. /// Drops the RAM transaction without changing active or committed state.
pub fn abort(&mut self) -> bool { pub fn abort(&mut self) -> bool {
self.transaction.take().is_some() 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{ApplyPolicy, ConfigValue, ValidationRule, ValueKind}; use crate::{ConfigValue, ValidationRule, ValueKind};
const VALUE: FieldId = FieldId::new(1); const VALUE: FieldId = FieldId::new(1);
const OTHER: FieldId = FieldId::new(2); const OTHER: FieldId = FieldId::new(2);
@@ -268,9 +261,8 @@ mod tests {
kind: ValueKind::U32, kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)), minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)), maximum: Some(ConfigValue::U32(10)),
default: ConfigValue::U32(2), enum_options: &[],
unit: "count", unit: "count",
apply_policy: ApplyPolicy::RestartRequired,
}, },
FieldDescriptor { FieldDescriptor {
id: OTHER, id: OTHER,
@@ -278,9 +270,8 @@ mod tests {
kind: ValueKind::U32, kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)), minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)), maximum: Some(ConfigValue::U32(10)),
default: ConfigValue::U32(4), enum_options: &[],
unit: "count", unit: "count",
apply_policy: ApplyPolicy::RestartRequired,
}, },
]; ];
@@ -364,15 +355,29 @@ mod tests {
struct MemoryStore; struct MemoryStore;
impl ProfileStore<Candidate> for MemoryStore { impl ConfigStore<Candidate> for MemoryStore {
type Error = (); type Error = ();
fn load(&mut self) -> Result<Option<(Generation, Candidate)>, Self::Error> { fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
Ok(None) Ok(None)
} }
fn persist(&mut self, _: &Candidate) -> Result<Generation, Self::Error> { fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
Ok(Generation::new(7)) 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(); ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap(); manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(3)).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.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3))); 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); assert_eq!(manager.state(), ConfigState::RestartRequired);
manager.begin().unwrap(); manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(4)).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.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.state(), ConfigState::RestartRequired); 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. //! 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. /// Durable storage of complete configuration snapshots.
/// ///
/// Flight implementations use a two-slot journal: a record contains a header, /// 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 /// A backend must retain its previous valid record until the replacement is
/// fully verified. Host file stores may implement the same interface without /// fully verified. Host file stores may implement the same interface without
/// claiming flash power-loss semantics. /// claiming flash power-loss semantics.
pub trait ProfileStore<C> { pub trait ConfigStore<C> {
/// Backend-specific I/O or integrity failure. /// Backend-specific I/O or integrity failure.
type Error; type Error;
/// Loads the newest valid committed candidate, if one exists. /// 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. /// 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. /// `#[derive(kaptan_config::Config)]` implements this contract for both scalar
/// Cross-field, capability, calibration, and lifecycle invariants belong in a /// records and records composed from other configuration trees. Validation,
/// handwritten post-validation hook on the owning vehicle schema. /// metadata, field routing, and schema identity therefore come from one source.
pub trait ValidateFields { pub trait Config {
/// Rejects the first violated scalar or declared post-validation rule. /// Number of scalar fields in the flattened tree.
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.
const FIELD_COUNT: usize; const FIELD_COUNT: usize;
/// Builds the section from derive-generated defaults after validating it. /// Fingerprint generated from the complete declared schema.
fn default_section() -> Result<Self, ConfigViolation> const SCHEMA_ID: [u8; 16];
where
Self: Sized; /// Returns a descriptor from the flattened declaration order.
fn descriptor(index: usize) -> Option<&'static FieldDescriptor>;
/// Returns one scalar by stable field identity. /// Returns one scalar by stable field identity.
fn field_value(&self, id: FieldId) -> Option<ConfigValue>; fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
/// Applies one scalar only when this section owns the ID and its local /// Applies one scalar only when its owning record accepts the local value.
/// generated validation accepts the new 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>; fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
/// Validates only generated scalar invariants. This intentionally omits /// Validates every generated rule and declared post-validation hook.
/// a derive `post` hook so staged multi-field edits can remain temporarily fn validate(&self) -> Result<(), ConfigViolation>;
/// cross-field-invalid until aggregate validation/commit.
fn validate_local_fields(&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 /// A one-child wrapper is deliberately transparent, so adding or removing a
/// existing section cannot silently bypass local validation. The aggregate /// redundant structural wrapper does not invalidate persisted values.
/// owner still defines capability checks and immutable active construction. #[doc(hidden)]
pub trait ConfigAggregate: ValidateFields { pub const fn __combine_schema_ids(children: &[[u8; 16]]) -> [u8; 16] {
/// Builds and fully validates all nested sections from their derived if children.len() == 1 {
/// defaults. return children[0];
fn default_aggregate() -> Result<Self, ConfigViolation> }
where let mut state = [FINGERPRINT_LEFT_OFFSET, FINGERPRINT_RIGHT_OFFSET];
Self: Sized; state = hash_u64(state, children.len() as u64);
let mut child = 0;
/// Returns one nested scalar by stable field identity. while child < children.len() {
fn field_value(&self, id: FieldId) -> Option<ConfigValue>; let mut byte = 0;
while byte < children[child].len() {
/// Routes one scalar write to its owning generated section. The setter state = hash_byte(state, children[child][byte]);
/// applies local validation only, so a transaction may temporarily hold a byte += 1;
/// cross-field-invalid multi-edit candidate. }
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>; 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. /// Position, velocity, and acceleration reference in the local NED frame.
/// ///
/// The reference is generated continuously. A caller must not replace it with /// 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)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct KinematicReference { pub struct KinematicReference {
/// Local NED position in metres. /// Local NED position in metres.
@@ -9,7 +9,6 @@ version.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
kaptan-board = { path = "../kaptan-board" }
kaptan-log = { path = "../kaptan-log" } kaptan-log = { path = "../kaptan-log" }
kaptan-dma = { path = "../kaptan-dma" } kaptan-dma = { path = "../kaptan-dma" }
kaptan-mavlink = { path = "../kaptan-mavlink" } kaptan-mavlink = { path = "../kaptan-mavlink" }
+52 -155
View File
@@ -7,16 +7,14 @@
#![no_std] #![no_std]
use kaptan_board::{BoardConfig, DataChannelRates};
use kaptan_dma::TxCompletion; use kaptan_dma::TxCompletion;
use kaptan_log::KaptanError; use kaptan_log::KaptanError;
use kaptan_mavlink::message::MessageData; use kaptan_mavlink::message::MessageData;
use kaptan_mavlink::{ use kaptan_mavlink::{
MavlinkFrame, MavlinkFrame,
encoder::{DistanceSensorConfig, MavlinkEncoder}, encoder::{DistanceSensorConfig, MavlinkEncoder},
message::{MavCmd, MavResult, MavState}, message::MavState,
transmitter::FrameClass, transmitter::{EnqueueResult, FrameClass},
transmitter::TransmitFaultKind,
}; };
use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot}; use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
@@ -24,84 +22,36 @@ mod mailbox;
pub use mailbox::{LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox}; 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. /// Platform transport used by a data channel.
/// ///
/// The channel decides exactly which encoded frame is admitted and with which /// The channel decides exactly which encoded frame is admitted and with which
/// delivery class. The platform retains only DMA endpoint ownership. /// delivery class. The platform retains only DMA endpoint ownership.
pub trait FrameSink { pub trait FrameSink {
/// Admits one fully encoded frame to the platform's bounded transport. /// 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. /// Applies one terminal DMA interrupt and starts the next retained frame
fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion; /// when possible.
fn on_dma_complete(&mut self, completion: TxCompletion);
/// 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,
} }
/// Mailboxes read by one data-channel worker. /// Mailboxes read by one data-channel worker.
@@ -129,8 +79,6 @@ pub struct VerticalDataConfig {
/// Data-channel construction failure. /// Data-channel construction failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum DataChannelError { pub enum DataChannelError {
/// Rejected profiles must not construct a physical telemetry channel.
NotTelemetryProfile,
/// A configured channel must be due at least once per second. /// A configured channel must be due at least once per second.
ZeroRate, ZeroRate,
/// A configured channel exceeds the service frequency. /// A configured channel exceeds the service frequency.
@@ -159,20 +107,19 @@ impl<'a, Sink, const SERVICE_HZ: usize> DataChannel<'a, Sink, SERVICE_HZ>
where where
Sink: FrameSink, 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( pub fn new(
sink: Sink, sink: Sink,
config: BoardConfig, system_id: u8,
component_id: u8,
rates: DataChannelRates,
vertical: VerticalDataConfig, vertical: VerticalDataConfig,
sources: DataSources<'a>, sources: DataSources<'a>,
) -> Result<Self, DataChannelError> { ) -> Result<Self, DataChannelError> {
let telemetry = config let schedule = Schedule::from_rates(rates)?;
.telemetry()
.ok_or(DataChannelError::NotTelemetryProfile)?;
let schedule = Schedule::from_rates(telemetry.data_channel)?;
Ok(Self { Ok(Self {
sink, sink,
encoder: MavlinkEncoder::new(config.mavlink.system_id, config.mavlink.component_id), encoder: MavlinkEncoder::new(system_id, component_id),
schedule, schedule,
sources, sources,
vertical, vertical,
@@ -234,41 +181,16 @@ where
} }
/// Completes the active DMA lifecycle for this channel's transport. /// 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) 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 /// Encodes an adapter-owned generated MAVLink data record through the
/// channel's sole encoder and bounded transport sink. /// channel's sole encoder and bounded transport sink.
pub fn send_custom<M: MessageData>( pub fn send_custom<M: MessageData>(&mut self, class: FrameClass, message: &M) -> EnqueueResult {
&mut self,
class: FrameClass,
message: &M,
) -> FrameAdmission {
self.sink.send(class, self.encoder.custom(message)) 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) { fn refresh_vertical_data(&mut self) {
if let Some(range) = self.sources.range.take_newer(&mut self.observed_range) { if let Some(range) = self.sources.range.take_newer(&mut self.observed_range) {
self.last_range = Some(range); self.last_range = Some(range);
@@ -371,17 +293,15 @@ impl<const SERVICE_HZ: usize> Schedule<SERVICE_HZ> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
DataChannel, DataSources, DmaCompletion, FrameAdmission, FrameSink, LatestImuMailbox, DataChannel, DataChannelRates, DataSources, FrameSink, LatestImuMailbox,
LatestOpticalFlowMailbox, LatestRangeMailbox, TransmitFaultKind, TxHealth, LatestOpticalFlowMailbox, LatestRangeMailbox, VerticalDataConfig,
VerticalDataConfig,
};
use kaptan_board::{
BoardConfig, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
VerticalSensingKind,
}; };
use kaptan_dma::TxCompletion; use kaptan_dma::TxCompletion;
use kaptan_log::Severity; use kaptan_mavlink::{
use kaptan_mavlink::{MavlinkFrame, message::MavState, transmitter::FrameClass}; MavlinkFrame,
message::MavState,
transmitter::{EnqueueResult, FrameClass},
};
use kaptan_sensor::{ use kaptan_sensor::{
imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot, imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot,
}; };
@@ -394,50 +314,26 @@ mod tests {
} }
impl FrameSink for Sink { impl FrameSink for Sink {
fn send(&mut self, class: FrameClass, _: MavlinkFrame) -> FrameAdmission { fn send(&mut self, class: FrameClass, _: MavlinkFrame) -> EnqueueResult {
match class { match class {
FrameClass::Vital => self.critical += 1, FrameClass::Vital => self.critical += 1,
FrameClass::BestEffort => self.best_effort += 1, FrameClass::BestEffort => self.best_effort += 1,
FrameClass::Critical => self.critical += 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; self.completions += 1;
DmaCompletion::Complete
}
fn health(&self) -> TxHealth {
TxHealth::default()
}
fn take_transport_fault(&mut self) -> Option<TransmitFaultKind> {
None
} }
} }
const CONFIG: BoardConfig = BoardConfig { const RATES: DataChannelRates = DataChannelRates {
name: "test", service_hz: 100,
mavlink: MavlinkEndpoint { raw_imu_hz: 20,
system_id: 1, range_hz: Some(20),
component_id: 1, optical_flow_hz: Some(20),
port: MavlinkPort::Serial1, heartbeat_hz: 2,
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,
}),
}; };
#[test] #[test]
@@ -451,7 +347,9 @@ mod tests {
critical: 0, critical: 0,
completions: 0, completions: 0,
}, },
CONFIG, 1,
1,
RATES,
VerticalDataConfig { VerticalDataConfig {
distance: Some(kaptan_mavlink::encoder::DistanceSensorConfig { distance: Some(kaptan_mavlink::encoder::DistanceSensorConfig {
sensor_id: 0, sensor_id: 0,
@@ -488,7 +386,6 @@ mod tests {
channel.on_service_tick(MavState::MAV_STATE_STANDBY, false); channel.on_service_tick(MavState::MAV_STATE_STANDBY, false);
} }
channel.on_dma_complete(TxCompletion::Complete); channel.on_dma_complete(TxCompletion::Complete);
let sink = channel.sink; let sink = channel.sink;
assert_eq!(sink.best_effort, 60); assert_eq!(sink.best_effort, 60);
assert_eq!(sink.critical, 2); assert_eq!(sink.critical, 2);
@@ -13,17 +13,6 @@ pub struct Mpu6000Config {
pub sample_rate_divider: u8, 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. /// MPU6000 digital low-pass filter bandwidth selection.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DigitalLowPassFilter { pub enum DigitalLowPassFilter {
@@ -232,10 +232,20 @@ impl<Bus: SpiDevice> Mpu6000<Bus> {
#[cfg(test)] #[cfg(test)]
mod tests { 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_spi::{SpiCompletion, SpiDevice};
use kaptan_time::Timestamp; 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)] #[derive(Debug, Eq, PartialEq)]
enum FakeError { enum FakeError {
Start, Start,
@@ -277,7 +287,7 @@ mod tests {
fn configures_a_verified_device_without_waiting() { fn configures_a_verified_device_without_waiting() {
let mut bus = FakeBus::ready(); let mut bus = FakeBus::ready();
bus.receive[1] = 0x68; bus.receive[1] = 0x68;
let mut driver = Mpu6000::new(bus, Mpu6000Config::default()); let mut driver = Mpu6000::new(bus, TEST_CONFIG);
driver.begin_initialization().unwrap(); driver.begin_initialization().unwrap();
assert_eq!( assert_eq!(
@@ -302,7 +312,7 @@ mod tests {
fn rejects_an_unexpected_identity() { fn rejects_an_unexpected_identity() {
let mut bus = FakeBus::ready(); let mut bus = FakeBus::ready();
bus.receive[1] = 0x70; bus.receive[1] = 0x70;
let mut driver = Mpu6000::new(bus, Mpu6000Config::default()); let mut driver = Mpu6000::new(bus, TEST_CONFIG);
driver.begin_initialization().unwrap(); driver.begin_initialization().unwrap();
let _ = driver.on_transfer_complete(SpiCompletion::Complete); let _ = driver.on_transfer_complete(SpiCompletion::Complete);
@@ -322,7 +332,7 @@ mod tests {
]; ];
let mut bus = FakeBus::ready(); let mut bus = FakeBus::ready();
bus.receive[1] = 0x68; bus.receive[1] = 0x68;
let mut driver = Mpu6000::new(bus, Mpu6000Config::default()); let mut driver = Mpu6000::new(bus, TEST_CONFIG);
driver.begin_initialization().unwrap(); driver.begin_initialization().unwrap();
let _ = driver.on_transfer_complete(SpiCompletion::Complete); let _ = driver.on_transfer_complete(SpiCompletion::Complete);
@@ -352,7 +362,7 @@ mod tests {
fn reports_a_route_transfer_fault() { fn reports_a_route_transfer_fault() {
let mut bus = FakeBus::ready(); let mut bus = FakeBus::ready();
bus.receive[1] = 0x68; bus.receive[1] = 0x68;
let mut driver = Mpu6000::new(bus, Mpu6000Config::default()); let mut driver = Mpu6000::new(bus, TEST_CONFIG);
driver.begin_initialization().unwrap(); driver.begin_initialization().unwrap();
assert!(matches!( assert!(matches!(
@@ -89,10 +89,19 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{Mpu6000Service, Mpu6000ServiceEvent}; use super::{Mpu6000Service, Mpu6000ServiceEvent};
use crate::mpu6000::{Mpu6000, Mpu6000Config}; use crate::mpu6000::{
AccelerometerRange, DigitalLowPassFilter, GyroscopeRange, Mpu6000, Mpu6000Config,
};
use kaptan_spi::{SpiCompletion, SpiDevice}; use kaptan_spi::{SpiCompletion, SpiDevice};
use kaptan_time::{MonotonicClock, Timestamp}; 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; struct Clock;
impl MonotonicClock for Clock { impl MonotonicClock for Clock {
@@ -120,10 +129,7 @@ mod tests {
fn accepts_a_sample_only_after_initialization() { fn accepts_a_sample_only_after_initialization() {
let mut response = [0; 15]; let mut response = [0; 15];
response[1] = 0x68; response[1] = 0x68;
let mut service = Mpu6000Service::new( let mut service = Mpu6000Service::new(Clock, Mpu6000::new(Bus { response }, TEST_CONFIG));
Clock,
Mpu6000::new(Bus { response }, Mpu6000Config::default()),
);
service.begin().unwrap(); service.begin().unwrap();
assert!(matches!(service.on_data_ready(), Mpu6000ServiceEvent::Idle)); 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::{ use kaptan_config::{
ConfigModel, ConfigValue, ConfigViolation, FieldWriteError, ValidationRule, ValueKind, ConfigModel, ConfigValue, ConfigViolation, FieldWriteError, ValidationRule, ValueKind,
@@ -13,7 +13,7 @@ pub const MAXIMUM_CANDIDATE_BYTES: usize = 4096;
const FORMAT_KEY: u16 = 0; const FORMAT_KEY: u16 = 0;
const SCHEMA_KEY: u16 = u16::MAX; const SCHEMA_KEY: u16 = u16::MAX;
/// CBOR import failure. /// CBOR snapshot decoding failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum CborError { pub enum CborError {
/// The supplied payload exceeded the bounded configuration storage budget. /// 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() let (index, descriptor) = CandidateProfile::descriptors()
.iter() .enumerate()
.position(|descriptor| descriptor.id.get() == key) .find(|(_, descriptor)| descriptor.id.get() == key)
.ok_or(CborError::Malformed)?; .ok_or(CborError::Malformed)?;
if values[index].is_some() { if values[index].is_some() {
return Err(CborError::Malformed); return Err(CborError::Malformed);
} }
values[index] = Some(decode_value( values[index] = Some(decode_value(&mut decoder, descriptor.kind)?);
&mut decoder,
CandidateProfile::descriptors()[index].kind,
)?);
} }
} }
} }
@@ -151,10 +148,10 @@ fn decode_current(bytes: &[u8]) -> Result<CandidateProfile, CborError> {
if values.iter().any(Option::is_none) { if values.iter().any(Option::is_none) {
return Err(CborError::Malformed); return Err(CborError::Malformed);
} }
let mut candidate = CandidateProfile::default(); let mut candidate = CandidateProfile::factory();
for (index, value) in values.into_iter().enumerate() { for (index, value) in values.into_iter().enumerate() {
let descriptor = CandidateProfile::descriptors() let descriptor = CandidateProfile::descriptors()
.get(index) .nth(index)
.ok_or(CborError::Malformed)?; .ok_or(CborError::Malformed)?;
candidate candidate
.set_field(descriptor.id, value.ok_or(CborError::Malformed)?) .set_field(descriptor.id, value.ok_or(CborError::Malformed)?)
@@ -210,7 +207,7 @@ mod tests {
fn round_trip_and_trailing_rejection_are_exact() { fn round_trip_and_trailing_rejection_are_exact() {
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]); let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
let capabilities = VehicleCapabilities::quad_x(); 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 length = writer.position();
let bytes = writer.into_inner(); let bytes = writer.into_inner();
assert!(decode_candidate(&bytes[..length], &capabilities).is_ok()); assert!(decode_candidate(&bytes[..length], &capabilities).is_ok());
@@ -225,7 +222,7 @@ mod tests {
#[test] #[test]
fn encoding_rejects_a_cross_field_invalid_transaction_before_writing() { fn encoding_rejects_a_cross_field_invalid_transaction_before_writing() {
let capabilities = VehicleCapabilities::quad_x(); let capabilities = VehicleCapabilities::quad_x();
let mut candidate = CandidateProfile::default(); let mut candidate = CandidateProfile::factory();
candidate candidate
.set_field(kaptan_config::FieldId::new(2), ConfigValue::U64(50_001)) .set_field(kaptan_config::FieldId::new(2), ConfigValue::U64(50_001))
.expect("the scalar is locally valid"); .expect("the scalar is locally valid");
@@ -295,8 +292,8 @@ mod tests {
.u16(FORMAT_VERSION) .u16(FORMAT_VERSION)
.unwrap(); .unwrap();
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap(); encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
let candidate = CandidateProfile::default(); let candidate = CandidateProfile::factory();
for descriptor in CandidateProfile::descriptors().iter().skip(1) { for descriptor in CandidateProfile::descriptors().skip(1) {
encoder.u16(descriptor.id.get()).unwrap(); encoder.u16(descriptor.id.get()).unwrap();
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).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() { fn cbor_scalar_write_preserves_the_generated_field_and_rule() {
let capabilities = VehicleCapabilities::quad_x(); let capabilities = VehicleCapabilities::quad_x();
let mass = CandidateProfile::descriptors() let mass = CandidateProfile::descriptors()
.iter()
.find(|descriptor| descriptor.display_key == "thrust.mass_kg") .find(|descriptor| descriptor.display_key == "thrust.mass_kg")
.expect("declared mass descriptor"); .expect("declared mass descriptor");
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]); let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
@@ -326,7 +322,7 @@ mod tests {
.u16(FORMAT_VERSION) .u16(FORMAT_VERSION)
.unwrap(); .unwrap();
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap(); encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
let candidate = CandidateProfile::default(); let candidate = CandidateProfile::factory();
for descriptor in CandidateProfile::descriptors() { for descriptor in CandidateProfile::descriptors() {
encoder.u16(descriptor.id.get()).unwrap(); encoder.u16(descriptor.id.get()).unwrap();
let value = if descriptor.id == mass.id { let value = if descriptor.id == mass.id {
@@ -367,7 +363,7 @@ mod tests {
.u16(format_version) .u16(format_version)
.unwrap(); .unwrap();
encoder.u16(SCHEMA_KEY).unwrap().bytes(&schema_id).unwrap(); encoder.u16(SCHEMA_KEY).unwrap().bytes(&schema_id).unwrap();
let candidate = CandidateProfile::default(); let candidate = CandidateProfile::factory();
for descriptor in CandidateProfile::descriptors() { for descriptor in CandidateProfile::descriptors() {
encoder.u16(remap_key(descriptor.id.get())).unwrap(); encoder.u16(remap_key(descriptor.id.get())).unwrap();
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).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}; pub use profile::{CandidateProfile, FORMAT_VERSION, VehicleCapabilities, VehicleProfile};
/// Exact identifier of the one unreleased Quad-X profile schema. /// Generated fingerprint of the complete vehicle configuration schema.
/// pub const SCHEMA_ID: [u8; 16] = <CandidateProfile as kaptan_config::Config>::SCHEMA_ID;
/// 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";
#[cfg(test)] #[cfg(test)]
extern crate std; extern crate std;
@@ -201,7 +201,7 @@ pub struct VehicleProfile {
pub(crate) thrust: ThrustSettings, pub(crate) thrust: ThrustSettings,
pub(crate) rate_pid: PidSettings, pub(crate) rate_pid: PidSettings,
pub(crate) actuator_limits: ActuatorLimits, pub(crate) actuator_limits: ActuatorLimits,
pub(crate) observability: super::ObservabilitySettings, pub(crate) mavlink_logging: super::MavlinkLoggingSettings,
pub(crate) mixer: [[f32; 4]; 4], pub(crate) mixer: [[f32; 4]; 4],
} }
@@ -227,9 +227,9 @@ impl VehicleProfile {
pub const fn actuator_limits(self) -> ActuatorLimits { pub const fn actuator_limits(self) -> ActuatorLimits {
self.actuator_limits self.actuator_limits
} }
/// Immutable boot-active best-effort diagnostic policy. /// Immutable boot-active policy for the MAVLink diagnostic sink.
pub const fn observability(self) -> super::ObservabilitySettings { pub const fn mavlink_logging(self) -> super::MavlinkLoggingSettings {
self.observability self.mavlink_logging
} }
pub const fn mixer(self) -> [[f32; 4]; 4] { pub const fn mixer(self) -> [[f32; 4]; 4] {
self.mixer self.mixer
@@ -1,60 +1,100 @@
//! Private vehicle candidate and whole-profile validation boundary. //! Private vehicle candidate and whole-profile validation boundary.
use kaptan_config::{ use kaptan_config::{
ConfigAggregate, ConfigModel, ConfigValidate, ConfigValue, ConfigViolation, FieldDescriptor, Config, ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
FieldId, FieldWriteError, ValidateFields, ValidationRule, ValidationRule,
}; };
use kaptan_log::Severity;
use super::{ use super::{
ActuatorLimits, AidingSettings, ControlSettings, ObservabilitySettings, PidSettings, ActuatorLimits, AidingSettings, ControlSettings, MavlinkLoggingSettings, PidSettings,
SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile, SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile,
control::{ control::{
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate, ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
}, },
observability::ObservabilityCandidate, mavlink_logging::MavlinkLoggingCandidate,
registry,
safety::SafetyCandidate, safety::SafetyCandidate,
timing::TimingCandidate, timing::TimingCandidate,
}; };
/// Current persisted CBOR schema for the vehicle candidate. /// Current persisted CBOR schema for the vehicle candidate.
pub const FORMAT_VERSION: u16 = 1; pub const FORMAT_VERSION: u16 = 2;
/// Complete editable vehicle candidate. /// Complete editable vehicle candidate.
/// ///
/// Fields are private outside this crate. Operators, CBOR import, and MAVLink /// Fields are private outside this crate. Local snapshot recovery and MAVLink
/// transport all use the same descriptor/mutation and full-validation path. /// scalar writes both use the same descriptor/mutation and full-validation
#[derive(Clone, Copy, ConfigValidate)] /// path.
#[derive(Clone, Copy, Config)]
pub struct CandidateProfile { pub struct CandidateProfile {
#[config(section)]
pub(super) safety: SafetyCandidate, pub(super) safety: SafetyCandidate,
#[config(section)]
pub(super) timing: TimingCandidate, pub(super) timing: TimingCandidate,
#[config(section)]
pub(super) aiding: AidingCandidate, pub(super) aiding: AidingCandidate,
#[config(section)]
pub(super) control: ControlCandidate, pub(super) control: ControlCandidate,
#[config(section)]
pub(super) thrust: ThrustCandidate, pub(super) thrust: ThrustCandidate,
#[config(section)]
pub(super) rate_pid: PidCandidate, pub(super) rate_pid: PidCandidate,
#[config(section)]
pub(super) actuator_limits: ActuatorLimitsCandidate, pub(super) actuator_limits: ActuatorLimitsCandidate,
#[config(section)] pub(super) mavlink_logging: MavlinkLoggingCandidate,
pub(super) observability: ObservabilityCandidate,
}
impl Default for CandidateProfile {
fn default() -> Self {
<Self as ConfigAggregate>::default_aggregate()
.expect("compiled configuration defaults must validate")
}
} }
impl CandidateProfile { 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. /// Returns the complete transport-neutral descriptor registry.
pub const fn descriptors() -> &'static [FieldDescriptor; Self::FIELD_COUNT] { pub fn descriptors() -> impl ExactSizeIterator<Item = &'static FieldDescriptor> + Clone {
registry::descriptors() (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 { fn from_active(active: &VehicleProfile) -> Self {
@@ -65,7 +105,7 @@ impl CandidateProfile {
let thrust = active.thrust(); let thrust = active.thrust();
let rate_pid = active.rate_pid(); let rate_pid = active.rate_pid();
let actuator_limits = active.actuator_limits(); let actuator_limits = active.actuator_limits();
let observability = active.observability(); let mavlink_logging = active.mavlink_logging();
Self { Self {
safety: SafetyCandidate { safety: SafetyCandidate {
prearm_dwell_microseconds: safety.prearm_dwell_microseconds(), prearm_dwell_microseconds: safety.prearm_dwell_microseconds(),
@@ -106,8 +146,9 @@ impl CandidateProfile {
maximum: actuator_limits.maximum(), maximum: actuator_limits.maximum(),
slew_per_second: actuator_limits.slew_per_second(), slew_per_second: actuator_limits.slew_per_second(),
}, },
observability: ObservabilityCandidate { mavlink_logging: MavlinkLoggingCandidate {
maximum_log_level: observability.maximum_log_level(), enabled: mavlink_logging.enabled(),
maximum_severity: mavlink_logging.maximum_severity(),
}, },
} }
} }
@@ -116,7 +157,7 @@ impl CandidateProfile {
&self, &self,
capabilities: &VehicleCapabilities, capabilities: &VehicleCapabilities,
) -> Result<VehicleProfile, ConfigViolation> { ) -> Result<VehicleProfile, ConfigViolation> {
self.validate_fields()?; <Self as Config>::validate(self)?;
self.validate_capabilities(capabilities)?; self.validate_capabilities(capabilities)?;
Ok(VehicleProfile { Ok(VehicleProfile {
safety: SafetySettings { safety: SafetySettings {
@@ -162,7 +203,10 @@ impl CandidateProfile {
maximum: self.actuator_limits.maximum, maximum: self.actuator_limits.maximum,
slew_per_second: self.actuator_limits.slew_per_second, 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(), mixer: capabilities.mixer(),
}) })
} }
@@ -183,10 +227,10 @@ impl CandidateProfile {
impl ConfigModel for CandidateProfile { impl ConfigModel for CandidateProfile {
type Active = VehicleProfile; type Active = VehicleProfile;
type Context = VehicleCapabilities; 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> { fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
registry::descriptor(index) <Self as Config>::descriptor(usize::from(index))
} }
fn field_count() -> u16 { fn field_count() -> u16 {
@@ -194,15 +238,15 @@ impl ConfigModel for CandidateProfile {
} }
fn field_value(&self, id: FieldId) -> Option<ConfigValue> { 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> { 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> { 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> { fn validate(&self, context: &Self::Context) -> Result<Self::Active, ConfigViolation> {
@@ -214,36 +258,43 @@ impl ConfigModel for CandidateProfile {
mod tests { mod tests {
use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule}; use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule};
use super::{CandidateProfile, VehicleCapabilities}; use super::{CandidateProfile, Severity, VehicleCapabilities};
use crate::profile::ids; use crate::profile::ids;
const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x(); 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 { fn descriptor_id(display_key: &str) -> FieldId {
CandidateProfile::descriptors() CandidateProfile::descriptors()
.iter()
.find(|descriptor| descriptor.display_key == display_key) .find(|descriptor| descriptor.display_key == display_key)
.expect("declared field descriptor") .expect("declared field descriptor")
.id .id
} }
#[test] #[test]
fn every_descriptor_has_a_readable_default_value() { fn every_descriptor_has_a_readable_factory_value() {
let candidate = CandidateProfile::default(); let candidate = CandidateProfile::factory();
for descriptor in CandidateProfile::descriptors() { for descriptor in CandidateProfile::descriptors() {
assert_eq!( assert!(candidate.field_value(descriptor.id).is_some());
candidate.field_value(descriptor.id),
Some(descriptor.default)
);
} }
} }
#[test] #[test]
fn aggregated_descriptor_ids_are_unique() { fn aggregated_descriptor_ids_are_unique() {
for (index, descriptor) in CandidateProfile::descriptors().iter().enumerate() { for (index, descriptor) in CandidateProfile::descriptors().enumerate() {
assert!( assert!(
CandidateProfile::descriptors()[index + 1..] CandidateProfile::descriptors()
.iter() .skip(index + 1)
.all(|other| other.id != descriptor.id) .all(|other| other.id != descriptor.id)
); );
} }
@@ -251,7 +302,7 @@ mod tests {
#[test] #[test]
fn cross_field_invalid_edit_is_retained_only_in_the_candidate() { fn cross_field_invalid_edit_is_retained_only_in_the_candidate() {
let mut candidate = CandidateProfile::default(); let mut candidate = CandidateProfile::factory();
candidate candidate
.set_field( .set_field(
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS, ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS,
@@ -266,7 +317,7 @@ mod tests {
#[test] #[test]
fn local_write_error_retains_exact_field_and_rule() { fn local_write_error_retains_exact_field_and_rule() {
let mut candidate = CandidateProfile::default(); let mut candidate = CandidateProfile::factory();
assert_eq!( assert_eq!(
candidate.set_field(ids::MASS_KG, ConfigValue::F32(f32::NAN)), candidate.set_field(ids::MASS_KG, ConfigValue::F32(f32::NAN)),
Err(FieldWriteError::non_finite(ids::MASS_KG)) Err(FieldWriteError::non_finite(ids::MASS_KG))
@@ -280,7 +331,7 @@ mod tests {
.into_iter() .into_iter()
.flatten() .flatten()
{ {
let mut candidate = CandidateProfile::default(); let mut candidate = CandidateProfile::factory();
candidate candidate
.set_field(descriptor.id, bound) .set_field(descriptor.id, bound)
.expect("descriptor bound must pass the descriptor-backed setter"); .expect("descriptor bound must pass the descriptor-backed setter");
@@ -297,20 +348,26 @@ mod tests {
} }
#[test] #[test]
fn observability_filter_is_persisted_restart_only_and_cannot_hide_errors() { fn mavlink_logging_policy_is_persisted_and_restart_only() {
let mut candidate = CandidateProfile::default(); 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 candidate
.set_field( .set_field(
descriptor_id("observability.maximum_log_level"), descriptor_id("mavlink.logging.enabled"),
ConfigValue::U32(3), ConfigValue::Bool(false),
) )
.unwrap(); .unwrap();
let active = candidate.validate(&CAPABILITIES).unwrap(); let active = candidate.validate(&CAPABILITIES).unwrap();
assert_eq!(active.observability().maximum_log_level(), 3); assert!(!active.mavlink_logging().enabled());
assert_eq!(active.observability().maximum_log_level(), 3);
candidate candidate
.set_field( .set_field(
descriptor_id("observability.maximum_log_level"), descriptor_id("mavlink.logging.maximum_severity"),
ConfigValue::U32(5), ConfigValue::U32(5),
) )
.unwrap(); .unwrap();
@@ -318,27 +375,27 @@ mod tests {
candidate candidate
.validate(&CAPABILITIES) .validate(&CAPABILITIES)
.unwrap() .unwrap()
.observability() .mavlink_logging()
.maximum_log_level(), .maximum_severity(),
5 Severity::Notice
); );
assert!(matches!( assert!(matches!(
candidate.set_field( candidate.set_field(
descriptor_id("observability.maximum_log_level"), descriptor_id("mavlink.logging.maximum_severity"),
ConfigValue::U32(2), ConfigValue::U32(8),
), ),
Err(FieldWriteError::Violation(issue)) Err(FieldWriteError::Violation(issue))
if issue.rule() == ValidationRule::BelowMinimum if issue.rule() == ValidationRule::InvalidEnumValue
)); ));
} }
#[test] #[test]
fn every_persisted_log_severity_is_retained_exactly() { fn every_persisted_log_severity_is_retained_exactly() {
for raw in [3, 4, 5, 6, 7] { for raw in 0..=7 {
let mut candidate = CandidateProfile::default(); let mut candidate = CandidateProfile::factory();
candidate candidate
.set_field( .set_field(
descriptor_id("observability.maximum_log_level"), descriptor_id("mavlink.logging.maximum_severity"),
ConfigValue::U32(raw), ConfigValue::U32(raw),
) )
.expect("descriptor range accepts the stable severity"); .expect("descriptor range accepts the stable severity");
@@ -346,9 +403,9 @@ mod tests {
candidate candidate
.validate(&CAPABILITIES) .validate(&CAPABILITIES)
.expect("valid complete profile") .expect("valid complete profile")
.observability() .mavlink_logging()
.maximum_log_level(), .maximum_severity(),
raw 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; use super::ids;
#[derive(Clone, Copy, ConfigValidate)] #[derive(Clone, Copy, Config)]
pub(super) struct AidingCandidate { pub(super) struct AidingCandidate {
#[config( #[config(
id = 6, id = 6,
key = "aiding.position_variance_m2", key = "aiding.position_variance_m2",
unit = "m2", unit = "m2",
default = 0.04,
finite, finite,
range(min = 0.000_001, max = 10_000.0) range(min = 0.000_001, max = 10_000.0)
)] )]
@@ -17,20 +16,18 @@ pub(super) struct AidingCandidate {
id = 7, id = 7,
key = "aiding.velocity_variance_m2ps2", key = "aiding.velocity_variance_m2ps2",
unit = "m2/s2", unit = "m2/s2",
default = 0.04,
finite, finite,
range(min = 0.000_001, max = 10_000.0) range(min = 0.000_001, max = 10_000.0)
)] )]
pub(super) velocity_variance_m2_per_second_squared: f32, pub(super) velocity_variance_m2_per_second_squared: f32,
} }
#[derive(Clone, Copy, ConfigValidate)] #[derive(Clone, Copy, Config)]
pub(super) struct ControlCandidate { pub(super) struct ControlCandidate {
#[config( #[config(
id = 8, id = 8,
key = "control.position_gain_per_s2", key = "control.position_gain_per_s2",
unit = "1/s2", unit = "1/s2",
default = 0.8,
finite, finite,
range(min = 0.0, max = 100.0) range(min = 0.0, max = 100.0)
)] )]
@@ -39,7 +36,6 @@ pub(super) struct ControlCandidate {
id = 9, id = 9,
key = "control.velocity_gain_per_s", key = "control.velocity_gain_per_s",
unit = "1/s", unit = "1/s",
default = 1.2,
finite, finite,
range(min = 0.0, max = 100.0) range(min = 0.0, max = 100.0)
)] )]
@@ -48,7 +44,6 @@ pub(super) struct ControlCandidate {
id = 10, id = 10,
key = "control.maximum_horizontal_acceleration_mps2", key = "control.maximum_horizontal_acceleration_mps2",
unit = "m/s2", unit = "m/s2",
default = 3.0,
finite, finite,
range(min = 0.001, max = 100.0) range(min = 0.001, max = 100.0)
)] )]
@@ -57,7 +52,6 @@ pub(super) struct ControlCandidate {
id = 11, id = 11,
key = "control.maximum_upward_acceleration_mps2", key = "control.maximum_upward_acceleration_mps2",
unit = "m/s2", unit = "m/s2",
default = 2.0,
finite, finite,
range(min = 0.001, max = 100.0) range(min = 0.001, max = 100.0)
)] )]
@@ -66,20 +60,18 @@ pub(super) struct ControlCandidate {
id = 12, id = 12,
key = "control.maximum_downward_acceleration_mps2", key = "control.maximum_downward_acceleration_mps2",
unit = "m/s2", unit = "m/s2",
default = 2.0,
finite, finite,
range(min = 0.001, max = 100.0) range(min = 0.001, max = 100.0)
)] )]
pub(super) maximum_downward_acceleration_mps2: f32, pub(super) maximum_downward_acceleration_mps2: f32,
} }
#[derive(Clone, Copy, ConfigValidate)] #[derive(Clone, Copy, Config)]
#[config(post = Self::validate_thrust)] #[config(post = Self::validate_thrust)]
pub(super) struct ThrustCandidate { pub(super) struct ThrustCandidate {
#[config( #[config(
id = 13, id = 13,
key = "thrust.mass_kg", key = "thrust.mass_kg",
unit = "kg", unit = "kg",
default = 1.2,
finite, finite,
range(min = 0.01, max = 100.0) range(min = 0.01, max = 100.0)
)] )]
@@ -88,7 +80,6 @@ pub(super) struct ThrustCandidate {
id = 14, id = 14,
key = "thrust.maximum_collective_thrust_n", key = "thrust.maximum_collective_thrust_n",
unit = "N", unit = "N",
default = 18.0,
finite, finite,
range(min = 0.1, max = 10_000.0) range(min = 0.1, max = 10_000.0)
)] )]
@@ -97,7 +88,6 @@ pub(super) struct ThrustCandidate {
id = 15, id = 15,
key = "thrust.maximum_tilt_tangent", key = "thrust.maximum_tilt_tangent",
unit = "", unit = "",
default = 0.700_207_53,
finite, finite,
range(min = 0.001, max = 10.0) 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)] #[config(post = Self::validate_pid)]
pub(super) struct PidCandidate { pub(super) struct PidCandidate {
#[config( #[config(
id = 16, id = 16,
key = "control.rate_pid.proportional", key = "control.rate_pid.proportional",
unit = "", unit = "",
default = 0.035,
finite, finite,
range(min = 0.0, max = 10.0) range(min = 0.0, max = 10.0)
)] )]
@@ -132,7 +121,6 @@ pub(super) struct PidCandidate {
id = 17, id = 17,
key = "control.rate_pid.integral", key = "control.rate_pid.integral",
unit = "", unit = "",
default = 0.006,
finite, finite,
range(min = 0.0, max = 10.0) range(min = 0.0, max = 10.0)
)] )]
@@ -141,7 +129,6 @@ pub(super) struct PidCandidate {
id = 18, id = 18,
key = "control.rate_pid.derivative", key = "control.rate_pid.derivative",
unit = "", unit = "",
default = 0.0,
finite, finite,
range(min = 0.0, max = 10.0) range(min = 0.0, max = 10.0)
)] )]
@@ -150,7 +137,6 @@ pub(super) struct PidCandidate {
id = 19, id = 19,
key = "control.rate_pid.output_limit", key = "control.rate_pid.output_limit",
unit = "", unit = "",
default = 0.10,
finite, finite,
range(min = 0.001, max = 1.0) range(min = 0.001, max = 1.0)
)] )]
@@ -159,7 +145,6 @@ pub(super) struct PidCandidate {
id = 20, id = 20,
key = "control.rate_pid.integral_limit", key = "control.rate_pid.integral_limit",
unit = "", unit = "",
default = 0.025,
finite, finite,
range(min = 0.0, max = 1.0) 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)] #[config(post = Self::validate_actuator_limits)]
pub(super) struct ActuatorLimitsCandidate { pub(super) struct ActuatorLimitsCandidate {
#[config( #[config(
id = 21, id = 21,
key = "actuator.minimum", key = "actuator.minimum",
unit = "", unit = "",
default = 0.0,
finite, finite,
range(min = 0.0, max = 1.0) range(min = 0.0, max = 1.0)
)] )]
@@ -194,7 +178,6 @@ pub(super) struct ActuatorLimitsCandidate {
id = 22, id = 22,
key = "actuator.maximum", key = "actuator.maximum",
unit = "", unit = "",
default = 1.0,
finite, finite,
range(min = 0.0, max = 1.0) range(min = 0.0, max = 1.0)
)] )]
@@ -203,7 +186,6 @@ pub(super) struct ActuatorLimitsCandidate {
id = 23, id = 23,
key = "actuator.slew_per_second", key = "actuator.slew_per_second",
unit = "1/s", unit = "1/s",
default = 8.0,
finite, finite,
range(min = 0.001, max = 100.0) 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 candidate;
mod control; mod control;
mod ids; mod ids;
mod observability; mod mavlink_logging;
mod registry;
mod safety; mod safety;
mod timing; mod timing;
@@ -14,4 +13,4 @@ pub use active::{
TimingSettings, VehicleCapabilities, VehicleProfile, TimingSettings, VehicleCapabilities, VehicleProfile,
}; };
pub use candidate::{CandidateProfile, FORMAT_VERSION}; 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 { pub(super) struct SafetyCandidate {
#[config( #[config(
id = 1, id = 1,
key = "safety.prearm_dwell_us", key = "safety.prearm_dwell_us",
unit = "us", unit = "us",
default = 100_000,
nonzero, nonzero,
range(min = 1, max = 10_000_000) 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; use super::ids;
#[derive(Clone, Copy, ConfigValidate)] #[derive(Clone, Copy, Config)]
#[config(post = Self::validate_timing)] #[config(post = Self::validate_timing)]
pub(super) struct TimingCandidate { pub(super) struct TimingCandidate {
#[config( #[config(
id = 2, id = 2,
key = "timing.imu_max_interval_us", key = "timing.imu_max_interval_us",
unit = "us", unit = "us",
default = 50_000,
nonzero, nonzero,
range(min = 1, max = 1_000_000) range(min = 1, max = 1_000_000)
)] )]
@@ -18,7 +17,6 @@ pub(super) struct TimingCandidate {
id = 3, id = 3,
key = "timing.imu_freshness_us", key = "timing.imu_freshness_us",
unit = "us", unit = "us",
default = 50_000,
nonzero, nonzero,
range(min = 1, max = 1_000_000) range(min = 1, max = 1_000_000)
)] )]
@@ -27,7 +25,6 @@ pub(super) struct TimingCandidate {
id = 4, id = 4,
key = "timing.local_state_freshness_us", key = "timing.local_state_freshness_us",
unit = "us", unit = "us",
default = 150_000,
nonzero, nonzero,
range(min = 1, max = 10_000_000) range(min = 1, max = 10_000_000)
)] )]
@@ -36,7 +33,6 @@ pub(super) struct TimingCandidate {
id = 5, id = 5,
key = "timing.link_freshness_us", key = "timing.link_freshness_us",
unit = "us", unit = "us",
default = 500_000,
nonzero, nonzero,
range(min = 1, max = 10_000_000) range(min = 1, max = 10_000_000)
)] )]
+83 -54
View File
@@ -1,45 +1,48 @@
//! Central, transport-neutral flight-runtime composition. //! Central, transport-neutral flight-runtime composition.
//! //!
//! This crate owns the one configuration manager and one bounded log hub for //! This crate owns the one configuration manager and the bounded queue for the
//! an image. MAVLink adapters receive mutable borrows for bounded dispatch or //! image's MAVLink diagnostic sink. MAVLink adapters receive mutable borrows
//! draining, but neither can own configuration lifecycle or link state. //! for bounded dispatch or draining, but cannot own configuration lifecycle or
//! link state.
#![no_std] #![no_std]
use kaptan_config::{ConfigManager, ProfileStore, StartupError}; use kaptan_config::{ConfigManager, ConfigStore, StartupError};
use kaptan_flight_config::{CandidateProfile, VehicleCapabilities}; 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 /// Default bounded allocation for the MAVLink diagnostic sink. Error records
/// records are partitioned from optional detail. /// are partitioned from optional detail.
pub type RuntimeLogHub = EventHub<8, 16>; pub type RuntimeMavlinkLogQueue = LogSinkQueue<8, 16>;
/// One boot-active vehicle runtime. It deliberately has no MAVLink dependency. /// One boot-active vehicle runtime. It deliberately has no MAVLink dependency.
pub struct FlightRuntime<S> { pub struct FlightRuntime<S> {
configuration: ConfigManager<CandidateProfile, S>, configuration: ConfigManager<CandidateProfile, S>,
logs: RuntimeLogHub, mavlink_logs: RuntimeMavlinkLogQueue,
} }
impl<S> FlightRuntime<S> impl<S> FlightRuntime<S>
where where
S: ProfileStore<CandidateProfile>, S: ConfigStore<CandidateProfile>,
{ {
/// Recovers the central manager, validates its boot-active snapshot, and /// 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( pub fn recover(
factory: CandidateProfile,
capabilities: VehicleCapabilities, capabilities: VehicleCapabilities,
store: S, store: S,
) -> Result<Self, StartupError<S::Error>> { ) -> Result<Self, StartupError<S::Error>> {
let configuration = let configuration = ConfigManager::recover(factory, capabilities, store)?;
ConfigManager::recover(CandidateProfile::default(), capabilities, store)?; let logging = configuration.active().mavlink_logging();
let maximum_log_level = configuration.active().observability().maximum_log_level(); let filter = if logging.enabled() {
let logs = RuntimeLogHub::new(kaptan_log::LogFilter::new( kaptan_log::LogFilter::up_to(logging.maximum_severity())
Severity::try_from_wire(maximum_log_level as u8) } else {
.expect("validated flight profile has a MAVLink log severity"), kaptan_log::LogFilter::disabled()
)); };
let mavlink_logs = RuntimeMavlinkLogQueue::new(filter);
Ok(Self { Ok(Self {
configuration, configuration,
logs, mavlink_logs,
}) })
} }
@@ -48,82 +51,108 @@ where
&self.configuration &self.configuration
} }
/// Borrows the manager and hub together for a state-only protocol adapter. /// Mutably borrows the sole configuration manager for protocol dispatch.
/// This makes adapter ownership inversion unrepresentable in composition. pub fn configuration_mut(&mut self) -> &mut ConfigManager<CandidateProfile, S> {
pub fn configuration_and_logs_mut( &mut self.configuration
&mut self,
) -> (&mut ConfigManager<CandidateProfile, S>, &mut RuntimeLogHub) {
(&mut self.configuration, &mut self.logs)
} }
/// Publishes one preformatted bounded task record through the sole hub. /// Composition-owned fan-out boundary for one preformatted event.
pub fn log(&mut self, severity: Severity, flags: LogFlags, message: LogText) -> PublishOutcome { /// A future blackbox adds its independent sink admission here.
self.logs.publish_text(severity, flags, message) 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. /// Peeks the highest-priority pending text log for the sole telemetry link.
pub fn pending_report(&self) -> Option<LogRecord> { pub fn pending_mavlink_report(&self) -> Option<LogRecord> {
self.logs.peek() self.mavlink_logs.peek()
} }
/// Submits the next record and removes it only after transport admission. /// Submits the next record and removes it only after transport admission.
pub fn submit_next<R>( pub fn submit_next_mavlink<R>(
&mut self, &mut self,
submit: impl FnOnce(&LogRecord) -> (kaptan_log::TransportAdmission, R), submit: impl FnOnce(&LogRecord) -> (kaptan_log::TransportAdmission, R),
) -> Option<R> { ) -> Option<R> {
self.logs.submit_next(submit) self.mavlink_logs.submit_next(submit)
} }
/// Immutable boot-active log policy copied from the validated profile. /// Immutable boot-active MAVLink log policy copied from the validated profile.
pub const fn log_filter(&self) -> kaptan_log::LogFilter { pub const fn mavlink_log_filter(&self) -> kaptan_log::LogFilter {
self.logs.filter() self.mavlink_logs.filter()
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use kaptan_config::{Generation, ProfileStore}; use kaptan_config::{ConfigModel, ConfigStore, ConfigValue};
use kaptan_log::{LogFlags, LogText, Severity}; use kaptan_log::{LogFlags, LogRecord, LogText, Severity};
use super::FlightRuntime; use super::FlightRuntime;
struct Store; struct Store;
impl ProfileStore<kaptan_flight_config::CandidateProfile> for Store { impl ConfigStore<kaptan_flight_config::CandidateProfile> for Store {
type Error = (); type Error = ();
fn load( fn load(&mut self) -> Result<Option<kaptan_flight_config::CandidateProfile>, Self::Error> {
&mut self,
) -> Result<Option<(Generation, kaptan_flight_config::CandidateProfile)>, Self::Error>
{
Ok(None) Ok(None)
} }
fn persist( fn persist(
&mut self, &mut self,
_: &kaptan_flight_config::CandidateProfile, _: &kaptan_flight_config::CandidateProfile,
) -> Result<Generation, Self::Error> { ) -> Result<(), Self::Error> {
Ok(Generation::new(1)) Ok(())
} }
} }
#[test] #[test]
fn central_runtime_uses_the_active_profile_filter_and_owns_one_hub() { fn runtime_uses_the_active_mavlink_policy_and_owns_one_sink_queue() {
let mut runtime = let mut runtime = FlightRuntime::recover(
FlightRuntime::recover(kaptan_flight_config::VehicleCapabilities::quad_x(), Store) kaptan_flight_config::CandidateProfile::factory(),
.expect("factory profile validates"); kaptan_flight_config::VehicleCapabilities::quad_x(),
Store,
)
.expect("factory configuration validates");
assert_eq!( assert_eq!(
runtime runtime
.configuration() .configuration()
.active() .active()
.observability() .mavlink_logging()
.maximum_log_level(), .maximum_severity(),
runtime.log_filter().maximum_severity() as u32 runtime
.mavlink_log_filter()
.maximum_severity()
.expect("factory enables MAVLink logs")
); );
let _ = runtime.log( runtime.publish_log_event(LogRecord::new(
Severity::Info, Severity::Info,
LogFlags::NONE, LogFlags::NONE,
LogText::from_message("runtime ready"), 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)] #[cfg(test)]
extern crate std; 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::{ use kaptan_mavlink::message::kaptan::{
KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KAPTAN_SHUTDOWN_DATA, KaptanErrorFlags, KaptanLogFlags, KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KAPTAN_SHUTDOWN_DATA, KaptanErrorFlags, KaptanLogFlags,
KaptanShutdownReason, KaptanShutdownReason,
}; };
/// Exact capacity of the fixed text field in [`KAPTAN_LOG_DATA`]. /// 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. /// 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. /// 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 /// One generated diagnostic payload. A platform-owned link remains the sole
/// MAVLink framing, admission, DMA, and retry owner. /// 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"); .expect("log channel must contain ordinary message text");
debug_assert!(usize::from(message.len()) <= LOG_MESSAGE_BYTES); debug_assert!(usize::from(message.len()) <= LOG_MESSAGE_BYTES);
DiagnosticPayload::Log(KAPTAN_LOG_DATA { DiagnosticPayload::Log(KAPTAN_LOG_DATA {
level: record.severity() as u8, level: record.severity().wire_value(),
flags: KaptanLogFlags::from_bits_retain(record.flags().bits()), flags: KaptanLogFlags::from_bits_retain(record.flags().log_bits()),
message_length: message.len(), message_length: message.len(),
message: *message.bytes(), message: *message.bytes(),
}) })
@@ -58,47 +61,29 @@ pub fn encode_diagnostic(record: &LogRecord) -> DiagnosticPayload {
let additional_information = record let additional_information = record
.additional_information() .additional_information()
.expect("error channel must contain additional-information storage"); .expect("error channel must contain additional-information storage");
let (path, path_length, path_clipped) = project_text::<ERROR_PATH_BYTES>(path); debug_assert_eq!(path.bytes().len(), ERROR_PATH_BYTES);
let (additional_information, additional_information_length, information_clipped) = debug_assert_eq!(
project_text::<ERROR_ADDITIONAL_INFORMATION_BYTES>(additional_information); additional_information.bytes().len(),
let mut flags = record.flags().error_bits(); ERROR_ADDITIONAL_INFORMATION_BYTES
if path_clipped || information_clipped { );
flags |= KaptanErrorFlags::KAPTAN_ERROR_FLAG_TRUNCATED.bits();
}
DiagnosticPayload::Error(KAPTAN_ERROR_DATA { DiagnosticPayload::Error(KAPTAN_ERROR_DATA {
flags: KaptanErrorFlags::from_bits_retain(flags), level: record.severity().wire_value(),
path_length, flags: KaptanErrorFlags::from_bits_retain(record.flags().error_bits()),
additional_information_length, path_length: path.len(),
path, additional_information_length: additional_information.len(),
additional_information, 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 /// Encodes one typed shutdown event. The caller owns when a shutdown is safe
/// to report; this pure adapter cannot run a shutdown state machine. /// to report; this pure adapter cannot run a shutdown state machine.
pub const fn encode_shutdown(report: ShutdownReport) -> KAPTAN_SHUTDOWN_DATA { pub const fn encode_shutdown(report: ShutdownReport) -> KAPTAN_SHUTDOWN_DATA {
KAPTAN_SHUTDOWN_DATA { KAPTAN_SHUTDOWN_DATA {
reason: match report.reason() { 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, ShutdownReason::Safety => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_SAFETY,
}, },
} }
@@ -113,8 +98,8 @@ mod tests {
encode_diagnostic, encode_shutdown, encode_diagnostic, encode_shutdown,
}; };
use kaptan_log::{ use kaptan_log::{
ErrorAdditionalInformation, ErrorPath, EventHub, FacadeIngress, GlobalLogger, LogFilter, ErrorAdditionalInformation, ErrorPath, FacadeIngress, GlobalLogger, LogFlags, LogRecord,
LogFlags, LogRecord, LogText, Severity, ShutdownReason, ShutdownReport, LogText, Severity, ShutdownReason, ShutdownReport,
}; };
use kaptan_mavlink::{ use kaptan_mavlink::{
encoder::MavlinkEncoder, 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 ERROR_LOGGER: GlobalLogger<1> = GlobalLogger::new(&ERROR_INGRESS, never_forbidden);
static INSTALL_ERROR_LOGGER: Once = Once::new(); static INSTALL_ERROR_LOGGER: Once = Once::new();
@@ -136,13 +121,13 @@ mod tests {
fn ordinary_record_preserves_level_flags_and_bounded_text() { fn ordinary_record_preserves_level_flags_and_bounded_text() {
let record = LogRecord::new( let record = LogRecord::new(
Severity::Info, Severity::Info,
LogFlags::TRUNCATED, LogFlags::TRUNCATED.with(LogFlags::COALESCED),
LogText::from_message("imu unavailable"), LogText::from_message("imu unavailable"),
); );
let DiagnosticPayload::Log(data) = encode_diagnostic(&record) else { let DiagnosticPayload::Log(data) = encode_diagnostic(&record) else {
panic!("info record must use KAPTAN_LOG"); 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_length, 15);
assert_eq!(&data.message[..15], b"imu unavailable"); assert_eq!(&data.message[..15], b"imu unavailable");
assert!(data.message[15..].iter().all(|byte| *byte == 0)); 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::info!("optional ingress is occupied");
kaptan_log::log::error!("critical error survives"); kaptan_log::log::error!("critical error survives");
let mut hub = EventHub::<1, 1>::new(LogFilter::default()); let record = ERROR_INGRESS
assert!(ERROR_INGRESS.drain_one_into(&mut hub).is_some()); .take()
let record = hub.peek().expect("critical macro record retained"); .expect("critical macro record retained");
let DiagnosticPayload::Error(payload) = encode_diagnostic(&record) else { let DiagnosticPayload::Error(payload) = encode_diagnostic(&record) else {
panic!("log::error! must not produce KAPTAN_LOG"); panic!("log::error! must not produce KAPTAN_LOG");
}; };
@@ -211,6 +196,7 @@ mod tests {
panic!("error record must use KAPTAN_ERROR"); panic!("error record must use KAPTAN_ERROR");
}; };
assert_eq!(error_data.flags.bits(), LogFlags::COALESCED.bits()); 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.path_length, 23);
assert_eq!(error_data.additional_information_length, 0); assert_eq!(error_data.additional_information_length, 0);
assert_eq!(&error_data.path[..23], b"driver::Error::Transfer"); assert_eq!(&error_data.path[..23], b"driver::Error::Transfer");
@@ -220,10 +206,10 @@ mod tests {
.iter() .iter()
.all(|byte| *byte == 0) .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); let error_frame = encoder.custom(&error_data);
assert_eq!(error_frame.message_id(), 51_021); assert_eq!(error_frame.message_id(), 51_021);
assert!(error_frame.raw_bytes().len() <= 175); assert!(error_frame.raw_bytes().len() <= 176);
assert!(matches!( assert!(matches!(
MavMessage::parse( MavMessage::parse(
mavlink::MavlinkVersion::V2, mavlink::MavlinkVersion::V2,
@@ -235,7 +221,7 @@ mod tests {
} }
#[test] #[test]
fn error_wire_projection_clips_each_component_independently() { fn error_record_bounds_each_component_before_wire_projection() {
let error = LogRecord::error( let error = LogRecord::error(
LogFlags::NONE, LogFlags::NONE,
ErrorPath::from_message(&"p".repeat(LOG_MESSAGE_BYTES)), 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] #[test]
fn error_path_remains_decodable_when_only_additional_information_clips() { fn error_path_remains_decodable_when_only_additional_information_clips() {
let error = LogRecord::error( let error = LogRecord::error(
+1
View File
@@ -15,5 +15,6 @@ default = []
facade = ["dep:log"] facade = ["dep:log"]
[dependencies] [dependencies]
kaptan-config-derive = { path = "../kaptan-config-derive" }
kaptan-log-derive = { path = "../kaptan-log-derive" } kaptan-log-derive = { path = "../kaptan-log-derive" }
log = { version = "0.4.33", default-features = false, optional = true } log = { version = "0.4.33", default-features = false, optional = true }
+60 -79
View File
@@ -6,15 +6,12 @@
use core::{ use core::{
cell::UnsafeCell, cell::UnsafeCell,
sync::atomic::{AtomicBool, AtomicU8, Ordering}, sync::atomic::{AtomicBool, Ordering},
}; };
use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError}; use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError};
use crate::{ use crate::{ErrorAdditionalInformation, ErrorPath, LogFlags, LogRecord, LogText, Severity};
ErrorAdditionalInformation, ErrorPath, EventHub, LogFilter, LogFlags, LogText, PublishOutcome,
Severity,
};
/// Saturating, process-local accounting for the optional global facade. /// Saturating, process-local accounting for the optional global facade.
/// ///
@@ -24,8 +21,6 @@ use crate::{
pub struct FacadeHealth { pub struct FacadeHealth {
/// Calls rejected before formatting because their context is forbidden. /// Calls rejected before formatting because their context is forbidden.
pub forbidden_context: u32, pub forbidden_context: u32,
/// Optional records excluded by the active verbosity cap.
pub filtered: u32,
/// Calls that lost the one-attempt ingress ownership race. /// Calls that lost the one-attempt ingress ownership race.
pub contended: u32, pub contended: u32,
/// Calls dropped because their bounded ingress lane was full. /// Calls dropped because their bounded ingress lane was full.
@@ -36,7 +31,6 @@ pub struct FacadeHealth {
struct FacadeCounters { struct FacadeCounters {
forbidden_context: core::sync::atomic::AtomicU32, forbidden_context: core::sync::atomic::AtomicU32,
filtered: core::sync::atomic::AtomicU32,
contended: core::sync::atomic::AtomicU32, contended: core::sync::atomic::AtomicU32,
full: core::sync::atomic::AtomicU32, full: core::sync::atomic::AtomicU32,
truncated: core::sync::atomic::AtomicU32, truncated: core::sync::atomic::AtomicU32,
@@ -46,7 +40,6 @@ impl FacadeCounters {
const fn new() -> Self { const fn new() -> Self {
Self { Self {
forbidden_context: core::sync::atomic::AtomicU32::new(0), forbidden_context: core::sync::atomic::AtomicU32::new(0),
filtered: core::sync::atomic::AtomicU32::new(0),
contended: core::sync::atomic::AtomicU32::new(0), contended: core::sync::atomic::AtomicU32::new(0),
full: core::sync::atomic::AtomicU32::new(0), full: core::sync::atomic::AtomicU32::new(0),
truncated: core::sync::atomic::AtomicU32::new(0), truncated: core::sync::atomic::AtomicU32::new(0),
@@ -71,7 +64,6 @@ impl FacadeCounters {
fn snapshot(&self) -> FacadeHealth { fn snapshot(&self) -> FacadeHealth {
FacadeHealth { FacadeHealth {
forbidden_context: self.forbidden_context.load(Ordering::Relaxed), forbidden_context: self.forbidden_context.load(Ordering::Relaxed),
filtered: self.filtered.load(Ordering::Relaxed),
contended: self.contended.load(Ordering::Relaxed), contended: self.contended.load(Ordering::Relaxed),
full: self.full.load(Ordering::Relaxed), full: self.full.load(Ordering::Relaxed),
truncated: self.truncated.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 /// Fixed global-facade ingress. The normal task/runtime owner takes each
/// an [`EventHub`] after startup. /// transport-neutral record and fans it out to independently filtered sinks.
pub struct FacadeIngress<const N: usize> { pub struct FacadeIngress<const N: usize> {
/// One dedicated critical slot keeps error records independent from the /// One dedicated critical slot keeps error records independent from the
/// bounded ordinary FIFO. It is deliberately one slot: producers never /// bounded ordinary FIFO. It is deliberately one slot: producers never
@@ -157,7 +149,6 @@ pub struct FacadeIngress<const N: usize> {
critical_busy: AtomicBool, critical_busy: AtomicBool,
queue: UnsafeCell<IngressQueue<N>>, queue: UnsafeCell<IngressQueue<N>>,
queue_busy: AtomicBool, queue_busy: AtomicBool,
maximum_severity: AtomicU8,
counters: FacadeCounters, counters: FacadeCounters,
} }
@@ -167,29 +158,17 @@ pub struct FacadeIngress<const N: usize> {
unsafe impl<const N: usize> Sync for FacadeIngress<N> {} unsafe impl<const N: usize> Sync for FacadeIngress<N> {}
impl<const N: usize> FacadeIngress<N> { impl<const N: usize> FacadeIngress<N> {
/// Creates empty fixed ingress with the boot-active verbosity cap. /// Creates an empty fixed ingress.
pub const fn new(filter: LogFilter) -> Self { pub const fn new() -> Self {
Self { Self {
critical: UnsafeCell::new(None), critical: UnsafeCell::new(None),
critical_busy: AtomicBool::new(false), critical_busy: AtomicBool::new(false),
queue: UnsafeCell::new(IngressQueue::new()), queue: UnsafeCell::new(IngressQueue::new()),
queue_busy: AtomicBool::new(false), queue_busy: AtomicBool::new(false),
maximum_severity: AtomicU8::new(filter.maximum_severity() as u8),
counters: FacadeCounters::new(), 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) { fn push(&self, record: IngressRecord) {
if record.severity <= Severity::Error { if record.severity <= Severity::Error {
self.push_critical(record); self.push_critical(record);
@@ -222,24 +201,23 @@ impl<const N: usize> FacadeIngress<N> {
self.counters.snapshot() self.counters.snapshot()
} }
/// Moves at most one preformatted facade record into the central hub. /// Takes at most one preformatted event for composition-level fan-out.
pub fn drain_one_into<const CRITICAL: usize, const BEST_EFFORT: usize>( ///
&self, /// The ingress deliberately applies no sink policy. A caller may submit
hub: &mut EventHub<CRITICAL, BEST_EFFORT>, /// the returned copy to MAVLink, blackbox, or any later sink without one
) -> Option<PublishOutcome> { /// sink's verbosity selection suppressing another's event.
pub fn take(&self) -> Option<LogRecord> {
let record = match self.with_critical(Option::take) { let record = match self.with_critical(Option::take) {
Some(Some(record)) => record, Some(Some(record)) => record,
Some(None) => self.with_queue(IngressQueue::pop).flatten()?, Some(None) => self.with_queue(IngressQueue::pop).flatten()?,
None => return None, None => return None,
}; };
Some(match record.body { Some(match record.body {
IngressBody::Log { message } => { IngressBody::Log { message } => LogRecord::new(record.severity, record.flags, message),
hub.publish_text(record.severity, record.flags, message)
}
IngressBody::Error { IngressBody::Error {
path, path,
additional_information, 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 /// Installed standard logger. The context probe must return true for
/// ISR/panic/hard-fault/shutdown contexts; those calls are rejected before /// ISR/panic/hard-fault/shutdown contexts; those calls are rejected before
/// [`Record::args`] is formatted. /// [`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 /// 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> { pub fn install(&'static self) -> Result<(), SetLoggerError> {
log::set_logger(self)?; log::set_logger(self)?;
log::set_max_level(LevelFilter::Trace); log::set_max_level(LevelFilter::Trace);
@@ -305,18 +290,11 @@ impl<const N: usize> GlobalLogger<N> {
} }
impl<const N: usize> Log for 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)() { if (self.forbidden_context)() {
FacadeCounters::increment(&self.ingress.counters.forbidden_context); FacadeCounters::increment(&self.ingress.counters.forbidden_context);
return false; 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 true
} }
@@ -325,13 +303,7 @@ impl<const N: usize> Log for GlobalLogger<N> {
FacadeCounters::increment(&self.ingress.counters.forbidden_context); FacadeCounters::increment(&self.ingress.counters.forbidden_context);
return; return;
} }
let Some(severity) = severity(record.level()) else { let severity = severity(record.level());
return;
};
if !self.ingress.allows(severity) {
FacadeCounters::increment(&self.ingress.counters.filtered);
return;
}
let flags = if record.level() == Level::Trace { let flags = if record.level() == Level::Trace {
LogFlags::TRACE LogFlags::TRACE
} else { } else {
@@ -367,12 +339,12 @@ impl<const N: usize> Log for GlobalLogger<N> {
fn flush(&self) {} fn flush(&self) {}
} }
const fn severity(level: Level) -> Option<Severity> { const fn severity(level: Level) -> Severity {
match level { match level {
Level::Error => Some(Severity::Error), Level::Error => Severity::Error,
Level::Warn => Some(Severity::Warning), Level::Warn => Severity::Warning,
Level::Info => Some(Severity::Info), Level::Info => Severity::Info,
Level::Debug | Level::Trace => Some(Severity::Debug), Level::Debug | Level::Trace => Severity::Debug,
} }
} }
@@ -382,10 +354,10 @@ mod tests {
use std::sync::Once; use std::sync::Once;
use super::{FacadeIngress, GlobalLogger}; use super::{FacadeIngress, GlobalLogger};
use crate::{EventHub, LogFilter, RecordChannel, Severity}; use crate::{RecordChannel, Severity};
use log::{Level, Log, Metadata, Record}; 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 GLOBAL_LOGGER: GlobalLogger<2> = GlobalLogger::new(&GLOBAL_INGRESS, never_forbidden);
static INSTALL_GLOBAL: Once = Once::new(); static INSTALL_GLOBAL: Once = Once::new();
@@ -399,7 +371,7 @@ mod tests {
#[test] #[test]
fn rejects_forbidden_context_before_formatting() { 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 logger = GlobalLogger::new(&INGRESS, always_forbidden);
let metadata = Metadata::builder() let metadata = Metadata::builder()
.level(Level::Error) .level(Level::Error)
@@ -408,19 +380,31 @@ mod tests {
assert!(!logger.enabled(&metadata)); assert!(!logger.enabled(&metadata));
assert_eq!(INGRESS.health().forbidden_context, 1); assert_eq!(INGRESS.health().forbidden_context, 1);
static FILTERED: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Info)); static UNFILTERED: FacadeIngress<2> = FacadeIngress::new();
let filtered_logger = GlobalLogger::new(&FILTERED, never_forbidden); let unfiltered_logger = GlobalLogger::new(&UNFILTERED, never_forbidden);
let debug = Metadata::builder() let debug = Metadata::builder()
.level(Level::Debug) .level(Level::Debug)
.target("test") .target("test")
.build(); .build();
assert!(!filtered_logger.enabled(&debug)); assert!(unfiltered_logger.enabled(&debug));
assert_eq!(FILTERED.health().filtered, 1); 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] #[test]
fn formats_once_then_drains_into_priority_hub() { fn formats_once_then_exposes_a_transport_neutral_record() {
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug)); static INGRESS: FacadeIngress<2> = FacadeIngress::new();
let logger = GlobalLogger::new(&INGRESS, never_forbidden); let logger = GlobalLogger::new(&INGRESS, never_forbidden);
let record = Record::builder() let record = Record::builder()
.metadata( .metadata(
@@ -432,9 +416,7 @@ mod tests {
.args(format_args!("link failed")) .args(format_args!("link failed"))
.build(); .build();
logger.log(&record); logger.log(&record);
let mut hub = EventHub::<1, 1>::new(LogFilter::default()); let pending = INGRESS.take().expect("facade event retained");
assert!(INGRESS.drain_one_into(&mut hub).is_some());
let pending = hub.peek().unwrap();
assert_eq!(pending.error_path().unwrap().as_str(), "test"); assert_eq!(pending.error_path().unwrap().as_str(), "test");
assert_eq!( assert_eq!(
pending.additional_information().unwrap().as_str(), pending.additional_information().unwrap().as_str(),
@@ -456,11 +438,10 @@ mod tests {
fn standard_log_macros_enqueue_bounded_targeted_text() { fn standard_log_macros_enqueue_bounded_targeted_text() {
INSTALL_GLOBAL.call_once(|| GLOBAL_LOGGER.install().expect("one global test logger")); INSTALL_GLOBAL.call_once(|| GLOBAL_LOGGER.install().expect("one global test logger"));
log::info!("armed configuration loaded"); 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!( assert!(
hub.peek() GLOBAL_INGRESS
.unwrap() .take()
.expect("macro event retained")
.message() .message()
.unwrap() .unwrap()
.as_str() .as_str()
@@ -470,7 +451,7 @@ mod tests {
#[test] #[test]
fn forbidden_context_skips_argument_formatting() { 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); let logger = GlobalLogger::new(&INGRESS, always_forbidden);
logger.log( logger.log(
&Record::builder() &Record::builder()
@@ -488,7 +469,7 @@ mod tests {
#[test] #[test]
fn bounded_ingress_exposes_local_drop_and_truncation_accounting() { 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); let logger = GlobalLogger::new(&INGRESS, never_forbidden);
logger.log( logger.log(
&Record::builder() &Record::builder()
@@ -531,7 +512,7 @@ mod tests {
#[test] #[test]
fn full_optional_ingress_retains_an_error_for_the_error_channel() { 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); let logger = GlobalLogger::new(&INGRESS, never_forbidden);
logger.log( logger.log(
&Record::builder() &Record::builder()
@@ -556,9 +537,9 @@ mod tests {
.build(), .build(),
); );
let mut hub = EventHub::<1, 1>::new(LogFilter::default()); let error = INGRESS
assert!(INGRESS.drain_one_into(&mut hub).is_some()); .take()
let error = hub.peek().expect("error retained ahead of optional text"); .expect("error retained ahead of optional text");
assert_eq!(error.channel(), RecordChannel::Error); assert_eq!(error.channel(), RecordChannel::Error);
assert_eq!(error.error_path().unwrap().as_str(), "test"); assert_eq!(error.error_path().unwrap().as_str(), "test");
assert_eq!(error.additional_information().unwrap().as_str(), "critical"); assert_eq!(error.additional_information().unwrap().as_str(), "critical");
+59 -19
View File
@@ -2,37 +2,77 @@
use crate::Severity; 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 /// Filtering belongs to a sink rather than to event creation: MAVLink and a
/// their dedicated delivery lanes for those records. /// future blackbox may therefore select different policies without either
/// sink suppressing the other's events.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LogFilter { pub enum LogFilter {
maximum_severity: Severity, /// The sink receives no log events, including errors.
Disabled,
/// The sink receives this level and every more severe level.
UpTo(Severity),
} }
impl LogFilter { impl LogFilter {
/// Creates a best-effort verbosity cap. /// Creates a disabled sink policy.
pub const fn new(maximum_severity: Severity) -> Self { pub const fn disabled() -> Self {
Self { maximum_severity } Self::Disabled
} }
/// The selected MAVLink-compatible severity cap. /// Creates a sink policy with one inclusive verbosity cap.
/// pub const fn up_to(maximum_severity: Severity) -> Self {
/// The value is exact on the wire: in particular, `NOTICE` does not Self::UpTo(maximum_severity)
/// silently permit `INFO` records.
pub const fn maximum_severity(self) -> Severity {
self.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 { 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 { #[cfg(test)]
fn default() -> Self { mod tests {
Self::new(Severity::Info) 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::{ use crate::{
DeliveryClass, ErrorAdditionalInformation, ErrorPath, ErrorReport, LogFilter, LogFlags, DeliveryClass, ErrorAdditionalInformation, ErrorPath, ErrorReport, LogFilter, LogFlags,
LogRecord, LogText, Severity, StaticError, LogRecord, LogText, Severity, StaticError, StaticErrorMailbox,
}; };
/// Outcome from one bounded diagnostic admission attempt. /// Outcome from one bounded diagnostic admission attempt.
@@ -12,9 +12,7 @@ use crate::{
pub enum PublishOutcome { pub enum PublishOutcome {
/// The record was retained. /// The record was retained.
Queued, Queued,
/// An adjacent equivalent record absorbed this occurrence. /// A record was excluded by this sink's configured policy.
Coalesced,
/// An ordinary log record was excluded by the configured verbosity cap.
Filtered, Filtered,
/// A best-effort record was replaced by newer optional information. /// A best-effort record was replaced by newer optional information.
ReplacedOldest, ReplacedOldest,
@@ -26,37 +24,10 @@ pub enum PublishOutcome {
/// record to its lower transport. /// record to its lower transport.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransportAdmission { 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, Accepted,
/// The lower transport did not accept it yet; retain it for retry. /// The lower transport did not accept it yet; retain it for retry.
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> { 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 { 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 self.len == N {
if replace_oldest { if replace_oldest {
self.entries[self.head] = Some(record); self.entries[self.head] = Some(record);
@@ -99,6 +61,10 @@ impl<const N: usize> Lane<N> {
PublishOutcome::Queued PublishOutcome::Queued
} }
const fn is_full(&self) -> bool {
self.len == N
}
fn peek(&self) -> Option<LogRecord> { fn peek(&self) -> Option<LogRecord> {
self.entries.get(self.head).copied().flatten() 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. /// One bounded priority queue owned by a log sink.
pub struct EventHub<const CRITICAL: usize, const BEST_EFFORT: usize> { ///
/// It owns no global logger, MAVLink state, DMA, filesystem, or other sink.
pub struct LogSinkQueue<const CRITICAL: usize, const BEST_EFFORT: usize> {
filter: LogFilter, filter: LogFilter,
critical: Lane<CRITICAL>, critical: Lane<CRITICAL>,
best_effort: Lane<BEST_EFFORT>, best_effort: Lane<BEST_EFFORT>,
health: HubHealth,
} }
impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EFFORT> { impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BEST_EFFORT> {
/// Creates one hub with its immutable boot-active verbosity cap. /// Creates one sink queue with its immutable boot-active policy.
pub const fn new(filter: LogFilter) -> Self { pub const fn new(filter: LogFilter) -> Self {
Self { Self {
filter, filter,
critical: Lane::new(), critical: Lane::new(),
best_effort: 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 { pub const fn filter(&self) -> LogFilter {
self.filter self.filter
} }
/// Process-local bounded-admission accounting. /// Whether an error source may be consumed without losing an enabled
pub const fn health(&self) -> HubHealth { /// critical record. Filtered errors may be consumed intentionally.
self.health 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 /// 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 /// Retains one static derived error without formatting or exposing source
/// values. The adapter maps it to `KAPTAN_ERROR`. /// values. The adapter maps it to `KAPTAN_ERROR`.
pub fn publish_error<E: ErrorReport>(&mut self, flags: LogFlags, error: &E) -> PublishOutcome { 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 /// 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, flags: LogFlags,
error: &'static StaticError, error: &'static StaticError,
) -> PublishOutcome { ) -> PublishOutcome {
self.publish_error_text( self.publish_record(LogRecord::from_static_error(flags, error))
flags,
ErrorPath::from_message(error.path()),
ErrorAdditionalInformation::from_message(error.additional_information()),
)
} }
/// Retains a preformatted error identity and optional detail. Both /// Retains a preformatted error identity and optional detail. Both
/// components stay transport-neutral here; the MAVLink adapter enforces /// transport-neutral components already have the exact common diagnostic
/// its independent on-wire capacities. /// envelope capacities, so a sink adapter need not retain or clip them
/// again.
pub fn publish_error_text( pub fn publish_error_text(
&mut self, &mut self,
flags: LogFlags, 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 /// 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( pub fn publish_text(
&mut self, &mut self,
severity: Severity, severity: Severity,
flags: LogFlags, flags: LogFlags,
text: LogText, text: LogText,
) -> PublishOutcome { ) -> 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)) 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 delivery = record.delivery();
let outcome = match delivery { match delivery {
DeliveryClass::Critical => self.critical.publish(record, false), DeliveryClass::Critical => self.critical.publish(record, false),
DeliveryClass::BestEffort => self.best_effort.publish(record, true), 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. /// 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()) self.critical.peek().or_else(|| self.best_effort.peek())
} }
/// Submits the current record through the sole telemetry owner. The hub /// Submits the current record through the sink owner and removes it only
/// removes it only after `Accepted`, and only permits `DropBestEffort` for /// after `Accepted`. This keeps encoder/admission retry semantics local
/// optional detail. This keeps encoder/admission retry semantics local. /// for every severity.
pub fn submit_next<R>( pub fn submit_next<R>(
&mut self, &mut self,
submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R), submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R),
@@ -246,13 +239,7 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
TransportAdmission::Accepted => { TransportAdmission::Accepted => {
let _ = self.pop(); let _ = self.pop();
} }
TransportAdmission::DropBestEffort TransportAdmission::Retry => {}
if record.delivery() == DeliveryClass::BestEffort =>
{
let _ = self.best_effort.pop();
HubHealth::increment(&mut self.health.best_effort_transport_dropped);
}
TransportAdmission::Retry | TransportAdmission::DropBestEffort => {}
} }
Some(result) 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)] #[cfg(test)]
mod tests { mod tests {
use super::{EventHub, PublishOutcome, TransportAdmission}; use super::{LogSinkQueue, PublishOutcome, TransportAdmission};
use crate::{ use crate::{
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogText, DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogRecord,
RecordChannel, Severity, StaticError, LogText, RecordChannel, Severity, StaticError,
}; };
#[test] #[test]
fn errors_bypass_filter_but_debug_does_not() { fn sink_policy_applies_to_errors_and_ordinary_logs() {
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info)); let mut disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
assert_eq!( 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, Severity::Debug,
LogFlags::NONE, LogFlags::NONE,
LogText::from_message("debug") LogText::from_message("debug")
@@ -290,43 +280,110 @@ mod tests {
PublishOutcome::Filtered PublishOutcome::Filtered
); );
assert_eq!( assert_eq!(
hub.publish_text( queue.publish_text(
Severity::Error, Severity::Error,
LogFlags::NONE, LogFlags::NONE,
LogText::from_message("error") LogText::from_message("error")
), ),
PublishOutcome::Queued PublishOutcome::Queued
); );
let record = hub.peek().unwrap(); let record = queue.peek().unwrap();
assert_eq!(record.error_path().unwrap().as_str(), "error"); assert_eq!(record.error_path().unwrap().as_str(), "error");
assert!(record.additional_information().unwrap().is_empty()); assert!(record.additional_information().unwrap().is_empty());
assert_eq!(record.channel(), RecordChannel::Error); assert_eq!(record.channel(), RecordChannel::Error);
} }
#[test] #[test]
fn static_messages_and_repeats_are_bounded() { fn full_critical_lane_backpressures_only_an_enabled_error_source() {
static FAULT: StaticError = StaticError::new("driver::Fault"); let mut enabled = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
let mut hub = EventHub::<1, 1>::new(LogFilter::default()); assert!(enabled.can_publish_error());
assert_eq!( 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 PublishOutcome::Queued
); );
assert_eq!( assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT), queue.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Coalesced PublishOutcome::Queued
); );
let record = hub.peek().unwrap(); let record = queue.peek().unwrap();
assert_eq!(record.occurrences, 2); assert!(!record.flags().contains(LogFlags::COALESCED));
assert!(record.flags().contains(LogFlags::COALESCED));
assert_eq!(record.error_path().unwrap().as_str(), "driver::Fault"); assert_eq!(record.error_path().unwrap().as_str(), "driver::Fault");
assert!(record.additional_information().unwrap().is_empty()); assert!(record.additional_information().unwrap().is_empty());
assert_eq!(
queue.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
);
assert_eq!(queue.peek(), Some(record));
} }
#[test] #[test]
fn static_error_detail_needs_no_producer_formatting() { fn static_error_detail_needs_no_producer_formatting() {
static FAULT: StaticError = static FAULT: StaticError =
StaticError::with_additional_information("driver::Dma::Fault", "stream 7"); 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!( assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT), hub.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued PublishOutcome::Queued
@@ -341,7 +398,7 @@ mod tests {
#[test] #[test]
fn error_identity_and_detail_are_independent_record_components() { 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( let _ = hub.publish_error_text(
LogFlags::NONE, LogFlags::NONE,
ErrorPath::from_message("driver::Icm42688::Transfer"), ErrorPath::from_message("driver::Icm42688::Transfer"),
@@ -360,15 +417,15 @@ mod tests {
} }
#[test] #[test]
fn retry_retains_critical_and_only_best_effort_can_be_discarded() { fn retry_retains_every_severity() {
let mut hub = EventHub::<1, 1>::new(LogFilter::default()); let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
let _ = hub.publish_text( let _ = hub.publish_text(
Severity::Error, Severity::Error,
LogFlags::NONE, LogFlags::NONE,
LogText::from_message("fault"), LogText::from_message("fault"),
); );
assert_eq!( assert_eq!(
hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())), hub.submit_next(|_| (TransportAdmission::Retry, ())),
Some(()) Some(())
); );
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::Critical); assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::Critical);
@@ -384,50 +441,30 @@ mod tests {
LogText::from_message("detail"), LogText::from_message("detail"),
); );
assert_eq!( 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(()) Some(())
); );
assert!(hub.peek().is_none()); assert!(hub.peek().is_none());
assert_eq!(hub.health().best_effort_transport_dropped, 1);
} }
#[test] #[test]
fn admission_losses_are_retained_as_local_saturating_accounting() { fn independent_sink_policies_do_not_suppress_each_other() {
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info)); let record = LogRecord::new(
let _ = hub.publish_text(
Severity::Debug,
LogFlags::NONE,
LogText::from_message("filtered"),
);
let _ = hub.publish_text(
Severity::Info, Severity::Info,
LogFlags::NONE, LogFlags::NONE,
LogText::from_message("first optional"), LogText::from_message("flight ready"),
);
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,
}
); );
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 /// MAVLink-compatible severity. Numeric values deliberately match
/// `MAV_SEVERITY`. /// `MAV_SEVERITY`.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, kaptan_config_derive::ConfigEnum)]
#[repr(u8)] #[repr(u8)]
pub enum Severity { pub enum Severity {
/// System is unusable. /// System is unusable.
@@ -23,19 +23,14 @@ pub enum Severity {
Debug = 7, Debug = 7,
} }
impl Severity { #[cfg(test)]
/// Converts the stable MAVLink numeric representation. mod tests {
pub const fn try_from_wire(value: u8) -> Option<Self> { use core::mem::size_of;
match value {
0 => Some(Self::Emergency), use super::Severity;
1 => Some(Self::Alert),
2 => Some(Self::Critical), #[test]
3 => Some(Self::Error), fn severity_is_one_byte() {
4 => Some(Self::Warning), assert_eq!(size_of::<Severity>(), 1);
5 => Some(Self::Notice),
6 => Some(Self::Info),
7 => Some(Self::Debug),
_ => None,
}
} }
} }
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more