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