refactor(firmware): centralize configuration and diagnostics
This commit is contained in:
1 parent
7048ed89d0
commit
387fdaad23
140 files changed
+6891
-4541
No files matched your search
Generated
+23
-27
@@ -4,9 +4,9 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -151,13 +151,6 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|||||||
name = "kaptan-actuator"
|
name = "kaptan-actuator"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kaptan-board"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"kaptan-log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-config"
|
name = "kaptan-config"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -198,7 +191,6 @@ dependencies = [
|
|||||||
name = "kaptan-data-channel"
|
name = "kaptan-data-channel"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-board",
|
|
||||||
"kaptan-dma",
|
"kaptan-dma",
|
||||||
"kaptan-log",
|
"kaptan-log",
|
||||||
"kaptan-mavlink",
|
"kaptan-mavlink",
|
||||||
@@ -243,8 +235,9 @@ dependencies = [
|
|||||||
name = "kaptan-geprc-f722-v2"
|
name = "kaptan-geprc-f722-v2"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-board",
|
"kaptan-config",
|
||||||
"platform-stm32f7",
|
"platform-stm32f7",
|
||||||
|
"rtic",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -255,14 +248,16 @@ version = "0.1.0"
|
|||||||
name = "kaptan-kakute-f7-mini-v3"
|
name = "kaptan-kakute-f7-mini-v3"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-board",
|
"kaptan-config",
|
||||||
"platform-stm32f7",
|
"platform-stm32f7",
|
||||||
|
"rtic",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-log"
|
name = "kaptan-log"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"kaptan-config-derive",
|
||||||
"kaptan-log-derive",
|
"kaptan-log-derive",
|
||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
@@ -486,7 +481,8 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cortex-m",
|
"cortex-m",
|
||||||
"cortex-m-rt",
|
"cortex-m-rt",
|
||||||
"kaptan-board",
|
"kaptan-config",
|
||||||
|
"kaptan-config-mavlink",
|
||||||
"kaptan-data-channel",
|
"kaptan-data-channel",
|
||||||
"kaptan-dma",
|
"kaptan-dma",
|
||||||
"kaptan-drivers",
|
"kaptan-drivers",
|
||||||
@@ -510,9 +506,9 @@ checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.107"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -528,9 +524,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.46"
|
version = "1.0.47"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
@@ -549,9 +545,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.16"
|
version = "0.4.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -645,7 +641,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -694,9 +690,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.0"
|
version = "3.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -720,22 +716,22 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.19"
|
version = "2.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.19"
|
version = "2.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ members = [
|
|||||||
"boards/geprc-f722-v2",
|
"boards/geprc-f722-v2",
|
||||||
"boards/kakute-f7-mini-v3",
|
"boards/kakute-f7-mini-v3",
|
||||||
"libs/kaptan-actuator",
|
"libs/kaptan-actuator",
|
||||||
"libs/kaptan-board",
|
|
||||||
"libs/kaptan-control",
|
"libs/kaptan-control",
|
||||||
"libs/kaptan-config",
|
"libs/kaptan-config",
|
||||||
"libs/kaptan-config-derive",
|
"libs/kaptan-config-derive",
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ build = "build.rs"
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
kaptan-board = { path = "../../libs/kaptan-board" }
|
kaptan-config = { path = "../../libs/kaptan-config" }
|
||||||
platform-stm32f7 = { path = "../../platforms/stm32f7" }
|
platform-stm32f7 = { path = "../../platforms/stm32f7" }
|
||||||
|
# RTIC's attribute expansion emits absolute `rtic` support paths in the final
|
||||||
|
# board crate; the task graph and all RTIC source remain platform-owned.
|
||||||
|
rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "kaptan-geprc-f722-v2"
|
name = "kaptan-geprc-f722-v2"
|
||||||
|
|||||||
@@ -1,45 +1,92 @@
|
|||||||
//! Declarative support for the GEPRC TAKER F722 BLS 60A V2 stack.
|
//! Declarative support for the GEPRC TAKER F722 BLS 60A V2 stack.
|
||||||
|
|
||||||
use kaptan_board::{
|
|
||||||
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
|
|
||||||
Severity, TelemetryProfile, VerticalSensingKind,
|
|
||||||
};
|
|
||||||
use platform_stm32f7::{
|
use platform_stm32f7::{
|
||||||
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Icm42688, Mavlink, Pc3, VerticalSensor,
|
F7Board, F722Hardware, F722ImmutableConfig as ImmutableConfig, Icm42688, ImuMount, Mavlink,
|
||||||
|
Pa13ActiveHigh, Pb0Pb1Pb4Pb5Low, Pc3, Severity, VerticalSensor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// User-writable MAVLink endpoint policy.
|
||||||
|
#[derive(Clone, kaptan_config::Config)]
|
||||||
|
struct MavlinkConfig {
|
||||||
|
#[config(
|
||||||
|
id = 101,
|
||||||
|
key = "mavlink.system_id",
|
||||||
|
unit = "id",
|
||||||
|
range(min = 1, max = 255)
|
||||||
|
)]
|
||||||
|
system_id: u8,
|
||||||
|
#[config(
|
||||||
|
id = 102,
|
||||||
|
key = "mavlink.component_id",
|
||||||
|
unit = "id",
|
||||||
|
range(min = 1, max = 255)
|
||||||
|
)]
|
||||||
|
component_id: u8,
|
||||||
|
#[config(id = 108, key = "mavlink.logging.enabled", unit = "bool")]
|
||||||
|
logging_enabled: bool,
|
||||||
|
#[config(
|
||||||
|
enum,
|
||||||
|
id = 105,
|
||||||
|
key = "mavlink.logging.maximum_severity",
|
||||||
|
unit = "mav_severity"
|
||||||
|
)]
|
||||||
|
maximum_log_severity: Severity,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-writable telemetry publication policy.
|
||||||
|
#[derive(Clone, kaptan_config::Config)]
|
||||||
|
struct TelemetryConfig {
|
||||||
|
#[config(
|
||||||
|
id = 103,
|
||||||
|
key = "telemetry.raw_imu_hz",
|
||||||
|
unit = "Hz",
|
||||||
|
range(min = 1, max = 100)
|
||||||
|
)]
|
||||||
|
raw_imu_hz: u8,
|
||||||
|
#[config(
|
||||||
|
id = 104,
|
||||||
|
key = "telemetry.heartbeat_hz",
|
||||||
|
unit = "Hz",
|
||||||
|
range(min = 1, max = 10)
|
||||||
|
)]
|
||||||
|
heartbeat_hz: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete user-writable configuration advertised by this board.
|
||||||
|
#[derive(Clone, kaptan_config::Config)]
|
||||||
|
pub struct UserConfig {
|
||||||
|
mavlink: MavlinkConfig,
|
||||||
|
telemetry: TelemetryConfig,
|
||||||
|
}
|
||||||
|
|
||||||
/// ICM-42688-P-based GEP-F722-HD V2 paired with the 60 A stack.
|
/// ICM-42688-P-based GEP-F722-HD V2 paired with the 60 A stack.
|
||||||
pub struct GeprcF722V2;
|
pub struct GeprcF722V2;
|
||||||
|
|
||||||
impl F7Board for GeprcF722V2 {
|
impl F7Board for GeprcF722V2 {
|
||||||
const CONFIG: BoardConfig = BoardConfig {
|
type UserConfig = UserConfig;
|
||||||
name: "GEPRC TAKER F722 V2 (ICM-42688-P)",
|
type ImmutableConfig = ImmutableConfig;
|
||||||
mavlink: MavlinkEndpoint {
|
|
||||||
|
const FACTORY_USER_CONFIG: UserConfig = UserConfig {
|
||||||
|
mavlink: MavlinkConfig {
|
||||||
system_id: 1,
|
system_id: 1,
|
||||||
component_id: 1,
|
component_id: 1,
|
||||||
port: MavlinkPort::Serial2,
|
logging_enabled: true,
|
||||||
baud: 115_200,
|
maximum_log_severity: Severity::Info,
|
||||||
|
},
|
||||||
|
telemetry: TelemetryConfig {
|
||||||
|
raw_imu_hz: 20,
|
||||||
|
heartbeat_hz: 2,
|
||||||
},
|
},
|
||||||
profile: BoardProfile::TelemetryOnly(TelemetryProfile {
|
|
||||||
imu: ImuDevice::Icm42688,
|
|
||||||
imu_mount: ImuMount::Yaw180,
|
|
||||||
vertical_sensing: VerticalSensingKind::None,
|
|
||||||
data_channel: DataChannelRates {
|
|
||||||
service_hz: 100,
|
|
||||||
raw_imu_hz: 20,
|
|
||||||
range_hz: None,
|
|
||||||
optical_flow_hz: None,
|
|
||||||
heartbeat_hz: 2,
|
|
||||||
},
|
|
||||||
maximum_log_level: Severity::Info,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
const IMMUTABLE_CONFIG: ImmutableConfig = ImmutableConfig {
|
||||||
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> {
|
name: "GEPRC TAKER F722 V2 (ICM-42688-P)",
|
||||||
platform
|
service_hz: 100,
|
||||||
.imu(Icm42688::on_spi3().with_drdy(Pc3))
|
hardware: F722Hardware::new()
|
||||||
.mavlink(Mavlink::on_usart2())
|
.output_inhibit(Pb0Pb1Pb4Pb5Low)
|
||||||
|
.imu(Icm42688::on_spi3().with_drdy(Pc3).mounted(ImuMount::Yaw180))
|
||||||
|
.mavlink(Mavlink::on_usart2().baud(115_200))
|
||||||
.vertical_sensor(VerticalSensor::none())
|
.vertical_sensor(VerticalSensor::none())
|
||||||
.build()
|
.status_led(Pa13ActiveHigh)
|
||||||
}
|
.telemetry_only(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -3,4 +3,4 @@
|
|||||||
|
|
||||||
mod board;
|
mod board;
|
||||||
|
|
||||||
platform_stm32f7::flight_firmware!(board::GeprcF722V2);
|
platform_stm32f7::flight_firmware!(crate::board::GeprcF722V2);
|
||||||
@@ -8,12 +8,12 @@ version.workspace = true
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[features]
|
|
||||||
lidar-lite-v3 = []
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
kaptan-board = { path = "../../libs/kaptan-board" }
|
kaptan-config = { path = "../../libs/kaptan-config" }
|
||||||
platform-stm32f7 = { path = "../../platforms/stm32f7" }
|
platform-stm32f7 = { path = "../../platforms/stm32f7" }
|
||||||
|
# RTIC's attribute expansion emits absolute `rtic` support paths in the final
|
||||||
|
# board crate; the task graph and all RTIC source remain platform-owned.
|
||||||
|
rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "kaptan-kakute-f7-mini-v3"
|
name = "kaptan-kakute-f7-mini-v3"
|
||||||
|
|||||||
@@ -4,48 +4,120 @@
|
|||||||
//! code, interrupt names, DMA handling, or task scheduling. Those concerns
|
//! code, interrupt names, DMA handling, or task scheduling. Those concerns
|
||||||
//! belong to the STM32F7 platform crate.
|
//! belong to the STM32F7 platform crate.
|
||||||
|
|
||||||
use kaptan_board::{
|
|
||||||
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
|
|
||||||
Severity, TelemetryProfile, VerticalSensingKind,
|
|
||||||
};
|
|
||||||
use platform_stm32f7::{
|
use platform_stm32f7::{
|
||||||
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Mavlink, Mpu6000, Pa4, VerticalSensor,
|
AccelerometerRange, DigitalLowPassFilter, F7Board, F722Hardware,
|
||||||
|
F722ImmutableConfig as ImmutableConfig, GyroscopeRange, ImuMount, Mavlink, Mpu6000,
|
||||||
|
Mpu6000Config, Pa4, Pb0Pb1Pb4Pb5Low, Pd2ActiveLow, Severity, VerticalSensor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// User-writable MAVLink endpoint policy.
|
||||||
|
#[derive(Clone, kaptan_config::Config)]
|
||||||
|
struct MavlinkConfig {
|
||||||
|
#[config(
|
||||||
|
id = 101,
|
||||||
|
key = "mavlink.system_id",
|
||||||
|
unit = "id",
|
||||||
|
range(min = 1, max = 255)
|
||||||
|
)]
|
||||||
|
system_id: u8,
|
||||||
|
#[config(
|
||||||
|
id = 102,
|
||||||
|
key = "mavlink.component_id",
|
||||||
|
unit = "id",
|
||||||
|
range(min = 1, max = 255)
|
||||||
|
)]
|
||||||
|
component_id: u8,
|
||||||
|
#[config(id = 108, key = "mavlink.logging.enabled", unit = "bool")]
|
||||||
|
logging_enabled: bool,
|
||||||
|
#[config(
|
||||||
|
enum,
|
||||||
|
id = 105,
|
||||||
|
key = "mavlink.logging.maximum_severity",
|
||||||
|
unit = "mav_severity"
|
||||||
|
)]
|
||||||
|
maximum_log_severity: Severity,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-writable telemetry publication policy.
|
||||||
|
#[derive(Clone, kaptan_config::Config)]
|
||||||
|
struct TelemetryConfig {
|
||||||
|
#[config(
|
||||||
|
id = 103,
|
||||||
|
key = "telemetry.raw_imu_hz",
|
||||||
|
unit = "Hz",
|
||||||
|
range(min = 1, max = 100)
|
||||||
|
)]
|
||||||
|
raw_imu_hz: u8,
|
||||||
|
#[config(
|
||||||
|
id = 104,
|
||||||
|
key = "telemetry.heartbeat_hz",
|
||||||
|
unit = "Hz",
|
||||||
|
range(min = 1, max = 10)
|
||||||
|
)]
|
||||||
|
heartbeat_hz: u8,
|
||||||
|
#[config(
|
||||||
|
id = 106,
|
||||||
|
key = "telemetry.range_hz",
|
||||||
|
unit = "Hz",
|
||||||
|
range(min = 1, max = 100)
|
||||||
|
)]
|
||||||
|
range_hz: u8,
|
||||||
|
#[config(
|
||||||
|
id = 107,
|
||||||
|
key = "telemetry.optical_flow_hz",
|
||||||
|
unit = "Hz",
|
||||||
|
range(min = 1, max = 100)
|
||||||
|
)]
|
||||||
|
optical_flow_hz: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete user-writable configuration advertised by this board.
|
||||||
|
#[derive(Clone, kaptan_config::Config)]
|
||||||
|
pub struct UserConfig {
|
||||||
|
mavlink: MavlinkConfig,
|
||||||
|
telemetry: TelemetryConfig,
|
||||||
|
}
|
||||||
|
|
||||||
/// Holybro Kakute F7 Mini V3.
|
/// Holybro Kakute F7 Mini V3.
|
||||||
pub struct KakuteF7MiniV3;
|
pub struct KakuteF7MiniV3;
|
||||||
|
|
||||||
const CONFIG: BoardConfig = BoardConfig {
|
|
||||||
name: "Kakute F7 Mini V3",
|
|
||||||
mavlink: MavlinkEndpoint {
|
|
||||||
system_id: 1,
|
|
||||||
component_id: 1,
|
|
||||||
port: MavlinkPort::Serial1,
|
|
||||||
baud: 115_200,
|
|
||||||
},
|
|
||||||
profile: BoardProfile::TelemetryOnly(TelemetryProfile {
|
|
||||||
imu: ImuDevice::Mpu6000,
|
|
||||||
imu_mount: ImuMount::BodyAligned,
|
|
||||||
vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow,
|
|
||||||
data_channel: DataChannelRates {
|
|
||||||
service_hz: 100,
|
|
||||||
raw_imu_hz: 20,
|
|
||||||
range_hz: Some(20),
|
|
||||||
optical_flow_hz: Some(20),
|
|
||||||
heartbeat_hz: 2,
|
|
||||||
},
|
|
||||||
maximum_log_level: Severity::Info,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
impl F7Board for KakuteF7MiniV3 {
|
impl F7Board for KakuteF7MiniV3 {
|
||||||
const CONFIG: BoardConfig = CONFIG;
|
type UserConfig = UserConfig;
|
||||||
|
type ImmutableConfig = ImmutableConfig;
|
||||||
|
|
||||||
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> {
|
const FACTORY_USER_CONFIG: UserConfig = UserConfig {
|
||||||
platform
|
mavlink: MavlinkConfig {
|
||||||
.imu(Mpu6000::on_spi1().with_drdy(Pa4))
|
system_id: 1,
|
||||||
.mavlink(Mavlink::on_usart1())
|
component_id: 1,
|
||||||
.vertical_sensor(VerticalSensor::mtf01_on_uart4())
|
logging_enabled: true,
|
||||||
.build()
|
maximum_log_severity: Severity::Info,
|
||||||
}
|
},
|
||||||
|
telemetry: TelemetryConfig {
|
||||||
|
raw_imu_hz: 20,
|
||||||
|
heartbeat_hz: 2,
|
||||||
|
range_hz: 20,
|
||||||
|
optical_flow_hz: 20,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const IMMUTABLE_CONFIG: ImmutableConfig = ImmutableConfig {
|
||||||
|
name: "Kakute F7 Mini V3",
|
||||||
|
service_hz: 100,
|
||||||
|
hardware: F722Hardware::new()
|
||||||
|
.output_inhibit(Pb0Pb1Pb4Pb5Low)
|
||||||
|
.imu(
|
||||||
|
Mpu6000::on_spi1()
|
||||||
|
.with_drdy(Pa4)
|
||||||
|
.mounted(ImuMount::BodyAligned)
|
||||||
|
.configured(Mpu6000Config {
|
||||||
|
accelerometer: AccelerometerRange::PlusMinus4G,
|
||||||
|
gyroscope: GyroscopeRange::PlusMinus500DegreesPerSecond,
|
||||||
|
digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz,
|
||||||
|
sample_rate_divider: 9,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mavlink(Mavlink::on_usart1().baud(115_200))
|
||||||
|
.vertical_sensor(VerticalSensor::mtf01_on_uart4().baud(115_200))
|
||||||
|
.status_led(Pd2ActiveLow)
|
||||||
|
.telemetry_only(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
#![no_main]
|
#![no_main]
|
||||||
|
|
||||||
mod board;
|
mod board;
|
||||||
platform_stm32f7::flight_firmware!(board::KakuteF7MiniV3);
|
platform_stm32f7::flight_firmware!(crate::board::KakuteF7MiniV3);
|
||||||
@@ -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;
|
} = model;
|
||||||
match kind {
|
match kind {
|
||||||
ModelKind::Scalars => expand_scalars(ident, generics, crate_path, post_validate, fields),
|
ModelKind::Scalars => expand_scalars(ident, generics, crate_path, post_validate, fields),
|
||||||
ModelKind::Sections => expand_sections(ident, generics, crate_path, post_validate, fields),
|
ModelKind::Sections => expand_children(ident, generics, crate_path, post_validate, fields),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +29,6 @@ fn expand_scalars(
|
|||||||
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
||||||
let mut checks = Vec::new();
|
let mut checks = Vec::new();
|
||||||
let mut descriptors = Vec::new();
|
let mut descriptors = Vec::new();
|
||||||
let mut defaults = Vec::new();
|
|
||||||
let mut reads = Vec::new();
|
let mut reads = Vec::new();
|
||||||
let mut writes = Vec::new();
|
let mut writes = Vec::new();
|
||||||
for field in &fields {
|
for field in &fields {
|
||||||
@@ -37,38 +36,62 @@ fn expand_scalars(
|
|||||||
descriptor,
|
descriptor,
|
||||||
checks: field_checks,
|
checks: field_checks,
|
||||||
} = expand_field(&crate_path, field)?;
|
} = expand_field(&crate_path, field)?;
|
||||||
if let Some(descriptor) = descriptor {
|
descriptors.push(descriptor);
|
||||||
descriptors.push(descriptor);
|
|
||||||
}
|
|
||||||
checks.extend(field_checks);
|
checks.extend(field_checks);
|
||||||
let id = field.id.expect("ConfigValidate requires configured fields");
|
let id = field.id.expect("scalar configuration fields have IDs");
|
||||||
let default = field
|
let ty = &field.ty;
|
||||||
.default
|
|
||||||
.as_ref()
|
|
||||||
.expect("ConfigValidate requires defaults");
|
|
||||||
let (kind, value) = config_value_tokens(&crate_path, &field.ty)?;
|
|
||||||
let field_ident = &field.ident;
|
let field_ident = &field.ident;
|
||||||
defaults.push(quote! { #field_ident: #default });
|
let (kind, read, decode, out_of_domain_rule) = if !field.enum_backed {
|
||||||
|
(
|
||||||
|
quote! { <#ty as #crate_path::ConfigScalar>::KIND },
|
||||||
|
quote! { #crate_path::ConfigScalar::into_config_value(self.#field_ident) },
|
||||||
|
quote! { <#ty as #crate_path::ConfigScalar>::try_from_config_value(value) },
|
||||||
|
quote! { #crate_path::ValidationRule::AboveMaximum },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
quote! { #crate_path::ValueKind::U32 },
|
||||||
|
quote! {
|
||||||
|
#crate_path::ConfigValue::U32(u32::from(self.#field_ident.wire_value()))
|
||||||
|
},
|
||||||
|
quote! {
|
||||||
|
match value {
|
||||||
|
#crate_path::ConfigValue::U32(value) => u8::try_from(value)
|
||||||
|
.ok()
|
||||||
|
.and_then(<#ty>::try_from_wire)
|
||||||
|
.ok_or(#crate_path::ScalarValueError::OutOfDomain),
|
||||||
|
value => Err(#crate_path::ScalarValueError::WrongKind(value.kind())),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
quote! { #crate_path::ValidationRule::InvalidEnumValue },
|
||||||
|
)
|
||||||
|
};
|
||||||
reads.push(quote! {
|
reads.push(quote! {
|
||||||
#id => Some(#value(self.#field_ident)),
|
#id => Some(#read),
|
||||||
});
|
});
|
||||||
writes.push(quote! {
|
writes.push(quote! {
|
||||||
#id => {
|
#id => {
|
||||||
let next = match value {
|
let next = match #decode {
|
||||||
#value(next) => next,
|
Ok(next) => next,
|
||||||
actual => {
|
Err(#crate_path::ScalarValueError::WrongKind(actual)) => {
|
||||||
return Err(#crate_path::FieldWriteError::WrongType {
|
return Err(#crate_path::FieldWriteError::WrongType {
|
||||||
field: #crate_path::FieldId::new(#id),
|
field: #crate_path::FieldId::new(#id),
|
||||||
expected: #kind,
|
expected: #kind,
|
||||||
actual: actual.kind(),
|
actual,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Err(#crate_path::ScalarValueError::OutOfDomain) => {
|
||||||
|
return Err(#crate_path::FieldWriteError::Violation(
|
||||||
|
#crate_path::ConfigViolation::field(
|
||||||
|
#crate_path::FieldId::new(#id),
|
||||||
|
#out_of_domain_rule,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let previous = self.#field_ident;
|
let previous = self.#field_ident;
|
||||||
self.#field_ident = next;
|
self.#field_ident = next;
|
||||||
if let Err(violation) =
|
if let Err(violation) = self.__validate_local_configuration() {
|
||||||
<Self as #crate_path::ConfigSection>::validate_local_fields(self)
|
|
||||||
{
|
|
||||||
self.#field_ident = previous;
|
self.#field_ident = previous;
|
||||||
return Err(#crate_path::FieldWriteError::Violation(violation));
|
return Err(#crate_path::FieldWriteError::Violation(violation));
|
||||||
}
|
}
|
||||||
@@ -78,30 +101,32 @@ fn expand_scalars(
|
|||||||
}
|
}
|
||||||
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
|
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
|
||||||
Ok(quote! {
|
Ok(quote! {
|
||||||
impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause {
|
impl #impl_generics #ident #type_generics #where_clause {
|
||||||
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
|
/// Field metadata generated from the same declarations as validation.
|
||||||
<Self as #crate_path::ConfigSection>::validate_local_fields(self)?;
|
pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[
|
||||||
#post_validate
|
#(#descriptors),*
|
||||||
|
];
|
||||||
|
|
||||||
|
fn __validate_local_configuration(
|
||||||
|
&self,
|
||||||
|
) -> Result<(), #crate_path::ConfigViolation> {
|
||||||
|
if !#crate_path::validation::__valid_enum_options(Self::DESCRIPTORS) {
|
||||||
|
return Err(#crate_path::ConfigViolation::global(
|
||||||
|
#crate_path::ValidationRule::Inconsistent,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#(#checks)*
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl #impl_generics #ident #type_generics #where_clause {
|
impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause {
|
||||||
/// Field metadata generated from the same attributes as validation.
|
|
||||||
pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[
|
|
||||||
#(#descriptors),*
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
impl #impl_generics #crate_path::ConfigSection for #ident #type_generics #where_clause {
|
|
||||||
const FIELD_COUNT: usize = Self::DESCRIPTORS.len();
|
const FIELD_COUNT: usize = Self::DESCRIPTORS.len();
|
||||||
|
const SCHEMA_ID: [u8; 16] =
|
||||||
|
#crate_path::validation::__schema_id(Self::DESCRIPTORS);
|
||||||
|
|
||||||
fn default_section() -> Result<Self, #crate_path::ConfigViolation> {
|
fn descriptor(index: usize) -> Option<&'static #crate_path::FieldDescriptor> {
|
||||||
let section = Self {
|
Self::DESCRIPTORS.get(index)
|
||||||
#(#defaults),*
|
|
||||||
};
|
|
||||||
<Self as #crate_path::ValidateFields>::validate_fields(§ion)?;
|
|
||||||
Ok(section)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
|
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
|
||||||
@@ -122,15 +147,16 @@ fn expand_scalars(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_local_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
|
fn validate(&self) -> Result<(), #crate_path::ConfigViolation> {
|
||||||
#(#checks)*
|
self.__validate_local_configuration()?;
|
||||||
|
#post_validate
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expand_sections(
|
fn expand_children(
|
||||||
ident: syn::Ident,
|
ident: syn::Ident,
|
||||||
generics: syn::Generics,
|
generics: syn::Generics,
|
||||||
crate_path: syn::Path,
|
crate_path: syn::Path,
|
||||||
@@ -138,71 +164,55 @@ fn expand_sections(
|
|||||||
fields: Vec<crate::parse::FieldModel>,
|
fields: Vec<crate::parse::FieldModel>,
|
||||||
) -> Result<TokenStream> {
|
) -> Result<TokenStream> {
|
||||||
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
||||||
let validations = fields.iter().map(|field| {
|
let mut validations = Vec::new();
|
||||||
|
let mut reads = Vec::new();
|
||||||
|
let mut writes = Vec::new();
|
||||||
|
let mut counts = Vec::new();
|
||||||
|
let mut schema_ids = Vec::new();
|
||||||
|
let mut descriptor_lookups = Vec::new();
|
||||||
|
for field in &fields {
|
||||||
let field_ident = &field.ident;
|
let field_ident = &field.ident;
|
||||||
let ty = &field.ty;
|
let ty = &field.ty;
|
||||||
quote! {
|
validations.push(quote! {
|
||||||
<#ty as #crate_path::ValidateFields>::validate_fields(&self.#field_ident)?;
|
<#ty as #crate_path::Config>::validate(&self.#field_ident)?;
|
||||||
}
|
});
|
||||||
});
|
reads.push(quote! {
|
||||||
let defaults = fields.iter().map(|field| {
|
if let Some(value) = <#ty as #crate_path::Config>::field_value(
|
||||||
let field_ident = &field.ident;
|
|
||||||
let ty = &field.ty;
|
|
||||||
quote! {
|
|
||||||
#field_ident: <#ty as #crate_path::ConfigSection>::default_section()?
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let reads = fields.iter().map(|field| {
|
|
||||||
let field_ident = &field.ident;
|
|
||||||
let ty = &field.ty;
|
|
||||||
quote! {
|
|
||||||
if let Some(value) = <#ty as #crate_path::ConfigSection>::field_value(
|
|
||||||
&self.#field_ident,
|
&self.#field_ident,
|
||||||
id,
|
id,
|
||||||
) {
|
) {
|
||||||
return Some(value);
|
return Some(value);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
writes.push(quote! {
|
||||||
let writes = fields.iter().map(|field| {
|
if <#ty as #crate_path::Config>::field_value(&self.#field_ident, id).is_some() {
|
||||||
let field_ident = &field.ident;
|
return <#ty as #crate_path::Config>::set_field(
|
||||||
let ty = &field.ty;
|
|
||||||
quote! {
|
|
||||||
if <#ty as #crate_path::ConfigSection>::field_value(&self.#field_ident, id).is_some() {
|
|
||||||
return <#ty as #crate_path::ConfigSection>::set_field(
|
|
||||||
&mut self.#field_ident,
|
&mut self.#field_ident,
|
||||||
id,
|
id,
|
||||||
value,
|
value,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
counts.push(quote! { <#ty as #crate_path::Config>::FIELD_COUNT });
|
||||||
let counts = fields.iter().map(|field| {
|
schema_ids.push(quote! { <#ty as #crate_path::Config>::SCHEMA_ID });
|
||||||
let ty = &field.ty;
|
descriptor_lookups.push(quote! {
|
||||||
quote! { <#ty as #crate_path::ConfigSection>::FIELD_COUNT }
|
if index < <#ty as #crate_path::Config>::FIELD_COUNT {
|
||||||
});
|
return <#ty as #crate_path::Config>::descriptor(index);
|
||||||
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
|
|
||||||
Ok(quote! {
|
|
||||||
impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause {
|
|
||||||
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
|
|
||||||
#(#validations)*
|
|
||||||
#post_validate
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
index -= <#ty as #crate_path::Config>::FIELD_COUNT;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
|
||||||
|
|
||||||
impl #impl_generics #ident #type_generics #where_clause {
|
Ok(quote! {
|
||||||
/// Number of fields aggregated from derived nested sections.
|
impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause {
|
||||||
pub const FIELD_COUNT: usize = 0 #(+ #counts)*;
|
const FIELD_COUNT: usize = 0 #(+ #counts)*;
|
||||||
}
|
const SCHEMA_ID: [u8; 16] =
|
||||||
|
#crate_path::validation::__combine_schema_ids(&[#(#schema_ids),*]);
|
||||||
|
|
||||||
impl #impl_generics #crate_path::ConfigAggregate for #ident #type_generics #where_clause {
|
fn descriptor(mut index: usize) -> Option<&'static #crate_path::FieldDescriptor> {
|
||||||
fn default_aggregate() -> Result<Self, #crate_path::ConfigViolation> {
|
#(#descriptor_lookups)*
|
||||||
let aggregate = Self {
|
None
|
||||||
#(#defaults),*
|
|
||||||
};
|
|
||||||
<Self as #crate_path::ValidateFields>::validate_fields(&aggregate)?;
|
|
||||||
Ok(aggregate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
|
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
|
||||||
@@ -218,23 +228,39 @@ fn expand_sections(
|
|||||||
#(#writes)*
|
#(#writes)*
|
||||||
Err(#crate_path::FieldWriteError::UnknownField(id))
|
Err(#crate_path::FieldWriteError::UnknownField(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate(&self) -> Result<(), #crate_path::ConfigViolation> {
|
||||||
|
if !#crate_path::validation::__unique_field_ids::<Self>() {
|
||||||
|
return Err(#crate_path::ConfigViolation::global(
|
||||||
|
#crate_path::ValidationRule::Inconsistent,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !#crate_path::validation::__unique_display_keys::<Self>() {
|
||||||
|
return Err(#crate_path::ConfigViolation::global(
|
||||||
|
#crate_path::ValidationRule::Inconsistent,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#(#validations)*
|
||||||
|
#post_validate
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl #impl_generics #ident #type_generics #where_clause {
|
||||||
|
/// Number of fields contributed by the complete generated tree.
|
||||||
|
pub const FIELD_COUNT: usize = <Self as #crate_path::Config>::FIELD_COUNT;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FieldMetadata {
|
struct FieldMetadata {
|
||||||
descriptor: Option<TokenStream>,
|
descriptor: TokenStream,
|
||||||
checks: Vec<TokenStream>,
|
checks: Vec<TokenStream>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result<FieldMetadata> {
|
fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result<FieldMetadata> {
|
||||||
let mut checks = Vec::new();
|
let mut checks = Vec::new();
|
||||||
let Some(id) = field.id else {
|
let id = field.id.expect("scalar configuration fields have IDs");
|
||||||
return Ok(FieldMetadata {
|
|
||||||
descriptor: None,
|
|
||||||
checks,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
let minimum = field.checks.iter().find_map(|check| match check {
|
let minimum = field.checks.iter().find_map(|check| match check {
|
||||||
Check::Minimum(value) => Some(value),
|
Check::Minimum(value) => Some(value),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -248,23 +274,33 @@ fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Res
|
|||||||
}
|
}
|
||||||
let key = field.display_key.as_ref().expect("id fields have a key");
|
let key = field.display_key.as_ref().expect("id fields have a key");
|
||||||
let unit = field.unit.as_ref().expect("id fields have a unit");
|
let unit = field.unit.as_ref().expect("id fields have a unit");
|
||||||
let default = field.default.as_ref().expect("id fields have a default");
|
let ty = &field.ty;
|
||||||
let (kind, value) = config_value_tokens(crate_path, &field.ty)?;
|
let (kind, enum_options) = if !field.enum_backed {
|
||||||
|
(
|
||||||
|
quote! { <#ty as #crate_path::ConfigScalar>::KIND },
|
||||||
|
quote! { &[] },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
quote! { #crate_path::ValueKind::U32 },
|
||||||
|
quote! { <#ty>::CONFIG_ENUM_OPTIONS },
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let value = config_value_constructor_tokens(crate_path, &field.ty)?;
|
||||||
let minimum = option_value_tokens(value.clone(), minimum);
|
let minimum = option_value_tokens(value.clone(), minimum);
|
||||||
let maximum = option_value_tokens(value.clone(), maximum);
|
let maximum = option_value_tokens(value.clone(), maximum);
|
||||||
Ok(FieldMetadata {
|
Ok(FieldMetadata {
|
||||||
descriptor: Some(quote! {
|
descriptor: quote! {
|
||||||
#crate_path::FieldDescriptor {
|
#crate_path::FieldDescriptor {
|
||||||
id: #crate_path::FieldId::new(#id),
|
id: #crate_path::FieldId::new(#id),
|
||||||
display_key: #key,
|
display_key: #key,
|
||||||
kind: #kind,
|
kind: #kind,
|
||||||
minimum: #minimum,
|
minimum: #minimum,
|
||||||
maximum: #maximum,
|
maximum: #maximum,
|
||||||
default: #value(#default),
|
enum_options: #enum_options,
|
||||||
unit: #unit,
|
unit: #unit,
|
||||||
apply_policy: #crate_path::ApplyPolicy::RestartRequired,
|
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
checks,
|
checks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -276,10 +312,7 @@ fn option_value_tokens(value: TokenStream, expression: Option<&syn::Expr>) -> To
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_value_tokens(
|
fn config_value_constructor_tokens(crate_path: &syn::Path, ty: &syn::Type) -> Result<TokenStream> {
|
||||||
crate_path: &syn::Path,
|
|
||||||
ty: &syn::Type,
|
|
||||||
) -> Result<(TokenStream, TokenStream)> {
|
|
||||||
let syn::Type::Path(path) = ty else {
|
let syn::Type::Path(path) = ty else {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
ty,
|
ty,
|
||||||
@@ -292,39 +325,18 @@ fn config_value_tokens(
|
|||||||
"configuration metadata requires a scalar type",
|
"configuration metadata requires a scalar type",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let (kind, value) = match segment.ident.to_string().as_str() {
|
let value = match segment.ident.to_string().as_str() {
|
||||||
"bool" => (
|
"bool" => quote! { #crate_path::ConfigValue::Bool },
|
||||||
quote! { #crate_path::ValueKind::Bool },
|
"i32" => quote! { #crate_path::ConfigValue::I32 },
|
||||||
quote! { #crate_path::ConfigValue::Bool },
|
"u8" | "u16" | "u32" => quote! { #crate_path::ConfigValue::U32 },
|
||||||
),
|
"u64" => quote! { #crate_path::ConfigValue::U64 },
|
||||||
"i32" => (
|
"f32" => quote! { #crate_path::ConfigValue::F32 },
|
||||||
quote! { #crate_path::ValueKind::I32 },
|
"f64" => quote! { #crate_path::ConfigValue::F64 },
|
||||||
quote! { #crate_path::ConfigValue::I32 },
|
|
||||||
),
|
|
||||||
"u32" => (
|
|
||||||
quote! { #crate_path::ValueKind::U32 },
|
|
||||||
quote! { #crate_path::ConfigValue::U32 },
|
|
||||||
),
|
|
||||||
"u64" => (
|
|
||||||
quote! { #crate_path::ValueKind::U64 },
|
|
||||||
quote! { #crate_path::ConfigValue::U64 },
|
|
||||||
),
|
|
||||||
"f32" => (
|
|
||||||
quote! { #crate_path::ValueKind::F32 },
|
|
||||||
quote! { #crate_path::ConfigValue::F32 },
|
|
||||||
),
|
|
||||||
"f64" => (
|
|
||||||
quote! { #crate_path::ValueKind::F64 },
|
|
||||||
quote! { #crate_path::ConfigValue::F64 },
|
|
||||||
),
|
|
||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new_spanned(
|
return Ok(quote! { #crate_path::ConfigValue::U32 });
|
||||||
ty,
|
|
||||||
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok((kind, value))
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Check) -> TokenStream {
|
fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Check) -> TokenStream {
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
//! Procedural validation derive for Kaptan configuration records.
|
//! Procedural derive for recursively composable Kaptan configuration records.
|
||||||
|
|
||||||
|
mod config_enum;
|
||||||
mod expand;
|
mod expand;
|
||||||
mod parse;
|
mod parse;
|
||||||
|
|
||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use syn::parse_macro_input;
|
use syn::parse_macro_input;
|
||||||
|
|
||||||
/// Implements `kaptan_config::ValidateFields` from `#[config(...)]` helpers.
|
/// Generates validation, metadata, routing, and schema identity.
|
||||||
#[proc_macro_derive(ConfigValidate, attributes(config))]
|
///
|
||||||
pub fn derive_config_validate(input: TokenStream) -> TokenStream {
|
/// Primitive fields retain their native scalar type. Enum-backed fields must
|
||||||
|
/// opt in with `#[config(enum)]` and derive [`ConfigEnum`].
|
||||||
|
#[proc_macro_derive(Config, attributes(config))]
|
||||||
|
pub fn derive_config(input: TokenStream) -> TokenStream {
|
||||||
let input = parse_macro_input!(input as syn::DeriveInput);
|
let input = parse_macro_input!(input as syn::DeriveInput);
|
||||||
match parse::Model::parse(input).and_then(expand::expand) {
|
match parse::Model::parse(input).and_then(expand::expand) {
|
||||||
Ok(tokens) => tokens.into(),
|
Ok(tokens) => tokens.into(),
|
||||||
Err(error) => error.into_compile_error().into(),
|
Err(error) => error.into_compile_error().into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates the canonical table and conversions for a field configured as a
|
||||||
|
/// compact enum.
|
||||||
|
///
|
||||||
|
/// The enum must use `#[repr(u8)]`, have explicit integer discriminants, and
|
||||||
|
/// contain only fieldless variants. Variant identifiers are the default GCS
|
||||||
|
/// labels; `#[config_enum(label = "...")]` overrides one label.
|
||||||
|
#[proc_macro_derive(ConfigEnum, attributes(config_enum))]
|
||||||
|
pub fn derive_config_enum(input: TokenStream) -> TokenStream {
|
||||||
|
let input = parse_macro_input!(input as syn::DeriveInput);
|
||||||
|
match config_enum::expand(input) {
|
||||||
|
Ok(tokens) => tokens.into(),
|
||||||
|
Err(error) => error.into_compile_error().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ use syn::{
|
|||||||
spanned::Spanned,
|
spanned::Spanned,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_DISPLAY_KEY_BYTES: usize = 48;
|
||||||
|
const MAX_UNIT_BYTES: usize = 16;
|
||||||
|
|
||||||
pub(crate) struct Model {
|
pub(crate) struct Model {
|
||||||
pub(crate) ident: Ident,
|
pub(crate) ident: Ident,
|
||||||
pub(crate) generics: syn::Generics,
|
pub(crate) generics: syn::Generics,
|
||||||
@@ -24,9 +27,8 @@ pub(crate) struct FieldModel {
|
|||||||
pub(crate) id: Option<u16>,
|
pub(crate) id: Option<u16>,
|
||||||
pub(crate) display_key: Option<syn::LitStr>,
|
pub(crate) display_key: Option<syn::LitStr>,
|
||||||
pub(crate) unit: Option<syn::LitStr>,
|
pub(crate) unit: Option<syn::LitStr>,
|
||||||
pub(crate) default: Option<Expr>,
|
|
||||||
pub(crate) checks: Vec<Check>,
|
pub(crate) checks: Vec<Check>,
|
||||||
pub(crate) section: bool,
|
pub(crate) enum_backed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -43,19 +45,19 @@ impl Model {
|
|||||||
let Data::Struct(data) = input.data else {
|
let Data::Struct(data) = input.data else {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
input.ident.span(),
|
input.ident.span(),
|
||||||
"ConfigValidate can only be derived for structs",
|
"Config can only be derived for structs",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let Fields::Named(fields) = data.fields else {
|
let Fields::Named(fields) = data.fields else {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
input.ident.span(),
|
input.ident.span(),
|
||||||
"ConfigValidate requires named struct fields",
|
"Config requires named struct fields",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
if fields.named.is_empty() {
|
if fields.named.is_empty() {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
input.ident.span(),
|
input.ident.span(),
|
||||||
"ConfigValidate requires at least one configured field or section",
|
"Config requires at least one field",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut models = Vec::with_capacity(fields.named.len());
|
let mut models = Vec::with_capacity(fields.named.len());
|
||||||
@@ -73,38 +75,63 @@ impl Model {
|
|||||||
}
|
}
|
||||||
models.push(model);
|
models.push(model);
|
||||||
}
|
}
|
||||||
let kind = if models.iter().all(|field| field.section) {
|
let kind = if models.iter().all(|field| field.id.is_some()) {
|
||||||
ModelKind::Sections
|
|
||||||
} else if models.iter().all(|field| field.id.is_some()) {
|
|
||||||
ModelKind::Scalars
|
ModelKind::Scalars
|
||||||
|
} else if models.iter().all(|field| field.id.is_none()) {
|
||||||
|
ModelKind::Sections
|
||||||
} else {
|
} else {
|
||||||
let field = models
|
let field = models
|
||||||
.iter()
|
.iter()
|
||||||
.find(|field| field.section || field.id.is_none())
|
.find(|field| field.id.is_none())
|
||||||
.expect("mixed configuration model has one invalid field");
|
.expect("mixed configuration model has one invalid field");
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
field.ident.span(),
|
field.ident.span(),
|
||||||
"ConfigValidate records must contain either scalar config fields or config(section) fields exclusively",
|
"Config records must contain either scalar fields or child configuration records exclusively",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
if kind == ModelKind::Scalars {
|
if kind == ModelKind::Scalars {
|
||||||
|
let mut display_keys = Vec::with_capacity(models.len());
|
||||||
for field in &models {
|
for field in &models {
|
||||||
if field.display_key.is_none() {
|
let Some(display_key) = &field.display_key else {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
field.ident.span(),
|
field.ident.span(),
|
||||||
"configuration fields require config(key = \"...\")",
|
"configuration fields require config(key = \"...\")",
|
||||||
));
|
));
|
||||||
|
};
|
||||||
|
validate_metadata_text(
|
||||||
|
display_key,
|
||||||
|
MAX_DISPLAY_KEY_BYTES,
|
||||||
|
"configuration display key",
|
||||||
|
false,
|
||||||
|
)?;
|
||||||
|
if display_keys
|
||||||
|
.iter()
|
||||||
|
.any(|previous: &&syn::LitStr| previous.value() == display_key.value())
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
display_key.span(),
|
||||||
|
"duplicate configuration display key",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if field.unit.is_none() {
|
display_keys.push(display_key);
|
||||||
|
let Some(unit) = &field.unit else {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
field.ident.span(),
|
field.ident.span(),
|
||||||
"configuration fields require config(unit = \"...\")",
|
"configuration fields require config(unit = \"...\")",
|
||||||
));
|
));
|
||||||
}
|
};
|
||||||
if field.default.is_none() {
|
validate_metadata_text(unit, MAX_UNIT_BYTES, "configuration unit", true)?;
|
||||||
|
let primitive = scalar_type_name(&field.ty).is_some();
|
||||||
|
if field.enum_backed && primitive {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
field.ident.span(),
|
field.ty.span(),
|
||||||
"configuration fields require config(default = ...)",
|
"config(enum) requires a ConfigEnum-derived enum type",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !field.enum_backed && !primitive {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
field.ty.span(),
|
||||||
|
"non-primitive configuration fields require config(enum)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
validate_static_constraints(field)?;
|
validate_static_constraints(field)?;
|
||||||
@@ -121,6 +148,34 @@ impl Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_metadata_text(
|
||||||
|
literal: &syn::LitStr,
|
||||||
|
maximum_bytes: usize,
|
||||||
|
name: &str,
|
||||||
|
allow_empty: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let value = literal.value();
|
||||||
|
if !allow_empty && value.is_empty() {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
literal.span(),
|
||||||
|
format!("{name} must not be empty"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if value.contains('\0') {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
literal.span(),
|
||||||
|
format!("{name} cannot contain NUL bytes"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if value.len() > maximum_bytes {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
literal.span(),
|
||||||
|
format!("{name} exceeds the {maximum_bytes}-byte wire limit"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_container_attributes(attributes: &[Attribute]) -> Result<(Path, Option<Path>)> {
|
fn parse_container_attributes(attributes: &[Attribute]) -> Result<(Path, Option<Path>)> {
|
||||||
let mut crate_path = syn::parse_quote!(::kaptan_config);
|
let mut crate_path = syn::parse_quote!(::kaptan_config);
|
||||||
let mut has_crate_path = false;
|
let mut has_crate_path = false;
|
||||||
@@ -160,40 +215,17 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
|||||||
let mut id = None;
|
let mut id = None;
|
||||||
let mut display_key = None;
|
let mut display_key = None;
|
||||||
let mut unit = None;
|
let mut unit = None;
|
||||||
let mut default = None;
|
|
||||||
let mut finite = false;
|
let mut finite = false;
|
||||||
let mut nonzero = false;
|
let mut nonzero = false;
|
||||||
let mut range_minimum = false;
|
let mut range_minimum = false;
|
||||||
let mut range_maximum = false;
|
let mut range_maximum = false;
|
||||||
let mut section = false;
|
let mut enum_backed = false;
|
||||||
for attribute in field
|
for attribute in field
|
||||||
.attrs
|
.attrs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|attribute| attribute.path().is_ident("config"))
|
.filter(|attribute| attribute.path().is_ident("config"))
|
||||||
{
|
{
|
||||||
attribute.parse_nested_meta(|meta| {
|
attribute.parse_nested_meta(|meta| {
|
||||||
if section && !meta.path.is_ident("section") {
|
|
||||||
return Err(meta.error(
|
|
||||||
"config(section) cannot be combined with scalar id, metadata, or validation rules",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if meta.path.is_ident("section") {
|
|
||||||
if section {
|
|
||||||
return Err(meta.error("duplicate config(section)"));
|
|
||||||
}
|
|
||||||
if id.is_some()
|
|
||||||
|| display_key.is_some()
|
|
||||||
|| unit.is_some()
|
|
||||||
|| default.is_some()
|
|
||||||
|| !checks.is_empty()
|
|
||||||
{
|
|
||||||
return Err(meta.error(
|
|
||||||
"config(section) cannot be combined with scalar id, metadata, or validation rules",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
section = true;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if meta.path.is_ident("id") {
|
if meta.path.is_ident("id") {
|
||||||
if id.is_some() {
|
if id.is_some() {
|
||||||
return Err(meta.error("duplicate configuration field id"));
|
return Err(meta.error("duplicate configuration field id"));
|
||||||
@@ -220,13 +252,11 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
|||||||
unit = Some(meta.value()?.parse()?);
|
unit = Some(meta.value()?.parse()?);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if meta.path.is_ident("default") {
|
if meta.path.is_ident("enum") {
|
||||||
if default.is_some() {
|
if enum_backed {
|
||||||
return Err(meta.error("duplicate configuration default"));
|
return Err(meta.error("duplicate enum configuration marker"));
|
||||||
}
|
}
|
||||||
let expression = meta.value()?.parse()?;
|
enum_backed = true;
|
||||||
validate_default_literal(&expression, &field.ty)?;
|
|
||||||
default = Some(expression);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if meta.path.is_ident("finite") {
|
if meta.path.is_ident("finite") {
|
||||||
@@ -273,26 +303,16 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Err(meta.error(
|
Err(meta.error(
|
||||||
"supported field config rules are id, key, unit, default, finite, nonzero, and range",
|
"supported field config rules are id, key, unit, enum, finite, nonzero, and range",
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
if !section && !checks.is_empty() && id.is_none() {
|
if id.is_none()
|
||||||
return Err(syn::Error::new(
|
&& (display_key.is_some() || unit.is_some() || enum_backed || !checks.is_empty())
|
||||||
ident.span(),
|
|
||||||
"validated configuration fields require config(id = N)",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if section
|
|
||||||
&& (id.is_some()
|
|
||||||
|| display_key.is_some()
|
|
||||||
|| unit.is_some()
|
|
||||||
|| default.is_some()
|
|
||||||
|| !checks.is_empty())
|
|
||||||
{
|
{
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
ident.span(),
|
ident.span(),
|
||||||
"config(section) cannot be combined with scalar id, metadata, or validation rules",
|
"scalar configuration fields require config(id = N)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(FieldModel {
|
Ok(FieldModel {
|
||||||
@@ -301,9 +321,8 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
|||||||
id,
|
id,
|
||||||
display_key,
|
display_key,
|
||||||
unit,
|
unit,
|
||||||
default,
|
|
||||||
checks,
|
checks,
|
||||||
section,
|
enum_backed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,33 +372,19 @@ fn validate_numeric_literal(expression: &Expr) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_default_literal(expression: &Expr, ty: &Type) -> Result<()> {
|
|
||||||
if scalar_type_name(ty) == Some("bool") {
|
|
||||||
return match expression {
|
|
||||||
Expr::Lit(expression) if matches!(expression.lit, Lit::Bool(_)) => Ok(()),
|
|
||||||
_ => Err(syn::Error::new(
|
|
||||||
expression.span(),
|
|
||||||
"bool configuration defaults must be bool literals",
|
|
||||||
)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
validate_numeric_literal(expression)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_static_constraints(field: &FieldModel) -> Result<()> {
|
fn validate_static_constraints(field: &FieldModel) -> Result<()> {
|
||||||
let ty = scalar_type_name(&field.ty).ok_or_else(|| {
|
let Some(ty) = scalar_type_name(&field.ty) else {
|
||||||
syn::Error::new(
|
if field.checks.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(syn::Error::new(
|
||||||
field.ty.span(),
|
field.ty.span(),
|
||||||
"configuration metadata requires a supported scalar type",
|
"validation rules require a supported primitive scalar type",
|
||||||
)
|
));
|
||||||
})?;
|
};
|
||||||
if ty == "bool" {
|
if ty == "bool" {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let default = scalar_literal(
|
|
||||||
field.default.as_ref().expect("scalar fields have defaults"),
|
|
||||||
ty,
|
|
||||||
)?;
|
|
||||||
let minimum = field.checks.iter().find_map(|check| match check {
|
let minimum = field.checks.iter().find_map(|check| match check {
|
||||||
Check::Minimum(value) => Some(value),
|
Check::Minimum(value) => Some(value),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -406,70 +411,6 @@ fn validate_static_constraints(field: &FieldModel) -> Result<()> {
|
|||||||
"configuration range minimum exceeds maximum",
|
"configuration range minimum exceeds maximum",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if field
|
|
||||||
.checks
|
|
||||||
.iter()
|
|
||||||
.any(|check| matches!(check, Check::Finite))
|
|
||||||
&& !default.is_finite()
|
|
||||||
{
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
field
|
|
||||||
.default
|
|
||||||
.as_ref()
|
|
||||||
.expect("scalar fields have defaults")
|
|
||||||
.span(),
|
|
||||||
"configuration default violates finite",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !default.is_finite() {
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
field
|
|
||||||
.default
|
|
||||||
.as_ref()
|
|
||||||
.expect("scalar fields have defaults")
|
|
||||||
.span(),
|
|
||||||
"configuration default is not finite for its field type",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if field
|
|
||||||
.checks
|
|
||||||
.iter()
|
|
||||||
.any(|check| matches!(check, Check::Nonzero))
|
|
||||||
&& default.is_zero()
|
|
||||||
{
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
field
|
|
||||||
.default
|
|
||||||
.as_ref()
|
|
||||||
.expect("scalar fields have defaults")
|
|
||||||
.span(),
|
|
||||||
"configuration default violates nonzero",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(minimum) = minimum
|
|
||||||
&& compare_scalar(default, minimum).is_lt()
|
|
||||||
{
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
field
|
|
||||||
.default
|
|
||||||
.as_ref()
|
|
||||||
.expect("scalar fields have defaults")
|
|
||||||
.span(),
|
|
||||||
"configuration default is below its declared minimum",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(maximum) = maximum
|
|
||||||
&& compare_scalar(default, maximum).is_gt()
|
|
||||||
{
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
field
|
|
||||||
.default
|
|
||||||
.as_ref()
|
|
||||||
.expect("scalar fields have defaults")
|
|
||||||
.span(),
|
|
||||||
"configuration default is above its declared maximum",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,14 +428,6 @@ impl ScalarLiteral {
|
|||||||
Self::Float(value) => value.is_finite(),
|
Self::Float(value) => value.is_finite(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_zero(self) -> bool {
|
|
||||||
match self {
|
|
||||||
Self::Signed(value) => value == 0,
|
|
||||||
Self::Unsigned(value) => value == 0,
|
|
||||||
Self::Float(value) => value == 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
|
fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
|
||||||
@@ -519,6 +452,26 @@ fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
|
|||||||
})?;
|
})?;
|
||||||
Ok(ScalarLiteral::Unsigned(value))
|
Ok(ScalarLiteral::Unsigned(value))
|
||||||
}
|
}
|
||||||
|
"u8" => {
|
||||||
|
let value = unsigned_literal(expression)?;
|
||||||
|
u8::try_from(value).map_err(|_| {
|
||||||
|
syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"configuration literal is outside u8 range",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(ScalarLiteral::Unsigned(value))
|
||||||
|
}
|
||||||
|
"u16" => {
|
||||||
|
let value = unsigned_literal(expression)?;
|
||||||
|
u16::try_from(value).map_err(|_| {
|
||||||
|
syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"configuration literal is outside u16 range",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(ScalarLiteral::Unsigned(value))
|
||||||
|
}
|
||||||
"u64" => {
|
"u64" => {
|
||||||
let value = unsigned_literal(expression)?;
|
let value = unsigned_literal(expression)?;
|
||||||
u64::try_from(value).map_err(|_| {
|
u64::try_from(value).map_err(|_| {
|
||||||
@@ -535,7 +488,7 @@ fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
|
|||||||
"f64" => float_literal(expression).map(ScalarLiteral::Float),
|
"f64" => float_literal(expression).map(ScalarLiteral::Float),
|
||||||
_ => Err(syn::Error::new(
|
_ => Err(syn::Error::new(
|
||||||
expression.span(),
|
expression.span(),
|
||||||
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
|
"configuration metadata supports bool, i32, u8, u16, u32, u64, f32, and f64",
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
use kaptan_config::{ConfigAggregate, ConfigSection, ConfigViolation, ValidateFields};
|
use kaptan_config::{Config, ConfigViolation};
|
||||||
use kaptan_config_derive::ConfigValidate;
|
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct Child {
|
struct Child {
|
||||||
#[config(id = 1, key = "child.value", unit = "count", default = 1, range(min = 0, max = 2))]
|
#[config(id = 1, key = "child.value", unit = "count", range(min = 0, max = 2))]
|
||||||
value: u32,
|
value: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
#[config(post = Self::validate_complete)]
|
#[config(post = Self::validate_complete)]
|
||||||
struct Root {
|
struct Root {
|
||||||
#[config(section)]
|
|
||||||
child: Child,
|
child: Child,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Root {
|
impl Root {
|
||||||
fn validate_complete(&self) -> Result<(), ConfigViolation> {
|
fn validate_complete(&self) -> Result<(), ConfigViolation> {
|
||||||
self.child.validate_fields()
|
self.child.validate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let root = Root::default_aggregate().unwrap();
|
let root = Root {
|
||||||
|
child: Child { value: 1 },
|
||||||
|
};
|
||||||
assert_eq!(Root::FIELD_COUNT, 1);
|
assert_eq!(Root::FIELD_COUNT, 1);
|
||||||
assert_eq!(root.child.field_value(kaptan_config::FieldId::new(1)), Some(kaptan_config::ConfigValue::U32(1)));
|
assert_eq!(root.child.field_value(kaptan_config::FieldId::new(1)), Some(kaptan_config::ConfigValue::U32(1)));
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use kaptan_config_derive::{Config, ConfigEnum};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum Mode {
|
||||||
|
Safe = 1,
|
||||||
|
#[config_enum(label = "Full authority")]
|
||||||
|
FullAuthority = 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct EnumConfig {
|
||||||
|
#[config(enum, id = 1, key = "mode", unit = "mode")]
|
||||||
|
mode: Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
assert_eq!(Mode::Safe.wire_value(), 1);
|
||||||
|
assert!(matches!(Mode::try_from_wire(7), Some(Mode::FullAuthority)));
|
||||||
|
assert_eq!(Mode::CONFIG_ENUM_OPTIONS[1], (7, "Full authority"));
|
||||||
|
assert_eq!(
|
||||||
|
EnumConfig::DESCRIPTORS[0].enum_options,
|
||||||
|
Mode::CONFIG_ENUM_OPTIONS
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct RootBooleanConfig {
|
struct RootBooleanConfig {
|
||||||
#[config(id = 1, key = "enabled", unit = "bool", default = true)]
|
#[config(id = 1, key = "enabled", unit = "bool")]
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,22 @@
|
|||||||
use kaptan_config::{
|
use kaptan_config::{
|
||||||
ConfigAggregate, ConfigSection, ConfigValue, ConfigViolation, FieldId, FieldWriteError,
|
Config, ConfigEnum, ConfigValue, ConfigViolation, FieldId, FieldWriteError, ValidationRule,
|
||||||
ValidateFields, ValidationRule,
|
|
||||||
};
|
};
|
||||||
use kaptan_config_derive::ConfigValidate;
|
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct LocalSection {
|
struct LocalSection {
|
||||||
#[config(
|
#[config(id = 1, key = "local.first", unit = "count", range(min = 1, max = 9))]
|
||||||
id = 1,
|
|
||||||
key = "local.first",
|
|
||||||
unit = "count",
|
|
||||||
default = 2,
|
|
||||||
range(min = 1, max = 9)
|
|
||||||
)]
|
|
||||||
first: u32,
|
first: u32,
|
||||||
#[config(
|
#[config(id = 2, key = "local.second", unit = "count", range(min = 1, max = 9))]
|
||||||
id = 2,
|
|
||||||
key = "local.second",
|
|
||||||
unit = "count",
|
|
||||||
default = 4,
|
|
||||||
range(min = 1, max = 9)
|
|
||||||
)]
|
|
||||||
second: u32,
|
second: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
#[config(post = Self::validate_order)]
|
#[config(post = Self::validate_order)]
|
||||||
struct OrderedSection {
|
struct OrderedSection {
|
||||||
#[config(
|
#[config(
|
||||||
id = 3,
|
id = 3,
|
||||||
key = "ordered.minimum",
|
key = "ordered.minimum",
|
||||||
unit = "count",
|
unit = "count",
|
||||||
default = 1,
|
|
||||||
range(min = 0, max = 9)
|
range(min = 0, max = 9)
|
||||||
)]
|
)]
|
||||||
minimum: u32,
|
minimum: u32,
|
||||||
@@ -39,7 +24,6 @@ struct OrderedSection {
|
|||||||
id = 4,
|
id = 4,
|
||||||
key = "ordered.maximum",
|
key = "ordered.maximum",
|
||||||
unit = "count",
|
unit = "count",
|
||||||
default = 2,
|
|
||||||
range(min = 0, max = 9)
|
range(min = 0, max = 9)
|
||||||
)]
|
)]
|
||||||
maximum: u32,
|
maximum: u32,
|
||||||
@@ -58,17 +42,54 @@ impl OrderedSection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct Aggregate {
|
struct Aggregate {
|
||||||
#[config(section)]
|
|
||||||
local: LocalSection,
|
local: LocalSection,
|
||||||
#[config(section)]
|
|
||||||
ordered: OrderedSection,
|
ordered: OrderedSection,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct BoardOwnedSection {
|
||||||
|
#[config(
|
||||||
|
id = 10,
|
||||||
|
key = "board.endpoint",
|
||||||
|
unit = "id",
|
||||||
|
range(min = 1, max = 255)
|
||||||
|
)]
|
||||||
|
endpoint: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct BoardOwnedAggregate {
|
||||||
|
endpoint: BoardOwnedSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum TestMode {
|
||||||
|
Quiet = 1,
|
||||||
|
Loud = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct CompactSection {
|
||||||
|
#[config(
|
||||||
|
id = 20,
|
||||||
|
key = "compact.endpoint",
|
||||||
|
unit = "id",
|
||||||
|
range(min = 1, max = 255)
|
||||||
|
)]
|
||||||
|
endpoint: u8,
|
||||||
|
#[config(enum, id = 21, key = "compact.mode", unit = "mode")]
|
||||||
|
mode: TestMode,
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_section_owns_every_declared_scalar_path() {
|
fn generated_section_owns_every_declared_scalar_path() {
|
||||||
let mut section = LocalSection::default_section().unwrap();
|
let mut section = LocalSection {
|
||||||
|
first: 2,
|
||||||
|
second: 4,
|
||||||
|
};
|
||||||
assert_eq!(LocalSection::FIELD_COUNT, 2);
|
assert_eq!(LocalSection::FIELD_COUNT, 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
section.field_value(FieldId::new(2)),
|
section.field_value(FieldId::new(2)),
|
||||||
@@ -94,9 +115,52 @@ fn generated_section_owns_every_declared_scalar_path() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn narrow_scalars_and_compact_enums_keep_native_types_and_generic_metadata() {
|
||||||
|
let mut section = CompactSection {
|
||||||
|
endpoint: 7,
|
||||||
|
mode: TestMode::Quiet,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
section.field_value(FieldId::new(20)),
|
||||||
|
Some(ConfigValue::U32(7))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CompactSection::DESCRIPTORS[1].enum_options,
|
||||||
|
TestMode::CONFIG_ENUM_OPTIONS
|
||||||
|
);
|
||||||
|
assert_eq!(TestMode::Quiet.wire_value(), 1);
|
||||||
|
assert_eq!(TestMode::try_from_wire(3), Some(TestMode::Loud));
|
||||||
|
assert_eq!(TestMode::try_from_wire(2), None);
|
||||||
|
|
||||||
|
section
|
||||||
|
.set_field(FieldId::new(21), ConfigValue::U32(3))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(section.mode, TestMode::Loud);
|
||||||
|
assert!(matches!(
|
||||||
|
section.set_field(FieldId::new(21), ConfigValue::U32(2)),
|
||||||
|
Err(FieldWriteError::Violation(issue))
|
||||||
|
if issue.rule() == ValidationRule::InvalidEnumValue
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
section.set_field(FieldId::new(20), ConfigValue::U32(256)),
|
||||||
|
Err(FieldWriteError::Violation(issue))
|
||||||
|
if issue.rule() == ValidationRule::AboveMaximum
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
||||||
let mut aggregate = Aggregate::default_aggregate().unwrap();
|
let mut aggregate = Aggregate {
|
||||||
|
local: LocalSection {
|
||||||
|
first: 2,
|
||||||
|
second: 4,
|
||||||
|
},
|
||||||
|
ordered: OrderedSection {
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
assert_eq!(Aggregate::FIELD_COUNT, 4);
|
assert_eq!(Aggregate::FIELD_COUNT, 4);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
aggregate.field_value(FieldId::new(2)),
|
aggregate.field_value(FieldId::new(2)),
|
||||||
@@ -106,7 +170,7 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
|||||||
.set_field(FieldId::new(3), ConfigValue::U32(3))
|
.set_field(FieldId::new(3), ConfigValue::U32(3))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
aggregate.validate_fields(),
|
aggregate.validate(),
|
||||||
Err(issue)
|
Err(issue)
|
||||||
if issue.primary() == Some(FieldId::new(3))
|
if issue.primary() == Some(FieldId::new(3))
|
||||||
&& issue.related_field() == Some(FieldId::new(4))
|
&& issue.related_field() == Some(FieldId::new(4))
|
||||||
@@ -115,5 +179,101 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
|||||||
aggregate
|
aggregate
|
||||||
.set_field(FieldId::new(4), ConfigValue::U32(3))
|
.set_field(FieldId::new(4), ConfigValue::U32(3))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
aggregate.validate_fields().unwrap();
|
aggregate.validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aggregate_derives_its_flattened_descriptor_order() {
|
||||||
|
let descriptors: Vec<_> = (0..Aggregate::FIELD_COUNT)
|
||||||
|
.map(|index| Aggregate::descriptor(index).unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
descriptors
|
||||||
|
.iter()
|
||||||
|
.map(|descriptor| descriptor.id.get())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[1, 2, 3, 4]
|
||||||
|
);
|
||||||
|
assert!(Aggregate::descriptor(Aggregate::FIELD_COUNT).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_values_are_owned_outside_the_generated_schema() {
|
||||||
|
let mut aggregate = BoardOwnedAggregate {
|
||||||
|
endpoint: BoardOwnedSection { endpoint: 42 },
|
||||||
|
};
|
||||||
|
assert_eq!(BoardOwnedAggregate::FIELD_COUNT, 1);
|
||||||
|
assert_eq!(
|
||||||
|
aggregate.field_value(FieldId::new(10)),
|
||||||
|
Some(ConfigValue::U32(42))
|
||||||
|
);
|
||||||
|
aggregate
|
||||||
|
.set_field(FieldId::new(10), ConfigValue::U32(43))
|
||||||
|
.unwrap();
|
||||||
|
aggregate.validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct DuplicateChildren {
|
||||||
|
first: LocalSection,
|
||||||
|
second: LocalSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct TransparentRoot {
|
||||||
|
local: LocalSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct FirstKeyOwner {
|
||||||
|
#[config(id = 30, key = "shared.key", unit = "count")]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct SecondKeyOwner {
|
||||||
|
#[config(id = 31, key = "shared.key", unit = "count")]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct DuplicateKeysAcrossChildren {
|
||||||
|
first: FirstKeyOwner,
|
||||||
|
second: SecondKeyOwner,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_fingerprint_is_generated_and_one_child_wrappers_are_transparent() {
|
||||||
|
assert_eq!(TransparentRoot::SCHEMA_ID, LocalSection::SCHEMA_ID);
|
||||||
|
assert_ne!(Aggregate::SCHEMA_ID, LocalSection::SCHEMA_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recursive_tree_rejects_duplicate_field_ids() {
|
||||||
|
let duplicate = DuplicateChildren {
|
||||||
|
first: LocalSection {
|
||||||
|
first: 2,
|
||||||
|
second: 4,
|
||||||
|
},
|
||||||
|
second: LocalSection {
|
||||||
|
first: 2,
|
||||||
|
second: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
duplicate.validate(),
|
||||||
|
Err(issue) if issue.rule() == ValidationRule::Inconsistent
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recursive_tree_rejects_duplicate_display_keys() {
|
||||||
|
let duplicate = DuplicateKeysAcrossChildren {
|
||||||
|
first: FirstKeyOwner { value: 1 },
|
||||||
|
second: SecondKeyOwner { value: 2 },
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
duplicate.validate(),
|
||||||
|
Err(issue) if issue.rule() == ValidationRule::Inconsistent
|
||||||
|
));
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use kaptan_config_derive::ConfigEnum;
|
||||||
|
|
||||||
|
#[derive(ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum ImplicitDiscriminant {
|
||||||
|
First = 1,
|
||||||
|
Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: ConfigEnum variants require explicit u8 discriminants
|
||||||
|
--> tests/ui/config_enum_implicit_discriminant.rs:7:5
|
||||||
|
|
|
||||||
|
7 | Second,
|
||||||
|
| ^^^^^^
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use kaptan_config_derive::ConfigEnum;
|
||||||
|
|
||||||
|
#[derive(ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum LabelWithNul {
|
||||||
|
#[config_enum(label = "invalid\0label")]
|
||||||
|
Value = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: ConfigEnum display labels must be non-empty text without NUL bytes
|
||||||
|
--> tests/ui/config_enum_label_nul.rs:6:27
|
||||||
|
|
|
||||||
|
6 | #[config_enum(label = "invalid\0label")]
|
||||||
|
| ^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use kaptan_config_derive::ConfigEnum;
|
||||||
|
|
||||||
|
#[derive(ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum LabelTooManyBytes {
|
||||||
|
#[config_enum(label = "şşşşşşşşşşşşşşşşş")]
|
||||||
|
Value = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: ConfigEnum display label exceeds the 32-byte wire limit
|
||||||
|
--> tests/ui/config_enum_label_too_many_bytes.rs:6:27
|
||||||
|
|
|
||||||
|
6 | #[config_enum(label = "şşşşşşşşşşşşşşşşş")]
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
use kaptan_config_derive::ConfigEnum;
|
||||||
|
|
||||||
|
#[derive(ConfigEnum)]
|
||||||
|
enum MissingRepresentation {
|
||||||
|
First = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: ConfigEnum requires exactly #[repr(u8)]
|
||||||
|
--> tests/ui/config_enum_missing_repr.rs:4:6
|
||||||
|
|
|
||||||
|
4 | enum MissingRepresentation {
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::ConfigEnum;
|
||||||
|
|
||||||
|
#[derive(ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum Payload {
|
||||||
|
Value(u8) = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: ConfigEnum variants cannot contain fields
|
||||||
|
--> tests/ui/config_enum_payload.rs:6:10
|
||||||
|
|
|
||||||
|
6 | Value(u8) = 1,
|
||||||
|
| ^^^^
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
|
||||||
struct DefaultBelowMinimum {
|
|
||||||
#[config(id = 1, key = "value", unit = "count", default = 1, range(min = 2, max = 3))]
|
|
||||||
value: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
error: configuration default is below its declared minimum
|
|
||||||
--> tests/ui/default_below_minimum.rs:5:63
|
|
||||||
|
|
|
||||||
5 | #[config(id = 1, key = "value", unit = "count", default = 1, range(min = 2, max = 3))]
|
|
||||||
| ^
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
|
||||||
struct NonFiniteDefault {
|
|
||||||
#[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
|
|
||||||
value: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
error: configuration default violates finite
|
|
||||||
--> tests/ui/default_violates_finite.rs:5:63
|
|
||||||
|
|
|
||||||
5 | #[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
|
|
||||||
| ^^^^^
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
|
||||||
struct ZeroDefault {
|
|
||||||
#[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
|
|
||||||
value: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
error: configuration default violates nonzero
|
|
||||||
--> tests/ui/default_violates_nonzero.rs:5:63
|
|
||||||
|
|
|
||||||
5 | #[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
|
|
||||||
| ^
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct DisplayKeyTooLong {
|
||||||
|
#[config(
|
||||||
|
id = 1,
|
||||||
|
key = "configuration.display.key.with.more.than.forty.eight.bytes",
|
||||||
|
unit = "count"
|
||||||
|
)]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: configuration display key exceeds the 48-byte wire limit
|
||||||
|
--> tests/ui/display_key_too_long.rs:7:15
|
||||||
|
|
|
||||||
|
7 | key = "configuration.display.key.with.more.than.forty.eight.bytes",
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct DuplicateDisplayKey {
|
||||||
|
#[config(id = 1, key = "duplicate.key", unit = "count")]
|
||||||
|
first: u32,
|
||||||
|
#[config(id = 2, key = "duplicate.key", unit = "count")]
|
||||||
|
second: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: duplicate configuration display key
|
||||||
|
--> tests/ui/duplicate_display_key.rs:7:28
|
||||||
|
|
|
||||||
|
7 | #[config(id = 2, key = "duplicate.key", unit = "count")]
|
||||||
|
| ^^^^^^^^^^^^^^^
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct DuplicateId {
|
struct DuplicateId {
|
||||||
#[config(id = 7, nonzero)]
|
#[config(id = 7, nonzero)]
|
||||||
first: u32,
|
first: u32,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct DuplicateRange {
|
struct DuplicateRange {
|
||||||
#[config(id = 7, range(min = 1), range(min = 2))]
|
#[config(id = 7, range(min = 1), range(min = 2))]
|
||||||
value: u32,
|
value: u32,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct Empty {}
|
struct Empty {}
|
||||||
|
|
||||||
fn main() {}
|
fn main() {}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
error: ConfigValidate requires at least one configured field or section
|
error: Config requires at least one field
|
||||||
--> tests/ui/empty.rs:4:8
|
--> tests/ui/empty.rs:4:8
|
||||||
|
|
|
|
||||||
4 | struct Empty {}
|
4 | struct Empty {}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct PrimitiveEnumMarker {
|
||||||
|
#[config(enum, id = 1, key = "mode", unit = "mode")]
|
||||||
|
mode: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: config(enum) requires a ConfigEnum-derived enum type
|
||||||
|
--> tests/ui/enum_marker_on_primitive.rs:6:11
|
||||||
|
|
|
||||||
|
6 | mode: u8,
|
||||||
|
| ^^
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use kaptan_config_derive::{Config, ConfigEnum};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum Mode {
|
||||||
|
Safe = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct MissingEnumMarker {
|
||||||
|
#[config(id = 1, key = "mode", unit = "mode")]
|
||||||
|
mode: Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: non-primitive configuration fields require config(enum)
|
||||||
|
--> tests/ui/missing_enum_marker.rs:12:11
|
||||||
|
|
|
||||||
|
12 | mode: Mode,
|
||||||
|
| ^^^^
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct MissingId {
|
struct MissingId {
|
||||||
#[config(nonzero)]
|
#[config(nonzero)]
|
||||||
value: u32,
|
value: u32,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
error: validated configuration fields require config(id = N)
|
error: scalar configuration fields require config(id = N)
|
||||||
--> tests/ui/missing_id.rs:6:5
|
--> tests/ui/missing_id.rs:6:5
|
||||||
|
|
|
|
||||||
6 | value: u32,
|
6 | value: u32,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct MissingMetadata {
|
struct MissingMetadata {
|
||||||
#[config(id = 7, unit = "count", default = 1, range(min = 0, max = 10))]
|
#[config(id = 7, unit = "count", range(min = 0, max = 10))]
|
||||||
value: u32,
|
value: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct MixedSectionAndScalar {
|
struct MixedSectionAndScalar {
|
||||||
#[config(section)]
|
|
||||||
child: u32,
|
child: u32,
|
||||||
#[config(id = 1, key = "scalar", unit = "count", default = 1, range(min = 0, max = 2))]
|
#[config(id = 1, key = "scalar", unit = "count", range(min = 0, max = 2))]
|
||||||
scalar: u32,
|
scalar: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
error: ConfigValidate records must contain either scalar config fields or config(section) fields exclusively
|
error: Config records must contain either scalar fields or child configuration records exclusively
|
||||||
--> tests/ui/mixed_section_scalar.rs:6:5
|
--> tests/ui/mixed_section_scalar.rs:5:5
|
||||||
|
|
|
|
||||||
6 | child: u32,
|
5 | child: u32,
|
||||||
| ^^^^^
|
| ^^^^^
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct InvalidRange {
|
struct InvalidRange {
|
||||||
#[config(id = 1, key = "value", unit = "count", default = 2, range(min = 3, max = 1))]
|
#[config(id = 1, key = "value", unit = "count", range(min = 3, max = 1))]
|
||||||
value: u32,
|
value: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct UnitTooLong {
|
||||||
|
#[config(id = 1, key = "value", unit = "seventeen_bytes___")]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: configuration unit exceeds the 16-byte wire limit
|
||||||
|
--> tests/ui/unit_too_long.rs:5:44
|
||||||
|
|
|
||||||
|
5 | #[config(id = 1, key = "value", unit = "seventeen_bytes___")]
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct UnsupportedDefault {
|
||||||
|
#[config(id = 1, key = "value", unit = "count", default = 1)]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: supported field config rules are id, key, unit, enum, finite, nonzero, and range
|
||||||
|
--> tests/ui/unsupported_default.rs:5:53
|
||||||
|
|
|
||||||
|
5 | #[config(id = 1, key = "value", unit = "count", default = 1)]
|
||||||
|
| ^^^^^^^
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
#[config(external_defaults)]
|
||||||
|
struct UnsupportedExternalDefaults {
|
||||||
|
#[config(id = 1, key = "value", unit = "count")]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: struct config options are crate = path and post = path
|
||||||
|
--> tests/ui/unsupported_external_defaults.rs:4:10
|
||||||
|
|
|
||||||
|
4 | #[config(external_defaults)]
|
||||||
|
| ^^^^^^^^^^^^^^^^^
|
||||||
@@ -8,11 +8,6 @@ version.workspace = true
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[features]
|
|
||||||
# Explicitly opt-in host/SITL test authority. Production cannot construct an
|
|
||||||
# authorization token from a CRC-valid raw MAVLink frame.
|
|
||||||
host-authority = []
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
kaptan-config = { path = "../kaptan-config" }
|
kaptan-config = { path = "../kaptan-config" }
|
||||||
kaptan-log = { path = "../kaptan-log" }
|
kaptan-log = { path = "../kaptan-log" }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Authorized, transport-neutral configuration commands.
|
//! Transport-neutral configuration commands.
|
||||||
|
|
||||||
use kaptan_config::{ConfigValue, FieldId};
|
use kaptan_config::{ConfigValue, FieldId};
|
||||||
|
|
||||||
@@ -11,6 +11,30 @@ pub struct Target {
|
|||||||
component: u8,
|
component: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exact MAVLink endpoint that owns a configuration transaction lease.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Principal(u16);
|
||||||
|
|
||||||
|
impl Principal {
|
||||||
|
/// Creates a principal from one exact source system/component pair.
|
||||||
|
pub const fn new(system: u8, component: u8) -> Self {
|
||||||
|
assert!(
|
||||||
|
system != 0 && component != 0,
|
||||||
|
"configuration principal must be exact"
|
||||||
|
);
|
||||||
|
Self(u16::from_be_bytes([system, component]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an exact source principal from an untrusted transport.
|
||||||
|
pub const fn try_new(system: u8, component: u8) -> Option<Self> {
|
||||||
|
if system == 0 || component == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(Self(u16::from_be_bytes([system, component])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Selects the configuration snapshot read by a value request.
|
/// Selects the configuration snapshot read by a value request.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum ValueSource {
|
pub enum ValueSource {
|
||||||
@@ -45,21 +69,30 @@ impl Target {
|
|||||||
/// One command accepted by the Kaptan configuration microservice.
|
/// One command accepted by the Kaptan configuration microservice.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum ConfigCommand {
|
pub enum ConfigCommand {
|
||||||
/// Reads protocol/schema metadata before any descriptor or upload request.
|
/// Reads protocol/schema metadata before descriptor and value requests.
|
||||||
InfoRequest {
|
InfoRequest {
|
||||||
/// Client correlation identity.
|
/// Client correlation identity.
|
||||||
request_id: u32,
|
request_id: u32,
|
||||||
/// Exact addressed endpoint.
|
/// Exact addressed endpoint.
|
||||||
target: Target,
|
target: Target,
|
||||||
},
|
},
|
||||||
/// Enumerates a bounded page of schema descriptors.
|
/// Reads one schema descriptor by stable index.
|
||||||
DescriptorRequest {
|
DescriptorRequest {
|
||||||
/// Client correlation identity.
|
/// Client correlation identity.
|
||||||
request_id: u32,
|
request_id: u32,
|
||||||
/// First stable descriptor index.
|
/// Stable descriptor index.
|
||||||
first: u16,
|
index: u16,
|
||||||
/// Requested number of descriptors, clamped to the protocol bound.
|
/// Exact addressed endpoint.
|
||||||
count: u16,
|
target: Target,
|
||||||
|
},
|
||||||
|
/// Reads one named choice for one descriptor.
|
||||||
|
EnumOptionRequest {
|
||||||
|
/// Client correlation identity.
|
||||||
|
request_id: u32,
|
||||||
|
/// Stable index of the owning descriptor.
|
||||||
|
descriptor_index: u16,
|
||||||
|
/// Stable enum-option index.
|
||||||
|
option_index: u8,
|
||||||
/// Exact addressed endpoint.
|
/// Exact addressed endpoint.
|
||||||
target: Target,
|
target: Target,
|
||||||
},
|
},
|
||||||
@@ -76,59 +109,10 @@ pub enum ConfigCommand {
|
|||||||
/// Exact addressed endpoint.
|
/// Exact addressed endpoint.
|
||||||
target: Target,
|
target: Target,
|
||||||
},
|
},
|
||||||
/// Starts one bounded CBOR snapshot import into the caller's transaction.
|
/// Starts an edit transaction from the latest committed configuration.
|
||||||
ImportBegin {
|
|
||||||
/// Client correlation identity.
|
|
||||||
request_id: u32,
|
|
||||||
/// Lease-bound transaction identity.
|
|
||||||
session_id: u32,
|
|
||||||
/// Optimistic transaction revision.
|
|
||||||
expected_revision: u32,
|
|
||||||
/// Complete CBOR byte length.
|
|
||||||
length: u16,
|
|
||||||
/// Exact schema identifier advertised by `KAPTAN_CONFIG_INFO`.
|
|
||||||
schema_id: [u8; 16],
|
|
||||||
/// CRC-32C over exactly `length` CBOR bytes.
|
|
||||||
crc32c: u32,
|
|
||||||
/// Exact addressed endpoint.
|
|
||||||
target: Target,
|
|
||||||
},
|
|
||||||
/// Supplies one fixed-bounded CBOR import chunk.
|
|
||||||
ImportChunk {
|
|
||||||
/// Client correlation identity.
|
|
||||||
request_id: u32,
|
|
||||||
/// Lease-bound transaction identity.
|
|
||||||
session_id: u32,
|
|
||||||
/// Server-issued ID returned by the matching import-begin result.
|
|
||||||
transfer_id: u32,
|
|
||||||
/// Byte offset into the complete import.
|
|
||||||
offset: u16,
|
|
||||||
/// Used bytes of `data`.
|
|
||||||
length: u8,
|
|
||||||
/// Fixed MAVLink payload data.
|
|
||||||
data: [u8; 192],
|
|
||||||
/// Exact addressed endpoint.
|
|
||||||
target: Target,
|
|
||||||
},
|
|
||||||
/// Checks the completed import and replaces only the RAM transaction.
|
|
||||||
ImportFinish {
|
|
||||||
/// Client correlation identity.
|
|
||||||
request_id: u32,
|
|
||||||
/// Lease-bound transaction identity.
|
|
||||||
session_id: u32,
|
|
||||||
/// Server-issued ID returned by the matching import-begin result.
|
|
||||||
transfer_id: u32,
|
|
||||||
/// Optimistic transaction revision.
|
|
||||||
expected_revision: u32,
|
|
||||||
/// Exact addressed endpoint.
|
|
||||||
target: Target,
|
|
||||||
},
|
|
||||||
/// Starts an edit transaction from the expected committed generation.
|
|
||||||
Begin {
|
Begin {
|
||||||
/// Client correlation identity.
|
/// Client correlation identity.
|
||||||
request_id: u32,
|
request_id: u32,
|
||||||
/// Optimistic concurrency generation.
|
|
||||||
expected_generation: u64,
|
|
||||||
/// Exact addressed endpoint.
|
/// Exact addressed endpoint.
|
||||||
target: Target,
|
target: Target,
|
||||||
},
|
},
|
||||||
@@ -164,8 +148,6 @@ pub enum ConfigCommand {
|
|||||||
request_id: u32,
|
request_id: u32,
|
||||||
/// Lease-bound transaction identity.
|
/// Lease-bound transaction identity.
|
||||||
session_id: u32,
|
session_id: u32,
|
||||||
/// Optimistic committed generation.
|
|
||||||
expected_generation: u64,
|
|
||||||
/// Optimistic transaction revision.
|
/// Optimistic transaction revision.
|
||||||
expected_revision: u32,
|
expected_revision: u32,
|
||||||
/// Exact addressed endpoint.
|
/// Exact addressed endpoint.
|
||||||
@@ -188,10 +170,8 @@ impl ConfigCommand {
|
|||||||
match self {
|
match self {
|
||||||
Self::InfoRequest { request_id, .. }
|
Self::InfoRequest { request_id, .. }
|
||||||
| Self::DescriptorRequest { request_id, .. }
|
| Self::DescriptorRequest { request_id, .. }
|
||||||
|
| Self::EnumOptionRequest { request_id, .. }
|
||||||
| Self::ValueRequest { request_id, .. }
|
| Self::ValueRequest { request_id, .. }
|
||||||
| Self::ImportBegin { request_id, .. }
|
|
||||||
| Self::ImportChunk { request_id, .. }
|
|
||||||
| Self::ImportFinish { request_id, .. }
|
|
||||||
| Self::Begin { request_id, .. }
|
| Self::Begin { request_id, .. }
|
||||||
| Self::Set { request_id, .. }
|
| Self::Set { request_id, .. }
|
||||||
| Self::Validate { request_id, .. }
|
| Self::Validate { request_id, .. }
|
||||||
@@ -205,10 +185,8 @@ impl ConfigCommand {
|
|||||||
match self {
|
match self {
|
||||||
Self::InfoRequest { target, .. }
|
Self::InfoRequest { target, .. }
|
||||||
| Self::DescriptorRequest { target, .. }
|
| Self::DescriptorRequest { target, .. }
|
||||||
|
| Self::EnumOptionRequest { target, .. }
|
||||||
| Self::ValueRequest { target, .. }
|
| Self::ValueRequest { target, .. }
|
||||||
| Self::ImportBegin { target, .. }
|
|
||||||
| Self::ImportChunk { target, .. }
|
|
||||||
| Self::ImportFinish { target, .. }
|
|
||||||
| Self::Begin { target, .. }
|
| Self::Begin { target, .. }
|
||||||
| Self::Set { target, .. }
|
| Self::Set { target, .. }
|
||||||
| Self::Validate { target, .. }
|
| Self::Validate { target, .. }
|
||||||
@@ -218,54 +196,25 @@ impl ConfigCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A capability token produced only by the future signing/replay authority.
|
/// Test command paired with its lease principal.
|
||||||
///
|
#[cfg(test)]
|
||||||
/// The token carries a stable principal identity so the service can bind a
|
|
||||||
/// transaction lease to one authenticated source. It is intentionally opaque:
|
|
||||||
/// an unverified raw MAVLink frame cannot create one.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct VerifiedAuthority {
|
|
||||||
principal: u64,
|
|
||||||
_private: (),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VerifiedAuthority {
|
|
||||||
/// Returns the authenticated principal identity for ownership checks.
|
|
||||||
pub const fn principal(self) -> u64 {
|
|
||||||
self.principal
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(test, feature = "host-authority"))]
|
|
||||||
/// Creates an explicit host/SITL authority token.
|
|
||||||
///
|
|
||||||
/// This exists only for host integration and protocol tests. Flight code
|
|
||||||
/// must receive the equivalent token from the reviewed signing/replay path.
|
|
||||||
pub const fn host(principal: u64) -> Self {
|
|
||||||
Self {
|
|
||||||
principal,
|
|
||||||
_private: (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A command whose authority was verified before it reached this adapter.
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct Authorized<T> {
|
pub struct Authorized<T> {
|
||||||
authority: VerifiedAuthority,
|
principal: Principal,
|
||||||
command: T,
|
command: T,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
impl<T> Authorized<T> {
|
impl<T> Authorized<T> {
|
||||||
#[cfg(any(test, feature = "host-authority"))]
|
/// Pairs a test command with component `principal` in test system 1.
|
||||||
/// Wraps a host/SITL command in an explicit test-only authority token.
|
pub const fn host(principal: u8, command: T) -> Self {
|
||||||
pub const fn host(principal: u64, command: T) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
authority: VerifiedAuthority::host(principal),
|
principal: Principal::new(1, principal),
|
||||||
command,
|
command,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn into_parts(self) -> (VerifiedAuthority, T) {
|
pub(crate) fn into_parts(self) -> (Principal, T) {
|
||||||
(self.authority, self.command)
|
(self.principal, self.command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,19 +2,34 @@
|
|||||||
//!
|
//!
|
||||||
//! This crate translates generated `kaptan.xml` records to the transport-
|
//! This crate translates generated `kaptan.xml` records to the transport-
|
||||||
//! neutral configuration lifecycle. It does not parse raw frames, authenticate
|
//! neutral configuration lifecycle. It does not parse raw frames, authenticate
|
||||||
//! peers, deserialize CBOR, access storage directly, or mutate active flight
|
//! peers, access storage directly, or mutate active flight
|
||||||
//! consumers.
|
//! consumers.
|
||||||
|
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
extern crate std;
|
||||||
|
|
||||||
mod command;
|
mod command;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod wire;
|
pub mod wire;
|
||||||
|
|
||||||
pub use command::{Authorized, ConfigCommand, Target, ValueSource, VerifiedAuthority};
|
#[cfg(test)]
|
||||||
|
pub use command::Authorized;
|
||||||
|
pub use command::{ConfigCommand, Principal, Target, ValueSource};
|
||||||
pub use protocol::{ConfigReply, Operation, ProtocolError, ResultKind};
|
pub use protocol::{ConfigReply, Operation, ProtocolError, ResultKind};
|
||||||
pub use service::{
|
pub use service::{
|
||||||
CAPABILITY_CBOR_IMPORT, ConfigInfo, ConfigProtocol, DescriptorPage, NoSnapshotImporter,
|
ConfigInfo, ConfigProtocol, DescriptorResponse, EnumOptionResponse, ServiceResponse,
|
||||||
ServiceResponse, SnapshotImporter, ValueResponse,
|
ValueResponse,
|
||||||
};
|
};
|
||||||
|
pub use wire::{ResponseVisitor, visit_response};
|
||||||
|
|
||||||
|
/// Wire protocol implemented by the descriptor/value configuration service.
|
||||||
|
pub const PROTOCOL_MAJOR: u8 = 2;
|
||||||
|
pub const PROTOCOL_MINOR: u8 = 0;
|
||||||
|
|
||||||
|
/// `KAPTAN_CONFIG_DESCRIPTOR.flags` bit: `minimum_bits` is present.
|
||||||
|
pub const DESCRIPTOR_HAS_MINIMUM: u8 = 1 << 0;
|
||||||
|
/// `KAPTAN_CONFIG_DESCRIPTOR.flags` bit: `maximum_bits` is present.
|
||||||
|
pub const DESCRIPTOR_HAS_MAXIMUM: u8 = 1 << 1;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Stable typed outcomes for the Kaptan configuration microservice.
|
//! Stable typed outcomes for the Kaptan configuration microservice.
|
||||||
|
|
||||||
use kaptan_config::{ConfigState, ConfigViolation, FieldId};
|
use kaptan_config::{ConfigState, ConfigViolation, FieldId};
|
||||||
use kaptan_log::{EventHub, KaptanError, LogFlags, PublishOutcome};
|
use kaptan_log::KaptanError;
|
||||||
|
|
||||||
/// Operation reported in an authoritative result record.
|
/// Operation reported in an authoritative result record.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum Operation {
|
pub enum Operation {
|
||||||
@@ -17,8 +18,6 @@ pub enum Operation {
|
|||||||
Commit,
|
Commit,
|
||||||
/// Transaction abort.
|
/// Transaction abort.
|
||||||
Abort,
|
Abort,
|
||||||
/// CBOR snapshot import into the isolated transaction.
|
|
||||||
Import,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stable result classification.
|
/// Stable result classification.
|
||||||
@@ -38,11 +37,7 @@ pub enum ResultKind {
|
|||||||
pub enum ProtocolError {
|
pub enum ProtocolError {
|
||||||
/// The request target did not match this component.
|
/// The request target did not match this component.
|
||||||
BadTarget,
|
BadTarget,
|
||||||
/// Authorization was absent or insufficient.
|
/// The configuration lease or single transaction slot is occupied.
|
||||||
AuthorizationDenied,
|
|
||||||
/// The optimistic durable generation did not match.
|
|
||||||
GenerationConflict,
|
|
||||||
/// The single transaction slot is occupied.
|
|
||||||
SessionBusy,
|
SessionBusy,
|
||||||
/// The session is unknown, expired, or belongs to a different principal.
|
/// The session is unknown, expired, or belongs to a different principal.
|
||||||
InvalidSession,
|
InvalidSession,
|
||||||
@@ -54,22 +49,14 @@ pub enum ProtocolError {
|
|||||||
BadState,
|
BadState,
|
||||||
/// The field is not present in this schema.
|
/// The field is not present in this schema.
|
||||||
UnknownField(FieldId),
|
UnknownField(FieldId),
|
||||||
|
/// A query requested an empty or out-of-range descriptor span.
|
||||||
|
InvalidQuery,
|
||||||
/// The typed setter does not match the field descriptor.
|
/// The typed setter does not match the field descriptor.
|
||||||
TypeMismatch(FieldId),
|
TypeMismatch(FieldId),
|
||||||
/// A local or complete validation issue.
|
/// A local or complete validation issue.
|
||||||
Violation(ConfigViolation),
|
Violation(ConfigViolation),
|
||||||
/// Persistence failed after a complete validation pass.
|
/// Persistence failed after a complete validation pass.
|
||||||
StorageFailure,
|
StorageFailure,
|
||||||
/// A CBOR snapshot could not be decoded into this schema.
|
|
||||||
CborMalformed,
|
|
||||||
/// A CBOR snapshot exceeded the fixed import budget.
|
|
||||||
CborTooLarge,
|
|
||||||
/// The complete CBOR snapshot did not match the supplied CRC-32C.
|
|
||||||
BlobCrc,
|
|
||||||
/// The uploaded CBOR blob was prepared for a different schema.
|
|
||||||
SchemaMismatch,
|
|
||||||
/// This deployment does not expose bounded CBOR transaction import.
|
|
||||||
ImportUnavailable,
|
|
||||||
/// The adapter reached an impossible internal state.
|
/// The adapter reached an impossible internal state.
|
||||||
Internal,
|
Internal,
|
||||||
}
|
}
|
||||||
@@ -81,12 +68,8 @@ pub struct ConfigReply {
|
|||||||
pub request_id: u32,
|
pub request_id: u32,
|
||||||
/// The current session, or zero when none exists.
|
/// The current session, or zero when none exists.
|
||||||
pub session_id: u32,
|
pub session_id: u32,
|
||||||
/// Server-assigned import transfer identity, or zero when not importing.
|
|
||||||
pub transfer_id: u32,
|
|
||||||
/// The transaction revision, or zero when no transaction exists.
|
/// The transaction revision, or zero when no transaction exists.
|
||||||
pub revision: u32,
|
pub revision: u32,
|
||||||
/// Latest committed generation, or zero for the factory profile.
|
|
||||||
pub generation: u64,
|
|
||||||
/// Completed operation.
|
/// Completed operation.
|
||||||
pub operation: Operation,
|
pub operation: Operation,
|
||||||
/// High-level result.
|
/// High-level result.
|
||||||
@@ -130,66 +113,4 @@ impl ConfigReply {
|
|||||||
_ => 0,
|
_ => 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueues a critical diagnostic companion for a rejected structured
|
|
||||||
/// result. The `KAPTAN_CONFIG_RESULT` remains the correlated,
|
|
||||||
/// field/rule-precise authority; this sends only the derive-generated
|
|
||||||
/// high-level `ProtocolError` display identity through `KAPTAN_ERROR`.
|
|
||||||
///
|
|
||||||
/// The helper deliberately carries no request/session/generation fields
|
|
||||||
/// into the error channel. A composition owner may call it after it has
|
|
||||||
/// emitted or retained the authoritative result.
|
|
||||||
pub fn publish_rejection<const CRITICAL: usize, const BEST_EFFORT: usize>(
|
|
||||||
self,
|
|
||||||
hub: &mut EventHub<CRITICAL, BEST_EFFORT>,
|
|
||||||
) -> Option<PublishOutcome> {
|
|
||||||
if self.result != ResultKind::Rejected {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.error
|
|
||||||
.as_ref()
|
|
||||||
.map(|error| hub.publish_error(LogFlags::NONE, error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use kaptan_log::{EventHub, LogFilter, PublishOutcome, RecordChannel};
|
|
||||||
|
|
||||||
use super::{ConfigReply, Operation, ProtocolError, ResultKind};
|
|
||||||
use kaptan_config::ConfigState;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejected_result_enqueues_only_its_high_level_error_identity() {
|
|
||||||
let reply = ConfigReply {
|
|
||||||
request_id: 42,
|
|
||||||
session_id: 7,
|
|
||||||
transfer_id: 0,
|
|
||||||
revision: 3,
|
|
||||||
generation: 9,
|
|
||||||
operation: Operation::Set,
|
|
||||||
result: ResultKind::Rejected,
|
|
||||||
error: Some(ProtocolError::BadState),
|
|
||||||
state: ConfigState::Editing,
|
|
||||||
};
|
|
||||||
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
|
||||||
assert_eq!(
|
|
||||||
reply.publish_rejection(&mut hub),
|
|
||||||
Some(PublishOutcome::Queued)
|
|
||||||
);
|
|
||||||
let record = hub.peek().expect("rejection is retained");
|
|
||||||
assert_eq!(record.channel(), RecordChannel::Error);
|
|
||||||
assert_eq!(
|
|
||||||
record.error_path().unwrap().as_str(),
|
|
||||||
"kaptan_config_mavlink::protocol::ProtocolError::BadState"
|
|
||||||
);
|
|
||||||
assert!(record.additional_information().unwrap().is_empty());
|
|
||||||
|
|
||||||
let accepted = ConfigReply {
|
|
||||||
result: ResultKind::Accepted,
|
|
||||||
error: None,
|
|
||||||
..reply
|
|
||||||
};
|
|
||||||
assert_eq!(accepted.publish_rejection(&mut hub), None);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
//! Transport-neutral façade for the custom configuration service.
|
//! Transport-neutral façade for the custom configuration service.
|
||||||
//!
|
//!
|
||||||
//! Public callers see one small protocol API here. Its bounded query,
|
//! Public callers see one small protocol API here. Its bounded query,
|
||||||
//! session, replay, and CBOR-import mechanics stay in private sibling modules
|
//! session and replay mechanics stay in private sibling modules
|
||||||
//! so no one concern becomes a second service owner.
|
//! so no one concern becomes a second service owner.
|
||||||
|
|
||||||
mod import;
|
|
||||||
mod query;
|
mod query;
|
||||||
mod replay;
|
mod replay;
|
||||||
mod session;
|
mod session;
|
||||||
|
|
||||||
pub use import::{CAPABILITY_CBOR_IMPORT, NoSnapshotImporter, SnapshotImporter};
|
pub use query::{
|
||||||
pub use query::{ConfigInfo, DescriptorPage, ServiceResponse, ValueResponse};
|
ConfigInfo, DescriptorResponse, EnumOptionResponse, ServiceResponse, ValueResponse,
|
||||||
|
};
|
||||||
pub use session::ConfigProtocol;
|
pub use session::ConfigProtocol;
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
//! Bounded CBOR snapshot transfer state.
|
|
||||||
|
|
||||||
use kaptan_config::{CommitError, ConfigManager, ConfigModel, ProfileStore};
|
|
||||||
|
|
||||||
use super::session::{ConfigProtocol, Session};
|
|
||||||
use crate::{ConfigCommand, ConfigReply, Operation, ProtocolError, ResultKind};
|
|
||||||
|
|
||||||
/// Maximum complete CBOR snapshot accepted by the protocol.
|
|
||||||
pub(super) const IMPORT_MAXIMUM_BYTES: usize = 4096;
|
|
||||||
/// Payload bytes carried by each aligned import chunk.
|
|
||||||
pub(super) const IMPORT_CHUNK_BYTES: usize = 192;
|
|
||||||
pub(super) const IMPORT_CHUNK_COUNT: usize = IMPORT_MAXIMUM_BYTES.div_ceil(IMPORT_CHUNK_BYTES);
|
|
||||||
|
|
||||||
/// `KAPTAN_CONFIG_INFO.capability_flags`: bounded CBOR transaction import.
|
|
||||||
pub const CAPABILITY_CBOR_IMPORT: u8 = 1;
|
|
||||||
|
|
||||||
/// Decodes a complete bounded CBOR snapshot into this service's candidate.
|
|
||||||
///
|
|
||||||
/// Vehicle-specific implementations live above this transport adapter so the
|
|
||||||
/// generic MAVLink layer never owns a profile schema or board capabilities.
|
|
||||||
pub trait SnapshotImporter<C> {
|
|
||||||
/// Whether this concrete deployment exposes the bounded import path.
|
|
||||||
const IMPORT_SUPPORTED: bool;
|
|
||||||
/// Current format carried inside the deployment's raw CBOR profile blob.
|
|
||||||
const PROFILE_FORMAT: u16;
|
|
||||||
/// Converts one complete CBOR payload to a candidate. The service performs
|
|
||||||
/// the subsequent complete-context validation before replacing the RAM
|
|
||||||
/// transaction.
|
|
||||||
fn decode(&self, bytes: &[u8]) -> Result<C, ProtocolError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default importer used when a deployment deliberately does not expose CBOR
|
|
||||||
/// import yet. It keeps the service type usable for descriptor-only tests.
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
|
||||||
pub struct NoSnapshotImporter;
|
|
||||||
|
|
||||||
impl<C> SnapshotImporter<C> for NoSnapshotImporter {
|
|
||||||
const IMPORT_SUPPORTED: bool = false;
|
|
||||||
const PROFILE_FORMAT: u16 = 0;
|
|
||||||
|
|
||||||
fn decode(&self, _: &[u8]) -> Result<C, ProtocolError> {
|
|
||||||
Err(ProtocolError::CborMalformed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub(super) struct ImportBuffer {
|
|
||||||
owner: u64,
|
|
||||||
session_id: u32,
|
|
||||||
transfer_id: u32,
|
|
||||||
revision: u32,
|
|
||||||
length: u16,
|
|
||||||
crc32c: u32,
|
|
||||||
received: [bool; IMPORT_CHUNK_COUNT],
|
|
||||||
data: [u8; IMPORT_MAXIMUM_BYTES],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<I> ConfigProtocol<I> {
|
|
||||||
pub(super) fn begin_import<C>(
|
|
||||||
&mut self,
|
|
||||||
owner: u64,
|
|
||||||
command: ConfigCommand,
|
|
||||||
session: Session,
|
|
||||||
) -> ConfigReply
|
|
||||||
where
|
|
||||||
C: ConfigModel,
|
|
||||||
I: SnapshotImporter<C>,
|
|
||||||
{
|
|
||||||
let ConfigCommand::ImportBegin {
|
|
||||||
request_id,
|
|
||||||
length,
|
|
||||||
schema_id,
|
|
||||||
crc32c,
|
|
||||||
..
|
|
||||||
} = command
|
|
||||||
else {
|
|
||||||
return self.rejected(command, ProtocolError::Internal);
|
|
||||||
};
|
|
||||||
if !I::IMPORT_SUPPORTED {
|
|
||||||
return self.rejected(command, ProtocolError::ImportUnavailable);
|
|
||||||
}
|
|
||||||
if schema_id != C::SCHEMA_ID {
|
|
||||||
return self.rejected(command, ProtocolError::SchemaMismatch);
|
|
||||||
}
|
|
||||||
if length == 0 || usize::from(length) > IMPORT_MAXIMUM_BYTES {
|
|
||||||
return self.rejected(command, ProtocolError::CborTooLarge);
|
|
||||||
}
|
|
||||||
let Some(transfer_id) = self.allocate_transfer_id() else {
|
|
||||||
return self.rejected(command, ProtocolError::Internal);
|
|
||||||
};
|
|
||||||
self.import = Some(ImportBuffer {
|
|
||||||
owner,
|
|
||||||
session_id: session.id,
|
|
||||||
transfer_id,
|
|
||||||
revision: session.revision,
|
|
||||||
length,
|
|
||||||
crc32c,
|
|
||||||
received: [false; IMPORT_CHUNK_COUNT],
|
|
||||||
data: [0; IMPORT_MAXIMUM_BYTES],
|
|
||||||
});
|
|
||||||
let mut reply = self.reply(
|
|
||||||
request_id,
|
|
||||||
session.id,
|
|
||||||
session.revision,
|
|
||||||
Operation::Import,
|
|
||||||
ResultKind::Accepted,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
reply.transfer_id = transfer_id;
|
|
||||||
reply
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn import_chunk(&mut self, command: ConfigCommand, session: Session) -> ConfigReply {
|
|
||||||
let ConfigCommand::ImportChunk {
|
|
||||||
request_id,
|
|
||||||
transfer_id,
|
|
||||||
offset,
|
|
||||||
length,
|
|
||||||
data,
|
|
||||||
..
|
|
||||||
} = command
|
|
||||||
else {
|
|
||||||
return self.rejected(command, ProtocolError::Internal);
|
|
||||||
};
|
|
||||||
let offset = usize::from(offset);
|
|
||||||
let length = usize::from(length);
|
|
||||||
let Some(import) = self.import.as_mut() else {
|
|
||||||
return self.rejected(command, ProtocolError::BadState);
|
|
||||||
};
|
|
||||||
if import.owner != session.owner
|
|
||||||
|| import.session_id != session.id
|
|
||||||
|| import.transfer_id != transfer_id
|
|
||||||
{
|
|
||||||
return self.rejected(command, ProtocolError::InvalidSession);
|
|
||||||
}
|
|
||||||
if import.revision != session.revision {
|
|
||||||
return self.rejected(command, ProtocolError::StaleRevision);
|
|
||||||
}
|
|
||||||
let total = usize::from(import.length);
|
|
||||||
let Some(end) = offset.checked_add(length) else {
|
|
||||||
return self.rejected(command, ProtocolError::CborMalformed);
|
|
||||||
};
|
|
||||||
if length == 0
|
|
||||||
|| offset % IMPORT_CHUNK_BYTES != 0
|
|
||||||
|| end > total
|
|
||||||
|| (length != IMPORT_CHUNK_BYTES && end != total)
|
|
||||||
{
|
|
||||||
return self.rejected(command, ProtocolError::CborMalformed);
|
|
||||||
}
|
|
||||||
let chunk = offset / IMPORT_CHUNK_BYTES;
|
|
||||||
if import.received[chunk] {
|
|
||||||
if import.data[offset..end] != data[..length] {
|
|
||||||
return self.rejected(command, ProtocolError::RequestConflict);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
import.data[offset..end].copy_from_slice(&data[..length]);
|
|
||||||
import.received[chunk] = true;
|
|
||||||
}
|
|
||||||
self.reply(
|
|
||||||
request_id,
|
|
||||||
session.id,
|
|
||||||
session.revision,
|
|
||||||
Operation::Import,
|
|
||||||
ResultKind::Accepted,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn finish_import<C, S>(
|
|
||||||
&mut self,
|
|
||||||
manager: &mut ConfigManager<C, S>,
|
|
||||||
command: ConfigCommand,
|
|
||||||
session: Session,
|
|
||||||
request_id: u32,
|
|
||||||
transfer_id: u32,
|
|
||||||
) -> ConfigReply
|
|
||||||
where
|
|
||||||
C: ConfigModel,
|
|
||||||
S: ProfileStore<C>,
|
|
||||||
I: SnapshotImporter<C>,
|
|
||||||
{
|
|
||||||
let Some(import) = self.import else {
|
|
||||||
return self.rejected(command, ProtocolError::BadState);
|
|
||||||
};
|
|
||||||
if import.owner != session.owner
|
|
||||||
|| import.session_id != session.id
|
|
||||||
|| import.transfer_id != transfer_id
|
|
||||||
{
|
|
||||||
return self.rejected(command, ProtocolError::InvalidSession);
|
|
||||||
}
|
|
||||||
if import.revision != session.revision {
|
|
||||||
return self.rejected(command, ProtocolError::StaleRevision);
|
|
||||||
}
|
|
||||||
let total = usize::from(import.length);
|
|
||||||
let chunks = total.div_ceil(IMPORT_CHUNK_BYTES);
|
|
||||||
if import.received[..chunks].iter().any(|received| !received) {
|
|
||||||
return self.rejected(command, ProtocolError::CborMalformed);
|
|
||||||
}
|
|
||||||
if crc32c(&import.data[..total]) != import.crc32c {
|
|
||||||
return self.rejected(command, ProtocolError::BlobCrc);
|
|
||||||
}
|
|
||||||
let candidate = match self.importer.decode(&import.data[..total]) {
|
|
||||||
Ok(candidate) => candidate,
|
|
||||||
Err(error) => return self.rejected(command, error),
|
|
||||||
};
|
|
||||||
let Some(next_revision) = session.revision.checked_add(1) else {
|
|
||||||
return self.rejected(command, ProtocolError::Internal);
|
|
||||||
};
|
|
||||||
match manager.replace_transaction(candidate) {
|
|
||||||
Ok(()) => {
|
|
||||||
let Some(session) = self.session.as_mut() else {
|
|
||||||
return self.rejected(command, ProtocolError::Internal);
|
|
||||||
};
|
|
||||||
session.revision = next_revision;
|
|
||||||
let (session_id, revision) = (session.id, session.revision);
|
|
||||||
self.import = None;
|
|
||||||
self.reply(
|
|
||||||
request_id,
|
|
||||||
session_id,
|
|
||||||
revision,
|
|
||||||
Operation::Import,
|
|
||||||
ResultKind::Accepted,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(CommitError::NoTransaction) => self.rejected(command, ProtocolError::BadState),
|
|
||||||
Err(CommitError::InvalidCandidate(violation)) => {
|
|
||||||
self.rejected(command, ProtocolError::Violation(violation))
|
|
||||||
}
|
|
||||||
Err(CommitError::Store(_)) => self.rejected(command, ProtocolError::Internal),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn crc32c(bytes: &[u8]) -> u32 {
|
|
||||||
let mut crc = !0_u32;
|
|
||||||
for byte in bytes {
|
|
||||||
crc ^= u32::from(*byte);
|
|
||||||
for _ in 0..8 {
|
|
||||||
crc = if crc & 1 == 0 {
|
|
||||||
crc >> 1
|
|
||||||
} else {
|
|
||||||
(crc >> 1) ^ 0x82f6_3b78
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
!crc
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,39 @@
|
|||||||
//! Read-only protocol discovery and value-query responses.
|
//! Read-only protocol discovery and value-query responses.
|
||||||
|
|
||||||
use kaptan_config::{
|
use kaptan_config::{
|
||||||
ConfigManager, ConfigModel, ConfigState, ConfigValue, FieldDescriptor, FieldId, Generation,
|
ConfigManager, ConfigModel, ConfigState, ConfigStore, ConfigValue, EnumOption, FieldDescriptor,
|
||||||
ProfileStore,
|
FieldId,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{import::SnapshotImporter, session::ConfigProtocol};
|
use super::session::ConfigProtocol;
|
||||||
use crate::{ProtocolError, ValueSource};
|
use crate::{Principal, ProtocolError, ValueSource};
|
||||||
|
|
||||||
/// Bounded descriptor page returned by a schema discovery request.
|
/// One descriptor returned by a schema discovery request.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct DescriptorPage {
|
pub struct DescriptorResponse {
|
||||||
/// Request being answered.
|
/// Request being answered.
|
||||||
pub request_id: u32,
|
pub request_id: u32,
|
||||||
/// First requested descriptor index.
|
/// Stable index of this descriptor in the advertised schema.
|
||||||
pub first: u16,
|
pub index: u16,
|
||||||
/// Number of descriptors present in `descriptors`.
|
/// Self-describing field metadata.
|
||||||
pub len: u8,
|
pub descriptor: FieldDescriptor,
|
||||||
/// Fixed-capacity response page; `None` slots are unused.
|
/// Factory value selected by the concrete board/image composition.
|
||||||
pub descriptors: [Option<&'static FieldDescriptor>; 4],
|
pub default: ConfigValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One named enum choice associated with a descriptor response.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct EnumOptionResponse {
|
||||||
|
/// Request being answered.
|
||||||
|
pub request_id: u32,
|
||||||
|
/// Stable schema index of the owning descriptor.
|
||||||
|
pub descriptor_index: u16,
|
||||||
|
/// Stable field identity of the owning descriptor.
|
||||||
|
pub field: FieldId,
|
||||||
|
/// Stable zero-based order within the descriptor's choices.
|
||||||
|
pub option_index: u8,
|
||||||
|
/// Self-describing named choice.
|
||||||
|
pub option: EnumOption,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One typed value read from an explicitly selected configuration snapshot.
|
/// One typed value read from an explicitly selected configuration snapshot.
|
||||||
@@ -41,20 +56,12 @@ pub struct ValueResponse {
|
|||||||
pub struct ConfigInfo {
|
pub struct ConfigInfo {
|
||||||
/// Request being answered.
|
/// Request being answered.
|
||||||
pub request_id: u32,
|
pub request_id: u32,
|
||||||
/// Boot-active generation, or `None` for the factory profile.
|
/// Stable schema identity for this target-described field registry.
|
||||||
pub active_generation: Option<Generation>,
|
|
||||||
/// Last committed generation, or `None` for the factory profile.
|
|
||||||
pub committed_generation: Option<Generation>,
|
|
||||||
/// Exact schema identifier expected by profile import.
|
|
||||||
pub schema_id: [u8; 16],
|
pub schema_id: [u8; 16],
|
||||||
/// Version carried inside the one raw CBOR profile blob.
|
|
||||||
pub profile_format: u16,
|
|
||||||
/// Number of editable fields in the registry.
|
/// Number of editable fields in the registry.
|
||||||
pub field_count: u16,
|
pub field_count: u16,
|
||||||
/// Current configuration lifecycle state.
|
/// Current configuration lifecycle state.
|
||||||
pub state: ConfigState,
|
pub state: ConfigState,
|
||||||
/// Deployment capabilities such as bounded CBOR import.
|
|
||||||
pub capability_flags: u8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exact response produced by the custom configuration service.
|
/// Exact response produced by the custom configuration service.
|
||||||
@@ -64,13 +71,15 @@ pub enum ServiceResponse {
|
|||||||
Info(ConfigInfo),
|
Info(ConfigInfo),
|
||||||
/// Completion for a mutating operation or rejected query.
|
/// Completion for a mutating operation or rejected query.
|
||||||
Result(crate::ConfigReply),
|
Result(crate::ConfigReply),
|
||||||
/// Bounded self-describing schema page.
|
/// One self-describing schema field.
|
||||||
Descriptors(DescriptorPage),
|
Descriptor(DescriptorResponse),
|
||||||
|
/// One named choice for its explicitly identified enum descriptor.
|
||||||
|
EnumOption(EnumOptionResponse),
|
||||||
/// One typed scalar value.
|
/// One typed scalar value.
|
||||||
Value(ValueResponse),
|
Value(ValueResponse),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I> ConfigProtocol<I> {
|
impl ConfigProtocol {
|
||||||
/// Returns static descriptor metadata by stable schema index.
|
/// Returns static descriptor metadata by stable schema index.
|
||||||
pub fn descriptor<C, S>(
|
pub fn descriptor<C, S>(
|
||||||
&self,
|
&self,
|
||||||
@@ -79,7 +88,7 @@ impl<I> ConfigProtocol<I> {
|
|||||||
) -> Option<&'static FieldDescriptor>
|
) -> Option<&'static FieldDescriptor>
|
||||||
where
|
where
|
||||||
C: ConfigModel,
|
C: ConfigModel,
|
||||||
S: ProfileStore<C>,
|
S: ConfigStore<C>,
|
||||||
{
|
{
|
||||||
manager.descriptor(index)
|
manager.descriptor(index)
|
||||||
}
|
}
|
||||||
@@ -93,7 +102,7 @@ impl<I> ConfigProtocol<I> {
|
|||||||
) -> Option<ConfigValue>
|
) -> Option<ConfigValue>
|
||||||
where
|
where
|
||||||
C: ConfigModel,
|
C: ConfigModel,
|
||||||
S: ProfileStore<C>,
|
S: ConfigStore<C>,
|
||||||
{
|
{
|
||||||
match source {
|
match source {
|
||||||
ValueSource::Active => manager.active_value(field),
|
ValueSource::Active => manager.active_value(field),
|
||||||
@@ -102,60 +111,65 @@ impl<I> ConfigProtocol<I> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn descriptors<C, S>(
|
pub(super) fn descriptor_response<C, S>(
|
||||||
&self,
|
&self,
|
||||||
manager: &ConfigManager<C, S>,
|
manager: &ConfigManager<C, S>,
|
||||||
request_id: u32,
|
request_id: u32,
|
||||||
first: u16,
|
index: u16,
|
||||||
requested: u16,
|
) -> Option<DescriptorResponse>
|
||||||
) -> DescriptorPage
|
|
||||||
where
|
where
|
||||||
C: ConfigModel,
|
C: ConfigModel,
|
||||||
S: ProfileStore<C>,
|
S: ConfigStore<C>,
|
||||||
{
|
{
|
||||||
let mut descriptors = [None; 4];
|
let descriptor = *manager.descriptor(index)?;
|
||||||
let count = requested.min(descriptors.len() as u16);
|
let default = manager.factory_value(descriptor.id)?;
|
||||||
let mut len = 0;
|
Some(DescriptorResponse {
|
||||||
for offset in 0..count {
|
|
||||||
let Some(index) = first.checked_add(offset) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let Some(descriptor) = manager.descriptor(index) else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
descriptors[usize::from(len)] = Some(descriptor);
|
|
||||||
len += 1;
|
|
||||||
}
|
|
||||||
DescriptorPage {
|
|
||||||
request_id,
|
request_id,
|
||||||
first,
|
index,
|
||||||
len,
|
descriptor,
|
||||||
descriptors,
|
default,
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn enum_option_response<C, S>(
|
||||||
|
&self,
|
||||||
|
manager: &ConfigManager<C, S>,
|
||||||
|
request_id: u32,
|
||||||
|
descriptor_index: u16,
|
||||||
|
option_index: u8,
|
||||||
|
) -> Option<EnumOptionResponse>
|
||||||
|
where
|
||||||
|
C: ConfigModel,
|
||||||
|
S: ConfigStore<C>,
|
||||||
|
{
|
||||||
|
let descriptor = manager.descriptor(descriptor_index)?;
|
||||||
|
let option = *descriptor.enum_options.get(usize::from(option_index))?;
|
||||||
|
Some(EnumOptionResponse {
|
||||||
|
request_id,
|
||||||
|
descriptor_index,
|
||||||
|
field: descriptor.id,
|
||||||
|
option_index,
|
||||||
|
option,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn info<C, S>(&self, manager: &ConfigManager<C, S>, request_id: u32) -> ConfigInfo
|
pub(super) fn info<C, S>(&self, manager: &ConfigManager<C, S>, request_id: u32) -> ConfigInfo
|
||||||
where
|
where
|
||||||
C: ConfigModel,
|
C: ConfigModel,
|
||||||
S: ProfileStore<C>,
|
S: ConfigStore<C>,
|
||||||
I: SnapshotImporter<C>,
|
|
||||||
{
|
{
|
||||||
ConfigInfo {
|
ConfigInfo {
|
||||||
request_id,
|
request_id,
|
||||||
active_generation: manager.active_generation(),
|
|
||||||
committed_generation: manager.committed_generation(),
|
|
||||||
schema_id: C::SCHEMA_ID,
|
schema_id: C::SCHEMA_ID,
|
||||||
profile_format: I::PROFILE_FORMAT,
|
|
||||||
field_count: manager.field_count(),
|
field_count: manager.field_count(),
|
||||||
state: self.state,
|
state: self.state,
|
||||||
capability_flags: self.capability_flags_for::<C>(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn value_response<C, S>(
|
pub(super) fn value_response<C, S>(
|
||||||
&self,
|
&self,
|
||||||
manager: &ConfigManager<C, S>,
|
manager: &ConfigManager<C, S>,
|
||||||
owner: u64,
|
owner: Principal,
|
||||||
request_id: u32,
|
request_id: u32,
|
||||||
session_id: u32,
|
session_id: u32,
|
||||||
field: FieldId,
|
field: FieldId,
|
||||||
@@ -163,7 +177,7 @@ impl<I> ConfigProtocol<I> {
|
|||||||
) -> ServiceResponse
|
) -> ServiceResponse
|
||||||
where
|
where
|
||||||
C: ConfigModel,
|
C: ConfigModel,
|
||||||
S: ProfileStore<C>,
|
S: ConfigStore<C>,
|
||||||
{
|
{
|
||||||
let (value, reply_session, revision) = match source {
|
let (value, reply_session, revision) = match source {
|
||||||
ValueSource::Active => (manager.active_value(field), 0, 0),
|
ValueSource::Active => (manager.active_value(field), 0, 0),
|
||||||
@@ -176,7 +190,7 @@ impl<I> ConfigProtocol<I> {
|
|||||||
ProtocolError::InvalidSession,
|
ProtocolError::InvalidSession,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
if session.id != session_id || session.owner != owner {
|
if session.id != session_id || self.principal != Some(owner) {
|
||||||
return ServiceResponse::Result(self.rejected_for(
|
return ServiceResponse::Result(self.rejected_for(
|
||||||
request_id,
|
request_id,
|
||||||
session_id,
|
session_id,
|
||||||
|
|||||||
@@ -3,34 +3,42 @@
|
|||||||
use kaptan_config::ConfigValue;
|
use kaptan_config::ConfigValue;
|
||||||
|
|
||||||
use super::session::ConfigProtocol;
|
use super::session::ConfigProtocol;
|
||||||
use crate::{ConfigCommand, ConfigReply, ProtocolError};
|
use crate::{ConfigCommand, ConfigReply, Operation, Principal, ProtocolError};
|
||||||
|
|
||||||
pub(super) const REQUEST_CACHE_CAPACITY: usize = 8;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub(super) struct RememberedResult {
|
pub(super) struct RememberedResult {
|
||||||
pub(super) owner: u64,
|
|
||||||
pub(super) command: ConfigCommand,
|
pub(super) command: ConfigCommand,
|
||||||
pub(super) reply: ConfigReply,
|
pub(super) reply: ConfigReply,
|
||||||
|
pub(super) owner: Principal,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I> ConfigProtocol<I> {
|
impl ConfigProtocol {
|
||||||
pub(super) fn cached(&self, owner: u64, command: ConfigCommand) -> Option<ConfigReply> {
|
pub(super) fn cached(&self, owner: Principal, command: ConfigCommand) -> Option<ConfigReply> {
|
||||||
for cached in self.remembered.iter().flatten() {
|
let cached = self.remembered?;
|
||||||
if cached.owner != owner || cached.command.request_id() != command.request_id() {
|
if cached.owner != owner || cached.command.request_id() != command.request_id() {
|
||||||
continue;
|
return None;
|
||||||
}
|
}
|
||||||
if same_command(cached.command, command) {
|
if same_command(cached.command, command) {
|
||||||
return Some(cached.reply);
|
Some(cached.reply)
|
||||||
}
|
} else {
|
||||||
return Some(self.rejected(command, ProtocolError::RequestConflict));
|
Some(self.rejected(command, ProtocolError::RequestConflict))
|
||||||
}
|
}
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn remember(&mut self, result: RememberedResult) {
|
pub(super) fn remember(
|
||||||
self.remembered[self.next_remembered] = Some(result);
|
&mut self,
|
||||||
self.next_remembered = (self.next_remembered + 1) % REQUEST_CACHE_CAPACITY;
|
owner: Principal,
|
||||||
|
command: ConfigCommand,
|
||||||
|
reply: ConfigReply,
|
||||||
|
) {
|
||||||
|
if reply.operation == Operation::Query {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.remembered = Some(RememberedResult {
|
||||||
|
command,
|
||||||
|
reply,
|
||||||
|
owner,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,20 +57,36 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
|
|||||||
(
|
(
|
||||||
ConfigCommand::DescriptorRequest {
|
ConfigCommand::DescriptorRequest {
|
||||||
request_id: left_request,
|
request_id: left_request,
|
||||||
first: left_first,
|
index: left_index,
|
||||||
count: left_count,
|
|
||||||
target: left_target,
|
target: left_target,
|
||||||
},
|
},
|
||||||
ConfigCommand::DescriptorRequest {
|
ConfigCommand::DescriptorRequest {
|
||||||
request_id: right_request,
|
request_id: right_request,
|
||||||
first: right_first,
|
index: right_index,
|
||||||
count: right_count,
|
|
||||||
target: right_target,
|
target: right_target,
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
left_request == right_request
|
left_request == right_request
|
||||||
&& left_first == right_first
|
&& left_index == right_index
|
||||||
&& left_count == right_count
|
&& left_target == right_target
|
||||||
|
}
|
||||||
|
(
|
||||||
|
ConfigCommand::EnumOptionRequest {
|
||||||
|
request_id: left_request,
|
||||||
|
descriptor_index: left_descriptor,
|
||||||
|
option_index: left_option,
|
||||||
|
target: left_target,
|
||||||
|
},
|
||||||
|
ConfigCommand::EnumOptionRequest {
|
||||||
|
request_id: right_request,
|
||||||
|
descriptor_index: right_descriptor,
|
||||||
|
option_index: right_option,
|
||||||
|
target: right_target,
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
left_request == right_request
|
||||||
|
&& left_descriptor == right_descriptor
|
||||||
|
&& left_option == right_option
|
||||||
&& left_target == right_target
|
&& left_target == right_target
|
||||||
}
|
}
|
||||||
(
|
(
|
||||||
@@ -90,19 +114,13 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
|
|||||||
(
|
(
|
||||||
ConfigCommand::Begin {
|
ConfigCommand::Begin {
|
||||||
request_id: left_request,
|
request_id: left_request,
|
||||||
expected_generation: left_generation,
|
|
||||||
target: left_target,
|
target: left_target,
|
||||||
},
|
},
|
||||||
ConfigCommand::Begin {
|
ConfigCommand::Begin {
|
||||||
request_id: right_request,
|
request_id: right_request,
|
||||||
expected_generation: right_generation,
|
|
||||||
target: right_target,
|
target: right_target,
|
||||||
},
|
},
|
||||||
) => {
|
) => left_request == right_request && left_target == right_target,
|
||||||
left_request == right_request
|
|
||||||
&& left_generation == right_generation
|
|
||||||
&& left_target == right_target
|
|
||||||
}
|
|
||||||
(
|
(
|
||||||
ConfigCommand::Set {
|
ConfigCommand::Set {
|
||||||
request_id: left_request,
|
request_id: left_request,
|
||||||
@@ -151,21 +169,18 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
|
|||||||
ConfigCommand::Commit {
|
ConfigCommand::Commit {
|
||||||
request_id: left_request,
|
request_id: left_request,
|
||||||
session_id: left_session,
|
session_id: left_session,
|
||||||
expected_generation: left_generation,
|
|
||||||
expected_revision: left_revision,
|
expected_revision: left_revision,
|
||||||
target: left_target,
|
target: left_target,
|
||||||
},
|
},
|
||||||
ConfigCommand::Commit {
|
ConfigCommand::Commit {
|
||||||
request_id: right_request,
|
request_id: right_request,
|
||||||
session_id: right_session,
|
session_id: right_session,
|
||||||
expected_generation: right_generation,
|
|
||||||
expected_revision: right_revision,
|
expected_revision: right_revision,
|
||||||
target: right_target,
|
target: right_target,
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
left_request == right_request
|
left_request == right_request
|
||||||
&& left_session == right_session
|
&& left_session == right_session
|
||||||
&& left_generation == right_generation
|
|
||||||
&& left_revision == right_revision
|
&& left_revision == right_revision
|
||||||
&& left_target == right_target
|
&& left_target == right_target
|
||||||
}
|
}
|
||||||
@@ -185,84 +200,6 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
|
|||||||
&& left_session == right_session
|
&& left_session == right_session
|
||||||
&& left_target == right_target
|
&& left_target == right_target
|
||||||
}
|
}
|
||||||
(
|
|
||||||
ConfigCommand::ImportBegin {
|
|
||||||
request_id: left_request,
|
|
||||||
session_id: left_session,
|
|
||||||
expected_revision: left_revision,
|
|
||||||
length: left_length,
|
|
||||||
schema_id: left_schema,
|
|
||||||
crc32c: left_crc,
|
|
||||||
target: left_target,
|
|
||||||
},
|
|
||||||
ConfigCommand::ImportBegin {
|
|
||||||
request_id: right_request,
|
|
||||||
session_id: right_session,
|
|
||||||
expected_revision: right_revision,
|
|
||||||
length: right_length,
|
|
||||||
schema_id: right_schema,
|
|
||||||
crc32c: right_crc,
|
|
||||||
target: right_target,
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
left_request == right_request
|
|
||||||
&& left_session == right_session
|
|
||||||
&& left_revision == right_revision
|
|
||||||
&& left_length == right_length
|
|
||||||
&& left_schema == right_schema
|
|
||||||
&& left_crc == right_crc
|
|
||||||
&& left_target == right_target
|
|
||||||
}
|
|
||||||
(
|
|
||||||
ConfigCommand::ImportChunk {
|
|
||||||
request_id: left_request,
|
|
||||||
session_id: left_session,
|
|
||||||
transfer_id: left_transfer,
|
|
||||||
offset: left_offset,
|
|
||||||
length: left_length,
|
|
||||||
data: left_data,
|
|
||||||
target: left_target,
|
|
||||||
},
|
|
||||||
ConfigCommand::ImportChunk {
|
|
||||||
request_id: right_request,
|
|
||||||
session_id: right_session,
|
|
||||||
transfer_id: right_transfer,
|
|
||||||
offset: right_offset,
|
|
||||||
length: right_length,
|
|
||||||
data: right_data,
|
|
||||||
target: right_target,
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
left_request == right_request
|
|
||||||
&& left_session == right_session
|
|
||||||
&& left_transfer == right_transfer
|
|
||||||
&& left_offset == right_offset
|
|
||||||
&& left_length == right_length
|
|
||||||
&& left_data == right_data
|
|
||||||
&& left_target == right_target
|
|
||||||
}
|
|
||||||
(
|
|
||||||
ConfigCommand::ImportFinish {
|
|
||||||
request_id: left_request,
|
|
||||||
session_id: left_session,
|
|
||||||
transfer_id: left_transfer,
|
|
||||||
expected_revision: left_revision,
|
|
||||||
target: left_target,
|
|
||||||
},
|
|
||||||
ConfigCommand::ImportFinish {
|
|
||||||
request_id: right_request,
|
|
||||||
session_id: right_session,
|
|
||||||
transfer_id: right_transfer,
|
|
||||||
expected_revision: right_revision,
|
|
||||||
target: right_target,
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
left_request == right_request
|
|
||||||
&& left_session == right_session
|
|
||||||
&& left_transfer == right_transfer
|
|
||||||
&& left_revision == right_revision
|
|
||||||
&& left_target == right_target
|
|
||||||
}
|
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,23 +1,68 @@
|
|||||||
//! Generated Kaptan-dialect message translation.
|
//! Generated Kaptan-dialect message translation.
|
||||||
|
|
||||||
use kaptan_config::{
|
use kaptan_config::{
|
||||||
ApplyPolicy, ConfigState, ConfigValue, FieldDescriptor, FieldId, Generation, ValidationRule,
|
ConfigState, ConfigValue, FieldDescriptor, FieldId, ValidationRule, ValueKind,
|
||||||
ValueKind,
|
|
||||||
};
|
};
|
||||||
use kaptan_log::KaptanError;
|
use kaptan_log::KaptanError;
|
||||||
use kaptan_mavlink::message::Message;
|
use kaptan_mavlink::message::Message;
|
||||||
use kaptan_mavlink::message::kaptan::{
|
use kaptan_mavlink::message::kaptan::{
|
||||||
KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_INFO_DATA, KAPTAN_CONFIG_RESULT_DATA,
|
KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_ENUM_OPTION_DATA, KAPTAN_CONFIG_INFO_DATA,
|
||||||
KAPTAN_CONFIG_VALUE_DATA, KaptanConfigError, KaptanConfigOperation, KaptanConfigPolicy,
|
KAPTAN_CONFIG_RESULT_DATA, KAPTAN_CONFIG_VALUE_DATA, KaptanConfigError, KaptanConfigOperation,
|
||||||
KaptanConfigResult, KaptanConfigState, KaptanConfigValueKind, KaptanConfigValueSource,
|
KaptanConfigResult, KaptanConfigState, KaptanConfigValueKind, KaptanConfigValueSource,
|
||||||
MavMessage,
|
MavMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ConfigCommand, ConfigReply, Operation, ProtocolError, ResultKind, Target, ValueSource,
|
ConfigCommand, ConfigReply, DESCRIPTOR_HAS_MAXIMUM, DESCRIPTOR_HAS_MINIMUM, Operation,
|
||||||
service::{ConfigInfo, ValueResponse},
|
PROTOCOL_MAJOR, PROTOCOL_MINOR, ProtocolError, ResultKind, Target, ValueSource,
|
||||||
|
service::{ConfigInfo, EnumOptionResponse, ServiceResponse, ValueResponse},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Transport-owned consumer of generated configuration response payloads.
|
||||||
|
///
|
||||||
|
/// Implementors retain ownership of MAVLink framing, sender sequence, queue
|
||||||
|
/// classification, and physical transmission.
|
||||||
|
pub trait ResponseVisitor {
|
||||||
|
/// Transport-specific admission or serialization failure.
|
||||||
|
type Error;
|
||||||
|
|
||||||
|
/// Visits one protocol/schema information response.
|
||||||
|
fn info(&mut self, response: &KAPTAN_CONFIG_INFO_DATA) -> Result<(), Self::Error>;
|
||||||
|
|
||||||
|
/// Visits one authoritative operation result.
|
||||||
|
fn result(&mut self, response: &KAPTAN_CONFIG_RESULT_DATA) -> Result<(), Self::Error>;
|
||||||
|
|
||||||
|
/// Visits one field descriptor.
|
||||||
|
fn descriptor(&mut self, response: &KAPTAN_CONFIG_DESCRIPTOR_DATA) -> Result<(), Self::Error>;
|
||||||
|
|
||||||
|
/// Visits one named choice for an enum-backed field descriptor.
|
||||||
|
fn enum_option(&mut self, response: &KAPTAN_CONFIG_ENUM_OPTION_DATA)
|
||||||
|
-> Result<(), Self::Error>;
|
||||||
|
|
||||||
|
/// Visits one typed configuration value.
|
||||||
|
fn value(&mut self, response: &KAPTAN_CONFIG_VALUE_DATA) -> Result<(), Self::Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Projects one transport-neutral service response into generated MAVLink
|
||||||
|
/// payloads without allocating or assuming a board, schema, or field set.
|
||||||
|
pub fn visit_response<V>(response: ServiceResponse, visitor: &mut V) -> Result<(), V::Error>
|
||||||
|
where
|
||||||
|
V: ResponseVisitor,
|
||||||
|
{
|
||||||
|
match response {
|
||||||
|
ServiceResponse::Info(info) => visitor.info(&encode_info(info)),
|
||||||
|
ServiceResponse::Result(result) => visitor.result(&encode_result(result)),
|
||||||
|
ServiceResponse::Descriptor(response) => visitor.descriptor(&encode_descriptor(
|
||||||
|
response.request_id,
|
||||||
|
response.index,
|
||||||
|
response.descriptor,
|
||||||
|
response.default,
|
||||||
|
)),
|
||||||
|
ServiceResponse::EnumOption(response) => visitor.enum_option(&encode_enum_option(response)),
|
||||||
|
ServiceResponse::Value(value) => visitor.value(&encode_value(value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Rejects a generated message that is not a Kaptan configuration command.
|
/// Rejects a generated message that is not a Kaptan configuration command.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum DecodeError {
|
pub enum DecodeError {
|
||||||
@@ -35,8 +80,9 @@ pub enum DecodeError {
|
|||||||
|
|
||||||
/// Converts one generated custom request into a transport-neutral command.
|
/// Converts one generated custom request into a transport-neutral command.
|
||||||
///
|
///
|
||||||
/// This only decodes a validated MAVLink payload. An authenticated ingress must
|
/// This only decodes a validated MAVLink payload. CRC validity is not peer
|
||||||
/// still wrap the result in [`crate::Authorized`] before dispatch.
|
/// authentication; the platform composition must separately decide whether
|
||||||
|
/// the ingress may dispatch the command and bind its transaction principal.
|
||||||
pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError> {
|
pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError> {
|
||||||
if matches!(message.target_system_id(), Some(0))
|
if matches!(message.target_system_id(), Some(0))
|
||||||
|| matches!(message.target_component_id(), Some(0))
|
|| matches!(message.target_component_id(), Some(0))
|
||||||
@@ -51,8 +97,15 @@ pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError
|
|||||||
MavMessage::KAPTAN_CONFIG_DESCRIPTOR_REQUEST(data) => {
|
MavMessage::KAPTAN_CONFIG_DESCRIPTOR_REQUEST(data) => {
|
||||||
Ok(ConfigCommand::DescriptorRequest {
|
Ok(ConfigCommand::DescriptorRequest {
|
||||||
request_id: data.request_id,
|
request_id: data.request_id,
|
||||||
first: data.first,
|
index: data.descriptor_index,
|
||||||
count: data.count,
|
target: Target::new(data.target_system, data.target_component),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(data) => {
|
||||||
|
Ok(ConfigCommand::EnumOptionRequest {
|
||||||
|
request_id: data.request_id,
|
||||||
|
descriptor_index: data.descriptor_index,
|
||||||
|
option_index: data.option_index,
|
||||||
target: Target::new(data.target_system, data.target_component),
|
target: Target::new(data.target_system, data.target_component),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -63,34 +116,8 @@ pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError
|
|||||||
source: value_source(data.source)?,
|
source: value_source(data.source)?,
|
||||||
target: Target::new(data.target_system, data.target_component),
|
target: Target::new(data.target_system, data.target_component),
|
||||||
}),
|
}),
|
||||||
MavMessage::KAPTAN_CONFIG_IMPORT_BEGIN(data) => Ok(ConfigCommand::ImportBegin {
|
|
||||||
request_id: data.request_id,
|
|
||||||
session_id: data.session_id,
|
|
||||||
expected_revision: data.expected_revision,
|
|
||||||
length: data.length,
|
|
||||||
schema_id: data.schema_id,
|
|
||||||
crc32c: data.crc32c,
|
|
||||||
target: Target::new(data.target_system, data.target_component),
|
|
||||||
}),
|
|
||||||
MavMessage::KAPTAN_CONFIG_IMPORT_CHUNK(data) => Ok(ConfigCommand::ImportChunk {
|
|
||||||
request_id: data.request_id,
|
|
||||||
session_id: data.session_id,
|
|
||||||
transfer_id: data.transfer_id,
|
|
||||||
offset: data.offset,
|
|
||||||
length: data.length,
|
|
||||||
data: data.data,
|
|
||||||
target: Target::new(data.target_system, data.target_component),
|
|
||||||
}),
|
|
||||||
MavMessage::KAPTAN_CONFIG_IMPORT_FINISH(data) => Ok(ConfigCommand::ImportFinish {
|
|
||||||
request_id: data.request_id,
|
|
||||||
session_id: data.session_id,
|
|
||||||
transfer_id: data.transfer_id,
|
|
||||||
expected_revision: data.expected_revision,
|
|
||||||
target: Target::new(data.target_system, data.target_component),
|
|
||||||
}),
|
|
||||||
MavMessage::KAPTAN_CONFIG_BEGIN(data) => Ok(ConfigCommand::Begin {
|
MavMessage::KAPTAN_CONFIG_BEGIN(data) => Ok(ConfigCommand::Begin {
|
||||||
request_id: data.request_id,
|
request_id: data.request_id,
|
||||||
expected_generation: data.expected_generation,
|
|
||||||
target: Target::new(data.target_system, data.target_component),
|
target: Target::new(data.target_system, data.target_component),
|
||||||
}),
|
}),
|
||||||
MavMessage::KAPTAN_CONFIG_SET_F32(data) => Ok(ConfigCommand::Set {
|
MavMessage::KAPTAN_CONFIG_SET_F32(data) => Ok(ConfigCommand::Set {
|
||||||
@@ -150,7 +177,6 @@ pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError
|
|||||||
MavMessage::KAPTAN_CONFIG_COMMIT(data) => Ok(ConfigCommand::Commit {
|
MavMessage::KAPTAN_CONFIG_COMMIT(data) => Ok(ConfigCommand::Commit {
|
||||||
request_id: data.request_id,
|
request_id: data.request_id,
|
||||||
session_id: data.session_id,
|
session_id: data.session_id,
|
||||||
expected_generation: data.expected_generation,
|
|
||||||
expected_revision: data.expected_revision,
|
expected_revision: data.expected_revision,
|
||||||
target: Target::new(data.target_system, data.target_component),
|
target: Target::new(data.target_system, data.target_component),
|
||||||
}),
|
}),
|
||||||
@@ -191,10 +217,8 @@ const fn boolean(value: u8) -> Result<bool, DecodeError> {
|
|||||||
/// Encodes one authoritative service completion.
|
/// Encodes one authoritative service completion.
|
||||||
pub fn encode_result(reply: ConfigReply) -> KAPTAN_CONFIG_RESULT_DATA {
|
pub fn encode_result(reply: ConfigReply) -> KAPTAN_CONFIG_RESULT_DATA {
|
||||||
KAPTAN_CONFIG_RESULT_DATA {
|
KAPTAN_CONFIG_RESULT_DATA {
|
||||||
generation: reply.generation,
|
|
||||||
request_id: reply.request_id,
|
request_id: reply.request_id,
|
||||||
session_id: reply.session_id,
|
session_id: reply.session_id,
|
||||||
transfer_id: reply.transfer_id,
|
|
||||||
revision: reply.revision,
|
revision: reply.revision,
|
||||||
primary_field: reply.primary_field(),
|
primary_field: reply.primary_field(),
|
||||||
related_field: reply.related_field(),
|
related_field: reply.related_field(),
|
||||||
@@ -209,16 +233,12 @@ pub fn encode_result(reply: ConfigReply) -> KAPTAN_CONFIG_RESULT_DATA {
|
|||||||
/// Encodes configuration protocol/schema status for a Kaptan control station.
|
/// Encodes configuration protocol/schema status for a Kaptan control station.
|
||||||
pub fn encode_info(info: ConfigInfo) -> KAPTAN_CONFIG_INFO_DATA {
|
pub fn encode_info(info: ConfigInfo) -> KAPTAN_CONFIG_INFO_DATA {
|
||||||
KAPTAN_CONFIG_INFO_DATA {
|
KAPTAN_CONFIG_INFO_DATA {
|
||||||
active_generation: info.active_generation.map_or(0, Generation::get),
|
|
||||||
committed_generation: info.committed_generation.map_or(0, Generation::get),
|
|
||||||
request_id: info.request_id,
|
request_id: info.request_id,
|
||||||
schema_id: info.schema_id,
|
schema_id: info.schema_id,
|
||||||
profile_format: info.profile_format,
|
|
||||||
field_count: info.field_count,
|
field_count: info.field_count,
|
||||||
protocol_major: 1,
|
protocol_major: PROTOCOL_MAJOR,
|
||||||
protocol_minor: 0,
|
protocol_minor: PROTOCOL_MINOR,
|
||||||
config_state: state(info.state),
|
config_state: state(info.state),
|
||||||
capability_flags: info.capability_flags,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,19 +247,34 @@ pub fn encode_descriptor(
|
|||||||
request_id: u32,
|
request_id: u32,
|
||||||
descriptor_index: u16,
|
descriptor_index: u16,
|
||||||
descriptor: FieldDescriptor,
|
descriptor: FieldDescriptor,
|
||||||
|
default: ConfigValue,
|
||||||
) -> KAPTAN_CONFIG_DESCRIPTOR_DATA {
|
) -> KAPTAN_CONFIG_DESCRIPTOR_DATA {
|
||||||
KAPTAN_CONFIG_DESCRIPTOR_DATA {
|
KAPTAN_CONFIG_DESCRIPTOR_DATA {
|
||||||
minimum_bits: descriptor.minimum.map_or(0, ConfigValue::raw_bits),
|
minimum_bits: descriptor.minimum.map_or(0, ConfigValue::raw_bits),
|
||||||
maximum_bits: descriptor.maximum.map_or(0, ConfigValue::raw_bits),
|
maximum_bits: descriptor.maximum.map_or(0, ConfigValue::raw_bits),
|
||||||
default_bits: descriptor.default.raw_bits(),
|
default_bits: default.raw_bits(),
|
||||||
request_id,
|
request_id,
|
||||||
descriptor_index,
|
descriptor_index,
|
||||||
field_id: descriptor.id.get(),
|
field_id: descriptor.id.get(),
|
||||||
|
enum_option_count: u8::try_from(descriptor.enum_options.len())
|
||||||
|
.expect("validated enum option count fits u8"),
|
||||||
display_key: descriptor.display_key.into(),
|
display_key: descriptor.display_key.into(),
|
||||||
unit: descriptor.unit.into(),
|
unit: descriptor.unit.into(),
|
||||||
value_kind: value_kind(descriptor.kind),
|
value_kind: value_kind(descriptor.kind),
|
||||||
apply_policy: policy(descriptor.apply_policy),
|
flags: descriptor.minimum.map_or(0, |_| DESCRIPTOR_HAS_MINIMUM)
|
||||||
flags: 0,
|
| descriptor.maximum.map_or(0, |_| DESCRIPTOR_HAS_MAXIMUM),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes one generic named enum choice.
|
||||||
|
pub fn encode_enum_option(response: EnumOptionResponse) -> KAPTAN_CONFIG_ENUM_OPTION_DATA {
|
||||||
|
KAPTAN_CONFIG_ENUM_OPTION_DATA {
|
||||||
|
value: response.option.0,
|
||||||
|
request_id: response.request_id,
|
||||||
|
descriptor_index: response.descriptor_index,
|
||||||
|
field_id: response.field.get(),
|
||||||
|
option_index: response.option_index,
|
||||||
|
label: response.option.1.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,14 +301,6 @@ const fn value_kind(kind: ValueKind) -> KaptanConfigValueKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn policy(policy: ApplyPolicy) -> KaptanConfigPolicy {
|
|
||||||
match policy {
|
|
||||||
ApplyPolicy::Live => KaptanConfigPolicy::KAPTAN_CONFIG_POLICY_LIVE,
|
|
||||||
ApplyPolicy::DisarmedReset => KaptanConfigPolicy::KAPTAN_CONFIG_POLICY_DISARMED_RESET,
|
|
||||||
ApplyPolicy::RestartRequired => KaptanConfigPolicy::KAPTAN_CONFIG_POLICY_RESTART_REQUIRED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn operation(operation: Operation) -> KaptanConfigOperation {
|
const fn operation(operation: Operation) -> KaptanConfigOperation {
|
||||||
match operation {
|
match operation {
|
||||||
Operation::Query => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_QUERY,
|
Operation::Query => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_QUERY,
|
||||||
@@ -282,7 +309,6 @@ const fn operation(operation: Operation) -> KaptanConfigOperation {
|
|||||||
Operation::Validate => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE,
|
Operation::Validate => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE,
|
||||||
Operation::Commit => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT,
|
Operation::Commit => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT,
|
||||||
Operation::Abort => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT,
|
Operation::Abort => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT,
|
||||||
Operation::Import => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_IMPORT,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,12 +334,6 @@ const fn error(error: Option<ProtocolError>) -> KaptanConfigError {
|
|||||||
match error {
|
match error {
|
||||||
None => KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE,
|
None => KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE,
|
||||||
Some(ProtocolError::BadTarget) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BAD_TARGET,
|
Some(ProtocolError::BadTarget) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BAD_TARGET,
|
||||||
Some(ProtocolError::AuthorizationDenied) => {
|
|
||||||
KaptanConfigError::KAPTAN_CONFIG_ERROR_AUTHORIZATION_DENIED
|
|
||||||
}
|
|
||||||
Some(ProtocolError::GenerationConflict) => {
|
|
||||||
KaptanConfigError::KAPTAN_CONFIG_ERROR_GENERATION_CONFLICT
|
|
||||||
}
|
|
||||||
Some(ProtocolError::SessionBusy) => KaptanConfigError::KAPTAN_CONFIG_ERROR_SESSION_BUSY,
|
Some(ProtocolError::SessionBusy) => KaptanConfigError::KAPTAN_CONFIG_ERROR_SESSION_BUSY,
|
||||||
Some(ProtocolError::InvalidSession) => {
|
Some(ProtocolError::InvalidSession) => {
|
||||||
KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_SESSION
|
KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_SESSION
|
||||||
@@ -326,6 +346,7 @@ const fn error(error: Option<ProtocolError>) -> KaptanConfigError {
|
|||||||
Some(ProtocolError::UnknownField(_)) => {
|
Some(ProtocolError::UnknownField(_)) => {
|
||||||
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNKNOWN_FIELD
|
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNKNOWN_FIELD
|
||||||
}
|
}
|
||||||
|
Some(ProtocolError::InvalidQuery) => KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_QUERY,
|
||||||
Some(ProtocolError::TypeMismatch(_)) => {
|
Some(ProtocolError::TypeMismatch(_)) => {
|
||||||
KaptanConfigError::KAPTAN_CONFIG_ERROR_TYPE_MISMATCH
|
KaptanConfigError::KAPTAN_CONFIG_ERROR_TYPE_MISMATCH
|
||||||
}
|
}
|
||||||
@@ -339,36 +360,88 @@ const fn error(error: Option<ProtocolError>) -> KaptanConfigError {
|
|||||||
ValidationRule::UnsupportedCapability => {
|
ValidationRule::UnsupportedCapability => {
|
||||||
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_CAPABILITY
|
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_CAPABILITY
|
||||||
}
|
}
|
||||||
ValidationRule::UnsupportedFormat => {
|
ValidationRule::UnsupportedFormat => KaptanConfigError::KAPTAN_CONFIG_ERROR_INTERNAL,
|
||||||
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_FORMAT
|
ValidationRule::InvalidEnumValue => {
|
||||||
|
KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_ENUM_VALUE
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(ProtocolError::StorageFailure) => {
|
Some(ProtocolError::StorageFailure) => {
|
||||||
KaptanConfigError::KAPTAN_CONFIG_ERROR_STORAGE_FAILURE
|
KaptanConfigError::KAPTAN_CONFIG_ERROR_STORAGE_FAILURE
|
||||||
}
|
}
|
||||||
Some(ProtocolError::CborMalformed) => KaptanConfigError::KAPTAN_CONFIG_ERROR_CBOR_MALFORMED,
|
|
||||||
Some(ProtocolError::CborTooLarge) => KaptanConfigError::KAPTAN_CONFIG_ERROR_CBOR_TOO_LARGE,
|
|
||||||
Some(ProtocolError::BlobCrc) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BLOB_CRC,
|
|
||||||
Some(ProtocolError::SchemaMismatch) => {
|
|
||||||
KaptanConfigError::KAPTAN_CONFIG_ERROR_SCHEMA_MISMATCH
|
|
||||||
}
|
|
||||||
Some(ProtocolError::ImportUnavailable) => {
|
|
||||||
KaptanConfigError::KAPTAN_CONFIG_ERROR_IMPORT_UNAVAILABLE
|
|
||||||
}
|
|
||||||
Some(ProtocolError::Internal) => KaptanConfigError::KAPTAN_CONFIG_ERROR_INTERNAL,
|
Some(ProtocolError::Internal) => KaptanConfigError::KAPTAN_CONFIG_ERROR_INTERNAL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use kaptan_config::{ConfigState, ConfigViolation, ValidationRule};
|
use kaptan_config::{
|
||||||
|
ConfigState, ConfigValue, ConfigViolation, FieldDescriptor, ValidationRule, ValueKind,
|
||||||
|
};
|
||||||
use kaptan_mavlink::message::kaptan::{
|
use kaptan_mavlink::message::kaptan::{
|
||||||
KAPTAN_CONFIG_INFO_REQUEST_DATA, KAPTAN_CONFIG_SET_F32_DATA, MavMessage,
|
KAPTAN_CONFIG_ENUM_OPTION_REQUEST_DATA, KAPTAN_CONFIG_INFO_REQUEST_DATA,
|
||||||
|
KAPTAN_CONFIG_SET_F32_DATA, MavMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::CAPABILITY_CBOR_IMPORT;
|
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
enum Emitted {
|
||||||
|
Info(KAPTAN_CONFIG_INFO_DATA),
|
||||||
|
Result(KAPTAN_CONFIG_RESULT_DATA),
|
||||||
|
Descriptor(KAPTAN_CONFIG_DESCRIPTOR_DATA),
|
||||||
|
EnumOption(KAPTAN_CONFIG_ENUM_OPTION_DATA),
|
||||||
|
Value(KAPTAN_CONFIG_VALUE_DATA),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingVisitor {
|
||||||
|
emitted: std::vec::Vec<Emitted>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseVisitor for RecordingVisitor {
|
||||||
|
type Error = core::convert::Infallible;
|
||||||
|
|
||||||
|
fn info(&mut self, response: &KAPTAN_CONFIG_INFO_DATA) -> Result<(), Self::Error> {
|
||||||
|
self.emitted.push(Emitted::Info(response.clone()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result(&mut self, response: &KAPTAN_CONFIG_RESULT_DATA) -> Result<(), Self::Error> {
|
||||||
|
self.emitted.push(Emitted::Result(response.clone()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn descriptor(
|
||||||
|
&mut self,
|
||||||
|
response: &KAPTAN_CONFIG_DESCRIPTOR_DATA,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
self.emitted.push(Emitted::Descriptor(response.clone()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enum_option(
|
||||||
|
&mut self,
|
||||||
|
response: &KAPTAN_CONFIG_ENUM_OPTION_DATA,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
self.emitted.push(Emitted::EnumOption(response.clone()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(&mut self, response: &KAPTAN_CONFIG_VALUE_DATA) -> Result<(), Self::Error> {
|
||||||
|
self.emitted.push(Emitted::Value(response.clone()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIRST_DESCRIPTOR: FieldDescriptor = FieldDescriptor {
|
||||||
|
id: FieldId::new(17),
|
||||||
|
display_key: "future.sensor.rate_hz",
|
||||||
|
kind: ValueKind::U32,
|
||||||
|
minimum: Some(ConfigValue::U32(1)),
|
||||||
|
maximum: Some(ConfigValue::U32(200)),
|
||||||
|
enum_options: &[],
|
||||||
|
unit: "Hz",
|
||||||
|
};
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_set_message_preserves_typed_value_and_identifiers() {
|
fn generated_set_message_preserves_typed_value_and_identifiers() {
|
||||||
let message = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
|
let message = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
|
||||||
@@ -409,6 +482,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_enum_option_request_preserves_the_explicit_index() {
|
||||||
|
let message =
|
||||||
|
MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(KAPTAN_CONFIG_ENUM_OPTION_REQUEST_DATA {
|
||||||
|
request_id: 41,
|
||||||
|
descriptor_index: 3,
|
||||||
|
option_index: 6,
|
||||||
|
target_system: 1,
|
||||||
|
target_component: 2,
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
decode_command(&message),
|
||||||
|
Ok(ConfigCommand::EnumOptionRequest {
|
||||||
|
request_id: 41,
|
||||||
|
descriptor_index: 3,
|
||||||
|
option_index: 6,
|
||||||
|
target: Target::new(1, 2),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_config_requests_reject_broadcast_targets() {
|
fn generated_config_requests_reject_broadcast_targets() {
|
||||||
let zero_system = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
|
let zero_system = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
|
||||||
@@ -444,9 +538,7 @@ mod tests {
|
|||||||
let reply = ConfigReply {
|
let reply = ConfigReply {
|
||||||
request_id: 1,
|
request_id: 1,
|
||||||
session_id: 2,
|
session_id: 2,
|
||||||
transfer_id: 0,
|
|
||||||
revision: 3,
|
revision: 3,
|
||||||
generation: 4,
|
|
||||||
operation: Operation::Set,
|
operation: Operation::Set,
|
||||||
result: ResultKind::Rejected,
|
result: ResultKind::Rejected,
|
||||||
error: Some(ProtocolError::Violation(ConfigViolation::field(
|
error: Some(ProtocolError::Violation(ConfigViolation::field(
|
||||||
@@ -465,37 +557,118 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unavailable_import_has_its_own_structured_error_code() {
|
fn info_preserves_schema_and_protocol_identity() {
|
||||||
let reply = ConfigReply {
|
let info = encode_info(ConfigInfo {
|
||||||
request_id: 1,
|
request_id: 7,
|
||||||
session_id: 0,
|
schema_id: [0; 16],
|
||||||
transfer_id: 0,
|
field_count: 23,
|
||||||
revision: 0,
|
|
||||||
generation: 0,
|
|
||||||
operation: Operation::Import,
|
|
||||||
result: ResultKind::Rejected,
|
|
||||||
error: Some(ProtocolError::ImportUnavailable),
|
|
||||||
state: ConfigState::Active,
|
state: ConfigState::Active,
|
||||||
};
|
});
|
||||||
|
assert_eq!(info.request_id, 7);
|
||||||
|
assert_eq!(info.schema_id, [0; 16]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
encode_result(reply).error,
|
(info.protocol_major, info.protocol_minor),
|
||||||
KaptanConfigError::KAPTAN_CONFIG_ERROR_IMPORT_UNAVAILABLE
|
(PROTOCOL_MAJOR, PROTOCOL_MINOR)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn info_preserves_service_capabilities() {
|
fn descriptor_flags_identify_present_bounds() {
|
||||||
let info = encode_info(ConfigInfo {
|
let descriptor = FieldDescriptor {
|
||||||
|
id: FieldId::new(9),
|
||||||
|
display_key: "test.value",
|
||||||
|
kind: ValueKind::U32,
|
||||||
|
minimum: Some(ConfigValue::U32(1)),
|
||||||
|
maximum: Some(ConfigValue::U32(10)),
|
||||||
|
enum_options: &[],
|
||||||
|
unit: "count",
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
encode_descriptor(1, 0, descriptor, ConfigValue::U32(2)).flags,
|
||||||
|
DESCRIPTOR_HAS_MINIMUM | DESCRIPTOR_HAS_MAXIMUM
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
encode_descriptor(1, 0, descriptor, ConfigValue::U32(2)).enum_option_count,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn response_visitor_projects_every_singleton_response_variant() {
|
||||||
|
let info = ConfigInfo {
|
||||||
request_id: 7,
|
request_id: 7,
|
||||||
active_generation: None,
|
schema_id: *b"UNKNOWN-SCHEMA01",
|
||||||
committed_generation: None,
|
field_count: 2,
|
||||||
schema_id: [0; 16],
|
state: ConfigState::RestartRequired,
|
||||||
profile_format: 1,
|
};
|
||||||
field_count: 23,
|
let result = ConfigReply {
|
||||||
state: ConfigState::Active,
|
request_id: 8,
|
||||||
capability_flags: CAPABILITY_CBOR_IMPORT,
|
session_id: 9,
|
||||||
});
|
revision: 2,
|
||||||
assert_eq!(info.request_id, 7);
|
operation: Operation::Commit,
|
||||||
assert_eq!(info.capability_flags, CAPABILITY_CBOR_IMPORT);
|
result: ResultKind::CommittedRestartRequired,
|
||||||
|
error: None,
|
||||||
|
state: ConfigState::RestartRequired,
|
||||||
|
};
|
||||||
|
let value = ValueResponse {
|
||||||
|
request_id: 10,
|
||||||
|
session_id: 0,
|
||||||
|
revision: 0,
|
||||||
|
field: FieldId::new(401),
|
||||||
|
value: ConfigValue::Bool(true),
|
||||||
|
};
|
||||||
|
let enum_option = crate::EnumOptionResponse {
|
||||||
|
request_id: 9,
|
||||||
|
descriptor_index: 12,
|
||||||
|
field: FieldId::new(17),
|
||||||
|
option_index: 1,
|
||||||
|
option: (3, "Error"),
|
||||||
|
};
|
||||||
|
let mut visitor = RecordingVisitor::default();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
visit_response(ServiceResponse::Info(info), &mut visitor),
|
||||||
|
Ok(())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
visit_response(ServiceResponse::Result(result), &mut visitor),
|
||||||
|
Ok(())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
visit_response(
|
||||||
|
ServiceResponse::Descriptor(crate::DescriptorResponse {
|
||||||
|
request_id: 9,
|
||||||
|
index: 12,
|
||||||
|
descriptor: FIRST_DESCRIPTOR,
|
||||||
|
default: ConfigValue::U32(20),
|
||||||
|
}),
|
||||||
|
&mut visitor,
|
||||||
|
),
|
||||||
|
Ok(())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
visit_response(ServiceResponse::EnumOption(enum_option), &mut visitor),
|
||||||
|
Ok(())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
visit_response(ServiceResponse::Value(value), &mut visitor),
|
||||||
|
Ok(())
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
visitor.emitted,
|
||||||
|
std::vec![
|
||||||
|
Emitted::Info(encode_info(info)),
|
||||||
|
Emitted::Result(encode_result(result)),
|
||||||
|
Emitted::Descriptor(encode_descriptor(
|
||||||
|
9,
|
||||||
|
12,
|
||||||
|
FIRST_DESCRIPTOR,
|
||||||
|
ConfigValue::U32(20),
|
||||||
|
)),
|
||||||
|
Emitted::EnumOption(encode_enum_option(enum_option)),
|
||||||
|
Emitted::Value(encode_value(value)),
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use crate::FieldId;
|
use crate::FieldId;
|
||||||
|
|
||||||
/// The invariant violated by a configuration field or complete profile.
|
/// The invariant violated by a configuration field or complete candidate.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
#[repr(u16)]
|
#[repr(u16)]
|
||||||
pub enum ValidationRule {
|
pub enum ValidationRule {
|
||||||
@@ -20,6 +20,8 @@ pub enum ValidationRule {
|
|||||||
UnsupportedCapability = 6,
|
UnsupportedCapability = 6,
|
||||||
/// The encoded snapshot format cannot be used by this firmware.
|
/// The encoded snapshot format cannot be used by this firmware.
|
||||||
UnsupportedFormat = 7,
|
UnsupportedFormat = 7,
|
||||||
|
/// A scalar does not name a declared variant of its typed enum field.
|
||||||
|
InvalidEnumValue = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ValidationRule {
|
impl ValidationRule {
|
||||||
@@ -59,7 +61,7 @@ impl ConfigViolation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Constructs a profile-level violation with no scalar identity.
|
/// Constructs a configuration-wide violation with no scalar identity.
|
||||||
pub const fn global(rule: ValidationRule) -> Self {
|
pub const fn global(rule: ValidationRule) -> Self {
|
||||||
Self {
|
Self {
|
||||||
primary: None,
|
primary: None,
|
||||||
|
|||||||
@@ -3,9 +3,18 @@
|
|||||||
use crate::{ConfigViolation, ValidationRule};
|
use crate::{ConfigViolation, ValidationRule};
|
||||||
use kaptan_log::KaptanError;
|
use kaptan_log::KaptanError;
|
||||||
|
|
||||||
|
/// Maximum choices representable by one enum-backed field.
|
||||||
|
///
|
||||||
|
/// Enum values and indexed discovery use their native `u8` domain. Options
|
||||||
|
/// are immutable flash metadata, so no smaller runtime resource limit is
|
||||||
|
/// necessary.
|
||||||
|
pub const MAX_ENUM_OPTIONS_PER_FIELD: usize = u8::MAX as usize;
|
||||||
|
/// Maximum UTF-8 bytes advertised for one enum variant name.
|
||||||
|
pub const MAX_ENUM_OPTION_LABEL_BYTES: usize = 32;
|
||||||
|
|
||||||
/// A stable, schema-local identity for one editable configuration field.
|
/// A stable, schema-local identity for one editable configuration field.
|
||||||
///
|
///
|
||||||
/// IDs are never reused within a schema generation. They are compact wire
|
/// IDs are never reused within a schema version. They are compact wire
|
||||||
/// identities; display strings belong to metadata and diagnostics only.
|
/// identities; display strings belong to metadata and diagnostics only.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)]
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)]
|
||||||
#[repr(transparent)]
|
#[repr(transparent)]
|
||||||
@@ -56,6 +65,88 @@ pub enum ConfigValue {
|
|||||||
F64(f64),
|
F64(f64),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One named value advertised for an enum-backed configuration field.
|
||||||
|
///
|
||||||
|
/// Enum metadata belongs to the compiled schema. Generic transports can
|
||||||
|
/// therefore render choices without knowing field keys or firmware-specific
|
||||||
|
/// enum types. The tuple is `(stable u8 representation, display label)`.
|
||||||
|
pub type EnumOption = (u8, &'static str);
|
||||||
|
|
||||||
|
/// Failure to project a generic configuration scalar into its native type.
|
||||||
|
#[doc(hidden)]
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ScalarValueError {
|
||||||
|
/// The generic scalar kind does not match the native field type.
|
||||||
|
WrongKind(ValueKind),
|
||||||
|
/// The representation has the correct scalar kind but no native value.
|
||||||
|
OutOfDomain,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native representation used by generated configuration fields.
|
||||||
|
///
|
||||||
|
/// The generic manager and transports retain their compact fixed scalar
|
||||||
|
/// protocol while board and runtime structures keep the narrow or enum type
|
||||||
|
/// that actually models the value.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub trait ConfigScalar: Copy {
|
||||||
|
/// Generic scalar kind used at schema and transport boundaries.
|
||||||
|
const KIND: ValueKind;
|
||||||
|
/// Projects the native value into the generic scalar boundary.
|
||||||
|
fn into_config_value(self) -> ConfigValue;
|
||||||
|
/// Validates and projects one generic scalar into the native type.
|
||||||
|
fn try_from_config_value(value: ConfigValue) -> Result<Self, ScalarValueError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! direct_config_scalar {
|
||||||
|
($type:ty, $kind:ident, $variant:ident) => {
|
||||||
|
impl ConfigScalar for $type {
|
||||||
|
const KIND: ValueKind = ValueKind::$kind;
|
||||||
|
|
||||||
|
fn into_config_value(self) -> ConfigValue {
|
||||||
|
ConfigValue::$variant(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_from_config_value(value: ConfigValue) -> Result<Self, ScalarValueError> {
|
||||||
|
match value {
|
||||||
|
ConfigValue::$variant(value) => Ok(value),
|
||||||
|
value => Err(ScalarValueError::WrongKind(value.kind())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
direct_config_scalar!(bool, Bool, Bool);
|
||||||
|
direct_config_scalar!(i32, I32, I32);
|
||||||
|
direct_config_scalar!(u32, U32, U32);
|
||||||
|
direct_config_scalar!(u64, U64, U64);
|
||||||
|
direct_config_scalar!(f32, F32, F32);
|
||||||
|
direct_config_scalar!(f64, F64, F64);
|
||||||
|
|
||||||
|
macro_rules! narrow_unsigned_config_scalar {
|
||||||
|
($type:ty) => {
|
||||||
|
impl ConfigScalar for $type {
|
||||||
|
const KIND: ValueKind = ValueKind::U32;
|
||||||
|
|
||||||
|
fn into_config_value(self) -> ConfigValue {
|
||||||
|
ConfigValue::U32(u32::from(self))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_from_config_value(value: ConfigValue) -> Result<Self, ScalarValueError> {
|
||||||
|
match value {
|
||||||
|
ConfigValue::U32(value) => {
|
||||||
|
Self::try_from(value).map_err(|_| ScalarValueError::OutOfDomain)
|
||||||
|
}
|
||||||
|
value => Err(ScalarValueError::WrongKind(value.kind())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
narrow_unsigned_config_scalar!(u8);
|
||||||
|
narrow_unsigned_config_scalar!(u16);
|
||||||
|
|
||||||
impl ConfigValue {
|
impl ConfigValue {
|
||||||
/// The declared value kind.
|
/// The declared value kind.
|
||||||
pub const fn kind(self) -> ValueKind {
|
pub const fn kind(self) -> ValueKind {
|
||||||
@@ -80,6 +171,26 @@ impl ConfigValue {
|
|||||||
Self::F64(value) => value.to_bits(),
|
Self::F64(value) => value.to_bits(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decodes the canonical scalar representation used by snapshots and the
|
||||||
|
/// configuration wire protocol.
|
||||||
|
pub const fn try_from_raw_bits(kind: ValueKind, bits: u64) -> Option<Self> {
|
||||||
|
match kind {
|
||||||
|
ValueKind::Bool => match bits {
|
||||||
|
0 => Some(Self::Bool(false)),
|
||||||
|
1 => Some(Self::Bool(true)),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
ValueKind::I32 if bits <= u32::MAX as u64 => Some(Self::I32(bits as u32 as i32)),
|
||||||
|
ValueKind::U32 if bits <= u32::MAX as u64 => Some(Self::U32(bits as u32)),
|
||||||
|
ValueKind::U64 => Some(Self::U64(bits)),
|
||||||
|
ValueKind::F32 if bits <= u32::MAX as u64 => {
|
||||||
|
Some(Self::F32(f32::from_bits(bits as u32)))
|
||||||
|
}
|
||||||
|
ValueKind::F64 => Some(Self::F64(f64::from_bits(bits))),
|
||||||
|
ValueKind::I32 | ValueKind::U32 | ValueKind::F32 => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The scalar kind carried in a descriptor or typed protocol message.
|
/// The scalar kind carried in a descriptor or typed protocol message.
|
||||||
@@ -100,18 +211,6 @@ pub enum ValueKind {
|
|||||||
F64 = 6,
|
F64 = 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When a committed field may affect the active runtime.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
#[repr(u8)]
|
|
||||||
pub enum ApplyPolicy {
|
|
||||||
/// Reserved for individually proven live-safe changes.
|
|
||||||
Live = 1,
|
|
||||||
/// Reserved for controlled output-inhibited reconfiguration.
|
|
||||||
DisarmedReset = 2,
|
|
||||||
/// P1 policy: the field takes effect only at the next boot.
|
|
||||||
RestartRequired = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Static metadata for one configuration field.
|
/// Static metadata for one configuration field.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct FieldDescriptor {
|
pub struct FieldDescriptor {
|
||||||
@@ -125,12 +224,10 @@ pub struct FieldDescriptor {
|
|||||||
pub minimum: Option<ConfigValue>,
|
pub minimum: Option<ConfigValue>,
|
||||||
/// Inclusive upper bound when the value is ordered.
|
/// Inclusive upper bound when the value is ordered.
|
||||||
pub maximum: Option<ConfigValue>,
|
pub maximum: Option<ConfigValue>,
|
||||||
/// Firmware-build default.
|
/// Named choices for an enum-backed field, otherwise empty.
|
||||||
pub default: ConfigValue,
|
pub enum_options: &'static [EnumOption],
|
||||||
/// Human-readable unit for schema metadata.
|
/// Human-readable unit for schema metadata.
|
||||||
pub unit: &'static str,
|
pub unit: &'static str,
|
||||||
/// Activation policy advertised to a control station.
|
|
||||||
pub apply_policy: ApplyPolicy,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Failure while changing one transaction field.
|
/// Failure while changing one transaction field.
|
||||||
@@ -168,7 +265,7 @@ impl FieldWriteError {
|
|||||||
|
|
||||||
/// Vehicle-specific editable candidate model.
|
/// Vehicle-specific editable candidate model.
|
||||||
///
|
///
|
||||||
/// Implementations expose every operator-editable scalar through the static
|
/// Implementations expose every user-editable scalar through the static
|
||||||
/// descriptor registry. A field write performs only local validation; complete
|
/// descriptor registry. A field write performs only local validation; complete
|
||||||
/// relational and capability validation happens through [`Self::validate`].
|
/// relational and capability validation happens through [`Self::validate`].
|
||||||
pub trait ConfigModel: Clone {
|
pub trait ConfigModel: Clone {
|
||||||
|
|||||||
@@ -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,
|
//! This crate has no knowledge of MAVLink, filesystems, board routing, motors,
|
||||||
//! or a particular airframe. It owns the staged-versus-active lifecycle and
|
//! or a particular airframe. It owns the staged-versus-active lifecycle and
|
||||||
//! parameter metadata; vehicle crates provide the typed schema and complete
|
//! parameter metadata; vehicle crates provide the typed schema and complete
|
||||||
//! validation. CBOR storage and MAVLink transport are separate adapters.
|
//! validation. Persistence framing and transports remain separate adapters;
|
||||||
|
//! this crate provides only the bounded scalar snapshot payload shared by
|
||||||
|
//! storage backends.
|
||||||
|
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
@@ -11,15 +13,24 @@ extern crate self as kaptan_config;
|
|||||||
|
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod field;
|
pub mod field;
|
||||||
|
pub mod journal;
|
||||||
pub mod lifecycle;
|
pub mod lifecycle;
|
||||||
|
pub mod snapshot;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
|
|
||||||
pub use error::{ConfigViolation, ValidationRule};
|
pub use error::{ConfigViolation, ValidationRule};
|
||||||
pub use field::{
|
pub use field::{
|
||||||
ApplyPolicy, ConfigModel, ConfigValue, FieldDescriptor, FieldId, FieldWriteError, ValueKind,
|
ConfigModel, ConfigScalar, ConfigValue, EnumOption, FieldDescriptor, FieldId, FieldWriteError,
|
||||||
|
MAX_ENUM_OPTION_LABEL_BYTES, MAX_ENUM_OPTIONS_PER_FIELD, ScalarValueError, ValueKind,
|
||||||
};
|
};
|
||||||
pub use kaptan_config_derive::ConfigValidate;
|
pub use journal::{DualSlotJournal, DualSlotNor, JournalError};
|
||||||
|
pub use kaptan_config_derive::{Config, ConfigEnum};
|
||||||
pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError};
|
pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError};
|
||||||
pub use storage::{Generation, ProfileStore};
|
pub use snapshot::{
|
||||||
pub use validation::{ConfigAggregate, ConfigSection, ValidateFields};
|
SCALAR_SNAPSHOT_ENTRY_LENGTH, SCALAR_SNAPSHOT_HEADER_LENGTH, SCALAR_SNAPSHOT_VERSION,
|
||||||
|
ScalarSnapshotDecodeError, ScalarSnapshotEncodeError, SnapshotModelError,
|
||||||
|
decode_scalar_snapshot, encode_scalar_snapshot, scalar_snapshot_length,
|
||||||
|
};
|
||||||
|
pub use storage::ConfigStore;
|
||||||
|
pub use validation::Config;
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Transactional committed-versus-active configuration lifecycle.
|
//! Transactional committed-versus-active configuration lifecycle.
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
|
ConfigModel, ConfigStore, ConfigValue, ConfigViolation, FieldDescriptor, FieldId,
|
||||||
Generation, ProfileStore,
|
FieldWriteError,
|
||||||
};
|
};
|
||||||
use kaptan_log::KaptanError;
|
use kaptan_log::KaptanError;
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ pub enum ConfigState {
|
|||||||
Active,
|
Active,
|
||||||
/// One isolated RAM transaction is open.
|
/// One isolated RAM transaction is open.
|
||||||
Editing,
|
Editing,
|
||||||
/// A newer committed snapshot will take effect after reboot.
|
/// A newer committed snapshot will take effect after restart.
|
||||||
RestartRequired,
|
RestartRequired,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ pub enum CommitError<E> {
|
|||||||
Store(E),
|
Store(E),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns the immutable boot-active profile, the latest durable candidate, and
|
/// Owns the immutable boot-active configuration, the latest durable candidate, and
|
||||||
/// exactly one RAM edit transaction.
|
/// exactly one RAM edit transaction.
|
||||||
///
|
///
|
||||||
/// P1 deliberately has no activation operation: [`Self::active`] never
|
/// P1 deliberately has no activation operation: [`Self::active`] never
|
||||||
@@ -61,11 +61,10 @@ pub enum CommitError<E> {
|
|||||||
/// candidate, then reports [`ConfigState::RestartRequired`].
|
/// candidate, then reports [`ConfigState::RestartRequired`].
|
||||||
pub struct ConfigManager<C: ConfigModel, S> {
|
pub struct ConfigManager<C: ConfigModel, S> {
|
||||||
active: C::Active,
|
active: C::Active,
|
||||||
|
factory: C,
|
||||||
committed: C,
|
committed: C,
|
||||||
transaction: Option<C>,
|
transaction: Option<C>,
|
||||||
context: C::Context,
|
context: C::Context,
|
||||||
active_generation: Option<Generation>,
|
|
||||||
committed_generation: Option<Generation>,
|
|
||||||
store: S,
|
store: S,
|
||||||
restart_required: bool,
|
restart_required: bool,
|
||||||
}
|
}
|
||||||
@@ -73,7 +72,7 @@ pub struct ConfigManager<C: ConfigModel, S> {
|
|||||||
impl<C, S> ConfigManager<C, S>
|
impl<C, S> ConfigManager<C, S>
|
||||||
where
|
where
|
||||||
C: ConfigModel,
|
C: ConfigModel,
|
||||||
S: ProfileStore<C>,
|
S: ConfigStore<C>,
|
||||||
{
|
{
|
||||||
/// Starts from a validated factory candidate when no persistent record is
|
/// Starts from a validated factory candidate when no persistent record is
|
||||||
/// available. Flight callers separately decide whether setup/calibration
|
/// available. Flight callers separately decide whether setup/calibration
|
||||||
@@ -82,11 +81,10 @@ where
|
|||||||
let active = factory.validate(&context)?;
|
let active = factory.validate(&context)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
active,
|
active,
|
||||||
|
factory: factory.clone(),
|
||||||
committed: factory,
|
committed: factory,
|
||||||
transaction: None,
|
transaction: None,
|
||||||
context,
|
context,
|
||||||
active_generation: None,
|
|
||||||
committed_generation: None,
|
|
||||||
store,
|
store,
|
||||||
restart_required: false,
|
restart_required: false,
|
||||||
})
|
})
|
||||||
@@ -99,7 +97,7 @@ where
|
|||||||
context: C::Context,
|
context: C::Context,
|
||||||
mut store: S,
|
mut store: S,
|
||||||
) -> Result<Self, StartupError<S::Error>> {
|
) -> Result<Self, StartupError<S::Error>> {
|
||||||
let Some((generation, committed)) = store.load().map_err(StartupError::Store)? else {
|
let Some(committed) = store.load().map_err(StartupError::Store)? else {
|
||||||
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
|
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
|
||||||
};
|
};
|
||||||
let active = committed
|
let active = committed
|
||||||
@@ -107,11 +105,41 @@ where
|
|||||||
.map_err(StartupError::InvalidPersisted)?;
|
.map_err(StartupError::InvalidPersisted)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
active,
|
active,
|
||||||
|
factory,
|
||||||
|
committed,
|
||||||
|
transaction: None,
|
||||||
|
context,
|
||||||
|
store,
|
||||||
|
restart_required: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recovers the newest candidate compatible with the current context,
|
||||||
|
/// falling back to the supplied factory when no compatible record exists.
|
||||||
|
///
|
||||||
|
/// This is an explicit recovery policy for output-inhibited setup images.
|
||||||
|
/// Safety-critical compositions should use [`Self::recover`] if an
|
||||||
|
/// incompatible durable record must prevent initialization.
|
||||||
|
pub fn recover_compatible_or_factory(
|
||||||
|
factory: C,
|
||||||
|
context: C::Context,
|
||||||
|
mut store: S,
|
||||||
|
) -> Result<Self, StartupError<S::Error>> {
|
||||||
|
let Some(committed) = store
|
||||||
|
.load_matching(|candidate| candidate.validate(&context).is_ok())
|
||||||
|
.map_err(StartupError::Store)?
|
||||||
|
else {
|
||||||
|
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
|
||||||
|
};
|
||||||
|
let active = committed
|
||||||
|
.validate(&context)
|
||||||
|
.map_err(StartupError::InvalidPersisted)?;
|
||||||
|
Ok(Self {
|
||||||
|
active,
|
||||||
|
factory,
|
||||||
committed,
|
committed,
|
||||||
transaction: None,
|
transaction: None,
|
||||||
context,
|
context,
|
||||||
active_generation: Some(generation),
|
|
||||||
committed_generation: Some(generation),
|
|
||||||
store,
|
store,
|
||||||
restart_required: false,
|
restart_required: false,
|
||||||
})
|
})
|
||||||
@@ -127,6 +155,11 @@ where
|
|||||||
self.committed.field_value(id)
|
self.committed.field_value(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the selected board/image factory value for descriptor metadata.
|
||||||
|
pub fn factory_value(&self, id: FieldId) -> Option<ConfigValue> {
|
||||||
|
self.factory.field_value(id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns a value from the immutable boot-active snapshot.
|
/// Returns a value from the immutable boot-active snapshot.
|
||||||
pub fn active_value(&self, id: FieldId) -> Option<ConfigValue> {
|
pub fn active_value(&self, id: FieldId) -> Option<ConfigValue> {
|
||||||
C::active_field_value(&self.active, id)
|
C::active_field_value(&self.active, id)
|
||||||
@@ -158,23 +191,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the boot-active snapshot generation.
|
|
||||||
pub const fn active_generation(&self) -> Option<Generation> {
|
|
||||||
self.active_generation
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the most recently recovered or committed generation.
|
|
||||||
pub const fn committed_generation(&self) -> Option<Generation> {
|
|
||||||
self.committed_generation
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the most recently recovered or committed generation.
|
|
||||||
///
|
|
||||||
/// Retained as a concise alias for optimistic commit callers.
|
|
||||||
pub const fn generation(&self) -> Option<Generation> {
|
|
||||||
self.committed_generation
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Opens the only transaction slot from the last durably committed state.
|
/// Opens the only transaction slot from the last durably committed state.
|
||||||
pub fn begin(&mut self) -> Result<(), BeginError> {
|
pub fn begin(&mut self) -> Result<(), BeginError> {
|
||||||
if self.transaction.is_some() {
|
if self.transaction.is_some() {
|
||||||
@@ -209,25 +225,8 @@ where
|
|||||||
.map_err(CommitError::InvalidCandidate)
|
.map_err(CommitError::InvalidCandidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the open RAM transaction with one complete imported candidate.
|
|
||||||
///
|
|
||||||
/// Unlike scalar edits, imports are accepted only after complete
|
|
||||||
/// relational and capability validation. Neither the boot-active nor the
|
|
||||||
/// committed snapshot is changed here.
|
|
||||||
pub fn replace_transaction(&mut self, candidate: C) -> Result<(), CommitError<S::Error>> {
|
|
||||||
candidate
|
|
||||||
.validate(&self.context)
|
|
||||||
.map_err(CommitError::InvalidCandidate)?;
|
|
||||||
let transaction = self
|
|
||||||
.transaction
|
|
||||||
.as_mut()
|
|
||||||
.ok_or(CommitError::NoTransaction)?;
|
|
||||||
*transaction = candidate;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates and durably commits the complete open transaction.
|
/// Validates and durably commits the complete open transaction.
|
||||||
pub fn commit(&mut self) -> Result<Generation, CommitError<S::Error>> {
|
pub fn commit(&mut self) -> Result<(), CommitError<S::Error>> {
|
||||||
let candidate = self
|
let candidate = self
|
||||||
.transaction
|
.transaction
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -235,29 +234,23 @@ where
|
|||||||
candidate
|
candidate
|
||||||
.validate(&self.context)
|
.validate(&self.context)
|
||||||
.map_err(CommitError::InvalidCandidate)?;
|
.map_err(CommitError::InvalidCandidate)?;
|
||||||
let generation = self.store.persist(candidate).map_err(CommitError::Store)?;
|
self.store.persist(candidate).map_err(CommitError::Store)?;
|
||||||
self.committed = candidate.clone();
|
self.committed = candidate.clone();
|
||||||
self.transaction = None;
|
self.transaction = None;
|
||||||
self.committed_generation = Some(generation);
|
|
||||||
self.restart_required = true;
|
self.restart_required = true;
|
||||||
Ok(generation)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops the RAM transaction without changing active or committed state.
|
/// Drops the RAM transaction without changing active or committed state.
|
||||||
pub fn abort(&mut self) -> bool {
|
pub fn abort(&mut self) -> bool {
|
||||||
self.transaction.take().is_some()
|
self.transaction.take().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Releases the owned store when the configuration owner shuts down.
|
|
||||||
pub fn into_store(self) -> S {
|
|
||||||
self.store
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{ApplyPolicy, ConfigValue, ValidationRule, ValueKind};
|
use crate::{ConfigValue, ValidationRule, ValueKind};
|
||||||
|
|
||||||
const VALUE: FieldId = FieldId::new(1);
|
const VALUE: FieldId = FieldId::new(1);
|
||||||
const OTHER: FieldId = FieldId::new(2);
|
const OTHER: FieldId = FieldId::new(2);
|
||||||
@@ -268,9 +261,8 @@ mod tests {
|
|||||||
kind: ValueKind::U32,
|
kind: ValueKind::U32,
|
||||||
minimum: Some(ConfigValue::U32(1)),
|
minimum: Some(ConfigValue::U32(1)),
|
||||||
maximum: Some(ConfigValue::U32(10)),
|
maximum: Some(ConfigValue::U32(10)),
|
||||||
default: ConfigValue::U32(2),
|
enum_options: &[],
|
||||||
unit: "count",
|
unit: "count",
|
||||||
apply_policy: ApplyPolicy::RestartRequired,
|
|
||||||
},
|
},
|
||||||
FieldDescriptor {
|
FieldDescriptor {
|
||||||
id: OTHER,
|
id: OTHER,
|
||||||
@@ -278,9 +270,8 @@ mod tests {
|
|||||||
kind: ValueKind::U32,
|
kind: ValueKind::U32,
|
||||||
minimum: Some(ConfigValue::U32(1)),
|
minimum: Some(ConfigValue::U32(1)),
|
||||||
maximum: Some(ConfigValue::U32(10)),
|
maximum: Some(ConfigValue::U32(10)),
|
||||||
default: ConfigValue::U32(4),
|
enum_options: &[],
|
||||||
unit: "count",
|
unit: "count",
|
||||||
apply_policy: ApplyPolicy::RestartRequired,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -364,15 +355,29 @@ mod tests {
|
|||||||
|
|
||||||
struct MemoryStore;
|
struct MemoryStore;
|
||||||
|
|
||||||
impl ProfileStore<Candidate> for MemoryStore {
|
impl ConfigStore<Candidate> for MemoryStore {
|
||||||
type Error = ();
|
type Error = ();
|
||||||
|
|
||||||
fn load(&mut self) -> Result<Option<(Generation, Candidate)>, Self::Error> {
|
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist(&mut self, _: &Candidate) -> Result<Generation, Self::Error> {
|
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
|
||||||
Ok(Generation::new(7))
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IncompatibleStore;
|
||||||
|
|
||||||
|
impl ConfigStore<Candidate> for IncompatibleStore {
|
||||||
|
type Error = ();
|
||||||
|
|
||||||
|
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
|
||||||
|
Ok(Some(Candidate { value: 9, other: 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,11 +410,9 @@ mod tests {
|
|||||||
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
|
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
|
||||||
manager.begin().unwrap();
|
manager.begin().unwrap();
|
||||||
manager.set(VALUE, ConfigValue::U32(3)).unwrap();
|
manager.set(VALUE, ConfigValue::U32(3)).unwrap();
|
||||||
assert_eq!(manager.commit(), Ok(Generation::new(7)));
|
assert_eq!(manager.commit(), Ok(()));
|
||||||
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
|
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
|
||||||
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
|
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
|
||||||
assert_eq!(manager.active_generation(), None);
|
|
||||||
assert_eq!(manager.committed_generation(), Some(Generation::new(7)));
|
|
||||||
assert_eq!(manager.state(), ConfigState::RestartRequired);
|
assert_eq!(manager.state(), ConfigState::RestartRequired);
|
||||||
manager.begin().unwrap();
|
manager.begin().unwrap();
|
||||||
manager.set(VALUE, ConfigValue::U32(4)).unwrap();
|
manager.set(VALUE, ConfigValue::U32(4)).unwrap();
|
||||||
@@ -417,4 +420,17 @@ mod tests {
|
|||||||
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
|
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
|
||||||
assert_eq!(manager.state(), ConfigState::RestartRequired);
|
assert_eq!(manager.state(), ConfigState::RestartRequired);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_recovery_policy_uses_factory_when_no_record_matches_context() {
|
||||||
|
let factory = Candidate { value: 2, other: 4 };
|
||||||
|
assert!(matches!(
|
||||||
|
ConfigManager::recover(factory.clone(), (), IncompatibleStore),
|
||||||
|
Err(StartupError::InvalidPersisted(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
let manager =
|
||||||
|
ConfigManager::recover_compatible_or_factory(factory, (), IncompatibleStore).unwrap();
|
||||||
|
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
//! Persistence interface for complete validated candidate snapshots.
|
||||||
|
|
||||||
use core::num::NonZeroU64;
|
|
||||||
|
|
||||||
/// Storage-owned monotonically increasing committed snapshot generation.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
||||||
pub struct Generation(NonZeroU64);
|
|
||||||
|
|
||||||
impl Generation {
|
|
||||||
/// Creates a generation supplied only by a persistence backend.
|
|
||||||
pub const fn new(value: u64) -> Self {
|
|
||||||
match NonZeroU64::new(value) {
|
|
||||||
Some(value) => Self(value),
|
|
||||||
None => panic!("configuration generation must be nonzero"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses an on-disk or wire generation, rejecting the factory sentinel.
|
|
||||||
pub const fn try_new(value: u64) -> Option<Self> {
|
|
||||||
match NonZeroU64::new(value) {
|
|
||||||
Some(value) => Some(Self(value)),
|
|
||||||
None => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the committed generation value.
|
|
||||||
pub const fn get(self) -> u64 {
|
|
||||||
self.0.get()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Durable storage of complete configuration snapshots.
|
/// Durable storage of complete configuration snapshots.
|
||||||
///
|
///
|
||||||
/// Flight implementations use a two-slot journal: a record contains a header,
|
/// Flight implementations use a two-slot journal: a record contains a header,
|
||||||
/// bounded CBOR payload, integrity check, and a commit marker written last.
|
/// bounded payload, integrity check, and a commit marker written last.
|
||||||
/// A backend must retain its previous valid record until the replacement is
|
/// A backend must retain its previous valid record until the replacement is
|
||||||
/// fully verified. Host file stores may implement the same interface without
|
/// fully verified. Host file stores may implement the same interface without
|
||||||
/// claiming flash power-loss semantics.
|
/// claiming flash power-loss semantics.
|
||||||
pub trait ProfileStore<C> {
|
pub trait ConfigStore<C> {
|
||||||
/// Backend-specific I/O or integrity failure.
|
/// Backend-specific I/O or integrity failure.
|
||||||
type Error;
|
type Error;
|
||||||
|
|
||||||
/// Loads the newest valid committed candidate, if one exists.
|
/// Loads the newest valid committed candidate, if one exists.
|
||||||
fn load(&mut self) -> Result<Option<(Generation, C)>, Self::Error>;
|
fn load(&mut self) -> Result<Option<C>, Self::Error>;
|
||||||
|
|
||||||
|
/// Loads the newest structurally valid candidate accepted by `compatible`.
|
||||||
|
///
|
||||||
|
/// Single-record stores inherit this implementation. Journals should
|
||||||
|
/// override it so an incompatible newest record can fall back to an older
|
||||||
|
/// compatible slot without weakening structural integrity checks.
|
||||||
|
fn load_matching<F>(&mut self, mut compatible: F) -> Result<Option<C>, Self::Error>
|
||||||
|
where
|
||||||
|
F: FnMut(&C) -> bool,
|
||||||
|
{
|
||||||
|
Ok(self.load()?.filter(|candidate| compatible(candidate)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Persists one complete already-validated candidate atomically.
|
/// Persists one complete already-validated candidate atomically.
|
||||||
fn persist(&mut self, candidate: &C) -> Result<Generation, Self::Error>;
|
fn persist(&mut self, candidate: &C) -> Result<(), Self::Error>;
|
||||||
}
|
}
|
||||||
@@ -1,63 +1,199 @@
|
|||||||
//! Local scalar configuration sections implemented by the derive macro.
|
//! Generated configuration trees.
|
||||||
|
|
||||||
use crate::{ConfigValue, ConfigViolation, FieldId, FieldWriteError};
|
use crate::{ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError};
|
||||||
|
|
||||||
/// Validates local scalar invariants of one record.
|
/// One recursively composable configuration tree.
|
||||||
///
|
///
|
||||||
/// The derive macro handles finite, nonzero, and inclusive range checks.
|
/// `#[derive(kaptan_config::Config)]` implements this contract for both scalar
|
||||||
/// Cross-field, capability, calibration, and lifecycle invariants belong in a
|
/// records and records composed from other configuration trees. Validation,
|
||||||
/// handwritten post-validation hook on the owning vehicle schema.
|
/// metadata, field routing, and schema identity therefore come from one source.
|
||||||
pub trait ValidateFields {
|
pub trait Config {
|
||||||
/// Rejects the first violated scalar or declared post-validation rule.
|
/// Number of scalar fields in the flattened tree.
|
||||||
fn validate_fields(&self) -> Result<(), ConfigViolation>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A complete scalar configuration section generated from `#[config(...)]`
|
|
||||||
/// field attributes.
|
|
||||||
///
|
|
||||||
/// Its metadata, default construction, reads, local writes, and scalar
|
|
||||||
/// validation are all produced from the same field declarations. A section
|
|
||||||
/// setter never commits a locally invalid value. Cross-section and capability
|
|
||||||
/// invariants deliberately remain the aggregate candidate's responsibility.
|
|
||||||
pub trait ConfigSection: ValidateFields {
|
|
||||||
/// Number of scalar descriptors generated for this section.
|
|
||||||
const FIELD_COUNT: usize;
|
const FIELD_COUNT: usize;
|
||||||
|
|
||||||
/// Builds the section from derive-generated defaults after validating it.
|
/// Fingerprint generated from the complete declared schema.
|
||||||
fn default_section() -> Result<Self, ConfigViolation>
|
const SCHEMA_ID: [u8; 16];
|
||||||
where
|
|
||||||
Self: Sized;
|
/// Returns a descriptor from the flattened declaration order.
|
||||||
|
fn descriptor(index: usize) -> Option<&'static FieldDescriptor>;
|
||||||
|
|
||||||
/// Returns one scalar by stable field identity.
|
/// Returns one scalar by stable field identity.
|
||||||
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
|
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
|
||||||
|
|
||||||
/// Applies one scalar only when this section owns the ID and its local
|
/// Applies one scalar only when its owning record accepts the local value.
|
||||||
/// generated validation accepts the new value.
|
/// Whole-tree relational validation is intentionally deferred until the
|
||||||
|
/// candidate is validated or committed.
|
||||||
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
|
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
|
||||||
|
|
||||||
/// Validates only generated scalar invariants. This intentionally omits
|
/// Validates every generated rule and declared post-validation hook.
|
||||||
/// a derive `post` hook so staged multi-field edits can remain temporarily
|
fn validate(&self) -> Result<(), ConfigViolation>;
|
||||||
/// cross-field-invalid until aggregate validation/commit.
|
|
||||||
fn validate_local_fields(&self) -> Result<(), ConfigViolation>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An ordered aggregate of generated configuration sections.
|
/// Detects duplicate flattened field identities in a generated tree.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn __unique_field_ids<C: Config>() -> bool {
|
||||||
|
let mut index = 0;
|
||||||
|
while index < C::FIELD_COUNT {
|
||||||
|
let Some(descriptor) = C::descriptor(index) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut other = index + 1;
|
||||||
|
while other < C::FIELD_COUNT {
|
||||||
|
let Some(other_descriptor) = C::descriptor(other) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if descriptor.id == other_descriptor.id {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
other += 1;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
C::descriptor(C::FIELD_COUNT).is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks display-key uniqueness across one recursively composed schema.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn __unique_display_keys<C: Config>() -> bool {
|
||||||
|
let mut index = 0;
|
||||||
|
while index < C::FIELD_COUNT {
|
||||||
|
let Some(descriptor) = C::descriptor(index) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut other = index + 1;
|
||||||
|
while other < C::FIELD_COUNT {
|
||||||
|
let Some(other_descriptor) = C::descriptor(other) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if descriptor.display_key == other_descriptor.display_key {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
other += 1;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the bounded, self-describing enum portion of generated metadata.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn __valid_enum_options(descriptors: &[FieldDescriptor]) -> bool {
|
||||||
|
for descriptor in descriptors {
|
||||||
|
if descriptor.enum_options.len() > crate::MAX_ENUM_OPTIONS_PER_FIELD {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (index, option) in descriptor.enum_options.iter().enumerate() {
|
||||||
|
if option.1.is_empty()
|
||||||
|
|| option.1.len() > crate::MAX_ENUM_OPTION_LABEL_BYTES
|
||||||
|
|| descriptor.kind != crate::ValueKind::U32
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if descriptor.enum_options[..index]
|
||||||
|
.iter()
|
||||||
|
.any(|previous| previous.0 == option.0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
const FINGERPRINT_LEFT_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
const FINGERPRINT_RIGHT_OFFSET: u64 = 0x6c62_272e_07bb_0142;
|
||||||
|
const FINGERPRINT_LEFT_PRIME: u64 = 0x0000_0100_0000_01b3;
|
||||||
|
const FINGERPRINT_RIGHT_PRIME: u64 = 0x9e37_79b1_85eb_ca87;
|
||||||
|
|
||||||
|
const fn hash_byte(mut state: [u64; 2], byte: u8) -> [u64; 2] {
|
||||||
|
state[0] = (state[0] ^ byte as u64).wrapping_mul(FINGERPRINT_LEFT_PRIME);
|
||||||
|
state[1] = (state[1] ^ byte as u64).wrapping_mul(FINGERPRINT_RIGHT_PRIME);
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn hash_u64(mut state: [u64; 2], value: u64) -> [u64; 2] {
|
||||||
|
let bytes = value.to_le_bytes();
|
||||||
|
let mut index = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
state = hash_byte(state, bytes[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn hash_str(mut state: [u64; 2], value: &str) -> [u64; 2] {
|
||||||
|
state = hash_u64(state, value.len() as u64);
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
let mut index = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
state = hash_byte(state, bytes[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn hash_value(mut state: [u64; 2], value: Option<ConfigValue>) -> [u64; 2] {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return hash_byte(state, 0);
|
||||||
|
};
|
||||||
|
state = hash_byte(state, value.kind() as u8);
|
||||||
|
hash_u64(state, value.raw_bits())
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn finish_fingerprint(state: [u64; 2]) -> [u8; 16] {
|
||||||
|
let left = state[0].to_le_bytes();
|
||||||
|
let right = state[1].to_le_bytes();
|
||||||
|
[
|
||||||
|
left[0], left[1], left[2], left[3], left[4], left[5], left[6], left[7], right[0], right[1],
|
||||||
|
right[2], right[3], right[4], right[5], right[6], right[7],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produces the stable schema fingerprint for one scalar record.
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub const fn __schema_id(descriptors: &[FieldDescriptor]) -> [u8; 16] {
|
||||||
|
let mut state = [FINGERPRINT_LEFT_OFFSET, FINGERPRINT_RIGHT_OFFSET];
|
||||||
|
state = hash_u64(state, descriptors.len() as u64);
|
||||||
|
let mut index = 0;
|
||||||
|
while index < descriptors.len() {
|
||||||
|
let descriptor = descriptors[index];
|
||||||
|
state = hash_u64(state, descriptor.id.get() as u64);
|
||||||
|
state = hash_str(state, descriptor.display_key);
|
||||||
|
state = hash_byte(state, descriptor.kind as u8);
|
||||||
|
state = hash_value(state, descriptor.minimum);
|
||||||
|
state = hash_value(state, descriptor.maximum);
|
||||||
|
state = hash_u64(state, descriptor.enum_options.len() as u64);
|
||||||
|
let mut option = 0;
|
||||||
|
while option < descriptor.enum_options.len() {
|
||||||
|
state = hash_u64(state, descriptor.enum_options[option].0 as u64);
|
||||||
|
state = hash_str(state, descriptor.enum_options[option].1);
|
||||||
|
option += 1;
|
||||||
|
}
|
||||||
|
state = hash_str(state, descriptor.unit);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
finish_fingerprint(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produces a parent fingerprint from ordered child fingerprints.
|
||||||
///
|
///
|
||||||
/// The derive owns routing/default construction so adding a field to an
|
/// A one-child wrapper is deliberately transparent, so adding or removing a
|
||||||
/// existing section cannot silently bypass local validation. The aggregate
|
/// redundant structural wrapper does not invalidate persisted values.
|
||||||
/// owner still defines capability checks and immutable active construction.
|
#[doc(hidden)]
|
||||||
pub trait ConfigAggregate: ValidateFields {
|
pub const fn __combine_schema_ids(children: &[[u8; 16]]) -> [u8; 16] {
|
||||||
/// Builds and fully validates all nested sections from their derived
|
if children.len() == 1 {
|
||||||
/// defaults.
|
return children[0];
|
||||||
fn default_aggregate() -> Result<Self, ConfigViolation>
|
}
|
||||||
where
|
let mut state = [FINGERPRINT_LEFT_OFFSET, FINGERPRINT_RIGHT_OFFSET];
|
||||||
Self: Sized;
|
state = hash_u64(state, children.len() as u64);
|
||||||
|
let mut child = 0;
|
||||||
/// Returns one nested scalar by stable field identity.
|
while child < children.len() {
|
||||||
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
|
let mut byte = 0;
|
||||||
|
while byte < children[child].len() {
|
||||||
/// Routes one scalar write to its owning generated section. The setter
|
state = hash_byte(state, children[child][byte]);
|
||||||
/// applies local validation only, so a transaction may temporarily hold a
|
byte += 1;
|
||||||
/// cross-field-invalid multi-edit candidate.
|
}
|
||||||
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
|
child += 1;
|
||||||
|
}
|
||||||
|
finish_fingerprint(state)
|
||||||
}
|
}
|
||||||
@@ -292,7 +292,7 @@ impl RatePid {
|
|||||||
/// Position, velocity, and acceleration reference in the local NED frame.
|
/// Position, velocity, and acceleration reference in the local NED frame.
|
||||||
///
|
///
|
||||||
/// The reference is generated continuously. A caller must not replace it with
|
/// The reference is generated continuously. A caller must not replace it with
|
||||||
/// a position step merely because an operator requested a distant goal.
|
/// a position step merely because a user requested a distant goal.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct KinematicReference {
|
pub struct KinematicReference {
|
||||||
/// Local NED position in metres.
|
/// Local NED position in metres.
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ version.workspace = true
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
kaptan-board = { path = "../kaptan-board" }
|
|
||||||
kaptan-log = { path = "../kaptan-log" }
|
kaptan-log = { path = "../kaptan-log" }
|
||||||
kaptan-dma = { path = "../kaptan-dma" }
|
kaptan-dma = { path = "../kaptan-dma" }
|
||||||
kaptan-mavlink = { path = "../kaptan-mavlink" }
|
kaptan-mavlink = { path = "../kaptan-mavlink" }
|
||||||
|
|||||||
@@ -7,16 +7,14 @@
|
|||||||
|
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
use kaptan_board::{BoardConfig, DataChannelRates};
|
|
||||||
use kaptan_dma::TxCompletion;
|
use kaptan_dma::TxCompletion;
|
||||||
use kaptan_log::KaptanError;
|
use kaptan_log::KaptanError;
|
||||||
use kaptan_mavlink::message::MessageData;
|
use kaptan_mavlink::message::MessageData;
|
||||||
use kaptan_mavlink::{
|
use kaptan_mavlink::{
|
||||||
MavlinkFrame,
|
MavlinkFrame,
|
||||||
encoder::{DistanceSensorConfig, MavlinkEncoder},
|
encoder::{DistanceSensorConfig, MavlinkEncoder},
|
||||||
message::{MavCmd, MavResult, MavState},
|
message::MavState,
|
||||||
transmitter::FrameClass,
|
transmitter::{EnqueueResult, FrameClass},
|
||||||
transmitter::TransmitFaultKind,
|
|
||||||
};
|
};
|
||||||
use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
|
use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
|
||||||
|
|
||||||
@@ -24,84 +22,36 @@ mod mailbox;
|
|||||||
|
|
||||||
pub use mailbox::{LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox};
|
pub use mailbox::{LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox};
|
||||||
|
|
||||||
|
/// Validated publication schedule consumed by the outbound data channel.
|
||||||
|
///
|
||||||
|
/// This is a derived runtime input, not a persisted configuration root. Board
|
||||||
|
/// packages own the factory values and the platform constructs this view only
|
||||||
|
/// after validating the active user configuration against immutable hardware.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct DataChannelRates {
|
||||||
|
/// Frequency of the platform service tick.
|
||||||
|
pub service_hz: usize,
|
||||||
|
/// Raw-IMU publication rate.
|
||||||
|
pub raw_imu_hz: usize,
|
||||||
|
/// Range publication rate; `None` disables the stream.
|
||||||
|
pub range_hz: Option<usize>,
|
||||||
|
/// Optical-flow publication rate; `None` disables the stream.
|
||||||
|
pub optical_flow_hz: Option<usize>,
|
||||||
|
/// Heartbeat publication rate.
|
||||||
|
pub heartbeat_hz: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// Platform transport used by a data channel.
|
/// Platform transport used by a data channel.
|
||||||
///
|
///
|
||||||
/// The channel decides exactly which encoded frame is admitted and with which
|
/// The channel decides exactly which encoded frame is admitted and with which
|
||||||
/// delivery class. The platform retains only DMA endpoint ownership.
|
/// delivery class. The platform retains only DMA endpoint ownership.
|
||||||
pub trait FrameSink {
|
pub trait FrameSink {
|
||||||
/// Admits one fully encoded frame to the platform's bounded transport.
|
/// Admits one fully encoded frame to the platform's bounded transport.
|
||||||
fn send(&mut self, class: FrameClass, frame: MavlinkFrame) -> FrameAdmission;
|
fn send(&mut self, class: FrameClass, frame: MavlinkFrame) -> EnqueueResult;
|
||||||
|
|
||||||
/// Releases or faults the active DMA frame after a terminal interrupt.
|
/// Applies one terminal DMA interrupt and starts the next retained frame
|
||||||
fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion;
|
/// when possible.
|
||||||
|
fn on_dma_complete(&mut self, completion: TxCompletion);
|
||||||
/// Returns unconfigurable sole-link loss counters.
|
|
||||||
fn health(&self) -> TxHealth;
|
|
||||||
|
|
||||||
/// Removes one exact terminal transport-fault category for the central
|
|
||||||
/// observability owner. It is never encoded from an ISR or transport
|
|
||||||
/// driver directly.
|
|
||||||
fn take_transport_fault(&mut self) -> Option<TransmitFaultKind>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Outcome returned to the one platform telemetry owner for each attempted
|
|
||||||
/// frame admission. It never triggers logging recursively.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum FrameAdmission {
|
|
||||||
/// The queue retained this encoded frame, possibly after replacing a
|
|
||||||
/// pending lower-priority frame.
|
|
||||||
Retained(kaptan_mavlink::transmitter::EnqueueResult),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FrameAdmission {
|
|
||||||
/// Whether the frame is represented by the lower transport and the log
|
|
||||||
/// hub may acknowledge its source record.
|
|
||||||
pub const fn is_retained(self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Self::Retained(
|
|
||||||
kaptan_mavlink::transmitter::EnqueueResult::Enqueued
|
|
||||||
| kaptan_mavlink::transmitter::EnqueueResult::ReplacedBestEffort
|
|
||||||
| kaptan_mavlink::transmitter::EnqueueResult::ReplacedCritical
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether no pending queue slot accepted the source frame.
|
|
||||||
pub const fn is_queue_full(self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Self::Retained(
|
|
||||||
kaptan_mavlink::transmitter::EnqueueResult::VitalQueueFull
|
|
||||||
| kaptan_mavlink::transmitter::EnqueueResult::CriticalQueueFull
|
|
||||||
| kaptan_mavlink::transmitter::EnqueueResult::BestEffortDropped
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Terminal outcome from the sole DMA owner.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum DmaCompletion {
|
|
||||||
/// No new transport fault was observed.
|
|
||||||
Complete,
|
|
||||||
/// A DMA terminal or restart fault was counted by the platform.
|
|
||||||
Fault,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unconfigurable loss counters from sole-link admission/DMA ownership.
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
||||||
pub struct TxHealth {
|
|
||||||
/// Dropped or displaced pending frames, including vital admission loss.
|
|
||||||
pub admission_loss_count: u32,
|
|
||||||
/// DMA preparation/start/terminal transfer faults.
|
|
||||||
pub dma_fault_count: u32,
|
|
||||||
/// DMA-visible memory preparation faults.
|
|
||||||
pub memory_fault_count: u32,
|
|
||||||
/// DMA-engine transfer-start faults.
|
|
||||||
pub engine_fault_count: u32,
|
|
||||||
/// Active-DMA terminal faults or halts.
|
|
||||||
pub terminal_fault_count: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mailboxes read by one data-channel worker.
|
/// Mailboxes read by one data-channel worker.
|
||||||
@@ -129,8 +79,6 @@ pub struct VerticalDataConfig {
|
|||||||
/// Data-channel construction failure.
|
/// Data-channel construction failure.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum DataChannelError {
|
pub enum DataChannelError {
|
||||||
/// Rejected profiles must not construct a physical telemetry channel.
|
|
||||||
NotTelemetryProfile,
|
|
||||||
/// A configured channel must be due at least once per second.
|
/// A configured channel must be due at least once per second.
|
||||||
ZeroRate,
|
ZeroRate,
|
||||||
/// A configured channel exceeds the service frequency.
|
/// A configured channel exceeds the service frequency.
|
||||||
@@ -159,20 +107,19 @@ impl<'a, Sink, const SERVICE_HZ: usize> DataChannel<'a, Sink, SERVICE_HZ>
|
|||||||
where
|
where
|
||||||
Sink: FrameSink,
|
Sink: FrameSink,
|
||||||
{
|
{
|
||||||
/// Creates an outbound data flow from validated portable board metadata.
|
/// Creates an outbound data flow from already-validated runtime inputs.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
sink: Sink,
|
sink: Sink,
|
||||||
config: BoardConfig,
|
system_id: u8,
|
||||||
|
component_id: u8,
|
||||||
|
rates: DataChannelRates,
|
||||||
vertical: VerticalDataConfig,
|
vertical: VerticalDataConfig,
|
||||||
sources: DataSources<'a>,
|
sources: DataSources<'a>,
|
||||||
) -> Result<Self, DataChannelError> {
|
) -> Result<Self, DataChannelError> {
|
||||||
let telemetry = config
|
let schedule = Schedule::from_rates(rates)?;
|
||||||
.telemetry()
|
|
||||||
.ok_or(DataChannelError::NotTelemetryProfile)?;
|
|
||||||
let schedule = Schedule::from_rates(telemetry.data_channel)?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
sink,
|
sink,
|
||||||
encoder: MavlinkEncoder::new(config.mavlink.system_id, config.mavlink.component_id),
|
encoder: MavlinkEncoder::new(system_id, component_id),
|
||||||
schedule,
|
schedule,
|
||||||
sources,
|
sources,
|
||||||
vertical,
|
vertical,
|
||||||
@@ -234,41 +181,16 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Completes the active DMA lifecycle for this channel's transport.
|
/// Completes the active DMA lifecycle for this channel's transport.
|
||||||
pub fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion {
|
pub fn on_dma_complete(&mut self, completion: TxCompletion) {
|
||||||
self.sink.on_dma_complete(completion)
|
self.sink.on_dma_complete(completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emits a critical acknowledgement for a received MAVLink command.
|
|
||||||
pub fn acknowledge_command(&mut self, command: MavCmd, result: MavResult) {
|
|
||||||
let _ = self.sink.send(
|
|
||||||
FrameClass::Critical,
|
|
||||||
self.encoder.command_ack(command, result),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes an adapter-owned generated MAVLink data record through the
|
/// Encodes an adapter-owned generated MAVLink data record through the
|
||||||
/// channel's sole encoder and bounded transport sink.
|
/// channel's sole encoder and bounded transport sink.
|
||||||
pub fn send_custom<M: MessageData>(
|
pub fn send_custom<M: MessageData>(&mut self, class: FrameClass, message: &M) -> EnqueueResult {
|
||||||
&mut self,
|
|
||||||
class: FrameClass,
|
|
||||||
message: &M,
|
|
||||||
) -> FrameAdmission {
|
|
||||||
self.sink.send(class, self.encoder.custom(message))
|
self.sink.send(class, self.encoder.custom(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns local sole-link accounting for platform diagnostics. It is not
|
|
||||||
/// serialized through Kaptan's bounded diagnostic messages.
|
|
||||||
pub fn transport_health(&self) -> TxHealth {
|
|
||||||
self.sink.health()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes one exact sole-link transport fault category after the platform
|
|
||||||
/// has counted it. The platform maps it to a typed log record on its
|
|
||||||
/// bounded telemetry worker.
|
|
||||||
pub fn take_transport_fault(&mut self) -> Option<TransmitFaultKind> {
|
|
||||||
self.sink.take_transport_fault()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn refresh_vertical_data(&mut self) {
|
fn refresh_vertical_data(&mut self) {
|
||||||
if let Some(range) = self.sources.range.take_newer(&mut self.observed_range) {
|
if let Some(range) = self.sources.range.take_newer(&mut self.observed_range) {
|
||||||
self.last_range = Some(range);
|
self.last_range = Some(range);
|
||||||
@@ -371,17 +293,15 @@ impl<const SERVICE_HZ: usize> Schedule<SERVICE_HZ> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
DataChannel, DataSources, DmaCompletion, FrameAdmission, FrameSink, LatestImuMailbox,
|
DataChannel, DataChannelRates, DataSources, FrameSink, LatestImuMailbox,
|
||||||
LatestOpticalFlowMailbox, LatestRangeMailbox, TransmitFaultKind, TxHealth,
|
LatestOpticalFlowMailbox, LatestRangeMailbox, VerticalDataConfig,
|
||||||
VerticalDataConfig,
|
|
||||||
};
|
|
||||||
use kaptan_board::{
|
|
||||||
BoardConfig, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
|
|
||||||
VerticalSensingKind,
|
|
||||||
};
|
};
|
||||||
use kaptan_dma::TxCompletion;
|
use kaptan_dma::TxCompletion;
|
||||||
use kaptan_log::Severity;
|
use kaptan_mavlink::{
|
||||||
use kaptan_mavlink::{MavlinkFrame, message::MavState, transmitter::FrameClass};
|
MavlinkFrame,
|
||||||
|
message::MavState,
|
||||||
|
transmitter::{EnqueueResult, FrameClass},
|
||||||
|
};
|
||||||
use kaptan_sensor::{
|
use kaptan_sensor::{
|
||||||
imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot,
|
imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot,
|
||||||
};
|
};
|
||||||
@@ -394,50 +314,26 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FrameSink for Sink {
|
impl FrameSink for Sink {
|
||||||
fn send(&mut self, class: FrameClass, _: MavlinkFrame) -> FrameAdmission {
|
fn send(&mut self, class: FrameClass, _: MavlinkFrame) -> EnqueueResult {
|
||||||
match class {
|
match class {
|
||||||
FrameClass::Vital => self.critical += 1,
|
FrameClass::Vital => self.critical += 1,
|
||||||
FrameClass::BestEffort => self.best_effort += 1,
|
FrameClass::BestEffort => self.best_effort += 1,
|
||||||
FrameClass::Critical => self.critical += 1,
|
FrameClass::Critical => self.critical += 1,
|
||||||
}
|
}
|
||||||
FrameAdmission::Retained(kaptan_mavlink::transmitter::EnqueueResult::Enqueued)
|
EnqueueResult::Enqueued
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_dma_complete(&mut self, _: TxCompletion) -> DmaCompletion {
|
fn on_dma_complete(&mut self, _: TxCompletion) {
|
||||||
self.completions += 1;
|
self.completions += 1;
|
||||||
DmaCompletion::Complete
|
|
||||||
}
|
|
||||||
|
|
||||||
fn health(&self) -> TxHealth {
|
|
||||||
TxHealth::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn take_transport_fault(&mut self) -> Option<TransmitFaultKind> {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG: BoardConfig = BoardConfig {
|
const RATES: DataChannelRates = DataChannelRates {
|
||||||
name: "test",
|
service_hz: 100,
|
||||||
mavlink: MavlinkEndpoint {
|
raw_imu_hz: 20,
|
||||||
system_id: 1,
|
range_hz: Some(20),
|
||||||
component_id: 1,
|
optical_flow_hz: Some(20),
|
||||||
port: MavlinkPort::Serial1,
|
heartbeat_hz: 2,
|
||||||
baud: 115_200,
|
|
||||||
},
|
|
||||||
profile: kaptan_board::BoardProfile::TelemetryOnly(kaptan_board::TelemetryProfile {
|
|
||||||
imu: ImuDevice::Mpu6000,
|
|
||||||
imu_mount: ImuMount::BodyAligned,
|
|
||||||
vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow,
|
|
||||||
data_channel: DataChannelRates {
|
|
||||||
service_hz: 100,
|
|
||||||
raw_imu_hz: 20,
|
|
||||||
range_hz: Some(20),
|
|
||||||
optical_flow_hz: Some(20),
|
|
||||||
heartbeat_hz: 2,
|
|
||||||
},
|
|
||||||
maximum_log_level: Severity::Info,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -451,7 +347,9 @@ mod tests {
|
|||||||
critical: 0,
|
critical: 0,
|
||||||
completions: 0,
|
completions: 0,
|
||||||
},
|
},
|
||||||
CONFIG,
|
1,
|
||||||
|
1,
|
||||||
|
RATES,
|
||||||
VerticalDataConfig {
|
VerticalDataConfig {
|
||||||
distance: Some(kaptan_mavlink::encoder::DistanceSensorConfig {
|
distance: Some(kaptan_mavlink::encoder::DistanceSensorConfig {
|
||||||
sensor_id: 0,
|
sensor_id: 0,
|
||||||
@@ -488,7 +386,6 @@ mod tests {
|
|||||||
channel.on_service_tick(MavState::MAV_STATE_STANDBY, false);
|
channel.on_service_tick(MavState::MAV_STATE_STANDBY, false);
|
||||||
}
|
}
|
||||||
channel.on_dma_complete(TxCompletion::Complete);
|
channel.on_dma_complete(TxCompletion::Complete);
|
||||||
|
|
||||||
let sink = channel.sink;
|
let sink = channel.sink;
|
||||||
assert_eq!(sink.best_effort, 60);
|
assert_eq!(sink.best_effort, 60);
|
||||||
assert_eq!(sink.critical, 2);
|
assert_eq!(sink.critical, 2);
|
||||||
|
|||||||
@@ -13,17 +13,6 @@ pub struct Mpu6000Config {
|
|||||||
pub sample_rate_divider: u8,
|
pub sample_rate_divider: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Mpu6000Config {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
accelerometer: AccelerometerRange::PlusMinus4G,
|
|
||||||
gyroscope: GyroscopeRange::PlusMinus1000DegreesPerSecond,
|
|
||||||
digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz,
|
|
||||||
sample_rate_divider: 9,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MPU6000 digital low-pass filter bandwidth selection.
|
/// MPU6000 digital low-pass filter bandwidth selection.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum DigitalLowPassFilter {
|
pub enum DigitalLowPassFilter {
|
||||||
|
|||||||
@@ -232,10 +232,20 @@ impl<Bus: SpiDevice> Mpu6000<Bus> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{Error, Mpu6000, Mpu6000Config, Mpu6000Event, TRANSFER_CAPACITY};
|
use super::{
|
||||||
|
AccelerometerRange, DigitalLowPassFilter, Error, GyroscopeRange, Mpu6000, Mpu6000Config,
|
||||||
|
Mpu6000Event, TRANSFER_CAPACITY,
|
||||||
|
};
|
||||||
use kaptan_spi::{SpiCompletion, SpiDevice};
|
use kaptan_spi::{SpiCompletion, SpiDevice};
|
||||||
use kaptan_time::Timestamp;
|
use kaptan_time::Timestamp;
|
||||||
|
|
||||||
|
const TEST_CONFIG: Mpu6000Config = Mpu6000Config {
|
||||||
|
accelerometer: AccelerometerRange::PlusMinus4G,
|
||||||
|
gyroscope: GyroscopeRange::PlusMinus1000DegreesPerSecond,
|
||||||
|
digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz,
|
||||||
|
sample_rate_divider: 9,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
enum FakeError {
|
enum FakeError {
|
||||||
Start,
|
Start,
|
||||||
@@ -277,7 +287,7 @@ mod tests {
|
|||||||
fn configures_a_verified_device_without_waiting() {
|
fn configures_a_verified_device_without_waiting() {
|
||||||
let mut bus = FakeBus::ready();
|
let mut bus = FakeBus::ready();
|
||||||
bus.receive[1] = 0x68;
|
bus.receive[1] = 0x68;
|
||||||
let mut driver = Mpu6000::new(bus, Mpu6000Config::default());
|
let mut driver = Mpu6000::new(bus, TEST_CONFIG);
|
||||||
|
|
||||||
driver.begin_initialization().unwrap();
|
driver.begin_initialization().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -302,7 +312,7 @@ mod tests {
|
|||||||
fn rejects_an_unexpected_identity() {
|
fn rejects_an_unexpected_identity() {
|
||||||
let mut bus = FakeBus::ready();
|
let mut bus = FakeBus::ready();
|
||||||
bus.receive[1] = 0x70;
|
bus.receive[1] = 0x70;
|
||||||
let mut driver = Mpu6000::new(bus, Mpu6000Config::default());
|
let mut driver = Mpu6000::new(bus, TEST_CONFIG);
|
||||||
|
|
||||||
driver.begin_initialization().unwrap();
|
driver.begin_initialization().unwrap();
|
||||||
let _ = driver.on_transfer_complete(SpiCompletion::Complete);
|
let _ = driver.on_transfer_complete(SpiCompletion::Complete);
|
||||||
@@ -322,7 +332,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
let mut bus = FakeBus::ready();
|
let mut bus = FakeBus::ready();
|
||||||
bus.receive[1] = 0x68;
|
bus.receive[1] = 0x68;
|
||||||
let mut driver = Mpu6000::new(bus, Mpu6000Config::default());
|
let mut driver = Mpu6000::new(bus, TEST_CONFIG);
|
||||||
|
|
||||||
driver.begin_initialization().unwrap();
|
driver.begin_initialization().unwrap();
|
||||||
let _ = driver.on_transfer_complete(SpiCompletion::Complete);
|
let _ = driver.on_transfer_complete(SpiCompletion::Complete);
|
||||||
@@ -352,7 +362,7 @@ mod tests {
|
|||||||
fn reports_a_route_transfer_fault() {
|
fn reports_a_route_transfer_fault() {
|
||||||
let mut bus = FakeBus::ready();
|
let mut bus = FakeBus::ready();
|
||||||
bus.receive[1] = 0x68;
|
bus.receive[1] = 0x68;
|
||||||
let mut driver = Mpu6000::new(bus, Mpu6000Config::default());
|
let mut driver = Mpu6000::new(bus, TEST_CONFIG);
|
||||||
|
|
||||||
driver.begin_initialization().unwrap();
|
driver.begin_initialization().unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|||||||
@@ -89,10 +89,19 @@ where
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{Mpu6000Service, Mpu6000ServiceEvent};
|
use super::{Mpu6000Service, Mpu6000ServiceEvent};
|
||||||
use crate::mpu6000::{Mpu6000, Mpu6000Config};
|
use crate::mpu6000::{
|
||||||
|
AccelerometerRange, DigitalLowPassFilter, GyroscopeRange, Mpu6000, Mpu6000Config,
|
||||||
|
};
|
||||||
use kaptan_spi::{SpiCompletion, SpiDevice};
|
use kaptan_spi::{SpiCompletion, SpiDevice};
|
||||||
use kaptan_time::{MonotonicClock, Timestamp};
|
use kaptan_time::{MonotonicClock, Timestamp};
|
||||||
|
|
||||||
|
const TEST_CONFIG: Mpu6000Config = Mpu6000Config {
|
||||||
|
accelerometer: AccelerometerRange::PlusMinus4G,
|
||||||
|
gyroscope: GyroscopeRange::PlusMinus1000DegreesPerSecond,
|
||||||
|
digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz,
|
||||||
|
sample_rate_divider: 9,
|
||||||
|
};
|
||||||
|
|
||||||
struct Clock;
|
struct Clock;
|
||||||
|
|
||||||
impl MonotonicClock for Clock {
|
impl MonotonicClock for Clock {
|
||||||
@@ -120,10 +129,7 @@ mod tests {
|
|||||||
fn accepts_a_sample_only_after_initialization() {
|
fn accepts_a_sample_only_after_initialization() {
|
||||||
let mut response = [0; 15];
|
let mut response = [0; 15];
|
||||||
response[1] = 0x68;
|
response[1] = 0x68;
|
||||||
let mut service = Mpu6000Service::new(
|
let mut service = Mpu6000Service::new(Clock, Mpu6000::new(Bus { response }, TEST_CONFIG));
|
||||||
Clock,
|
|
||||||
Mpu6000::new(Bus { response }, Mpu6000Config::default()),
|
|
||||||
);
|
|
||||||
|
|
||||||
service.begin().unwrap();
|
service.begin().unwrap();
|
||||||
assert!(matches!(service.on_data_ready(), Mpu6000ServiceEvent::Idle));
|
assert!(matches!(service.on_data_ready(), Mpu6000ServiceEvent::Idle));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Bounded CBOR import/export for one explicit current record.
|
//! Bounded CBOR persistence codec for one explicit current record.
|
||||||
|
|
||||||
use kaptan_config::{
|
use kaptan_config::{
|
||||||
ConfigModel, ConfigValue, ConfigViolation, FieldWriteError, ValidationRule, ValueKind,
|
ConfigModel, ConfigValue, ConfigViolation, FieldWriteError, ValidationRule, ValueKind,
|
||||||
@@ -13,7 +13,7 @@ pub const MAXIMUM_CANDIDATE_BYTES: usize = 4096;
|
|||||||
const FORMAT_KEY: u16 = 0;
|
const FORMAT_KEY: u16 = 0;
|
||||||
const SCHEMA_KEY: u16 = u16::MAX;
|
const SCHEMA_KEY: u16 = u16::MAX;
|
||||||
|
|
||||||
/// CBOR import failure.
|
/// CBOR snapshot decoding failure.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum CborError {
|
pub enum CborError {
|
||||||
/// The supplied payload exceeded the bounded configuration storage budget.
|
/// The supplied payload exceeded the bounded configuration storage budget.
|
||||||
@@ -126,17 +126,14 @@ fn decode_current(bytes: &[u8]) -> Result<CandidateProfile, CborError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let index = CandidateProfile::descriptors()
|
let (index, descriptor) = CandidateProfile::descriptors()
|
||||||
.iter()
|
.enumerate()
|
||||||
.position(|descriptor| descriptor.id.get() == key)
|
.find(|(_, descriptor)| descriptor.id.get() == key)
|
||||||
.ok_or(CborError::Malformed)?;
|
.ok_or(CborError::Malformed)?;
|
||||||
if values[index].is_some() {
|
if values[index].is_some() {
|
||||||
return Err(CborError::Malformed);
|
return Err(CborError::Malformed);
|
||||||
}
|
}
|
||||||
values[index] = Some(decode_value(
|
values[index] = Some(decode_value(&mut decoder, descriptor.kind)?);
|
||||||
&mut decoder,
|
|
||||||
CandidateProfile::descriptors()[index].kind,
|
|
||||||
)?);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,10 +148,10 @@ fn decode_current(bytes: &[u8]) -> Result<CandidateProfile, CborError> {
|
|||||||
if values.iter().any(Option::is_none) {
|
if values.iter().any(Option::is_none) {
|
||||||
return Err(CborError::Malformed);
|
return Err(CborError::Malformed);
|
||||||
}
|
}
|
||||||
let mut candidate = CandidateProfile::default();
|
let mut candidate = CandidateProfile::factory();
|
||||||
for (index, value) in values.into_iter().enumerate() {
|
for (index, value) in values.into_iter().enumerate() {
|
||||||
let descriptor = CandidateProfile::descriptors()
|
let descriptor = CandidateProfile::descriptors()
|
||||||
.get(index)
|
.nth(index)
|
||||||
.ok_or(CborError::Malformed)?;
|
.ok_or(CborError::Malformed)?;
|
||||||
candidate
|
candidate
|
||||||
.set_field(descriptor.id, value.ok_or(CborError::Malformed)?)
|
.set_field(descriptor.id, value.ok_or(CborError::Malformed)?)
|
||||||
@@ -210,7 +207,7 @@ mod tests {
|
|||||||
fn round_trip_and_trailing_rejection_are_exact() {
|
fn round_trip_and_trailing_rejection_are_exact() {
|
||||||
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||||
let capabilities = VehicleCapabilities::quad_x();
|
let capabilities = VehicleCapabilities::quad_x();
|
||||||
encode_candidate(CandidateProfile::default(), &capabilities, &mut writer).unwrap();
|
encode_candidate(CandidateProfile::factory(), &capabilities, &mut writer).unwrap();
|
||||||
let length = writer.position();
|
let length = writer.position();
|
||||||
let bytes = writer.into_inner();
|
let bytes = writer.into_inner();
|
||||||
assert!(decode_candidate(&bytes[..length], &capabilities).is_ok());
|
assert!(decode_candidate(&bytes[..length], &capabilities).is_ok());
|
||||||
@@ -225,7 +222,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn encoding_rejects_a_cross_field_invalid_transaction_before_writing() {
|
fn encoding_rejects_a_cross_field_invalid_transaction_before_writing() {
|
||||||
let capabilities = VehicleCapabilities::quad_x();
|
let capabilities = VehicleCapabilities::quad_x();
|
||||||
let mut candidate = CandidateProfile::default();
|
let mut candidate = CandidateProfile::factory();
|
||||||
candidate
|
candidate
|
||||||
.set_field(kaptan_config::FieldId::new(2), ConfigValue::U64(50_001))
|
.set_field(kaptan_config::FieldId::new(2), ConfigValue::U64(50_001))
|
||||||
.expect("the scalar is locally valid");
|
.expect("the scalar is locally valid");
|
||||||
@@ -295,8 +292,8 @@ mod tests {
|
|||||||
.u16(FORMAT_VERSION)
|
.u16(FORMAT_VERSION)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
|
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
|
||||||
let candidate = CandidateProfile::default();
|
let candidate = CandidateProfile::factory();
|
||||||
for descriptor in CandidateProfile::descriptors().iter().skip(1) {
|
for descriptor in CandidateProfile::descriptors().skip(1) {
|
||||||
encoder.u16(descriptor.id.get()).unwrap();
|
encoder.u16(descriptor.id.get()).unwrap();
|
||||||
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap();
|
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap();
|
||||||
}
|
}
|
||||||
@@ -312,7 +309,6 @@ mod tests {
|
|||||||
fn cbor_scalar_write_preserves_the_generated_field_and_rule() {
|
fn cbor_scalar_write_preserves_the_generated_field_and_rule() {
|
||||||
let capabilities = VehicleCapabilities::quad_x();
|
let capabilities = VehicleCapabilities::quad_x();
|
||||||
let mass = CandidateProfile::descriptors()
|
let mass = CandidateProfile::descriptors()
|
||||||
.iter()
|
|
||||||
.find(|descriptor| descriptor.display_key == "thrust.mass_kg")
|
.find(|descriptor| descriptor.display_key == "thrust.mass_kg")
|
||||||
.expect("declared mass descriptor");
|
.expect("declared mass descriptor");
|
||||||
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||||
@@ -326,7 +322,7 @@ mod tests {
|
|||||||
.u16(FORMAT_VERSION)
|
.u16(FORMAT_VERSION)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
|
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
|
||||||
let candidate = CandidateProfile::default();
|
let candidate = CandidateProfile::factory();
|
||||||
for descriptor in CandidateProfile::descriptors() {
|
for descriptor in CandidateProfile::descriptors() {
|
||||||
encoder.u16(descriptor.id.get()).unwrap();
|
encoder.u16(descriptor.id.get()).unwrap();
|
||||||
let value = if descriptor.id == mass.id {
|
let value = if descriptor.id == mass.id {
|
||||||
@@ -367,7 +363,7 @@ mod tests {
|
|||||||
.u16(format_version)
|
.u16(format_version)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
encoder.u16(SCHEMA_KEY).unwrap().bytes(&schema_id).unwrap();
|
encoder.u16(SCHEMA_KEY).unwrap().bytes(&schema_id).unwrap();
|
||||||
let candidate = CandidateProfile::default();
|
let candidate = CandidateProfile::factory();
|
||||||
for descriptor in CandidateProfile::descriptors() {
|
for descriptor in CandidateProfile::descriptors() {
|
||||||
encoder.u16(remap_key(descriptor.id.get())).unwrap();
|
encoder.u16(remap_key(descriptor.id.get())).unwrap();
|
||||||
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap();
|
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap();
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ pub use cbor::{
|
|||||||
};
|
};
|
||||||
pub use profile::{CandidateProfile, FORMAT_VERSION, VehicleCapabilities, VehicleProfile};
|
pub use profile::{CandidateProfile, FORMAT_VERSION, VehicleCapabilities, VehicleProfile};
|
||||||
|
|
||||||
/// Exact identifier of the one unreleased Quad-X profile schema.
|
/// Generated fingerprint of the complete vehicle configuration schema.
|
||||||
///
|
pub const SCHEMA_ID: [u8; 16] = <CandidateProfile as kaptan_config::Config>::SCHEMA_ID;
|
||||||
/// This is an identifier, not a cryptographic fingerprint. A GCS includes it
|
|
||||||
/// with an import so a profile prepared for another schema cannot be applied.
|
|
||||||
pub const SCHEMA_ID: [u8; 16] = *b"KAPTANPROFILE-01";
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
extern crate std;
|
extern crate std;
|
||||||
@@ -201,7 +201,7 @@ pub struct VehicleProfile {
|
|||||||
pub(crate) thrust: ThrustSettings,
|
pub(crate) thrust: ThrustSettings,
|
||||||
pub(crate) rate_pid: PidSettings,
|
pub(crate) rate_pid: PidSettings,
|
||||||
pub(crate) actuator_limits: ActuatorLimits,
|
pub(crate) actuator_limits: ActuatorLimits,
|
||||||
pub(crate) observability: super::ObservabilitySettings,
|
pub(crate) mavlink_logging: super::MavlinkLoggingSettings,
|
||||||
pub(crate) mixer: [[f32; 4]; 4],
|
pub(crate) mixer: [[f32; 4]; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,9 +227,9 @@ impl VehicleProfile {
|
|||||||
pub const fn actuator_limits(self) -> ActuatorLimits {
|
pub const fn actuator_limits(self) -> ActuatorLimits {
|
||||||
self.actuator_limits
|
self.actuator_limits
|
||||||
}
|
}
|
||||||
/// Immutable boot-active best-effort diagnostic policy.
|
/// Immutable boot-active policy for the MAVLink diagnostic sink.
|
||||||
pub const fn observability(self) -> super::ObservabilitySettings {
|
pub const fn mavlink_logging(self) -> super::MavlinkLoggingSettings {
|
||||||
self.observability
|
self.mavlink_logging
|
||||||
}
|
}
|
||||||
pub const fn mixer(self) -> [[f32; 4]; 4] {
|
pub const fn mixer(self) -> [[f32; 4]; 4] {
|
||||||
self.mixer
|
self.mixer
|
||||||
|
|||||||
@@ -1,60 +1,100 @@
|
|||||||
//! Private vehicle candidate and whole-profile validation boundary.
|
//! Private vehicle candidate and whole-profile validation boundary.
|
||||||
|
|
||||||
use kaptan_config::{
|
use kaptan_config::{
|
||||||
ConfigAggregate, ConfigModel, ConfigValidate, ConfigValue, ConfigViolation, FieldDescriptor,
|
Config, ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
|
||||||
FieldId, FieldWriteError, ValidateFields, ValidationRule,
|
ValidationRule,
|
||||||
};
|
};
|
||||||
|
use kaptan_log::Severity;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ActuatorLimits, AidingSettings, ControlSettings, ObservabilitySettings, PidSettings,
|
ActuatorLimits, AidingSettings, ControlSettings, MavlinkLoggingSettings, PidSettings,
|
||||||
SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile,
|
SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile,
|
||||||
control::{
|
control::{
|
||||||
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
|
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
|
||||||
},
|
},
|
||||||
observability::ObservabilityCandidate,
|
mavlink_logging::MavlinkLoggingCandidate,
|
||||||
registry,
|
|
||||||
safety::SafetyCandidate,
|
safety::SafetyCandidate,
|
||||||
timing::TimingCandidate,
|
timing::TimingCandidate,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Current persisted CBOR schema for the vehicle candidate.
|
/// Current persisted CBOR schema for the vehicle candidate.
|
||||||
pub const FORMAT_VERSION: u16 = 1;
|
pub const FORMAT_VERSION: u16 = 2;
|
||||||
|
|
||||||
/// Complete editable vehicle candidate.
|
/// Complete editable vehicle candidate.
|
||||||
///
|
///
|
||||||
/// Fields are private outside this crate. Operators, CBOR import, and MAVLink
|
/// Fields are private outside this crate. Local snapshot recovery and MAVLink
|
||||||
/// transport all use the same descriptor/mutation and full-validation path.
|
/// scalar writes both use the same descriptor/mutation and full-validation
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
/// path.
|
||||||
|
#[derive(Clone, Copy, Config)]
|
||||||
pub struct CandidateProfile {
|
pub struct CandidateProfile {
|
||||||
#[config(section)]
|
|
||||||
pub(super) safety: SafetyCandidate,
|
pub(super) safety: SafetyCandidate,
|
||||||
#[config(section)]
|
|
||||||
pub(super) timing: TimingCandidate,
|
pub(super) timing: TimingCandidate,
|
||||||
#[config(section)]
|
|
||||||
pub(super) aiding: AidingCandidate,
|
pub(super) aiding: AidingCandidate,
|
||||||
#[config(section)]
|
|
||||||
pub(super) control: ControlCandidate,
|
pub(super) control: ControlCandidate,
|
||||||
#[config(section)]
|
|
||||||
pub(super) thrust: ThrustCandidate,
|
pub(super) thrust: ThrustCandidate,
|
||||||
#[config(section)]
|
|
||||||
pub(super) rate_pid: PidCandidate,
|
pub(super) rate_pid: PidCandidate,
|
||||||
#[config(section)]
|
|
||||||
pub(super) actuator_limits: ActuatorLimitsCandidate,
|
pub(super) actuator_limits: ActuatorLimitsCandidate,
|
||||||
#[config(section)]
|
pub(super) mavlink_logging: MavlinkLoggingCandidate,
|
||||||
pub(super) observability: ObservabilityCandidate,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CandidateProfile {
|
|
||||||
fn default() -> Self {
|
|
||||||
<Self as ConfigAggregate>::default_aggregate()
|
|
||||||
.expect("compiled configuration defaults must validate")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CandidateProfile {
|
impl CandidateProfile {
|
||||||
|
/// Explicit factory candidate used by the vehicle-profile examples.
|
||||||
|
///
|
||||||
|
/// Board firmware supplies its own factory [`Config`] through the board
|
||||||
|
/// composition root; configuration types never synthesize values through
|
||||||
|
/// [`Default`].
|
||||||
|
pub const fn factory() -> Self {
|
||||||
|
Self {
|
||||||
|
safety: SafetyCandidate {
|
||||||
|
prearm_dwell_microseconds: 100_000,
|
||||||
|
},
|
||||||
|
timing: TimingCandidate {
|
||||||
|
maximum_imu_interval_microseconds: 50_000,
|
||||||
|
imu_freshness_microseconds: 50_000,
|
||||||
|
local_state_freshness_microseconds: 150_000,
|
||||||
|
command_link_freshness_microseconds: 500_000,
|
||||||
|
},
|
||||||
|
aiding: AidingCandidate {
|
||||||
|
position_variance_m2: 0.04,
|
||||||
|
velocity_variance_m2_per_second_squared: 0.04,
|
||||||
|
},
|
||||||
|
control: ControlCandidate {
|
||||||
|
position_gain_per_second_squared: 0.8,
|
||||||
|
velocity_gain_per_second: 1.2,
|
||||||
|
maximum_horizontal_acceleration_mps2: 3.0,
|
||||||
|
maximum_upward_acceleration_mps2: 2.0,
|
||||||
|
maximum_downward_acceleration_mps2: 2.0,
|
||||||
|
},
|
||||||
|
thrust: ThrustCandidate {
|
||||||
|
mass_kg: 1.2,
|
||||||
|
maximum_collective_thrust_newtons: 18.0,
|
||||||
|
maximum_tilt_tangent: 0.700_207_53,
|
||||||
|
},
|
||||||
|
rate_pid: PidCandidate {
|
||||||
|
proportional: 0.035,
|
||||||
|
integral: 0.006,
|
||||||
|
derivative: 0.0,
|
||||||
|
output_limit: 0.10,
|
||||||
|
integral_limit: 0.025,
|
||||||
|
},
|
||||||
|
actuator_limits: ActuatorLimitsCandidate {
|
||||||
|
minimum: 0.0,
|
||||||
|
maximum: 1.0,
|
||||||
|
slew_per_second: 8.0,
|
||||||
|
},
|
||||||
|
mavlink_logging: MavlinkLoggingCandidate {
|
||||||
|
enabled: true,
|
||||||
|
maximum_severity: Severity::Info,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the complete transport-neutral descriptor registry.
|
/// Returns the complete transport-neutral descriptor registry.
|
||||||
pub const fn descriptors() -> &'static [FieldDescriptor; Self::FIELD_COUNT] {
|
pub fn descriptors() -> impl ExactSizeIterator<Item = &'static FieldDescriptor> + Clone {
|
||||||
registry::descriptors()
|
(0..Self::FIELD_COUNT).map(|index| {
|
||||||
|
<Self as Config>::descriptor(index)
|
||||||
|
.expect("derived aggregate descriptor count must be exact")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_active(active: &VehicleProfile) -> Self {
|
fn from_active(active: &VehicleProfile) -> Self {
|
||||||
@@ -65,7 +105,7 @@ impl CandidateProfile {
|
|||||||
let thrust = active.thrust();
|
let thrust = active.thrust();
|
||||||
let rate_pid = active.rate_pid();
|
let rate_pid = active.rate_pid();
|
||||||
let actuator_limits = active.actuator_limits();
|
let actuator_limits = active.actuator_limits();
|
||||||
let observability = active.observability();
|
let mavlink_logging = active.mavlink_logging();
|
||||||
Self {
|
Self {
|
||||||
safety: SafetyCandidate {
|
safety: SafetyCandidate {
|
||||||
prearm_dwell_microseconds: safety.prearm_dwell_microseconds(),
|
prearm_dwell_microseconds: safety.prearm_dwell_microseconds(),
|
||||||
@@ -106,8 +146,9 @@ impl CandidateProfile {
|
|||||||
maximum: actuator_limits.maximum(),
|
maximum: actuator_limits.maximum(),
|
||||||
slew_per_second: actuator_limits.slew_per_second(),
|
slew_per_second: actuator_limits.slew_per_second(),
|
||||||
},
|
},
|
||||||
observability: ObservabilityCandidate {
|
mavlink_logging: MavlinkLoggingCandidate {
|
||||||
maximum_log_level: observability.maximum_log_level(),
|
enabled: mavlink_logging.enabled(),
|
||||||
|
maximum_severity: mavlink_logging.maximum_severity(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +157,7 @@ impl CandidateProfile {
|
|||||||
&self,
|
&self,
|
||||||
capabilities: &VehicleCapabilities,
|
capabilities: &VehicleCapabilities,
|
||||||
) -> Result<VehicleProfile, ConfigViolation> {
|
) -> Result<VehicleProfile, ConfigViolation> {
|
||||||
self.validate_fields()?;
|
<Self as Config>::validate(self)?;
|
||||||
self.validate_capabilities(capabilities)?;
|
self.validate_capabilities(capabilities)?;
|
||||||
Ok(VehicleProfile {
|
Ok(VehicleProfile {
|
||||||
safety: SafetySettings {
|
safety: SafetySettings {
|
||||||
@@ -162,7 +203,10 @@ impl CandidateProfile {
|
|||||||
maximum: self.actuator_limits.maximum,
|
maximum: self.actuator_limits.maximum,
|
||||||
slew_per_second: self.actuator_limits.slew_per_second,
|
slew_per_second: self.actuator_limits.slew_per_second,
|
||||||
},
|
},
|
||||||
observability: ObservabilitySettings::new(self.observability.maximum_log_level),
|
mavlink_logging: MavlinkLoggingSettings::new(
|
||||||
|
self.mavlink_logging.enabled,
|
||||||
|
self.mavlink_logging.maximum_severity,
|
||||||
|
),
|
||||||
mixer: capabilities.mixer(),
|
mixer: capabilities.mixer(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -183,10 +227,10 @@ impl CandidateProfile {
|
|||||||
impl ConfigModel for CandidateProfile {
|
impl ConfigModel for CandidateProfile {
|
||||||
type Active = VehicleProfile;
|
type Active = VehicleProfile;
|
||||||
type Context = VehicleCapabilities;
|
type Context = VehicleCapabilities;
|
||||||
const SCHEMA_ID: [u8; 16] = crate::SCHEMA_ID;
|
const SCHEMA_ID: [u8; 16] = <Self as Config>::SCHEMA_ID;
|
||||||
|
|
||||||
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
|
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
|
||||||
registry::descriptor(index)
|
<Self as Config>::descriptor(usize::from(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_count() -> u16 {
|
fn field_count() -> u16 {
|
||||||
@@ -194,15 +238,15 @@ impl ConfigModel for CandidateProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
|
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
|
||||||
<Self as ConfigAggregate>::field_value(self, id)
|
<Self as Config>::field_value(self, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
|
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
|
||||||
<Self as ConfigAggregate>::field_value(&Self::from_active(active), id)
|
<Self as Config>::field_value(&Self::from_active(active), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
|
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
|
||||||
<Self as ConfigAggregate>::set_field(self, id, value)
|
<Self as Config>::set_field(self, id, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate(&self, context: &Self::Context) -> Result<Self::Active, ConfigViolation> {
|
fn validate(&self, context: &Self::Context) -> Result<Self::Active, ConfigViolation> {
|
||||||
@@ -214,36 +258,43 @@ impl ConfigModel for CandidateProfile {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule};
|
use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule};
|
||||||
|
|
||||||
use super::{CandidateProfile, VehicleCapabilities};
|
use super::{CandidateProfile, Severity, VehicleCapabilities};
|
||||||
use crate::profile::ids;
|
use crate::profile::ids;
|
||||||
|
|
||||||
const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
|
const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_schema_fingerprint_is_stable() {
|
||||||
|
assert_eq!(
|
||||||
|
crate::SCHEMA_ID,
|
||||||
|
[
|
||||||
|
0xc4, 0xf6, 0x36, 0xc0, 0x29, 0xd2, 0x3e, 0x29, 0xa3, 0x85, 0x5b, 0x36, 0xb7, 0xb3,
|
||||||
|
0x59, 0x9c,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn descriptor_id(display_key: &str) -> FieldId {
|
fn descriptor_id(display_key: &str) -> FieldId {
|
||||||
CandidateProfile::descriptors()
|
CandidateProfile::descriptors()
|
||||||
.iter()
|
|
||||||
.find(|descriptor| descriptor.display_key == display_key)
|
.find(|descriptor| descriptor.display_key == display_key)
|
||||||
.expect("declared field descriptor")
|
.expect("declared field descriptor")
|
||||||
.id
|
.id
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_descriptor_has_a_readable_default_value() {
|
fn every_descriptor_has_a_readable_factory_value() {
|
||||||
let candidate = CandidateProfile::default();
|
let candidate = CandidateProfile::factory();
|
||||||
for descriptor in CandidateProfile::descriptors() {
|
for descriptor in CandidateProfile::descriptors() {
|
||||||
assert_eq!(
|
assert!(candidate.field_value(descriptor.id).is_some());
|
||||||
candidate.field_value(descriptor.id),
|
|
||||||
Some(descriptor.default)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aggregated_descriptor_ids_are_unique() {
|
fn aggregated_descriptor_ids_are_unique() {
|
||||||
for (index, descriptor) in CandidateProfile::descriptors().iter().enumerate() {
|
for (index, descriptor) in CandidateProfile::descriptors().enumerate() {
|
||||||
assert!(
|
assert!(
|
||||||
CandidateProfile::descriptors()[index + 1..]
|
CandidateProfile::descriptors()
|
||||||
.iter()
|
.skip(index + 1)
|
||||||
.all(|other| other.id != descriptor.id)
|
.all(|other| other.id != descriptor.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -251,7 +302,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cross_field_invalid_edit_is_retained_only_in_the_candidate() {
|
fn cross_field_invalid_edit_is_retained_only_in_the_candidate() {
|
||||||
let mut candidate = CandidateProfile::default();
|
let mut candidate = CandidateProfile::factory();
|
||||||
candidate
|
candidate
|
||||||
.set_field(
|
.set_field(
|
||||||
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS,
|
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS,
|
||||||
@@ -266,7 +317,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_write_error_retains_exact_field_and_rule() {
|
fn local_write_error_retains_exact_field_and_rule() {
|
||||||
let mut candidate = CandidateProfile::default();
|
let mut candidate = CandidateProfile::factory();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
candidate.set_field(ids::MASS_KG, ConfigValue::F32(f32::NAN)),
|
candidate.set_field(ids::MASS_KG, ConfigValue::F32(f32::NAN)),
|
||||||
Err(FieldWriteError::non_finite(ids::MASS_KG))
|
Err(FieldWriteError::non_finite(ids::MASS_KG))
|
||||||
@@ -280,7 +331,7 @@ mod tests {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
{
|
{
|
||||||
let mut candidate = CandidateProfile::default();
|
let mut candidate = CandidateProfile::factory();
|
||||||
candidate
|
candidate
|
||||||
.set_field(descriptor.id, bound)
|
.set_field(descriptor.id, bound)
|
||||||
.expect("descriptor bound must pass the descriptor-backed setter");
|
.expect("descriptor bound must pass the descriptor-backed setter");
|
||||||
@@ -297,20 +348,26 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn observability_filter_is_persisted_restart_only_and_cannot_hide_errors() {
|
fn mavlink_logging_policy_is_persisted_and_restart_only() {
|
||||||
let mut candidate = CandidateProfile::default();
|
let mut candidate = CandidateProfile::factory();
|
||||||
|
let severity_descriptor = CandidateProfile::descriptors()
|
||||||
|
.find(|descriptor| descriptor.display_key == "mavlink.logging.maximum_severity")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
severity_descriptor.enum_options,
|
||||||
|
Severity::CONFIG_ENUM_OPTIONS
|
||||||
|
);
|
||||||
candidate
|
candidate
|
||||||
.set_field(
|
.set_field(
|
||||||
descriptor_id("observability.maximum_log_level"),
|
descriptor_id("mavlink.logging.enabled"),
|
||||||
ConfigValue::U32(3),
|
ConfigValue::Bool(false),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let active = candidate.validate(&CAPABILITIES).unwrap();
|
let active = candidate.validate(&CAPABILITIES).unwrap();
|
||||||
assert_eq!(active.observability().maximum_log_level(), 3);
|
assert!(!active.mavlink_logging().enabled());
|
||||||
assert_eq!(active.observability().maximum_log_level(), 3);
|
|
||||||
candidate
|
candidate
|
||||||
.set_field(
|
.set_field(
|
||||||
descriptor_id("observability.maximum_log_level"),
|
descriptor_id("mavlink.logging.maximum_severity"),
|
||||||
ConfigValue::U32(5),
|
ConfigValue::U32(5),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -318,27 +375,27 @@ mod tests {
|
|||||||
candidate
|
candidate
|
||||||
.validate(&CAPABILITIES)
|
.validate(&CAPABILITIES)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.observability()
|
.mavlink_logging()
|
||||||
.maximum_log_level(),
|
.maximum_severity(),
|
||||||
5
|
Severity::Notice
|
||||||
);
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
candidate.set_field(
|
candidate.set_field(
|
||||||
descriptor_id("observability.maximum_log_level"),
|
descriptor_id("mavlink.logging.maximum_severity"),
|
||||||
ConfigValue::U32(2),
|
ConfigValue::U32(8),
|
||||||
),
|
),
|
||||||
Err(FieldWriteError::Violation(issue))
|
Err(FieldWriteError::Violation(issue))
|
||||||
if issue.rule() == ValidationRule::BelowMinimum
|
if issue.rule() == ValidationRule::InvalidEnumValue
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_persisted_log_severity_is_retained_exactly() {
|
fn every_persisted_log_severity_is_retained_exactly() {
|
||||||
for raw in [3, 4, 5, 6, 7] {
|
for raw in 0..=7 {
|
||||||
let mut candidate = CandidateProfile::default();
|
let mut candidate = CandidateProfile::factory();
|
||||||
candidate
|
candidate
|
||||||
.set_field(
|
.set_field(
|
||||||
descriptor_id("observability.maximum_log_level"),
|
descriptor_id("mavlink.logging.maximum_severity"),
|
||||||
ConfigValue::U32(raw),
|
ConfigValue::U32(raw),
|
||||||
)
|
)
|
||||||
.expect("descriptor range accepts the stable severity");
|
.expect("descriptor range accepts the stable severity");
|
||||||
@@ -346,9 +403,9 @@ mod tests {
|
|||||||
candidate
|
candidate
|
||||||
.validate(&CAPABILITIES)
|
.validate(&CAPABILITIES)
|
||||||
.expect("valid complete profile")
|
.expect("valid complete profile")
|
||||||
.observability()
|
.mavlink_logging()
|
||||||
.maximum_log_level(),
|
.maximum_severity(),
|
||||||
raw
|
Severity::try_from_wire(u8::try_from(raw).unwrap()).unwrap()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use kaptan_config::{ConfigValidate, ConfigViolation, ValidationRule};
|
use kaptan_config::{Config, ConfigViolation, ValidationRule};
|
||||||
|
|
||||||
use super::ids;
|
use super::ids;
|
||||||
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, Config)]
|
||||||
pub(super) struct AidingCandidate {
|
pub(super) struct AidingCandidate {
|
||||||
#[config(
|
#[config(
|
||||||
id = 6,
|
id = 6,
|
||||||
key = "aiding.position_variance_m2",
|
key = "aiding.position_variance_m2",
|
||||||
unit = "m2",
|
unit = "m2",
|
||||||
default = 0.04,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.000_001, max = 10_000.0)
|
range(min = 0.000_001, max = 10_000.0)
|
||||||
)]
|
)]
|
||||||
@@ -17,20 +16,18 @@ pub(super) struct AidingCandidate {
|
|||||||
id = 7,
|
id = 7,
|
||||||
key = "aiding.velocity_variance_m2ps2",
|
key = "aiding.velocity_variance_m2ps2",
|
||||||
unit = "m2/s2",
|
unit = "m2/s2",
|
||||||
default = 0.04,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.000_001, max = 10_000.0)
|
range(min = 0.000_001, max = 10_000.0)
|
||||||
)]
|
)]
|
||||||
pub(super) velocity_variance_m2_per_second_squared: f32,
|
pub(super) velocity_variance_m2_per_second_squared: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, Config)]
|
||||||
pub(super) struct ControlCandidate {
|
pub(super) struct ControlCandidate {
|
||||||
#[config(
|
#[config(
|
||||||
id = 8,
|
id = 8,
|
||||||
key = "control.position_gain_per_s2",
|
key = "control.position_gain_per_s2",
|
||||||
unit = "1/s2",
|
unit = "1/s2",
|
||||||
default = 0.8,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.0, max = 100.0)
|
range(min = 0.0, max = 100.0)
|
||||||
)]
|
)]
|
||||||
@@ -39,7 +36,6 @@ pub(super) struct ControlCandidate {
|
|||||||
id = 9,
|
id = 9,
|
||||||
key = "control.velocity_gain_per_s",
|
key = "control.velocity_gain_per_s",
|
||||||
unit = "1/s",
|
unit = "1/s",
|
||||||
default = 1.2,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.0, max = 100.0)
|
range(min = 0.0, max = 100.0)
|
||||||
)]
|
)]
|
||||||
@@ -48,7 +44,6 @@ pub(super) struct ControlCandidate {
|
|||||||
id = 10,
|
id = 10,
|
||||||
key = "control.maximum_horizontal_acceleration_mps2",
|
key = "control.maximum_horizontal_acceleration_mps2",
|
||||||
unit = "m/s2",
|
unit = "m/s2",
|
||||||
default = 3.0,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.001, max = 100.0)
|
range(min = 0.001, max = 100.0)
|
||||||
)]
|
)]
|
||||||
@@ -57,7 +52,6 @@ pub(super) struct ControlCandidate {
|
|||||||
id = 11,
|
id = 11,
|
||||||
key = "control.maximum_upward_acceleration_mps2",
|
key = "control.maximum_upward_acceleration_mps2",
|
||||||
unit = "m/s2",
|
unit = "m/s2",
|
||||||
default = 2.0,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.001, max = 100.0)
|
range(min = 0.001, max = 100.0)
|
||||||
)]
|
)]
|
||||||
@@ -66,20 +60,18 @@ pub(super) struct ControlCandidate {
|
|||||||
id = 12,
|
id = 12,
|
||||||
key = "control.maximum_downward_acceleration_mps2",
|
key = "control.maximum_downward_acceleration_mps2",
|
||||||
unit = "m/s2",
|
unit = "m/s2",
|
||||||
default = 2.0,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.001, max = 100.0)
|
range(min = 0.001, max = 100.0)
|
||||||
)]
|
)]
|
||||||
pub(super) maximum_downward_acceleration_mps2: f32,
|
pub(super) maximum_downward_acceleration_mps2: f32,
|
||||||
}
|
}
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, Config)]
|
||||||
#[config(post = Self::validate_thrust)]
|
#[config(post = Self::validate_thrust)]
|
||||||
pub(super) struct ThrustCandidate {
|
pub(super) struct ThrustCandidate {
|
||||||
#[config(
|
#[config(
|
||||||
id = 13,
|
id = 13,
|
||||||
key = "thrust.mass_kg",
|
key = "thrust.mass_kg",
|
||||||
unit = "kg",
|
unit = "kg",
|
||||||
default = 1.2,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.01, max = 100.0)
|
range(min = 0.01, max = 100.0)
|
||||||
)]
|
)]
|
||||||
@@ -88,7 +80,6 @@ pub(super) struct ThrustCandidate {
|
|||||||
id = 14,
|
id = 14,
|
||||||
key = "thrust.maximum_collective_thrust_n",
|
key = "thrust.maximum_collective_thrust_n",
|
||||||
unit = "N",
|
unit = "N",
|
||||||
default = 18.0,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.1, max = 10_000.0)
|
range(min = 0.1, max = 10_000.0)
|
||||||
)]
|
)]
|
||||||
@@ -97,7 +88,6 @@ pub(super) struct ThrustCandidate {
|
|||||||
id = 15,
|
id = 15,
|
||||||
key = "thrust.maximum_tilt_tangent",
|
key = "thrust.maximum_tilt_tangent",
|
||||||
unit = "",
|
unit = "",
|
||||||
default = 0.700_207_53,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.001, max = 10.0)
|
range(min = 0.001, max = 10.0)
|
||||||
)]
|
)]
|
||||||
@@ -116,14 +106,13 @@ impl ThrustCandidate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, Config)]
|
||||||
#[config(post = Self::validate_pid)]
|
#[config(post = Self::validate_pid)]
|
||||||
pub(super) struct PidCandidate {
|
pub(super) struct PidCandidate {
|
||||||
#[config(
|
#[config(
|
||||||
id = 16,
|
id = 16,
|
||||||
key = "control.rate_pid.proportional",
|
key = "control.rate_pid.proportional",
|
||||||
unit = "",
|
unit = "",
|
||||||
default = 0.035,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.0, max = 10.0)
|
range(min = 0.0, max = 10.0)
|
||||||
)]
|
)]
|
||||||
@@ -132,7 +121,6 @@ pub(super) struct PidCandidate {
|
|||||||
id = 17,
|
id = 17,
|
||||||
key = "control.rate_pid.integral",
|
key = "control.rate_pid.integral",
|
||||||
unit = "",
|
unit = "",
|
||||||
default = 0.006,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.0, max = 10.0)
|
range(min = 0.0, max = 10.0)
|
||||||
)]
|
)]
|
||||||
@@ -141,7 +129,6 @@ pub(super) struct PidCandidate {
|
|||||||
id = 18,
|
id = 18,
|
||||||
key = "control.rate_pid.derivative",
|
key = "control.rate_pid.derivative",
|
||||||
unit = "",
|
unit = "",
|
||||||
default = 0.0,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.0, max = 10.0)
|
range(min = 0.0, max = 10.0)
|
||||||
)]
|
)]
|
||||||
@@ -150,7 +137,6 @@ pub(super) struct PidCandidate {
|
|||||||
id = 19,
|
id = 19,
|
||||||
key = "control.rate_pid.output_limit",
|
key = "control.rate_pid.output_limit",
|
||||||
unit = "",
|
unit = "",
|
||||||
default = 0.10,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.001, max = 1.0)
|
range(min = 0.001, max = 1.0)
|
||||||
)]
|
)]
|
||||||
@@ -159,7 +145,6 @@ pub(super) struct PidCandidate {
|
|||||||
id = 20,
|
id = 20,
|
||||||
key = "control.rate_pid.integral_limit",
|
key = "control.rate_pid.integral_limit",
|
||||||
unit = "",
|
unit = "",
|
||||||
default = 0.025,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.0, max = 1.0)
|
range(min = 0.0, max = 1.0)
|
||||||
)]
|
)]
|
||||||
@@ -178,14 +163,13 @@ impl PidCandidate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, Config)]
|
||||||
#[config(post = Self::validate_actuator_limits)]
|
#[config(post = Self::validate_actuator_limits)]
|
||||||
pub(super) struct ActuatorLimitsCandidate {
|
pub(super) struct ActuatorLimitsCandidate {
|
||||||
#[config(
|
#[config(
|
||||||
id = 21,
|
id = 21,
|
||||||
key = "actuator.minimum",
|
key = "actuator.minimum",
|
||||||
unit = "",
|
unit = "",
|
||||||
default = 0.0,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.0, max = 1.0)
|
range(min = 0.0, max = 1.0)
|
||||||
)]
|
)]
|
||||||
@@ -194,7 +178,6 @@ pub(super) struct ActuatorLimitsCandidate {
|
|||||||
id = 22,
|
id = 22,
|
||||||
key = "actuator.maximum",
|
key = "actuator.maximum",
|
||||||
unit = "",
|
unit = "",
|
||||||
default = 1.0,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.0, max = 1.0)
|
range(min = 0.0, max = 1.0)
|
||||||
)]
|
)]
|
||||||
@@ -203,7 +186,6 @@ pub(super) struct ActuatorLimitsCandidate {
|
|||||||
id = 23,
|
id = 23,
|
||||||
key = "actuator.slew_per_second",
|
key = "actuator.slew_per_second",
|
||||||
unit = "1/s",
|
unit = "1/s",
|
||||||
default = 8.0,
|
|
||||||
finite,
|
finite,
|
||||||
range(min = 0.001, max = 100.0)
|
range(min = 0.001, max = 100.0)
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//! Persisted, restart-only MAVLink diagnostic-sink policy.
|
||||||
|
|
||||||
|
use kaptan_config::Config;
|
||||||
|
use kaptan_log::Severity;
|
||||||
|
|
||||||
|
/// Candidate policy for the MAVLink diagnostic sink.
|
||||||
|
#[derive(Clone, Copy, Config)]
|
||||||
|
pub(super) struct MavlinkLoggingCandidate {
|
||||||
|
#[config(id = 24, key = "mavlink.logging.enabled", unit = "bool")]
|
||||||
|
pub(super) enabled: bool,
|
||||||
|
#[config(
|
||||||
|
enum,
|
||||||
|
id = 25,
|
||||||
|
key = "mavlink.logging.maximum_severity",
|
||||||
|
unit = "mav_severity"
|
||||||
|
)]
|
||||||
|
pub(super) maximum_severity: Severity,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable boot-active policy for the MAVLink diagnostic sink.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MavlinkLoggingSettings {
|
||||||
|
enabled: bool,
|
||||||
|
maximum_severity: Severity,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MavlinkLoggingSettings {
|
||||||
|
pub(crate) const fn new(enabled: bool, maximum_severity: Severity) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled,
|
||||||
|
maximum_severity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the MAVLink diagnostic sink accepts records.
|
||||||
|
pub const fn enabled(self) -> bool {
|
||||||
|
self.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum accepted MAVLink severity, from Emergency (0) through Debug (7).
|
||||||
|
pub const fn maximum_severity(self) -> Severity {
|
||||||
|
self.maximum_severity
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,7 @@ mod active;
|
|||||||
mod candidate;
|
mod candidate;
|
||||||
mod control;
|
mod control;
|
||||||
mod ids;
|
mod ids;
|
||||||
mod observability;
|
mod mavlink_logging;
|
||||||
mod registry;
|
|
||||||
mod safety;
|
mod safety;
|
||||||
mod timing;
|
mod timing;
|
||||||
|
|
||||||
@@ -14,4 +13,4 @@ pub use active::{
|
|||||||
TimingSettings, VehicleCapabilities, VehicleProfile,
|
TimingSettings, VehicleCapabilities, VehicleProfile,
|
||||||
};
|
};
|
||||||
pub use candidate::{CandidateProfile, FORMAT_VERSION};
|
pub use candidate::{CandidateProfile, FORMAT_VERSION};
|
||||||
pub use observability::ObservabilitySettings;
|
pub use mavlink_logging::MavlinkLoggingSettings;
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
//! Persisted, restart-only runtime-observability policy.
|
|
||||||
|
|
||||||
use kaptan_config::ConfigValidate;
|
|
||||||
|
|
||||||
/// Candidate-only observability policy. It deliberately controls only optional
|
|
||||||
/// detail; error-level records are never suppressible configuration fields.
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
|
||||||
pub(super) struct ObservabilityCandidate {
|
|
||||||
#[config(
|
|
||||||
id = 24,
|
|
||||||
key = "observability.maximum_log_level",
|
|
||||||
unit = "mav_severity",
|
|
||||||
default = 6,
|
|
||||||
range(min = 3, max = 7)
|
|
||||||
)]
|
|
||||||
pub(super) maximum_log_level: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Immutable boot-active best-effort observability policy.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct ObservabilitySettings {
|
|
||||||
maximum_log_level: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ObservabilitySettings {
|
|
||||||
pub(crate) const fn new(maximum_log_level: u32) -> Self {
|
|
||||||
Self { maximum_log_level }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maximum best-effort MAVLink severity: Error (3) through Debug (7).
|
|
||||||
/// This never filters error-level records.
|
|
||||||
pub const fn maximum_log_level(self) -> u32 {
|
|
||||||
self.maximum_log_level
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
//! Aggregate descriptor registry for the generated vehicle sections.
|
|
||||||
//!
|
|
||||||
//! Every descriptor is generated from its owning scalar field. This ordered
|
|
||||||
//! projection is intentionally exhaustive: `FIELD_COUNT` comes from the root
|
|
||||||
//! aggregate, so adding an attributed field without extending this view fails
|
|
||||||
//! at compile time instead of silently omitting it from CBOR or transport.
|
|
||||||
|
|
||||||
use kaptan_config::FieldDescriptor;
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
candidate::CandidateProfile,
|
|
||||||
control::{
|
|
||||||
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
|
|
||||||
},
|
|
||||||
observability::ObservabilityCandidate,
|
|
||||||
safety::SafetyCandidate,
|
|
||||||
timing::TimingCandidate,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Number of operator-editable fields in the current vehicle profile.
|
|
||||||
///
|
|
||||||
/// Allocation topology remains immutable board capability data, not an
|
|
||||||
/// operator parameter.
|
|
||||||
pub(super) const FIELD_COUNT: usize = CandidateProfile::FIELD_COUNT;
|
|
||||||
|
|
||||||
/// Ordered view of metadata generated from field attributes.
|
|
||||||
///
|
|
||||||
/// This has no independently-maintained defaults, bounds, values, or setter
|
|
||||||
/// logic. The length is tied to `CandidateProfile::FIELD_COUNT` as a
|
|
||||||
/// compile-time completeness guard.
|
|
||||||
const FIELDS: [FieldDescriptor; FIELD_COUNT] = [
|
|
||||||
SafetyCandidate::DESCRIPTORS[0],
|
|
||||||
TimingCandidate::DESCRIPTORS[0],
|
|
||||||
TimingCandidate::DESCRIPTORS[1],
|
|
||||||
TimingCandidate::DESCRIPTORS[2],
|
|
||||||
TimingCandidate::DESCRIPTORS[3],
|
|
||||||
AidingCandidate::DESCRIPTORS[0],
|
|
||||||
AidingCandidate::DESCRIPTORS[1],
|
|
||||||
ControlCandidate::DESCRIPTORS[0],
|
|
||||||
ControlCandidate::DESCRIPTORS[1],
|
|
||||||
ControlCandidate::DESCRIPTORS[2],
|
|
||||||
ControlCandidate::DESCRIPTORS[3],
|
|
||||||
ControlCandidate::DESCRIPTORS[4],
|
|
||||||
ThrustCandidate::DESCRIPTORS[0],
|
|
||||||
ThrustCandidate::DESCRIPTORS[1],
|
|
||||||
ThrustCandidate::DESCRIPTORS[2],
|
|
||||||
PidCandidate::DESCRIPTORS[0],
|
|
||||||
PidCandidate::DESCRIPTORS[1],
|
|
||||||
PidCandidate::DESCRIPTORS[2],
|
|
||||||
PidCandidate::DESCRIPTORS[3],
|
|
||||||
PidCandidate::DESCRIPTORS[4],
|
|
||||||
ActuatorLimitsCandidate::DESCRIPTORS[0],
|
|
||||||
ActuatorLimitsCandidate::DESCRIPTORS[1],
|
|
||||||
ActuatorLimitsCandidate::DESCRIPTORS[2],
|
|
||||||
ObservabilityCandidate::DESCRIPTORS[0],
|
|
||||||
];
|
|
||||||
|
|
||||||
const _: () = assert_unique_ids(&FIELDS);
|
|
||||||
|
|
||||||
const fn assert_unique_ids(fields: &[FieldDescriptor]) {
|
|
||||||
let mut index = 0;
|
|
||||||
while index < fields.len() {
|
|
||||||
let mut other = index + 1;
|
|
||||||
while other < fields.len() {
|
|
||||||
if fields[index].id.get() == fields[other].id.get() {
|
|
||||||
panic!("duplicate field id in the aggregated vehicle registry");
|
|
||||||
}
|
|
||||||
other += 1;
|
|
||||||
}
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) const fn descriptors() -> &'static [FieldDescriptor; FIELD_COUNT] {
|
|
||||||
&FIELDS
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
|
|
||||||
FIELDS.get(usize::from(index))
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
use kaptan_config::ConfigValidate;
|
use kaptan_config::Config;
|
||||||
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, Config)]
|
||||||
pub(super) struct SafetyCandidate {
|
pub(super) struct SafetyCandidate {
|
||||||
#[config(
|
#[config(
|
||||||
id = 1,
|
id = 1,
|
||||||
key = "safety.prearm_dwell_us",
|
key = "safety.prearm_dwell_us",
|
||||||
unit = "us",
|
unit = "us",
|
||||||
default = 100_000,
|
|
||||||
nonzero,
|
nonzero,
|
||||||
range(min = 1, max = 10_000_000)
|
range(min = 1, max = 10_000_000)
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
use kaptan_config::{ConfigValidate, ConfigViolation, ValidationRule};
|
use kaptan_config::{Config, ConfigViolation, ValidationRule};
|
||||||
|
|
||||||
use super::ids;
|
use super::ids;
|
||||||
|
|
||||||
#[derive(Clone, Copy, ConfigValidate)]
|
#[derive(Clone, Copy, Config)]
|
||||||
#[config(post = Self::validate_timing)]
|
#[config(post = Self::validate_timing)]
|
||||||
pub(super) struct TimingCandidate {
|
pub(super) struct TimingCandidate {
|
||||||
#[config(
|
#[config(
|
||||||
id = 2,
|
id = 2,
|
||||||
key = "timing.imu_max_interval_us",
|
key = "timing.imu_max_interval_us",
|
||||||
unit = "us",
|
unit = "us",
|
||||||
default = 50_000,
|
|
||||||
nonzero,
|
nonzero,
|
||||||
range(min = 1, max = 1_000_000)
|
range(min = 1, max = 1_000_000)
|
||||||
)]
|
)]
|
||||||
@@ -18,7 +17,6 @@ pub(super) struct TimingCandidate {
|
|||||||
id = 3,
|
id = 3,
|
||||||
key = "timing.imu_freshness_us",
|
key = "timing.imu_freshness_us",
|
||||||
unit = "us",
|
unit = "us",
|
||||||
default = 50_000,
|
|
||||||
nonzero,
|
nonzero,
|
||||||
range(min = 1, max = 1_000_000)
|
range(min = 1, max = 1_000_000)
|
||||||
)]
|
)]
|
||||||
@@ -27,7 +25,6 @@ pub(super) struct TimingCandidate {
|
|||||||
id = 4,
|
id = 4,
|
||||||
key = "timing.local_state_freshness_us",
|
key = "timing.local_state_freshness_us",
|
||||||
unit = "us",
|
unit = "us",
|
||||||
default = 150_000,
|
|
||||||
nonzero,
|
nonzero,
|
||||||
range(min = 1, max = 10_000_000)
|
range(min = 1, max = 10_000_000)
|
||||||
)]
|
)]
|
||||||
@@ -36,7 +33,6 @@ pub(super) struct TimingCandidate {
|
|||||||
id = 5,
|
id = 5,
|
||||||
key = "timing.link_freshness_us",
|
key = "timing.link_freshness_us",
|
||||||
unit = "us",
|
unit = "us",
|
||||||
default = 500_000,
|
|
||||||
nonzero,
|
nonzero,
|
||||||
range(min = 1, max = 10_000_000)
|
range(min = 1, max = 10_000_000)
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -1,45 +1,48 @@
|
|||||||
//! Central, transport-neutral flight-runtime composition.
|
//! Central, transport-neutral flight-runtime composition.
|
||||||
//!
|
//!
|
||||||
//! This crate owns the one configuration manager and one bounded log hub for
|
//! This crate owns the one configuration manager and the bounded queue for the
|
||||||
//! an image. MAVLink adapters receive mutable borrows for bounded dispatch or
|
//! image's MAVLink diagnostic sink. MAVLink adapters receive mutable borrows
|
||||||
//! draining, but neither can own configuration lifecycle or link state.
|
//! for bounded dispatch or draining, but cannot own configuration lifecycle or
|
||||||
|
//! link state.
|
||||||
|
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
use kaptan_config::{ConfigManager, ProfileStore, StartupError};
|
use kaptan_config::{ConfigManager, ConfigStore, StartupError};
|
||||||
use kaptan_flight_config::{CandidateProfile, VehicleCapabilities};
|
use kaptan_flight_config::{CandidateProfile, VehicleCapabilities};
|
||||||
use kaptan_log::{EventHub, LogFlags, LogRecord, LogText, PublishOutcome, Severity};
|
use kaptan_log::{LogRecord, LogSinkQueue};
|
||||||
|
|
||||||
/// Default bounded allocation for central runtime text diagnostics. Error
|
/// Default bounded allocation for the MAVLink diagnostic sink. Error records
|
||||||
/// records are partitioned from optional detail.
|
/// are partitioned from optional detail.
|
||||||
pub type RuntimeLogHub = EventHub<8, 16>;
|
pub type RuntimeMavlinkLogQueue = LogSinkQueue<8, 16>;
|
||||||
|
|
||||||
/// One boot-active vehicle runtime. It deliberately has no MAVLink dependency.
|
/// One boot-active vehicle runtime. It deliberately has no MAVLink dependency.
|
||||||
pub struct FlightRuntime<S> {
|
pub struct FlightRuntime<S> {
|
||||||
configuration: ConfigManager<CandidateProfile, S>,
|
configuration: ConfigManager<CandidateProfile, S>,
|
||||||
logs: RuntimeLogHub,
|
mavlink_logs: RuntimeMavlinkLogQueue,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S> FlightRuntime<S>
|
impl<S> FlightRuntime<S>
|
||||||
where
|
where
|
||||||
S: ProfileStore<CandidateProfile>,
|
S: ConfigStore<CandidateProfile>,
|
||||||
{
|
{
|
||||||
/// Recovers the central manager, validates its boot-active snapshot, and
|
/// Recovers the central manager, validates its boot-active snapshot, and
|
||||||
/// copies the restart-only observability policy into the bounded hub.
|
/// copies the restart-only MAVLink sink policy into its bounded queue.
|
||||||
pub fn recover(
|
pub fn recover(
|
||||||
|
factory: CandidateProfile,
|
||||||
capabilities: VehicleCapabilities,
|
capabilities: VehicleCapabilities,
|
||||||
store: S,
|
store: S,
|
||||||
) -> Result<Self, StartupError<S::Error>> {
|
) -> Result<Self, StartupError<S::Error>> {
|
||||||
let configuration =
|
let configuration = ConfigManager::recover(factory, capabilities, store)?;
|
||||||
ConfigManager::recover(CandidateProfile::default(), capabilities, store)?;
|
let logging = configuration.active().mavlink_logging();
|
||||||
let maximum_log_level = configuration.active().observability().maximum_log_level();
|
let filter = if logging.enabled() {
|
||||||
let logs = RuntimeLogHub::new(kaptan_log::LogFilter::new(
|
kaptan_log::LogFilter::up_to(logging.maximum_severity())
|
||||||
Severity::try_from_wire(maximum_log_level as u8)
|
} else {
|
||||||
.expect("validated flight profile has a MAVLink log severity"),
|
kaptan_log::LogFilter::disabled()
|
||||||
));
|
};
|
||||||
|
let mavlink_logs = RuntimeMavlinkLogQueue::new(filter);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
configuration,
|
configuration,
|
||||||
logs,
|
mavlink_logs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,82 +51,108 @@ where
|
|||||||
&self.configuration
|
&self.configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Borrows the manager and hub together for a state-only protocol adapter.
|
/// Mutably borrows the sole configuration manager for protocol dispatch.
|
||||||
/// This makes adapter ownership inversion unrepresentable in composition.
|
pub fn configuration_mut(&mut self) -> &mut ConfigManager<CandidateProfile, S> {
|
||||||
pub fn configuration_and_logs_mut(
|
&mut self.configuration
|
||||||
&mut self,
|
|
||||||
) -> (&mut ConfigManager<CandidateProfile, S>, &mut RuntimeLogHub) {
|
|
||||||
(&mut self.configuration, &mut self.logs)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes one preformatted bounded task record through the sole hub.
|
/// Composition-owned fan-out boundary for one preformatted event.
|
||||||
pub fn log(&mut self, severity: Severity, flags: LogFlags, message: LogText) -> PublishOutcome {
|
/// A future blackbox adds its independent sink admission here.
|
||||||
self.logs.publish_text(severity, flags, message)
|
pub fn publish_log_event(&mut self, record: LogRecord) {
|
||||||
|
let _ = self.mavlink_logs.publish_record(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Peeks the highest-priority pending text log for the sole telemetry link.
|
/// Peeks the highest-priority pending text log for the sole telemetry link.
|
||||||
pub fn pending_report(&self) -> Option<LogRecord> {
|
pub fn pending_mavlink_report(&self) -> Option<LogRecord> {
|
||||||
self.logs.peek()
|
self.mavlink_logs.peek()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Submits the next record and removes it only after transport admission.
|
/// Submits the next record and removes it only after transport admission.
|
||||||
pub fn submit_next<R>(
|
pub fn submit_next_mavlink<R>(
|
||||||
&mut self,
|
&mut self,
|
||||||
submit: impl FnOnce(&LogRecord) -> (kaptan_log::TransportAdmission, R),
|
submit: impl FnOnce(&LogRecord) -> (kaptan_log::TransportAdmission, R),
|
||||||
) -> Option<R> {
|
) -> Option<R> {
|
||||||
self.logs.submit_next(submit)
|
self.mavlink_logs.submit_next(submit)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Immutable boot-active log policy copied from the validated profile.
|
/// Immutable boot-active MAVLink log policy copied from the validated profile.
|
||||||
pub const fn log_filter(&self) -> kaptan_log::LogFilter {
|
pub const fn mavlink_log_filter(&self) -> kaptan_log::LogFilter {
|
||||||
self.logs.filter()
|
self.mavlink_logs.filter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use kaptan_config::{Generation, ProfileStore};
|
use kaptan_config::{ConfigModel, ConfigStore, ConfigValue};
|
||||||
use kaptan_log::{LogFlags, LogText, Severity};
|
use kaptan_log::{LogFlags, LogRecord, LogText, Severity};
|
||||||
|
|
||||||
use super::FlightRuntime;
|
use super::FlightRuntime;
|
||||||
|
|
||||||
struct Store;
|
struct Store;
|
||||||
impl ProfileStore<kaptan_flight_config::CandidateProfile> for Store {
|
impl ConfigStore<kaptan_flight_config::CandidateProfile> for Store {
|
||||||
type Error = ();
|
type Error = ();
|
||||||
|
|
||||||
fn load(
|
fn load(&mut self) -> Result<Option<kaptan_flight_config::CandidateProfile>, Self::Error> {
|
||||||
&mut self,
|
|
||||||
) -> Result<Option<(Generation, kaptan_flight_config::CandidateProfile)>, Self::Error>
|
|
||||||
{
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persist(
|
fn persist(
|
||||||
&mut self,
|
&mut self,
|
||||||
_: &kaptan_flight_config::CandidateProfile,
|
_: &kaptan_flight_config::CandidateProfile,
|
||||||
) -> Result<Generation, Self::Error> {
|
) -> Result<(), Self::Error> {
|
||||||
Ok(Generation::new(1))
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn central_runtime_uses_the_active_profile_filter_and_owns_one_hub() {
|
fn runtime_uses_the_active_mavlink_policy_and_owns_one_sink_queue() {
|
||||||
let mut runtime =
|
let mut runtime = FlightRuntime::recover(
|
||||||
FlightRuntime::recover(kaptan_flight_config::VehicleCapabilities::quad_x(), Store)
|
kaptan_flight_config::CandidateProfile::factory(),
|
||||||
.expect("factory profile validates");
|
kaptan_flight_config::VehicleCapabilities::quad_x(),
|
||||||
|
Store,
|
||||||
|
)
|
||||||
|
.expect("factory configuration validates");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
runtime
|
runtime
|
||||||
.configuration()
|
.configuration()
|
||||||
.active()
|
.active()
|
||||||
.observability()
|
.mavlink_logging()
|
||||||
.maximum_log_level(),
|
.maximum_severity(),
|
||||||
runtime.log_filter().maximum_severity() as u32
|
runtime
|
||||||
|
.mavlink_log_filter()
|
||||||
|
.maximum_severity()
|
||||||
|
.expect("factory enables MAVLink logs")
|
||||||
);
|
);
|
||||||
let _ = runtime.log(
|
runtime.publish_log_event(LogRecord::new(
|
||||||
Severity::Info,
|
Severity::Info,
|
||||||
LogFlags::NONE,
|
LogFlags::NONE,
|
||||||
LogText::from_message("runtime ready"),
|
LogText::from_message("runtime ready"),
|
||||||
);
|
));
|
||||||
assert!(runtime.pending_report().is_some());
|
assert!(runtime.pending_mavlink_report().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_mavlink_sink_filters_every_severity() {
|
||||||
|
let mut factory = kaptan_flight_config::CandidateProfile::factory();
|
||||||
|
let enabled = kaptan_flight_config::CandidateProfile::descriptors()
|
||||||
|
.find(|descriptor| descriptor.display_key == "mavlink.logging.enabled")
|
||||||
|
.expect("MAVLink logging enable descriptor");
|
||||||
|
factory
|
||||||
|
.set_field(enabled.id, ConfigValue::Bool(false))
|
||||||
|
.unwrap();
|
||||||
|
let mut runtime = FlightRuntime::recover(
|
||||||
|
factory,
|
||||||
|
kaptan_flight_config::VehicleCapabilities::quad_x(),
|
||||||
|
Store,
|
||||||
|
)
|
||||||
|
.expect("disabled MAVLink sink is valid");
|
||||||
|
|
||||||
|
assert_eq!(runtime.mavlink_log_filter().maximum_severity(), None);
|
||||||
|
runtime.publish_log_event(LogRecord::new(
|
||||||
|
Severity::Error,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("not routed to MAVLink"),
|
||||||
|
));
|
||||||
|
assert!(runtime.pending_mavlink_report().is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,18 +9,21 @@
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
extern crate std;
|
extern crate std;
|
||||||
|
|
||||||
use kaptan_log::{LogRecord, LogText, RecordChannel, ShutdownReason, ShutdownReport};
|
use kaptan_log::{
|
||||||
|
LogRecord, MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES, MAXIMUM_ERROR_PATH_BYTES,
|
||||||
|
MAXIMUM_LOG_TEXT_BYTES, RecordChannel, ShutdownReason, ShutdownReport,
|
||||||
|
};
|
||||||
use kaptan_mavlink::message::kaptan::{
|
use kaptan_mavlink::message::kaptan::{
|
||||||
KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KAPTAN_SHUTDOWN_DATA, KaptanErrorFlags, KaptanLogFlags,
|
KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KAPTAN_SHUTDOWN_DATA, KaptanErrorFlags, KaptanLogFlags,
|
||||||
KaptanShutdownReason,
|
KaptanShutdownReason,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Exact capacity of the fixed text field in [`KAPTAN_LOG_DATA`].
|
/// Exact capacity of the fixed text field in [`KAPTAN_LOG_DATA`].
|
||||||
pub const LOG_MESSAGE_BYTES: usize = 160;
|
pub const LOG_MESSAGE_BYTES: usize = MAXIMUM_LOG_TEXT_BYTES;
|
||||||
/// Exact capacity of the fixed error identity path field.
|
/// Exact capacity of the fixed error identity path field.
|
||||||
pub const ERROR_PATH_BYTES: usize = 96;
|
pub const ERROR_PATH_BYTES: usize = MAXIMUM_ERROR_PATH_BYTES;
|
||||||
/// Exact capacity of the fixed error additional-information field.
|
/// Exact capacity of the fixed error additional-information field.
|
||||||
pub const ERROR_ADDITIONAL_INFORMATION_BYTES: usize = 64;
|
pub const ERROR_ADDITIONAL_INFORMATION_BYTES: usize = MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES;
|
||||||
|
|
||||||
/// One generated diagnostic payload. A platform-owned link remains the sole
|
/// One generated diagnostic payload. A platform-owned link remains the sole
|
||||||
/// MAVLink framing, admission, DMA, and retry owner.
|
/// MAVLink framing, admission, DMA, and retry owner.
|
||||||
@@ -45,8 +48,8 @@ pub fn encode_diagnostic(record: &LogRecord) -> DiagnosticPayload {
|
|||||||
.expect("log channel must contain ordinary message text");
|
.expect("log channel must contain ordinary message text");
|
||||||
debug_assert!(usize::from(message.len()) <= LOG_MESSAGE_BYTES);
|
debug_assert!(usize::from(message.len()) <= LOG_MESSAGE_BYTES);
|
||||||
DiagnosticPayload::Log(KAPTAN_LOG_DATA {
|
DiagnosticPayload::Log(KAPTAN_LOG_DATA {
|
||||||
level: record.severity() as u8,
|
level: record.severity().wire_value(),
|
||||||
flags: KaptanLogFlags::from_bits_retain(record.flags().bits()),
|
flags: KaptanLogFlags::from_bits_retain(record.flags().log_bits()),
|
||||||
message_length: message.len(),
|
message_length: message.len(),
|
||||||
message: *message.bytes(),
|
message: *message.bytes(),
|
||||||
})
|
})
|
||||||
@@ -58,47 +61,29 @@ pub fn encode_diagnostic(record: &LogRecord) -> DiagnosticPayload {
|
|||||||
let additional_information = record
|
let additional_information = record
|
||||||
.additional_information()
|
.additional_information()
|
||||||
.expect("error channel must contain additional-information storage");
|
.expect("error channel must contain additional-information storage");
|
||||||
let (path, path_length, path_clipped) = project_text::<ERROR_PATH_BYTES>(path);
|
debug_assert_eq!(path.bytes().len(), ERROR_PATH_BYTES);
|
||||||
let (additional_information, additional_information_length, information_clipped) =
|
debug_assert_eq!(
|
||||||
project_text::<ERROR_ADDITIONAL_INFORMATION_BYTES>(additional_information);
|
additional_information.bytes().len(),
|
||||||
let mut flags = record.flags().error_bits();
|
ERROR_ADDITIONAL_INFORMATION_BYTES
|
||||||
if path_clipped || information_clipped {
|
);
|
||||||
flags |= KaptanErrorFlags::KAPTAN_ERROR_FLAG_TRUNCATED.bits();
|
|
||||||
}
|
|
||||||
DiagnosticPayload::Error(KAPTAN_ERROR_DATA {
|
DiagnosticPayload::Error(KAPTAN_ERROR_DATA {
|
||||||
flags: KaptanErrorFlags::from_bits_retain(flags),
|
level: record.severity().wire_value(),
|
||||||
path_length,
|
flags: KaptanErrorFlags::from_bits_retain(record.flags().error_bits()),
|
||||||
additional_information_length,
|
path_length: path.len(),
|
||||||
path,
|
additional_information_length: additional_information.len(),
|
||||||
additional_information,
|
path: *path.bytes(),
|
||||||
|
additional_information: *additional_information.bytes(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project_text<const N: usize>(text: &LogText) -> ([u8; N], u8, bool) {
|
|
||||||
let source = text.as_str();
|
|
||||||
let retained = source
|
|
||||||
.char_indices()
|
|
||||||
.take_while(|(index, character)| index + character.len_utf8() <= N)
|
|
||||||
.map(|(index, character)| index + character.len_utf8())
|
|
||||||
.last()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let mut bytes = [0; N];
|
|
||||||
bytes[..retained].copy_from_slice(&source.as_bytes()[..retained]);
|
|
||||||
(
|
|
||||||
bytes,
|
|
||||||
retained as u8,
|
|
||||||
text.is_truncated() || retained != source.len(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes one typed shutdown event. The caller owns when a shutdown is safe
|
/// Encodes one typed shutdown event. The caller owns when a shutdown is safe
|
||||||
/// to report; this pure adapter cannot run a shutdown state machine.
|
/// to report; this pure adapter cannot run a shutdown state machine.
|
||||||
pub const fn encode_shutdown(report: ShutdownReport) -> KAPTAN_SHUTDOWN_DATA {
|
pub const fn encode_shutdown(report: ShutdownReport) -> KAPTAN_SHUTDOWN_DATA {
|
||||||
KAPTAN_SHUTDOWN_DATA {
|
KAPTAN_SHUTDOWN_DATA {
|
||||||
reason: match report.reason() {
|
reason: match report.reason() {
|
||||||
ShutdownReason::Operator => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_OPERATOR,
|
ShutdownReason::User => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_USER,
|
||||||
ShutdownReason::Safety => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_SAFETY,
|
ShutdownReason::Safety => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_SAFETY,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -113,8 +98,8 @@ mod tests {
|
|||||||
encode_diagnostic, encode_shutdown,
|
encode_diagnostic, encode_shutdown,
|
||||||
};
|
};
|
||||||
use kaptan_log::{
|
use kaptan_log::{
|
||||||
ErrorAdditionalInformation, ErrorPath, EventHub, FacadeIngress, GlobalLogger, LogFilter,
|
ErrorAdditionalInformation, ErrorPath, FacadeIngress, GlobalLogger, LogFlags, LogRecord,
|
||||||
LogFlags, LogRecord, LogText, Severity, ShutdownReason, ShutdownReport,
|
LogText, Severity, ShutdownReason, ShutdownReport,
|
||||||
};
|
};
|
||||||
use kaptan_mavlink::{
|
use kaptan_mavlink::{
|
||||||
encoder::MavlinkEncoder,
|
encoder::MavlinkEncoder,
|
||||||
@@ -124,7 +109,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
static ERROR_INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info));
|
static ERROR_INGRESS: FacadeIngress<1> = FacadeIngress::new();
|
||||||
static ERROR_LOGGER: GlobalLogger<1> = GlobalLogger::new(&ERROR_INGRESS, never_forbidden);
|
static ERROR_LOGGER: GlobalLogger<1> = GlobalLogger::new(&ERROR_INGRESS, never_forbidden);
|
||||||
static INSTALL_ERROR_LOGGER: Once = Once::new();
|
static INSTALL_ERROR_LOGGER: Once = Once::new();
|
||||||
|
|
||||||
@@ -136,13 +121,13 @@ mod tests {
|
|||||||
fn ordinary_record_preserves_level_flags_and_bounded_text() {
|
fn ordinary_record_preserves_level_flags_and_bounded_text() {
|
||||||
let record = LogRecord::new(
|
let record = LogRecord::new(
|
||||||
Severity::Info,
|
Severity::Info,
|
||||||
LogFlags::TRUNCATED,
|
LogFlags::TRUNCATED.with(LogFlags::COALESCED),
|
||||||
LogText::from_message("imu unavailable"),
|
LogText::from_message("imu unavailable"),
|
||||||
);
|
);
|
||||||
let DiagnosticPayload::Log(data) = encode_diagnostic(&record) else {
|
let DiagnosticPayload::Log(data) = encode_diagnostic(&record) else {
|
||||||
panic!("info record must use KAPTAN_LOG");
|
panic!("info record must use KAPTAN_LOG");
|
||||||
};
|
};
|
||||||
assert_eq!(data.level, Severity::Info as u8);
|
assert_eq!(data.level, Severity::Info.wire_value());
|
||||||
assert_eq!(data.message_length, 15);
|
assert_eq!(data.message_length, 15);
|
||||||
assert_eq!(&data.message[..15], b"imu unavailable");
|
assert_eq!(&data.message[..15], b"imu unavailable");
|
||||||
assert!(data.message[15..].iter().all(|byte| *byte == 0));
|
assert!(data.message[15..].iter().all(|byte| *byte == 0));
|
||||||
@@ -161,9 +146,9 @@ mod tests {
|
|||||||
kaptan_log::log::info!("optional ingress is occupied");
|
kaptan_log::log::info!("optional ingress is occupied");
|
||||||
kaptan_log::log::error!("critical error survives");
|
kaptan_log::log::error!("critical error survives");
|
||||||
|
|
||||||
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
let record = ERROR_INGRESS
|
||||||
assert!(ERROR_INGRESS.drain_one_into(&mut hub).is_some());
|
.take()
|
||||||
let record = hub.peek().expect("critical macro record retained");
|
.expect("critical macro record retained");
|
||||||
let DiagnosticPayload::Error(payload) = encode_diagnostic(&record) else {
|
let DiagnosticPayload::Error(payload) = encode_diagnostic(&record) else {
|
||||||
panic!("log::error! must not produce KAPTAN_LOG");
|
panic!("log::error! must not produce KAPTAN_LOG");
|
||||||
};
|
};
|
||||||
@@ -211,6 +196,7 @@ mod tests {
|
|||||||
panic!("error record must use KAPTAN_ERROR");
|
panic!("error record must use KAPTAN_ERROR");
|
||||||
};
|
};
|
||||||
assert_eq!(error_data.flags.bits(), LogFlags::COALESCED.bits());
|
assert_eq!(error_data.flags.bits(), LogFlags::COALESCED.bits());
|
||||||
|
assert_eq!(error_data.level, Severity::Error.wire_value());
|
||||||
assert_eq!(error_data.path_length, 23);
|
assert_eq!(error_data.path_length, 23);
|
||||||
assert_eq!(error_data.additional_information_length, 0);
|
assert_eq!(error_data.additional_information_length, 0);
|
||||||
assert_eq!(&error_data.path[..23], b"driver::Error::Transfer");
|
assert_eq!(&error_data.path[..23], b"driver::Error::Transfer");
|
||||||
@@ -220,10 +206,10 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.all(|byte| *byte == 0)
|
.all(|byte| *byte == 0)
|
||||||
);
|
);
|
||||||
assert_eq!(KAPTAN_ERROR_DATA::ENCODED_LEN, 163);
|
assert_eq!(KAPTAN_ERROR_DATA::ENCODED_LEN, 164);
|
||||||
let error_frame = encoder.custom(&error_data);
|
let error_frame = encoder.custom(&error_data);
|
||||||
assert_eq!(error_frame.message_id(), 51_021);
|
assert_eq!(error_frame.message_id(), 51_021);
|
||||||
assert!(error_frame.raw_bytes().len() <= 175);
|
assert!(error_frame.raw_bytes().len() <= 176);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
MavMessage::parse(
|
MavMessage::parse(
|
||||||
mavlink::MavlinkVersion::V2,
|
mavlink::MavlinkVersion::V2,
|
||||||
@@ -235,7 +221,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn error_wire_projection_clips_each_component_independently() {
|
fn error_record_bounds_each_component_before_wire_projection() {
|
||||||
let error = LogRecord::error(
|
let error = LogRecord::error(
|
||||||
LogFlags::NONE,
|
LogFlags::NONE,
|
||||||
ErrorPath::from_message(&"p".repeat(LOG_MESSAGE_BYTES)),
|
ErrorPath::from_message(&"p".repeat(LOG_MESSAGE_BYTES)),
|
||||||
@@ -257,6 +243,41 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_error_constructor_preserves_narrowing_as_truncation() {
|
||||||
|
let error = LogRecord::new(
|
||||||
|
Severity::Error,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message(&"p".repeat(ERROR_PATH_BYTES + 1)),
|
||||||
|
);
|
||||||
|
let DiagnosticPayload::Error(payload) = encode_diagnostic(&error) else {
|
||||||
|
panic!("error record must use KAPTAN_ERROR");
|
||||||
|
};
|
||||||
|
assert_eq!(usize::from(payload.path_length), ERROR_PATH_BYTES);
|
||||||
|
assert_eq!(&payload.path, &[b'p'; ERROR_PATH_BYTES]);
|
||||||
|
assert_eq!(payload.flags.bits(), LogFlags::TRUNCATED.bits());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_wire_preserves_every_error_severity() {
|
||||||
|
for severity in [
|
||||||
|
Severity::Emergency,
|
||||||
|
Severity::Alert,
|
||||||
|
Severity::Critical,
|
||||||
|
Severity::Error,
|
||||||
|
] {
|
||||||
|
let error = LogRecord::new(
|
||||||
|
severity,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("flight fault"),
|
||||||
|
);
|
||||||
|
let DiagnosticPayload::Error(payload) = encode_diagnostic(&error) else {
|
||||||
|
panic!("error severity must use KAPTAN_ERROR");
|
||||||
|
};
|
||||||
|
assert_eq!(payload.level, severity.wire_value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn error_path_remains_decodable_when_only_additional_information_clips() {
|
fn error_path_remains_decodable_when_only_additional_information_clips() {
|
||||||
let error = LogRecord::error(
|
let error = LogRecord::error(
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ default = []
|
|||||||
facade = ["dep:log"]
|
facade = ["dep:log"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
kaptan-config-derive = { path = "../kaptan-config-derive" }
|
||||||
kaptan-log-derive = { path = "../kaptan-log-derive" }
|
kaptan-log-derive = { path = "../kaptan-log-derive" }
|
||||||
log = { version = "0.4.33", default-features = false, optional = true }
|
log = { version = "0.4.33", default-features = false, optional = true }
|
||||||
@@ -6,15 +6,12 @@
|
|||||||
|
|
||||||
use core::{
|
use core::{
|
||||||
cell::UnsafeCell,
|
cell::UnsafeCell,
|
||||||
sync::atomic::{AtomicBool, AtomicU8, Ordering},
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
};
|
};
|
||||||
|
|
||||||
use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError};
|
use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError};
|
||||||
|
|
||||||
use crate::{
|
use crate::{ErrorAdditionalInformation, ErrorPath, LogFlags, LogRecord, LogText, Severity};
|
||||||
ErrorAdditionalInformation, ErrorPath, EventHub, LogFilter, LogFlags, LogText, PublishOutcome,
|
|
||||||
Severity,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Saturating, process-local accounting for the optional global facade.
|
/// Saturating, process-local accounting for the optional global facade.
|
||||||
///
|
///
|
||||||
@@ -24,8 +21,6 @@ use crate::{
|
|||||||
pub struct FacadeHealth {
|
pub struct FacadeHealth {
|
||||||
/// Calls rejected before formatting because their context is forbidden.
|
/// Calls rejected before formatting because their context is forbidden.
|
||||||
pub forbidden_context: u32,
|
pub forbidden_context: u32,
|
||||||
/// Optional records excluded by the active verbosity cap.
|
|
||||||
pub filtered: u32,
|
|
||||||
/// Calls that lost the one-attempt ingress ownership race.
|
/// Calls that lost the one-attempt ingress ownership race.
|
||||||
pub contended: u32,
|
pub contended: u32,
|
||||||
/// Calls dropped because their bounded ingress lane was full.
|
/// Calls dropped because their bounded ingress lane was full.
|
||||||
@@ -36,7 +31,6 @@ pub struct FacadeHealth {
|
|||||||
|
|
||||||
struct FacadeCounters {
|
struct FacadeCounters {
|
||||||
forbidden_context: core::sync::atomic::AtomicU32,
|
forbidden_context: core::sync::atomic::AtomicU32,
|
||||||
filtered: core::sync::atomic::AtomicU32,
|
|
||||||
contended: core::sync::atomic::AtomicU32,
|
contended: core::sync::atomic::AtomicU32,
|
||||||
full: core::sync::atomic::AtomicU32,
|
full: core::sync::atomic::AtomicU32,
|
||||||
truncated: core::sync::atomic::AtomicU32,
|
truncated: core::sync::atomic::AtomicU32,
|
||||||
@@ -46,7 +40,6 @@ impl FacadeCounters {
|
|||||||
const fn new() -> Self {
|
const fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
forbidden_context: core::sync::atomic::AtomicU32::new(0),
|
forbidden_context: core::sync::atomic::AtomicU32::new(0),
|
||||||
filtered: core::sync::atomic::AtomicU32::new(0),
|
|
||||||
contended: core::sync::atomic::AtomicU32::new(0),
|
contended: core::sync::atomic::AtomicU32::new(0),
|
||||||
full: core::sync::atomic::AtomicU32::new(0),
|
full: core::sync::atomic::AtomicU32::new(0),
|
||||||
truncated: core::sync::atomic::AtomicU32::new(0),
|
truncated: core::sync::atomic::AtomicU32::new(0),
|
||||||
@@ -71,7 +64,6 @@ impl FacadeCounters {
|
|||||||
fn snapshot(&self) -> FacadeHealth {
|
fn snapshot(&self) -> FacadeHealth {
|
||||||
FacadeHealth {
|
FacadeHealth {
|
||||||
forbidden_context: self.forbidden_context.load(Ordering::Relaxed),
|
forbidden_context: self.forbidden_context.load(Ordering::Relaxed),
|
||||||
filtered: self.filtered.load(Ordering::Relaxed),
|
|
||||||
contended: self.contended.load(Ordering::Relaxed),
|
contended: self.contended.load(Ordering::Relaxed),
|
||||||
full: self.full.load(Ordering::Relaxed),
|
full: self.full.load(Ordering::Relaxed),
|
||||||
truncated: self.truncated.load(Ordering::Relaxed),
|
truncated: self.truncated.load(Ordering::Relaxed),
|
||||||
@@ -146,8 +138,8 @@ impl<const N: usize> IngressQueue<N> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fixed global-facade ingress. The normal task/runtime owner drains it into
|
/// Fixed global-facade ingress. The normal task/runtime owner takes each
|
||||||
/// an [`EventHub`] after startup.
|
/// transport-neutral record and fans it out to independently filtered sinks.
|
||||||
pub struct FacadeIngress<const N: usize> {
|
pub struct FacadeIngress<const N: usize> {
|
||||||
/// One dedicated critical slot keeps error records independent from the
|
/// One dedicated critical slot keeps error records independent from the
|
||||||
/// bounded ordinary FIFO. It is deliberately one slot: producers never
|
/// bounded ordinary FIFO. It is deliberately one slot: producers never
|
||||||
@@ -157,7 +149,6 @@ pub struct FacadeIngress<const N: usize> {
|
|||||||
critical_busy: AtomicBool,
|
critical_busy: AtomicBool,
|
||||||
queue: UnsafeCell<IngressQueue<N>>,
|
queue: UnsafeCell<IngressQueue<N>>,
|
||||||
queue_busy: AtomicBool,
|
queue_busy: AtomicBool,
|
||||||
maximum_severity: AtomicU8,
|
|
||||||
counters: FacadeCounters,
|
counters: FacadeCounters,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,29 +158,17 @@ pub struct FacadeIngress<const N: usize> {
|
|||||||
unsafe impl<const N: usize> Sync for FacadeIngress<N> {}
|
unsafe impl<const N: usize> Sync for FacadeIngress<N> {}
|
||||||
|
|
||||||
impl<const N: usize> FacadeIngress<N> {
|
impl<const N: usize> FacadeIngress<N> {
|
||||||
/// Creates empty fixed ingress with the boot-active verbosity cap.
|
/// Creates an empty fixed ingress.
|
||||||
pub const fn new(filter: LogFilter) -> Self {
|
pub const fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
critical: UnsafeCell::new(None),
|
critical: UnsafeCell::new(None),
|
||||||
critical_busy: AtomicBool::new(false),
|
critical_busy: AtomicBool::new(false),
|
||||||
queue: UnsafeCell::new(IngressQueue::new()),
|
queue: UnsafeCell::new(IngressQueue::new()),
|
||||||
queue_busy: AtomicBool::new(false),
|
queue_busy: AtomicBool::new(false),
|
||||||
maximum_severity: AtomicU8::new(filter.maximum_severity() as u8),
|
|
||||||
counters: FacadeCounters::new(),
|
counters: FacadeCounters::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Updates the runtime-selected maximum non-error verbosity.
|
|
||||||
pub fn set_filter(&self, filter: LogFilter) {
|
|
||||||
self.maximum_severity
|
|
||||||
.store(filter.maximum_severity() as u8, Ordering::Release);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn allows(&self, severity: Severity) -> bool {
|
|
||||||
severity <= Severity::Error
|
|
||||||
|| (severity as u8) <= self.maximum_severity.load(Ordering::Acquire)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push(&self, record: IngressRecord) {
|
fn push(&self, record: IngressRecord) {
|
||||||
if record.severity <= Severity::Error {
|
if record.severity <= Severity::Error {
|
||||||
self.push_critical(record);
|
self.push_critical(record);
|
||||||
@@ -222,24 +201,23 @@ impl<const N: usize> FacadeIngress<N> {
|
|||||||
self.counters.snapshot()
|
self.counters.snapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves at most one preformatted facade record into the central hub.
|
/// Takes at most one preformatted event for composition-level fan-out.
|
||||||
pub fn drain_one_into<const CRITICAL: usize, const BEST_EFFORT: usize>(
|
///
|
||||||
&self,
|
/// The ingress deliberately applies no sink policy. A caller may submit
|
||||||
hub: &mut EventHub<CRITICAL, BEST_EFFORT>,
|
/// the returned copy to MAVLink, blackbox, or any later sink without one
|
||||||
) -> Option<PublishOutcome> {
|
/// sink's verbosity selection suppressing another's event.
|
||||||
|
pub fn take(&self) -> Option<LogRecord> {
|
||||||
let record = match self.with_critical(Option::take) {
|
let record = match self.with_critical(Option::take) {
|
||||||
Some(Some(record)) => record,
|
Some(Some(record)) => record,
|
||||||
Some(None) => self.with_queue(IngressQueue::pop).flatten()?,
|
Some(None) => self.with_queue(IngressQueue::pop).flatten()?,
|
||||||
None => return None,
|
None => return None,
|
||||||
};
|
};
|
||||||
Some(match record.body {
|
Some(match record.body {
|
||||||
IngressBody::Log { message } => {
|
IngressBody::Log { message } => LogRecord::new(record.severity, record.flags, message),
|
||||||
hub.publish_text(record.severity, record.flags, message)
|
|
||||||
}
|
|
||||||
IngressBody::Error {
|
IngressBody::Error {
|
||||||
path,
|
path,
|
||||||
additional_information,
|
additional_information,
|
||||||
} => hub.publish_error_text(record.flags, path, additional_information),
|
} => LogRecord::error(record.flags, path, additional_information),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +256,12 @@ impl<const N: usize> FacadeIngress<N> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<const N: usize> Default for FacadeIngress<N> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Installed standard logger. The context probe must return true for
|
/// Installed standard logger. The context probe must return true for
|
||||||
/// ISR/panic/hard-fault/shutdown contexts; those calls are rejected before
|
/// ISR/panic/hard-fault/shutdown contexts; those calls are rejected before
|
||||||
/// [`Record::args`] is formatted.
|
/// [`Record::args`] is formatted.
|
||||||
@@ -296,7 +280,8 @@ impl<const N: usize> GlobalLogger<N> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Installs this logger once for the process/image and enables all Rust
|
/// Installs this logger once for the process/image and enables all Rust
|
||||||
/// levels compiled into the image. Kaptan's own filter applies afterward.
|
/// levels compiled into the image. Each sink applies its policy after it
|
||||||
|
/// receives a record from the ingress.
|
||||||
pub fn install(&'static self) -> Result<(), SetLoggerError> {
|
pub fn install(&'static self) -> Result<(), SetLoggerError> {
|
||||||
log::set_logger(self)?;
|
log::set_logger(self)?;
|
||||||
log::set_max_level(LevelFilter::Trace);
|
log::set_max_level(LevelFilter::Trace);
|
||||||
@@ -305,18 +290,11 @@ impl<const N: usize> GlobalLogger<N> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<const N: usize> Log for GlobalLogger<N> {
|
impl<const N: usize> Log for GlobalLogger<N> {
|
||||||
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
|
fn enabled(&self, _: &Metadata<'_>) -> bool {
|
||||||
if (self.forbidden_context)() {
|
if (self.forbidden_context)() {
|
||||||
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
|
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let Some(severity) = severity(metadata.level()) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if !self.ingress.allows(severity) {
|
|
||||||
FacadeCounters::increment(&self.ingress.counters.filtered);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,13 +303,7 @@ impl<const N: usize> Log for GlobalLogger<N> {
|
|||||||
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
|
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(severity) = severity(record.level()) else {
|
let severity = severity(record.level());
|
||||||
return;
|
|
||||||
};
|
|
||||||
if !self.ingress.allows(severity) {
|
|
||||||
FacadeCounters::increment(&self.ingress.counters.filtered);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let flags = if record.level() == Level::Trace {
|
let flags = if record.level() == Level::Trace {
|
||||||
LogFlags::TRACE
|
LogFlags::TRACE
|
||||||
} else {
|
} else {
|
||||||
@@ -367,12 +339,12 @@ impl<const N: usize> Log for GlobalLogger<N> {
|
|||||||
fn flush(&self) {}
|
fn flush(&self) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn severity(level: Level) -> Option<Severity> {
|
const fn severity(level: Level) -> Severity {
|
||||||
match level {
|
match level {
|
||||||
Level::Error => Some(Severity::Error),
|
Level::Error => Severity::Error,
|
||||||
Level::Warn => Some(Severity::Warning),
|
Level::Warn => Severity::Warning,
|
||||||
Level::Info => Some(Severity::Info),
|
Level::Info => Severity::Info,
|
||||||
Level::Debug | Level::Trace => Some(Severity::Debug),
|
Level::Debug | Level::Trace => Severity::Debug,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,10 +354,10 @@ mod tests {
|
|||||||
use std::sync::Once;
|
use std::sync::Once;
|
||||||
|
|
||||||
use super::{FacadeIngress, GlobalLogger};
|
use super::{FacadeIngress, GlobalLogger};
|
||||||
use crate::{EventHub, LogFilter, RecordChannel, Severity};
|
use crate::{RecordChannel, Severity};
|
||||||
use log::{Level, Log, Metadata, Record};
|
use log::{Level, Log, Metadata, Record};
|
||||||
|
|
||||||
static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
|
static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new();
|
||||||
static GLOBAL_LOGGER: GlobalLogger<2> = GlobalLogger::new(&GLOBAL_INGRESS, never_forbidden);
|
static GLOBAL_LOGGER: GlobalLogger<2> = GlobalLogger::new(&GLOBAL_INGRESS, never_forbidden);
|
||||||
static INSTALL_GLOBAL: Once = Once::new();
|
static INSTALL_GLOBAL: Once = Once::new();
|
||||||
|
|
||||||
@@ -399,7 +371,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_forbidden_context_before_formatting() {
|
fn rejects_forbidden_context_before_formatting() {
|
||||||
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
|
static INGRESS: FacadeIngress<2> = FacadeIngress::new();
|
||||||
let logger = GlobalLogger::new(&INGRESS, always_forbidden);
|
let logger = GlobalLogger::new(&INGRESS, always_forbidden);
|
||||||
let metadata = Metadata::builder()
|
let metadata = Metadata::builder()
|
||||||
.level(Level::Error)
|
.level(Level::Error)
|
||||||
@@ -408,19 +380,31 @@ mod tests {
|
|||||||
assert!(!logger.enabled(&metadata));
|
assert!(!logger.enabled(&metadata));
|
||||||
assert_eq!(INGRESS.health().forbidden_context, 1);
|
assert_eq!(INGRESS.health().forbidden_context, 1);
|
||||||
|
|
||||||
static FILTERED: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Info));
|
static UNFILTERED: FacadeIngress<2> = FacadeIngress::new();
|
||||||
let filtered_logger = GlobalLogger::new(&FILTERED, never_forbidden);
|
let unfiltered_logger = GlobalLogger::new(&UNFILTERED, never_forbidden);
|
||||||
let debug = Metadata::builder()
|
let debug = Metadata::builder()
|
||||||
.level(Level::Debug)
|
.level(Level::Debug)
|
||||||
.target("test")
|
.target("test")
|
||||||
.build();
|
.build();
|
||||||
assert!(!filtered_logger.enabled(&debug));
|
assert!(unfiltered_logger.enabled(&debug));
|
||||||
assert_eq!(FILTERED.health().filtered, 1);
|
unfiltered_logger.log(
|
||||||
|
&Record::builder()
|
||||||
|
.metadata(debug)
|
||||||
|
.args(format_args!("sink decides whether to keep this"))
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
UNFILTERED
|
||||||
|
.take()
|
||||||
|
.expect("debug event reaches the unfiltered ingress")
|
||||||
|
.severity(),
|
||||||
|
Severity::Debug
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn formats_once_then_drains_into_priority_hub() {
|
fn formats_once_then_exposes_a_transport_neutral_record() {
|
||||||
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
|
static INGRESS: FacadeIngress<2> = FacadeIngress::new();
|
||||||
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
|
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
|
||||||
let record = Record::builder()
|
let record = Record::builder()
|
||||||
.metadata(
|
.metadata(
|
||||||
@@ -432,9 +416,7 @@ mod tests {
|
|||||||
.args(format_args!("link failed"))
|
.args(format_args!("link failed"))
|
||||||
.build();
|
.build();
|
||||||
logger.log(&record);
|
logger.log(&record);
|
||||||
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
let pending = INGRESS.take().expect("facade event retained");
|
||||||
assert!(INGRESS.drain_one_into(&mut hub).is_some());
|
|
||||||
let pending = hub.peek().unwrap();
|
|
||||||
assert_eq!(pending.error_path().unwrap().as_str(), "test");
|
assert_eq!(pending.error_path().unwrap().as_str(), "test");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pending.additional_information().unwrap().as_str(),
|
pending.additional_information().unwrap().as_str(),
|
||||||
@@ -456,11 +438,10 @@ mod tests {
|
|||||||
fn standard_log_macros_enqueue_bounded_targeted_text() {
|
fn standard_log_macros_enqueue_bounded_targeted_text() {
|
||||||
INSTALL_GLOBAL.call_once(|| GLOBAL_LOGGER.install().expect("one global test logger"));
|
INSTALL_GLOBAL.call_once(|| GLOBAL_LOGGER.install().expect("one global test logger"));
|
||||||
log::info!("armed configuration loaded");
|
log::info!("armed configuration loaded");
|
||||||
let mut hub = EventHub::<1, 2>::new(LogFilter::default());
|
|
||||||
assert!(GLOBAL_INGRESS.drain_one_into(&mut hub).is_some());
|
|
||||||
assert!(
|
assert!(
|
||||||
hub.peek()
|
GLOBAL_INGRESS
|
||||||
.unwrap()
|
.take()
|
||||||
|
.expect("macro event retained")
|
||||||
.message()
|
.message()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -470,7 +451,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn forbidden_context_skips_argument_formatting() {
|
fn forbidden_context_skips_argument_formatting() {
|
||||||
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
|
static INGRESS: FacadeIngress<2> = FacadeIngress::new();
|
||||||
let logger = GlobalLogger::new(&INGRESS, always_forbidden);
|
let logger = GlobalLogger::new(&INGRESS, always_forbidden);
|
||||||
logger.log(
|
logger.log(
|
||||||
&Record::builder()
|
&Record::builder()
|
||||||
@@ -488,7 +469,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bounded_ingress_exposes_local_drop_and_truncation_accounting() {
|
fn bounded_ingress_exposes_local_drop_and_truncation_accounting() {
|
||||||
static INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info));
|
static INGRESS: FacadeIngress<1> = FacadeIngress::new();
|
||||||
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
|
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
|
||||||
logger.log(
|
logger.log(
|
||||||
&Record::builder()
|
&Record::builder()
|
||||||
@@ -531,7 +512,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn full_optional_ingress_retains_an_error_for_the_error_channel() {
|
fn full_optional_ingress_retains_an_error_for_the_error_channel() {
|
||||||
static INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info));
|
static INGRESS: FacadeIngress<1> = FacadeIngress::new();
|
||||||
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
|
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
|
||||||
logger.log(
|
logger.log(
|
||||||
&Record::builder()
|
&Record::builder()
|
||||||
@@ -556,9 +537,9 @@ mod tests {
|
|||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
let error = INGRESS
|
||||||
assert!(INGRESS.drain_one_into(&mut hub).is_some());
|
.take()
|
||||||
let error = hub.peek().expect("error retained ahead of optional text");
|
.expect("error retained ahead of optional text");
|
||||||
assert_eq!(error.channel(), RecordChannel::Error);
|
assert_eq!(error.channel(), RecordChannel::Error);
|
||||||
assert_eq!(error.error_path().unwrap().as_str(), "test");
|
assert_eq!(error.error_path().unwrap().as_str(), "test");
|
||||||
assert_eq!(error.additional_information().unwrap().as_str(), "critical");
|
assert_eq!(error.additional_information().unwrap().as_str(), "critical");
|
||||||
|
|||||||
@@ -2,37 +2,77 @@
|
|||||||
|
|
||||||
use crate::Severity;
|
use crate::Severity;
|
||||||
|
|
||||||
/// Immutable policy for best-effort log admission.
|
/// Immutable policy for one log sink.
|
||||||
///
|
///
|
||||||
/// Error/critical/vital records are never filtered by this type; callers use
|
/// Filtering belongs to a sink rather than to event creation: MAVLink and a
|
||||||
/// their dedicated delivery lanes for those records.
|
/// future blackbox may therefore select different policies without either
|
||||||
|
/// sink suppressing the other's events.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct LogFilter {
|
pub enum LogFilter {
|
||||||
maximum_severity: Severity,
|
/// The sink receives no log events, including errors.
|
||||||
|
Disabled,
|
||||||
|
/// The sink receives this level and every more severe level.
|
||||||
|
UpTo(Severity),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LogFilter {
|
impl LogFilter {
|
||||||
/// Creates a best-effort verbosity cap.
|
/// Creates a disabled sink policy.
|
||||||
pub const fn new(maximum_severity: Severity) -> Self {
|
pub const fn disabled() -> Self {
|
||||||
Self { maximum_severity }
|
Self::Disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The selected MAVLink-compatible severity cap.
|
/// Creates a sink policy with one inclusive verbosity cap.
|
||||||
///
|
pub const fn up_to(maximum_severity: Severity) -> Self {
|
||||||
/// The value is exact on the wire: in particular, `NOTICE` does not
|
Self::UpTo(maximum_severity)
|
||||||
/// silently permit `INFO` records.
|
|
||||||
pub const fn maximum_severity(self) -> Severity {
|
|
||||||
self.maximum_severity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if a best-effort record should be retained.
|
/// The selected MAVLink-compatible severity cap, or `None` when disabled.
|
||||||
|
pub const fn maximum_severity(self) -> Option<Severity> {
|
||||||
|
match self {
|
||||||
|
Self::Disabled => None,
|
||||||
|
Self::UpTo(maximum_severity) => Some(maximum_severity),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if this sink accepts the event.
|
||||||
pub const fn allows(self, severity: Severity) -> bool {
|
pub const fn allows(self, severity: Severity) -> bool {
|
||||||
(severity as u8) <= (self.maximum_severity as u8)
|
match self {
|
||||||
|
Self::Disabled => false,
|
||||||
|
Self::UpTo(maximum_severity) => severity.wire_value() <= maximum_severity.wire_value(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LogFilter {
|
#[cfg(test)]
|
||||||
fn default() -> Self {
|
mod tests {
|
||||||
Self::new(Severity::Info)
|
use super::LogFilter;
|
||||||
|
use crate::Severity;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_policy_rejects_every_severity() {
|
||||||
|
let filter = LogFilter::disabled();
|
||||||
|
for severity in [
|
||||||
|
Severity::Emergency,
|
||||||
|
Severity::Alert,
|
||||||
|
Severity::Critical,
|
||||||
|
Severity::Error,
|
||||||
|
Severity::Warning,
|
||||||
|
Severity::Notice,
|
||||||
|
Severity::Info,
|
||||||
|
Severity::Debug,
|
||||||
|
] {
|
||||||
|
assert!(!filter.allows(severity));
|
||||||
|
}
|
||||||
|
assert_eq!(filter.maximum_severity(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn level_policy_is_inclusive_and_rejects_more_verbose_events() {
|
||||||
|
let filter = LogFilter::up_to(Severity::Info);
|
||||||
|
assert!(filter.allows(Severity::Emergency));
|
||||||
|
assert!(filter.allows(Severity::Error));
|
||||||
|
assert!(filter.allows(Severity::Info));
|
||||||
|
assert!(!filter.allows(Severity::Debug));
|
||||||
|
assert_eq!(filter.maximum_severity(), Some(Severity::Info));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+197
-160
@@ -4,7 +4,7 @@ use core::fmt;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
DeliveryClass, ErrorAdditionalInformation, ErrorPath, ErrorReport, LogFilter, LogFlags,
|
DeliveryClass, ErrorAdditionalInformation, ErrorPath, ErrorReport, LogFilter, LogFlags,
|
||||||
LogRecord, LogText, Severity, StaticError,
|
LogRecord, LogText, Severity, StaticError, StaticErrorMailbox,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Outcome from one bounded diagnostic admission attempt.
|
/// Outcome from one bounded diagnostic admission attempt.
|
||||||
@@ -12,9 +12,7 @@ use crate::{
|
|||||||
pub enum PublishOutcome {
|
pub enum PublishOutcome {
|
||||||
/// The record was retained.
|
/// The record was retained.
|
||||||
Queued,
|
Queued,
|
||||||
/// An adjacent equivalent record absorbed this occurrence.
|
/// A record was excluded by this sink's configured policy.
|
||||||
Coalesced,
|
|
||||||
/// An ordinary log record was excluded by the configured verbosity cap.
|
|
||||||
Filtered,
|
Filtered,
|
||||||
/// A best-effort record was replaced by newer optional information.
|
/// A best-effort record was replaced by newer optional information.
|
||||||
ReplacedOldest,
|
ReplacedOldest,
|
||||||
@@ -26,37 +24,10 @@ pub enum PublishOutcome {
|
|||||||
/// record to its lower transport.
|
/// record to its lower transport.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum TransportAdmission {
|
pub enum TransportAdmission {
|
||||||
/// The lower transport retained the record, so it may leave the hub.
|
/// The lower transport retained the record, so it may leave the queue.
|
||||||
Accepted,
|
Accepted,
|
||||||
/// The lower transport did not accept it yet; retain it for retry.
|
/// The lower transport did not accept it yet; retain it for retry.
|
||||||
Retry,
|
Retry,
|
||||||
/// The lower transport will not accept optional detail. This can only
|
|
||||||
/// discard a best-effort record; critical records remain queued.
|
|
||||||
DropBestEffort,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Saturating, process-local accounting for bounded diagnostic admission.
|
|
||||||
///
|
|
||||||
/// These counters intentionally are not part of `KAPTAN_LOG` or
|
|
||||||
/// `KAPTAN_ERROR`: neither wire message is a metrics protocol.
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
||||||
pub struct HubHealth {
|
|
||||||
/// Optional records rejected by the active verbosity cap.
|
|
||||||
pub filtered: u32,
|
|
||||||
/// Adjacent records folded into an existing entry.
|
|
||||||
pub coalesced: u32,
|
|
||||||
/// Optional entries replaced because their bounded lane was full.
|
|
||||||
pub best_effort_replaced: u32,
|
|
||||||
/// Error entries rejected because their bounded critical lane was full.
|
|
||||||
pub critical_rejected: u32,
|
|
||||||
/// Optional entries the lower transport refused permanently.
|
|
||||||
pub best_effort_transport_dropped: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HubHealth {
|
|
||||||
fn increment(value: &mut u32) {
|
|
||||||
*value = value.saturating_add(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Lane<const N: usize> {
|
struct Lane<const N: usize> {
|
||||||
@@ -76,15 +47,6 @@ impl<const N: usize> Lane<N> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn publish(&mut self, record: LogRecord, replace_oldest: bool) -> PublishOutcome {
|
fn publish(&mut self, record: LogRecord, replace_oldest: bool) -> PublishOutcome {
|
||||||
if self.len != 0 {
|
|
||||||
let tail = (self.head + self.len - 1) % N;
|
|
||||||
if let Some(previous) = self.entries[tail].as_mut()
|
|
||||||
&& previous.is_equivalent_to(&record)
|
|
||||||
{
|
|
||||||
previous.increment_occurrences();
|
|
||||||
return PublishOutcome::Coalesced;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.len == N {
|
if self.len == N {
|
||||||
if replace_oldest {
|
if replace_oldest {
|
||||||
self.entries[self.head] = Some(record);
|
self.entries[self.head] = Some(record);
|
||||||
@@ -99,6 +61,10 @@ impl<const N: usize> Lane<N> {
|
|||||||
PublishOutcome::Queued
|
PublishOutcome::Queued
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fn is_full(&self) -> bool {
|
||||||
|
self.len == N
|
||||||
|
}
|
||||||
|
|
||||||
fn peek(&self) -> Option<LogRecord> {
|
fn peek(&self) -> Option<LogRecord> {
|
||||||
self.entries.get(self.head).copied().flatten()
|
self.entries.get(self.head).copied().flatten()
|
||||||
}
|
}
|
||||||
@@ -114,39 +80,78 @@ impl<const N: usize> Lane<N> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One bounded priority hub. It owns no global logger, MAVLink state, or DMA.
|
/// One bounded priority queue owned by a log sink.
|
||||||
pub struct EventHub<const CRITICAL: usize, const BEST_EFFORT: usize> {
|
///
|
||||||
|
/// It owns no global logger, MAVLink state, DMA, filesystem, or other sink.
|
||||||
|
pub struct LogSinkQueue<const CRITICAL: usize, const BEST_EFFORT: usize> {
|
||||||
filter: LogFilter,
|
filter: LogFilter,
|
||||||
critical: Lane<CRITICAL>,
|
critical: Lane<CRITICAL>,
|
||||||
best_effort: Lane<BEST_EFFORT>,
|
best_effort: Lane<BEST_EFFORT>,
|
||||||
health: HubHealth,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EFFORT> {
|
impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BEST_EFFORT> {
|
||||||
/// Creates one hub with its immutable boot-active verbosity cap.
|
/// Creates one sink queue with its immutable boot-active policy.
|
||||||
pub const fn new(filter: LogFilter) -> Self {
|
pub const fn new(filter: LogFilter) -> Self {
|
||||||
Self {
|
Self {
|
||||||
filter,
|
filter,
|
||||||
critical: Lane::new(),
|
critical: Lane::new(),
|
||||||
best_effort: Lane::new(),
|
best_effort: Lane::new(),
|
||||||
health: HubHealth {
|
|
||||||
filtered: 0,
|
|
||||||
coalesced: 0,
|
|
||||||
best_effort_replaced: 0,
|
|
||||||
critical_rejected: 0,
|
|
||||||
best_effort_transport_dropped: 0,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Immutable best-effort verbosity policy.
|
/// Immutable sink policy.
|
||||||
pub const fn filter(&self) -> LogFilter {
|
pub const fn filter(&self) -> LogFilter {
|
||||||
self.filter
|
self.filter
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process-local bounded-admission accounting.
|
/// Whether an error source may be consumed without losing an enabled
|
||||||
pub const fn health(&self) -> HubHealth {
|
/// critical record. Filtered errors may be consumed intentionally.
|
||||||
self.health
|
pub const fn can_publish_error(&self) -> bool {
|
||||||
|
!self.filter.allows(Severity::Error) || !self.critical.is_full()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes at most one source-local static-error mailbox in round-robin
|
||||||
|
/// order. An enabled source remains untouched while the critical lane is
|
||||||
|
/// full; a filtered source is consumed intentionally.
|
||||||
|
pub fn drain_next_error_mailbox<const SOURCES: usize>(
|
||||||
|
&mut self,
|
||||||
|
mailboxes: [&StaticErrorMailbox; SOURCES],
|
||||||
|
cursor: &mut u8,
|
||||||
|
) -> bool {
|
||||||
|
assert!(
|
||||||
|
SOURCES != 0 && SOURCES <= u8::MAX as usize,
|
||||||
|
"error mailbox fan-in must fit its u8 cursor"
|
||||||
|
);
|
||||||
|
if !self.can_publish_error() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if usize::from(*cursor) >= SOURCES {
|
||||||
|
*cursor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut checked = 0;
|
||||||
|
while checked < SOURCES {
|
||||||
|
let index = usize::from(*cursor);
|
||||||
|
*cursor = if index + 1 == SOURCES { 0 } else { *cursor + 1 };
|
||||||
|
checked += 1;
|
||||||
|
|
||||||
|
let (error, coalesced) = mailboxes[index].take();
|
||||||
|
let Some(error) = error else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let flags = if coalesced {
|
||||||
|
LogFlags::COALESCED
|
||||||
|
} else {
|
||||||
|
LogFlags::NONE
|
||||||
|
};
|
||||||
|
let outcome = self.publish_error_static(flags, error);
|
||||||
|
debug_assert!(matches!(
|
||||||
|
outcome,
|
||||||
|
PublishOutcome::Queued | PublishOutcome::Filtered
|
||||||
|
));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formats and retains one ordinary task/worker diagnostic. Error-level
|
/// Formats and retains one ordinary task/worker diagnostic. Error-level
|
||||||
@@ -164,7 +169,7 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
|
|||||||
/// Retains one static derived error without formatting or exposing source
|
/// Retains one static derived error without formatting or exposing source
|
||||||
/// values. The adapter maps it to `KAPTAN_ERROR`.
|
/// values. The adapter maps it to `KAPTAN_ERROR`.
|
||||||
pub fn publish_error<E: ErrorReport>(&mut self, flags: LogFlags, error: &E) -> PublishOutcome {
|
pub fn publish_error<E: ErrorReport>(&mut self, flags: LogFlags, error: &E) -> PublishOutcome {
|
||||||
self.publish_error_static(flags, error.static_error())
|
self.publish_record(LogRecord::from_error(flags, error))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retains one static mailbox error. This is the ISR-safe counterpart to
|
/// Retains one static mailbox error. This is the ISR-safe counterpart to
|
||||||
@@ -174,16 +179,13 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
|
|||||||
flags: LogFlags,
|
flags: LogFlags,
|
||||||
error: &'static StaticError,
|
error: &'static StaticError,
|
||||||
) -> PublishOutcome {
|
) -> PublishOutcome {
|
||||||
self.publish_error_text(
|
self.publish_record(LogRecord::from_static_error(flags, error))
|
||||||
flags,
|
|
||||||
ErrorPath::from_message(error.path()),
|
|
||||||
ErrorAdditionalInformation::from_message(error.additional_information()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retains a preformatted error identity and optional detail. Both
|
/// Retains a preformatted error identity and optional detail. Both
|
||||||
/// components stay transport-neutral here; the MAVLink adapter enforces
|
/// transport-neutral components already have the exact common diagnostic
|
||||||
/// its independent on-wire capacities.
|
/// envelope capacities, so a sink adapter need not retain or clip them
|
||||||
|
/// again.
|
||||||
pub fn publish_error_text(
|
pub fn publish_error_text(
|
||||||
&mut self,
|
&mut self,
|
||||||
flags: LogFlags,
|
flags: LogFlags,
|
||||||
@@ -194,38 +196,29 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Retains one preformatted bounded text payload. Error-or-higher
|
/// Retains one preformatted bounded text payload. Error-or-higher
|
||||||
/// severities are independently retained and sent as `KAPTAN_ERROR`.
|
/// severities are independently encoded as `KAPTAN_ERROR`.
|
||||||
pub fn publish_text(
|
pub fn publish_text(
|
||||||
&mut self,
|
&mut self,
|
||||||
severity: Severity,
|
severity: Severity,
|
||||||
flags: LogFlags,
|
flags: LogFlags,
|
||||||
text: LogText,
|
text: LogText,
|
||||||
) -> PublishOutcome {
|
) -> PublishOutcome {
|
||||||
let delivery = delivery_for(severity);
|
|
||||||
if delivery == DeliveryClass::BestEffort && !self.filter.allows(severity) {
|
|
||||||
HubHealth::increment(&mut self.health.filtered);
|
|
||||||
return PublishOutcome::Filtered;
|
|
||||||
}
|
|
||||||
self.publish_record(LogRecord::new(severity, flags, text))
|
self.publish_record(LogRecord::new(severity, flags, text))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn publish_record(&mut self, record: LogRecord) -> PublishOutcome {
|
/// Applies this sink's policy and retains one already-constructed event.
|
||||||
|
///
|
||||||
|
/// Composition may submit the same transport-neutral record to multiple
|
||||||
|
/// sink queues without coupling event creation to any sink policy.
|
||||||
|
pub fn publish_record(&mut self, record: LogRecord) -> PublishOutcome {
|
||||||
|
if !self.filter.allows(record.severity()) {
|
||||||
|
return PublishOutcome::Filtered;
|
||||||
|
}
|
||||||
let delivery = record.delivery();
|
let delivery = record.delivery();
|
||||||
let outcome = match delivery {
|
match delivery {
|
||||||
DeliveryClass::Critical => self.critical.publish(record, false),
|
DeliveryClass::Critical => self.critical.publish(record, false),
|
||||||
DeliveryClass::BestEffort => self.best_effort.publish(record, true),
|
DeliveryClass::BestEffort => self.best_effort.publish(record, true),
|
||||||
};
|
|
||||||
match outcome {
|
|
||||||
PublishOutcome::Coalesced => HubHealth::increment(&mut self.health.coalesced),
|
|
||||||
PublishOutcome::ReplacedOldest => {
|
|
||||||
HubHealth::increment(&mut self.health.best_effort_replaced);
|
|
||||||
}
|
|
||||||
PublishOutcome::RejectedFull => {
|
|
||||||
HubHealth::increment(&mut self.health.critical_rejected);
|
|
||||||
}
|
|
||||||
PublishOutcome::Queued | PublishOutcome::Filtered => {}
|
|
||||||
}
|
}
|
||||||
outcome
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Highest-priority record without relinquishing it.
|
/// Highest-priority record without relinquishing it.
|
||||||
@@ -233,9 +226,9 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
|
|||||||
self.critical.peek().or_else(|| self.best_effort.peek())
|
self.critical.peek().or_else(|| self.best_effort.peek())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Submits the current record through the sole telemetry owner. The hub
|
/// Submits the current record through the sink owner and removes it only
|
||||||
/// removes it only after `Accepted`, and only permits `DropBestEffort` for
|
/// after `Accepted`. This keeps encoder/admission retry semantics local
|
||||||
/// optional detail. This keeps encoder/admission retry semantics local.
|
/// for every severity.
|
||||||
pub fn submit_next<R>(
|
pub fn submit_next<R>(
|
||||||
&mut self,
|
&mut self,
|
||||||
submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R),
|
submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R),
|
||||||
@@ -246,13 +239,7 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
|
|||||||
TransportAdmission::Accepted => {
|
TransportAdmission::Accepted => {
|
||||||
let _ = self.pop();
|
let _ = self.pop();
|
||||||
}
|
}
|
||||||
TransportAdmission::DropBestEffort
|
TransportAdmission::Retry => {}
|
||||||
if record.delivery() == DeliveryClass::BestEffort =>
|
|
||||||
{
|
|
||||||
let _ = self.best_effort.pop();
|
|
||||||
HubHealth::increment(&mut self.health.best_effort_transport_dropped);
|
|
||||||
}
|
|
||||||
TransportAdmission::Retry | TransportAdmission::DropBestEffort => {}
|
|
||||||
}
|
}
|
||||||
Some(result)
|
Some(result)
|
||||||
}
|
}
|
||||||
@@ -262,27 +249,30 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delivery_for(severity: Severity) -> DeliveryClass {
|
|
||||||
if severity <= Severity::Error {
|
|
||||||
DeliveryClass::Critical
|
|
||||||
} else {
|
|
||||||
DeliveryClass::BestEffort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{EventHub, PublishOutcome, TransportAdmission};
|
use super::{LogSinkQueue, PublishOutcome, TransportAdmission};
|
||||||
use crate::{
|
use crate::{
|
||||||
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogText,
|
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogRecord,
|
||||||
RecordChannel, Severity, StaticError,
|
LogText, RecordChannel, Severity, StaticError,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn errors_bypass_filter_but_debug_does_not() {
|
fn sink_policy_applies_to_errors_and_ordinary_logs() {
|
||||||
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info));
|
let mut disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hub.publish_text(
|
disabled.publish_text(
|
||||||
|
Severity::Error,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("error")
|
||||||
|
),
|
||||||
|
PublishOutcome::Filtered
|
||||||
|
);
|
||||||
|
assert!(disabled.peek().is_none());
|
||||||
|
|
||||||
|
let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
|
||||||
|
assert_eq!(
|
||||||
|
queue.publish_text(
|
||||||
Severity::Debug,
|
Severity::Debug,
|
||||||
LogFlags::NONE,
|
LogFlags::NONE,
|
||||||
LogText::from_message("debug")
|
LogText::from_message("debug")
|
||||||
@@ -290,43 +280,110 @@ mod tests {
|
|||||||
PublishOutcome::Filtered
|
PublishOutcome::Filtered
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hub.publish_text(
|
queue.publish_text(
|
||||||
Severity::Error,
|
Severity::Error,
|
||||||
LogFlags::NONE,
|
LogFlags::NONE,
|
||||||
LogText::from_message("error")
|
LogText::from_message("error")
|
||||||
),
|
),
|
||||||
PublishOutcome::Queued
|
PublishOutcome::Queued
|
||||||
);
|
);
|
||||||
let record = hub.peek().unwrap();
|
let record = queue.peek().unwrap();
|
||||||
assert_eq!(record.error_path().unwrap().as_str(), "error");
|
assert_eq!(record.error_path().unwrap().as_str(), "error");
|
||||||
assert!(record.additional_information().unwrap().is_empty());
|
assert!(record.additional_information().unwrap().is_empty());
|
||||||
assert_eq!(record.channel(), RecordChannel::Error);
|
assert_eq!(record.channel(), RecordChannel::Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn static_messages_and_repeats_are_bounded() {
|
fn full_critical_lane_backpressures_only_an_enabled_error_source() {
|
||||||
static FAULT: StaticError = StaticError::new("driver::Fault");
|
let mut enabled = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
|
||||||
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
assert!(enabled.can_publish_error());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hub.publish_error_static(LogFlags::NONE, &FAULT),
|
enabled.publish_text(
|
||||||
|
Severity::Error,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("pending")
|
||||||
|
),
|
||||||
|
PublishOutcome::Queued
|
||||||
|
);
|
||||||
|
assert!(!enabled.can_publish_error());
|
||||||
|
|
||||||
|
let disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
|
||||||
|
assert!(disabled.can_publish_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mailbox_fan_in_survives_backpressure_and_is_round_robin() {
|
||||||
|
static FIRST: StaticError = StaticError::new("first");
|
||||||
|
static SECOND: StaticError = StaticError::new("second");
|
||||||
|
static THIRD: StaticError = StaticError::new("third");
|
||||||
|
let first = crate::StaticErrorMailbox::new();
|
||||||
|
let second = crate::StaticErrorMailbox::new();
|
||||||
|
let third = crate::StaticErrorMailbox::new();
|
||||||
|
let mailboxes = [&first, &second, &third];
|
||||||
|
first.record(&FIRST);
|
||||||
|
second.record(&SECOND);
|
||||||
|
third.record(&THIRD);
|
||||||
|
|
||||||
|
let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info));
|
||||||
|
let mut cursor = 0;
|
||||||
|
assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor));
|
||||||
|
assert_eq!(
|
||||||
|
queue.peek().unwrap().error_path().unwrap().as_str(),
|
||||||
|
"first"
|
||||||
|
);
|
||||||
|
|
||||||
|
first.record(&FIRST);
|
||||||
|
assert!(!queue.drain_next_error_mailbox(mailboxes, &mut cursor));
|
||||||
|
assert_eq!(
|
||||||
|
queue.peek().unwrap().error_path().unwrap().as_str(),
|
||||||
|
"first"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut expect = |path| {
|
||||||
|
assert_eq!(queue.peek().unwrap().error_path().unwrap().as_str(), path);
|
||||||
|
assert_eq!(
|
||||||
|
queue.submit_next(|_| (TransportAdmission::Accepted, ())),
|
||||||
|
Some(())
|
||||||
|
);
|
||||||
|
assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor));
|
||||||
|
};
|
||||||
|
expect("first");
|
||||||
|
expect("second");
|
||||||
|
expect("third");
|
||||||
|
assert_eq!(
|
||||||
|
queue.peek().unwrap().error_path().unwrap().as_str(),
|
||||||
|
"first"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_events_remain_distinct_records() {
|
||||||
|
static FAULT: StaticError = StaticError::new("driver::Fault");
|
||||||
|
let mut queue = LogSinkQueue::<2, 1>::new(LogFilter::up_to(Severity::Debug));
|
||||||
|
assert_eq!(
|
||||||
|
queue.publish_error_static(LogFlags::NONE, &FAULT),
|
||||||
PublishOutcome::Queued
|
PublishOutcome::Queued
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hub.publish_error_static(LogFlags::NONE, &FAULT),
|
queue.publish_error_static(LogFlags::NONE, &FAULT),
|
||||||
PublishOutcome::Coalesced
|
PublishOutcome::Queued
|
||||||
);
|
);
|
||||||
let record = hub.peek().unwrap();
|
let record = queue.peek().unwrap();
|
||||||
assert_eq!(record.occurrences, 2);
|
assert!(!record.flags().contains(LogFlags::COALESCED));
|
||||||
assert!(record.flags().contains(LogFlags::COALESCED));
|
|
||||||
assert_eq!(record.error_path().unwrap().as_str(), "driver::Fault");
|
assert_eq!(record.error_path().unwrap().as_str(), "driver::Fault");
|
||||||
assert!(record.additional_information().unwrap().is_empty());
|
assert!(record.additional_information().unwrap().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
queue.submit_next(|_| (TransportAdmission::Accepted, ())),
|
||||||
|
Some(())
|
||||||
|
);
|
||||||
|
assert_eq!(queue.peek(), Some(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn static_error_detail_needs_no_producer_formatting() {
|
fn static_error_detail_needs_no_producer_formatting() {
|
||||||
static FAULT: StaticError =
|
static FAULT: StaticError =
|
||||||
StaticError::with_additional_information("driver::Dma::Fault", "stream 7");
|
StaticError::with_additional_information("driver::Dma::Fault", "stream 7");
|
||||||
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hub.publish_error_static(LogFlags::NONE, &FAULT),
|
hub.publish_error_static(LogFlags::NONE, &FAULT),
|
||||||
PublishOutcome::Queued
|
PublishOutcome::Queued
|
||||||
@@ -341,7 +398,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn error_identity_and_detail_are_independent_record_components() {
|
fn error_identity_and_detail_are_independent_record_components() {
|
||||||
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
|
||||||
let _ = hub.publish_error_text(
|
let _ = hub.publish_error_text(
|
||||||
LogFlags::NONE,
|
LogFlags::NONE,
|
||||||
ErrorPath::from_message("driver::Icm42688::Transfer"),
|
ErrorPath::from_message("driver::Icm42688::Transfer"),
|
||||||
@@ -360,15 +417,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn retry_retains_critical_and_only_best_effort_can_be_discarded() {
|
fn retry_retains_every_severity() {
|
||||||
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
|
||||||
let _ = hub.publish_text(
|
let _ = hub.publish_text(
|
||||||
Severity::Error,
|
Severity::Error,
|
||||||
LogFlags::NONE,
|
LogFlags::NONE,
|
||||||
LogText::from_message("fault"),
|
LogText::from_message("fault"),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())),
|
hub.submit_next(|_| (TransportAdmission::Retry, ())),
|
||||||
Some(())
|
Some(())
|
||||||
);
|
);
|
||||||
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::Critical);
|
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::Critical);
|
||||||
@@ -384,50 +441,30 @@ mod tests {
|
|||||||
LogText::from_message("detail"),
|
LogText::from_message("detail"),
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())),
|
hub.submit_next(|_| (TransportAdmission::Retry, ())),
|
||||||
|
Some(())
|
||||||
|
);
|
||||||
|
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::BestEffort);
|
||||||
|
assert_eq!(
|
||||||
|
hub.submit_next(|_| (TransportAdmission::Accepted, ())),
|
||||||
Some(())
|
Some(())
|
||||||
);
|
);
|
||||||
assert!(hub.peek().is_none());
|
assert!(hub.peek().is_none());
|
||||||
assert_eq!(hub.health().best_effort_transport_dropped, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn admission_losses_are_retained_as_local_saturating_accounting() {
|
fn independent_sink_policies_do_not_suppress_each_other() {
|
||||||
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info));
|
let record = LogRecord::new(
|
||||||
let _ = hub.publish_text(
|
|
||||||
Severity::Debug,
|
|
||||||
LogFlags::NONE,
|
|
||||||
LogText::from_message("filtered"),
|
|
||||||
);
|
|
||||||
let _ = hub.publish_text(
|
|
||||||
Severity::Info,
|
Severity::Info,
|
||||||
LogFlags::NONE,
|
LogFlags::NONE,
|
||||||
LogText::from_message("first optional"),
|
LogText::from_message("flight ready"),
|
||||||
);
|
|
||||||
let _ = hub.publish_text(
|
|
||||||
Severity::Info,
|
|
||||||
LogFlags::NONE,
|
|
||||||
LogText::from_message("newer optional"),
|
|
||||||
);
|
|
||||||
let _ = hub.publish_text(
|
|
||||||
Severity::Error,
|
|
||||||
LogFlags::NONE,
|
|
||||||
LogText::from_message("first error"),
|
|
||||||
);
|
|
||||||
let _ = hub.publish_text(
|
|
||||||
Severity::Error,
|
|
||||||
LogFlags::NONE,
|
|
||||||
LogText::from_message("second error"),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
hub.health(),
|
|
||||||
super::HubHealth {
|
|
||||||
filtered: 1,
|
|
||||||
coalesced: 0,
|
|
||||||
best_effort_replaced: 1,
|
|
||||||
critical_rejected: 1,
|
|
||||||
best_effort_transport_dropped: 0,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
let mut mavlink = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
|
||||||
|
let mut blackbox = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
|
||||||
|
|
||||||
|
assert_eq!(mavlink.publish_record(record), PublishOutcome::Filtered);
|
||||||
|
assert_eq!(blackbox.publish_record(record), PublishOutcome::Queued);
|
||||||
|
assert!(mavlink.peek().is_none());
|
||||||
|
assert_eq!(blackbox.peek(), Some(record));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
/// MAVLink-compatible severity. Numeric values deliberately match
|
/// MAVLink-compatible severity. Numeric values deliberately match
|
||||||
/// `MAV_SEVERITY`.
|
/// `MAV_SEVERITY`.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, kaptan_config_derive::ConfigEnum)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
pub enum Severity {
|
pub enum Severity {
|
||||||
/// System is unusable.
|
/// System is unusable.
|
||||||
@@ -23,19 +23,14 @@ pub enum Severity {
|
|||||||
Debug = 7,
|
Debug = 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Severity {
|
#[cfg(test)]
|
||||||
/// Converts the stable MAVLink numeric representation.
|
mod tests {
|
||||||
pub const fn try_from_wire(value: u8) -> Option<Self> {
|
use core::mem::size_of;
|
||||||
match value {
|
|
||||||
0 => Some(Self::Emergency),
|
use super::Severity;
|
||||||
1 => Some(Self::Alert),
|
|
||||||
2 => Some(Self::Critical),
|
#[test]
|
||||||
3 => Some(Self::Error),
|
fn severity_is_one_byte() {
|
||||||
4 => Some(Self::Warning),
|
assert_eq!(size_of::<Severity>(), 1);
|
||||||
5 => Some(Self::Notice),
|
|
||||||
6 => Some(Self::Info),
|
|
||||||
7 => Some(Self::Debug),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user