From 387fdaad231d215095a40685ff915d1800d60cf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onur=20=C3=96zkan?= Date: Sat, 22 Aug 2026 18:37:18 +0300 Subject: [PATCH] refactor(firmware): centralize configuration and diagnostics --- firmware/Cargo.lock | 50 +- firmware/Cargo.toml | 1 - firmware/boards/geprc-f722-v2/Cargo.toml | 5 +- firmware/boards/geprc-f722-v2/src/board.rs | 107 +- firmware/boards/geprc-f722-v2/src/main.rs | 2 +- firmware/boards/kakute-f7-mini-v3/Cargo.toml | 8 +- .../boards/kakute-f7-mini-v3/src/board.rs | 144 ++- firmware/boards/kakute-f7-mini-v3/src/main.rs | 2 +- firmware/libs/kaptan-board/Cargo.toml | 12 - firmware/libs/kaptan-board/src/lib.rs | 339 ----- .../kaptan-config-derive/src/config_enum.rs | 186 +++ .../libs/kaptan-config-derive/src/expand.rs | 288 +++-- firmware/libs/kaptan-config-derive/src/lib.rs | 27 +- .../libs/kaptan-config-derive/src/parse.rs | 267 ++-- .../tests/pass/aggregate_sections.rs | 16 +- .../tests/pass/config_enum.rs | 25 + .../tests/pass/root_bool.rs | 6 +- .../kaptan-config-derive/tests/section.rs | 216 +++- .../ui/config_enum_implicit_discriminant.rs | 10 + .../config_enum_implicit_discriminant.stderr | 5 + .../tests/ui/config_enum_label_nul.rs | 10 + .../tests/ui/config_enum_label_nul.stderr | 5 + .../ui/config_enum_label_too_many_bytes.rs | 10 + .../config_enum_label_too_many_bytes.stderr | 5 + .../tests/ui/config_enum_missing_repr.rs | 8 + .../tests/ui/config_enum_missing_repr.stderr | 5 + .../tests/ui/config_enum_payload.rs | 9 + .../tests/ui/config_enum_payload.stderr | 5 + .../tests/ui/default_below_minimum.rs | 9 - .../tests/ui/default_below_minimum.stderr | 5 - .../tests/ui/default_violates_finite.rs | 9 - .../tests/ui/default_violates_finite.stderr | 5 - .../tests/ui/default_violates_nonzero.rs | 9 - .../tests/ui/default_violates_nonzero.stderr | 5 - .../tests/ui/display_key_too_long.rs | 13 + .../tests/ui/display_key_too_long.stderr | 5 + .../tests/ui/duplicate_display_key.rs | 11 + .../tests/ui/duplicate_display_key.stderr | 5 + .../tests/ui/duplicate_id.rs | 4 +- .../tests/ui/duplicate_range.rs | 4 +- .../kaptan-config-derive/tests/ui/empty.rs | 4 +- .../tests/ui/empty.stderr | 2 +- .../tests/ui/enum_marker_on_primitive.rs | 9 + .../tests/ui/enum_marker_on_primitive.stderr | 5 + .../tests/ui/missing_enum_marker.rs | 15 + .../tests/ui/missing_enum_marker.stderr | 5 + .../tests/ui/missing_id.rs | 4 +- .../tests/ui/missing_id.stderr | 2 +- .../tests/ui/missing_metadata.rs | 6 +- .../tests/ui/mixed_section_scalar.rs | 7 +- .../tests/ui/mixed_section_scalar.stderr | 6 +- .../tests/ui/range_minimum_exceeds_maximum.rs | 6 +- .../tests/ui/unit_too_long.rs | 9 + .../tests/ui/unit_too_long.stderr | 5 + .../tests/ui/unsupported_default.rs | 9 + .../tests/ui/unsupported_default.stderr | 5 + .../tests/ui/unsupported_external_defaults.rs | 10 + .../ui/unsupported_external_defaults.stderr | 5 + .../libs/kaptan-config-mavlink/Cargo.toml | 5 - .../libs/kaptan-config-mavlink/src/command.rs | 155 +-- .../libs/kaptan-config-mavlink/src/lib.rs | 23 +- .../kaptan-config-mavlink/src/protocol.rs | 89 +- .../libs/kaptan-config-mavlink/src/service.rs | 8 +- .../src/service/import.rs | 248 ---- .../src/service/query.rs | 130 +- .../src/service/replay.rs | 161 +-- .../src/service/session.rs | 1113 +++++++++-------- .../libs/kaptan-config-mavlink/src/wire.rs | 381 ++++-- firmware/libs/kaptan-config/src/error.rs | 6 +- firmware/libs/kaptan-config/src/field.rs | 133 +- firmware/libs/kaptan-config/src/journal.rs | 524 ++++++++ firmware/libs/kaptan-config/src/lib.rs | 21 +- firmware/libs/kaptan-config/src/lifecycle.rs | 150 ++- firmware/libs/kaptan-config/src/snapshot.rs | 781 ++++++++++++ firmware/libs/kaptan-config/src/storage.rs | 49 +- firmware/libs/kaptan-config/src/validation.rs | 232 +++- firmware/libs/kaptan-control/src/lib.rs | 2 +- firmware/libs/kaptan-data-channel/Cargo.toml | 1 - firmware/libs/kaptan-data-channel/src/lib.rs | 207 +-- .../libs/kaptan-drivers/src/mpu6000/config.rs | 11 - .../libs/kaptan-drivers/src/mpu6000/mod.rs | 20 +- .../kaptan-drivers/src/mpu6000/service.rs | 16 +- .../libs/kaptan-flight-config/src/cbor.rs | 32 +- firmware/libs/kaptan-flight-config/src/lib.rs | 7 +- .../src/profile/active.rs | 8 +- .../src/profile/candidate.rs | 197 +-- .../src/profile/control.rs | 30 +- .../src/profile/mavlink_logging.rs | 44 + .../kaptan-flight-config/src/profile/mod.rs | 5 +- .../src/profile/observability.rs | 35 - .../src/profile/registry.rs | 80 -- .../src/profile/safety.rs | 5 +- .../src/profile/timing.rs | 8 +- .../libs/kaptan-flight-runtime/src/lib.rs | 137 +- firmware/libs/kaptan-log-mavlink/src/lib.rs | 115 +- firmware/libs/kaptan-log/Cargo.toml | 1 + firmware/libs/kaptan-log/src/facade.rs | 139 +- firmware/libs/kaptan-log/src/filter.rs | 78 +- firmware/libs/kaptan-log/src/hub.rs | 357 +++--- firmware/libs/kaptan-log/src/level.rs | 25 +- firmware/libs/kaptan-log/src/lib.rs | 16 +- firmware/libs/kaptan-log/src/mailbox.rs | 97 +- firmware/libs/kaptan-log/src/report.rs | 36 +- firmware/libs/kaptan-log/src/shutdown.rs | 4 +- firmware/libs/kaptan-log/src/text.rs | 82 +- .../definitions/kaptan/config.xml | 87 +- .../definitions/kaptan/error.xml | 3 +- .../kaptan-mavlink/definitions/kaptan/log.xml | 7 +- .../definitions/kaptan/shutdown.xml | 4 +- firmware/libs/kaptan-mavlink/src/encoder.rs | 9 - firmware/libs/kaptan-mavlink/src/lib.rs | 2 + firmware/libs/kaptan-mavlink/src/receiver.rs | 150 +-- .../libs/kaptan-mavlink/src/transmitter.rs | 33 +- firmware/libs/kaptan-tx/src/lib.rs | 10 + firmware/platforms/stm32f7/Cargo.toml | 6 +- firmware/platforms/stm32f7/src/f722/board.rs | 517 +++++--- firmware/platforms/stm32f7/src/f722/config.rs | 421 +++++++ .../stm32f7/src/f722/data_channel.rs | 116 +- .../platforms/stm32f7/src/f722/firmware.rs | 71 +- firmware/platforms/stm32f7/src/f722/mod.rs | 65 +- .../stm32f7/src/f722/motor_inhibit.rs | 7 +- .../stm32f7/src/f722/peripherals/usart.rs | 14 + .../peripherals/usart1_tx_dma2_stream7.rs | 37 +- .../peripherals/usart2_tx_dma1_stream6.rs | 44 +- firmware/platforms/stm32f7/src/f722/routes.rs | 21 +- .../platforms/stm32f7/src/f722/sensors.rs | 22 +- firmware/platforms/stm32f7/src/lib.rs | 626 +++++---- .../stm32f7/tests/config_active_state.rs | 250 ++++ .../stm32f7/tests/platform_safety_boundary.rs | 367 ++++-- firmware/scripts/report-test-coverage.sh | 1 - firmware/tools/sitl/Cargo.toml | 2 +- firmware/tools/sitl/kaptan-sim | 18 +- firmware/tools/sitl/src/config/logging.rs | 15 +- firmware/tools/sitl/src/config/mod.rs | 4 +- firmware/tools/sitl/src/config/runtime.rs | 87 +- firmware/tools/sitl/src/config/store.rs | 91 +- firmware/tools/sitl/src/config/tests.rs | 312 +---- firmware/tools/sitl/src/kaptan_sitl_quad.cc | 14 +- firmware/tools/sitl/src/main.rs | 22 +- .../tools/sitl/worlds/kaptan-sitl-quad.sdf.in | 2 +- 140 files changed, 6891 insertions(+), 4541 deletions(-) delete mode 100644 firmware/libs/kaptan-board/Cargo.toml delete mode 100644 firmware/libs/kaptan-board/src/lib.rs create mode 100644 firmware/libs/kaptan-config-derive/src/config_enum.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/pass/config_enum.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/config_enum_implicit_discriminant.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/config_enum_implicit_discriminant.stderr create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_nul.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_nul.stderr create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_too_many_bytes.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_too_many_bytes.stderr create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/config_enum_missing_repr.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/config_enum_missing_repr.stderr create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/config_enum_payload.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/config_enum_payload.stderr delete mode 100644 firmware/libs/kaptan-config-derive/tests/ui/default_below_minimum.rs delete mode 100644 firmware/libs/kaptan-config-derive/tests/ui/default_below_minimum.stderr delete mode 100644 firmware/libs/kaptan-config-derive/tests/ui/default_violates_finite.rs delete mode 100644 firmware/libs/kaptan-config-derive/tests/ui/default_violates_finite.stderr delete mode 100644 firmware/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.rs delete mode 100644 firmware/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.stderr create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/display_key_too_long.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/display_key_too_long.stderr create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/duplicate_display_key.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/duplicate_display_key.stderr create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/enum_marker_on_primitive.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/enum_marker_on_primitive.stderr create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/missing_enum_marker.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/missing_enum_marker.stderr create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/unit_too_long.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/unit_too_long.stderr create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/unsupported_default.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/unsupported_default.stderr create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/unsupported_external_defaults.rs create mode 100644 firmware/libs/kaptan-config-derive/tests/ui/unsupported_external_defaults.stderr delete mode 100644 firmware/libs/kaptan-config-mavlink/src/service/import.rs create mode 100644 firmware/libs/kaptan-config/src/journal.rs create mode 100644 firmware/libs/kaptan-config/src/snapshot.rs create mode 100644 firmware/libs/kaptan-flight-config/src/profile/mavlink_logging.rs delete mode 100644 firmware/libs/kaptan-flight-config/src/profile/observability.rs delete mode 100644 firmware/libs/kaptan-flight-config/src/profile/registry.rs create mode 100644 firmware/platforms/stm32f7/src/f722/config.rs create mode 100644 firmware/platforms/stm32f7/tests/config_active_state.rs diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock index 6195958..a8c1d6e 100644 --- a/firmware/Cargo.lock +++ b/firmware/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -151,13 +151,6 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" name = "kaptan-actuator" version = "0.1.0" -[[package]] -name = "kaptan-board" -version = "0.1.0" -dependencies = [ - "kaptan-log", -] - [[package]] name = "kaptan-config" version = "0.1.0" @@ -198,7 +191,6 @@ dependencies = [ name = "kaptan-data-channel" version = "0.1.0" dependencies = [ - "kaptan-board", "kaptan-dma", "kaptan-log", "kaptan-mavlink", @@ -243,8 +235,9 @@ dependencies = [ name = "kaptan-geprc-f722-v2" version = "0.1.0" dependencies = [ - "kaptan-board", + "kaptan-config", "platform-stm32f7", + "rtic", ] [[package]] @@ -255,14 +248,16 @@ version = "0.1.0" name = "kaptan-kakute-f7-mini-v3" version = "0.1.0" dependencies = [ - "kaptan-board", + "kaptan-config", "platform-stm32f7", + "rtic", ] [[package]] name = "kaptan-log" version = "0.1.0" dependencies = [ + "kaptan-config-derive", "kaptan-log-derive", "log", ] @@ -486,7 +481,8 @@ version = "0.1.0" dependencies = [ "cortex-m", "cortex-m-rt", - "kaptan-board", + "kaptan-config", + "kaptan-config-mavlink", "kaptan-data-channel", "kaptan-dma", "kaptan-drivers", @@ -510,9 +506,9 @@ checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -528,9 +524,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -549,9 +545,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -645,7 +641,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.0", + "syn 3.0.3", ] [[package]] @@ -694,9 +690,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.0" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -720,22 +716,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.0", + "syn 3.0.3", ] [[package]] diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index c9da178..6108f48 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -3,7 +3,6 @@ members = [ "boards/geprc-f722-v2", "boards/kakute-f7-mini-v3", "libs/kaptan-actuator", - "libs/kaptan-board", "libs/kaptan-control", "libs/kaptan-config", "libs/kaptan-config-derive", diff --git a/firmware/boards/geprc-f722-v2/Cargo.toml b/firmware/boards/geprc-f722-v2/Cargo.toml index 896ad54..b8112fe 100644 --- a/firmware/boards/geprc-f722-v2/Cargo.toml +++ b/firmware/boards/geprc-f722-v2/Cargo.toml @@ -10,8 +10,11 @@ build = "build.rs" workspace = true [dependencies] -kaptan-board = { path = "../../libs/kaptan-board" } +kaptan-config = { path = "../../libs/kaptan-config" } platform-stm32f7 = { path = "../../platforms/stm32f7" } +# RTIC's attribute expansion emits absolute `rtic` support paths in the final +# board crate; the task graph and all RTIC source remain platform-owned. +rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] } [[bin]] name = "kaptan-geprc-f722-v2" diff --git a/firmware/boards/geprc-f722-v2/src/board.rs b/firmware/boards/geprc-f722-v2/src/board.rs index 283cda8..4fba604 100644 --- a/firmware/boards/geprc-f722-v2/src/board.rs +++ b/firmware/boards/geprc-f722-v2/src/board.rs @@ -1,45 +1,92 @@ //! Declarative support for the GEPRC TAKER F722 BLS 60A V2 stack. -use kaptan_board::{ - BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort, - Severity, TelemetryProfile, VerticalSensingKind, -}; use platform_stm32f7::{ - BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Icm42688, Mavlink, Pc3, VerticalSensor, + F7Board, F722Hardware, F722ImmutableConfig as ImmutableConfig, Icm42688, ImuMount, Mavlink, + Pa13ActiveHigh, Pb0Pb1Pb4Pb5Low, Pc3, Severity, VerticalSensor, }; +/// User-writable MAVLink endpoint policy. +#[derive(Clone, kaptan_config::Config)] +struct MavlinkConfig { + #[config( + id = 101, + key = "mavlink.system_id", + unit = "id", + range(min = 1, max = 255) + )] + system_id: u8, + #[config( + id = 102, + key = "mavlink.component_id", + unit = "id", + range(min = 1, max = 255) + )] + component_id: u8, + #[config(id = 108, key = "mavlink.logging.enabled", unit = "bool")] + logging_enabled: bool, + #[config( + enum, + id = 105, + key = "mavlink.logging.maximum_severity", + unit = "mav_severity" + )] + maximum_log_severity: Severity, +} + +/// User-writable telemetry publication policy. +#[derive(Clone, kaptan_config::Config)] +struct TelemetryConfig { + #[config( + id = 103, + key = "telemetry.raw_imu_hz", + unit = "Hz", + range(min = 1, max = 100) + )] + raw_imu_hz: u8, + #[config( + id = 104, + key = "telemetry.heartbeat_hz", + unit = "Hz", + range(min = 1, max = 10) + )] + heartbeat_hz: u8, +} + +/// Complete user-writable configuration advertised by this board. +#[derive(Clone, kaptan_config::Config)] +pub struct UserConfig { + mavlink: MavlinkConfig, + telemetry: TelemetryConfig, +} + /// ICM-42688-P-based GEP-F722-HD V2 paired with the 60 A stack. pub struct GeprcF722V2; impl F7Board for GeprcF722V2 { - const CONFIG: BoardConfig = BoardConfig { - name: "GEPRC TAKER F722 V2 (ICM-42688-P)", - mavlink: MavlinkEndpoint { + type UserConfig = UserConfig; + type ImmutableConfig = ImmutableConfig; + + const FACTORY_USER_CONFIG: UserConfig = UserConfig { + mavlink: MavlinkConfig { system_id: 1, component_id: 1, - port: MavlinkPort::Serial2, - baud: 115_200, + logging_enabled: true, + maximum_log_severity: Severity::Info, + }, + telemetry: TelemetryConfig { + raw_imu_hz: 20, + heartbeat_hz: 2, }, - profile: BoardProfile::TelemetryOnly(TelemetryProfile { - imu: ImuDevice::Icm42688, - imu_mount: ImuMount::Yaw180, - vertical_sensing: VerticalSensingKind::None, - data_channel: DataChannelRates { - service_hz: 100, - raw_imu_hz: 20, - range_hz: None, - optical_flow_hz: None, - heartbeat_hz: 2, - }, - maximum_log_level: Severity::Info, - }), }; - - fn build(platform: F7BoardBuilder) -> Result { - platform - .imu(Icm42688::on_spi3().with_drdy(Pc3)) - .mavlink(Mavlink::on_usart2()) + const IMMUTABLE_CONFIG: ImmutableConfig = ImmutableConfig { + name: "GEPRC TAKER F722 V2 (ICM-42688-P)", + service_hz: 100, + hardware: F722Hardware::new() + .output_inhibit(Pb0Pb1Pb4Pb5Low) + .imu(Icm42688::on_spi3().with_drdy(Pc3).mounted(ImuMount::Yaw180)) + .mavlink(Mavlink::on_usart2().baud(115_200)) .vertical_sensor(VerticalSensor::none()) - .build() - } + .status_led(Pa13ActiveHigh) + .telemetry_only(), + }; } diff --git a/firmware/boards/geprc-f722-v2/src/main.rs b/firmware/boards/geprc-f722-v2/src/main.rs index 949b135..99ce239 100644 --- a/firmware/boards/geprc-f722-v2/src/main.rs +++ b/firmware/boards/geprc-f722-v2/src/main.rs @@ -3,4 +3,4 @@ mod board; -platform_stm32f7::flight_firmware!(board::GeprcF722V2); +platform_stm32f7::flight_firmware!(crate::board::GeprcF722V2); diff --git a/firmware/boards/kakute-f7-mini-v3/Cargo.toml b/firmware/boards/kakute-f7-mini-v3/Cargo.toml index ae468f1..4810773 100644 --- a/firmware/boards/kakute-f7-mini-v3/Cargo.toml +++ b/firmware/boards/kakute-f7-mini-v3/Cargo.toml @@ -8,12 +8,12 @@ version.workspace = true [lints] workspace = true -[features] -lidar-lite-v3 = [] - [dependencies] -kaptan-board = { path = "../../libs/kaptan-board" } +kaptan-config = { path = "../../libs/kaptan-config" } platform-stm32f7 = { path = "../../platforms/stm32f7" } +# RTIC's attribute expansion emits absolute `rtic` support paths in the final +# board crate; the task graph and all RTIC source remain platform-owned. +rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] } [[bin]] name = "kaptan-kakute-f7-mini-v3" diff --git a/firmware/boards/kakute-f7-mini-v3/src/board.rs b/firmware/boards/kakute-f7-mini-v3/src/board.rs index dc10554..8f9a2c9 100644 --- a/firmware/boards/kakute-f7-mini-v3/src/board.rs +++ b/firmware/boards/kakute-f7-mini-v3/src/board.rs @@ -4,48 +4,120 @@ //! code, interrupt names, DMA handling, or task scheduling. Those concerns //! belong to the STM32F7 platform crate. -use kaptan_board::{ - BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort, - Severity, TelemetryProfile, VerticalSensingKind, -}; use platform_stm32f7::{ - BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Mavlink, Mpu6000, Pa4, VerticalSensor, + AccelerometerRange, DigitalLowPassFilter, F7Board, F722Hardware, + F722ImmutableConfig as ImmutableConfig, GyroscopeRange, ImuMount, Mavlink, Mpu6000, + Mpu6000Config, Pa4, Pb0Pb1Pb4Pb5Low, Pd2ActiveLow, Severity, VerticalSensor, }; +/// User-writable MAVLink endpoint policy. +#[derive(Clone, kaptan_config::Config)] +struct MavlinkConfig { + #[config( + id = 101, + key = "mavlink.system_id", + unit = "id", + range(min = 1, max = 255) + )] + system_id: u8, + #[config( + id = 102, + key = "mavlink.component_id", + unit = "id", + range(min = 1, max = 255) + )] + component_id: u8, + #[config(id = 108, key = "mavlink.logging.enabled", unit = "bool")] + logging_enabled: bool, + #[config( + enum, + id = 105, + key = "mavlink.logging.maximum_severity", + unit = "mav_severity" + )] + maximum_log_severity: Severity, +} + +/// User-writable telemetry publication policy. +#[derive(Clone, kaptan_config::Config)] +struct TelemetryConfig { + #[config( + id = 103, + key = "telemetry.raw_imu_hz", + unit = "Hz", + range(min = 1, max = 100) + )] + raw_imu_hz: u8, + #[config( + id = 104, + key = "telemetry.heartbeat_hz", + unit = "Hz", + range(min = 1, max = 10) + )] + heartbeat_hz: u8, + #[config( + id = 106, + key = "telemetry.range_hz", + unit = "Hz", + range(min = 1, max = 100) + )] + range_hz: u8, + #[config( + id = 107, + key = "telemetry.optical_flow_hz", + unit = "Hz", + range(min = 1, max = 100) + )] + optical_flow_hz: u8, +} + +/// Complete user-writable configuration advertised by this board. +#[derive(Clone, kaptan_config::Config)] +pub struct UserConfig { + mavlink: MavlinkConfig, + telemetry: TelemetryConfig, +} + /// Holybro Kakute F7 Mini V3. pub struct KakuteF7MiniV3; -const CONFIG: BoardConfig = BoardConfig { - name: "Kakute F7 Mini V3", - mavlink: MavlinkEndpoint { - system_id: 1, - component_id: 1, - port: MavlinkPort::Serial1, - baud: 115_200, - }, - profile: BoardProfile::TelemetryOnly(TelemetryProfile { - imu: ImuDevice::Mpu6000, - imu_mount: ImuMount::BodyAligned, - vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow, - data_channel: DataChannelRates { - service_hz: 100, - raw_imu_hz: 20, - range_hz: Some(20), - optical_flow_hz: Some(20), - heartbeat_hz: 2, - }, - maximum_log_level: Severity::Info, - }), -}; - impl F7Board for KakuteF7MiniV3 { - const CONFIG: BoardConfig = CONFIG; + type UserConfig = UserConfig; + type ImmutableConfig = ImmutableConfig; - fn build(platform: F7BoardBuilder) -> Result { - platform - .imu(Mpu6000::on_spi1().with_drdy(Pa4)) - .mavlink(Mavlink::on_usart1()) - .vertical_sensor(VerticalSensor::mtf01_on_uart4()) - .build() - } + const FACTORY_USER_CONFIG: UserConfig = UserConfig { + mavlink: MavlinkConfig { + system_id: 1, + component_id: 1, + logging_enabled: true, + maximum_log_severity: Severity::Info, + }, + telemetry: TelemetryConfig { + raw_imu_hz: 20, + heartbeat_hz: 2, + range_hz: 20, + optical_flow_hz: 20, + }, + }; + const IMMUTABLE_CONFIG: ImmutableConfig = ImmutableConfig { + name: "Kakute F7 Mini V3", + service_hz: 100, + hardware: F722Hardware::new() + .output_inhibit(Pb0Pb1Pb4Pb5Low) + .imu( + Mpu6000::on_spi1() + .with_drdy(Pa4) + .mounted(ImuMount::BodyAligned) + .configured(Mpu6000Config { + accelerometer: AccelerometerRange::PlusMinus4G, + gyroscope: GyroscopeRange::PlusMinus500DegreesPerSecond, + digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz, + sample_rate_divider: 9, + }), + ) + .mavlink(Mavlink::on_usart1().baud(115_200)) + .vertical_sensor(VerticalSensor::mtf01_on_uart4().baud(115_200)) + .status_led(Pd2ActiveLow) + .telemetry_only(), + }; } diff --git a/firmware/boards/kakute-f7-mini-v3/src/main.rs b/firmware/boards/kakute-f7-mini-v3/src/main.rs index 8a69580..aede887 100644 --- a/firmware/boards/kakute-f7-mini-v3/src/main.rs +++ b/firmware/boards/kakute-f7-mini-v3/src/main.rs @@ -2,4 +2,4 @@ #![no_main] mod board; -platform_stm32f7::flight_firmware!(board::KakuteF7MiniV3); +platform_stm32f7::flight_firmware!(crate::board::KakuteF7MiniV3); diff --git a/firmware/libs/kaptan-board/Cargo.toml b/firmware/libs/kaptan-board/Cargo.toml deleted file mode 100644 index 9516a6d..0000000 --- a/firmware/libs/kaptan-board/Cargo.toml +++ /dev/null @@ -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" } diff --git a/firmware/libs/kaptan-board/src/lib.rs b/firmware/libs/kaptan-board/src/lib.rs deleted file mode 100644 index 343d226..0000000 --- a/firmware/libs/kaptan-board/src/lib.rs +++ /dev/null @@ -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, - /// Optical-flow publication rate. `None` means flow is not published. - pub optical_flow_hz: Option, - /// 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 { - 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) - ); - } -} diff --git a/firmware/libs/kaptan-config-derive/src/config_enum.rs b/firmware/libs/kaptan-config-derive/src/config_enum.rs new file mode 100644 index 0000000..51b1b8b --- /dev/null +++ b/firmware/libs/kaptan-config-derive/src/config_enum.rs @@ -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 { + 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 { + 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::>(); + if representations.len() != 1 { + return Err(syn::Error::new( + span, + "ConfigEnum requires exactly #[repr(u8)]", + )); + } + let arguments = + representations[0].parse_args_with(Punctuated::::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 { + 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::().map_err(|_| { + syn::Error::new( + literal.span(), + "ConfigEnum discriminant is outside the u8 domain", + ) + }) +} + +fn variant_label(attributes: &[Attribute], ident: &Ident) -> Result { + 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::()?); + 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) +} diff --git a/firmware/libs/kaptan-config-derive/src/expand.rs b/firmware/libs/kaptan-config-derive/src/expand.rs index 9330d8a..af061c7 100644 --- a/firmware/libs/kaptan-config-derive/src/expand.rs +++ b/firmware/libs/kaptan-config-derive/src/expand.rs @@ -15,7 +15,7 @@ pub(crate) fn expand(model: Model) -> Result { } = model; match kind { ModelKind::Scalars => expand_scalars(ident, generics, crate_path, post_validate, fields), - ModelKind::Sections => expand_sections(ident, generics, crate_path, post_validate, fields), + ModelKind::Sections => expand_children(ident, generics, crate_path, post_validate, fields), } } @@ -29,7 +29,6 @@ fn expand_scalars( let (impl_generics, type_generics, where_clause) = generics.split_for_impl(); let mut checks = Vec::new(); let mut descriptors = Vec::new(); - let mut defaults = Vec::new(); let mut reads = Vec::new(); let mut writes = Vec::new(); for field in &fields { @@ -37,38 +36,62 @@ fn expand_scalars( descriptor, checks: field_checks, } = expand_field(&crate_path, field)?; - if let Some(descriptor) = descriptor { - descriptors.push(descriptor); - } + descriptors.push(descriptor); checks.extend(field_checks); - let id = field.id.expect("ConfigValidate requires configured fields"); - let default = field - .default - .as_ref() - .expect("ConfigValidate requires defaults"); - let (kind, value) = config_value_tokens(&crate_path, &field.ty)?; + let id = field.id.expect("scalar configuration fields have IDs"); + let ty = &field.ty; let field_ident = &field.ident; - defaults.push(quote! { #field_ident: #default }); + let (kind, read, decode, out_of_domain_rule) = if !field.enum_backed { + ( + quote! { <#ty as #crate_path::ConfigScalar>::KIND }, + quote! { #crate_path::ConfigScalar::into_config_value(self.#field_ident) }, + quote! { <#ty as #crate_path::ConfigScalar>::try_from_config_value(value) }, + quote! { #crate_path::ValidationRule::AboveMaximum }, + ) + } else { + ( + quote! { #crate_path::ValueKind::U32 }, + quote! { + #crate_path::ConfigValue::U32(u32::from(self.#field_ident.wire_value())) + }, + quote! { + match value { + #crate_path::ConfigValue::U32(value) => u8::try_from(value) + .ok() + .and_then(<#ty>::try_from_wire) + .ok_or(#crate_path::ScalarValueError::OutOfDomain), + value => Err(#crate_path::ScalarValueError::WrongKind(value.kind())), + } + }, + quote! { #crate_path::ValidationRule::InvalidEnumValue }, + ) + }; reads.push(quote! { - #id => Some(#value(self.#field_ident)), + #id => Some(#read), }); writes.push(quote! { #id => { - let next = match value { - #value(next) => next, - actual => { + let next = match #decode { + Ok(next) => next, + Err(#crate_path::ScalarValueError::WrongKind(actual)) => { return Err(#crate_path::FieldWriteError::WrongType { field: #crate_path::FieldId::new(#id), expected: #kind, - actual: actual.kind(), + actual, }); } + Err(#crate_path::ScalarValueError::OutOfDomain) => { + return Err(#crate_path::FieldWriteError::Violation( + #crate_path::ConfigViolation::field( + #crate_path::FieldId::new(#id), + #out_of_domain_rule, + ), + )); + } }; let previous = self.#field_ident; self.#field_ident = next; - if let Err(violation) = - ::validate_local_fields(self) - { + if let Err(violation) = self.__validate_local_configuration() { self.#field_ident = previous; return Err(#crate_path::FieldWriteError::Violation(violation)); } @@ -78,30 +101,32 @@ fn expand_scalars( } let post_validate = post_validate.map(|hook| quote! { #hook(self)?; }); Ok(quote! { - impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause { - fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> { - ::validate_local_fields(self)?; - #post_validate + impl #impl_generics #ident #type_generics #where_clause { + /// Field metadata generated from the same declarations as validation. + pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[ + #(#descriptors),* + ]; + + fn __validate_local_configuration( + &self, + ) -> Result<(), #crate_path::ConfigViolation> { + if !#crate_path::validation::__valid_enum_options(Self::DESCRIPTORS) { + return Err(#crate_path::ConfigViolation::global( + #crate_path::ValidationRule::Inconsistent, + )); + } + #(#checks)* Ok(()) } } - impl #impl_generics #ident #type_generics #where_clause { - /// Field metadata generated from the same attributes as validation. - pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[ - #(#descriptors),* - ]; - } - - impl #impl_generics #crate_path::ConfigSection for #ident #type_generics #where_clause { + impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause { const FIELD_COUNT: usize = Self::DESCRIPTORS.len(); + const SCHEMA_ID: [u8; 16] = + #crate_path::validation::__schema_id(Self::DESCRIPTORS); - fn default_section() -> Result { - let section = Self { - #(#defaults),* - }; - ::validate_fields(§ion)?; - Ok(section) + fn descriptor(index: usize) -> Option<&'static #crate_path::FieldDescriptor> { + Self::DESCRIPTORS.get(index) } fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> { @@ -122,15 +147,16 @@ fn expand_scalars( } } - fn validate_local_fields(&self) -> Result<(), #crate_path::ConfigViolation> { - #(#checks)* + fn validate(&self) -> Result<(), #crate_path::ConfigViolation> { + self.__validate_local_configuration()?; + #post_validate Ok(()) } } }) } -fn expand_sections( +fn expand_children( ident: syn::Ident, generics: syn::Generics, crate_path: syn::Path, @@ -138,71 +164,55 @@ fn expand_sections( fields: Vec, ) -> Result { let (impl_generics, type_generics, where_clause) = generics.split_for_impl(); - let validations = fields.iter().map(|field| { + let mut validations = Vec::new(); + let mut reads = Vec::new(); + let mut writes = Vec::new(); + let mut counts = Vec::new(); + let mut schema_ids = Vec::new(); + let mut descriptor_lookups = Vec::new(); + for field in &fields { let field_ident = &field.ident; let ty = &field.ty; - quote! { - <#ty as #crate_path::ValidateFields>::validate_fields(&self.#field_ident)?; - } - }); - let defaults = fields.iter().map(|field| { - let field_ident = &field.ident; - let ty = &field.ty; - quote! { - #field_ident: <#ty as #crate_path::ConfigSection>::default_section()? - } - }); - let reads = fields.iter().map(|field| { - let field_ident = &field.ident; - let ty = &field.ty; - quote! { - if let Some(value) = <#ty as #crate_path::ConfigSection>::field_value( + validations.push(quote! { + <#ty as #crate_path::Config>::validate(&self.#field_ident)?; + }); + reads.push(quote! { + if let Some(value) = <#ty as #crate_path::Config>::field_value( &self.#field_ident, id, ) { return Some(value); } - } - }); - let writes = fields.iter().map(|field| { - let field_ident = &field.ident; - let ty = &field.ty; - quote! { - if <#ty as #crate_path::ConfigSection>::field_value(&self.#field_ident, id).is_some() { - return <#ty as #crate_path::ConfigSection>::set_field( + }); + writes.push(quote! { + if <#ty as #crate_path::Config>::field_value(&self.#field_ident, id).is_some() { + return <#ty as #crate_path::Config>::set_field( &mut self.#field_ident, id, value, ); } - } - }); - let counts = fields.iter().map(|field| { - let ty = &field.ty; - quote! { <#ty as #crate_path::ConfigSection>::FIELD_COUNT } - }); - let post_validate = post_validate.map(|hook| quote! { #hook(self)?; }); - Ok(quote! { - impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause { - fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> { - #(#validations)* - #post_validate - Ok(()) + }); + counts.push(quote! { <#ty as #crate_path::Config>::FIELD_COUNT }); + schema_ids.push(quote! { <#ty as #crate_path::Config>::SCHEMA_ID }); + descriptor_lookups.push(quote! { + if index < <#ty as #crate_path::Config>::FIELD_COUNT { + return <#ty as #crate_path::Config>::descriptor(index); } - } + index -= <#ty as #crate_path::Config>::FIELD_COUNT; + }); + } + let post_validate = post_validate.map(|hook| quote! { #hook(self)?; }); - impl #impl_generics #ident #type_generics #where_clause { - /// Number of fields aggregated from derived nested sections. - pub const FIELD_COUNT: usize = 0 #(+ #counts)*; - } + Ok(quote! { + impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause { + const FIELD_COUNT: usize = 0 #(+ #counts)*; + const SCHEMA_ID: [u8; 16] = + #crate_path::validation::__combine_schema_ids(&[#(#schema_ids),*]); - impl #impl_generics #crate_path::ConfigAggregate for #ident #type_generics #where_clause { - fn default_aggregate() -> Result { - let aggregate = Self { - #(#defaults),* - }; - ::validate_fields(&aggregate)?; - Ok(aggregate) + fn descriptor(mut index: usize) -> Option<&'static #crate_path::FieldDescriptor> { + #(#descriptor_lookups)* + None } fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> { @@ -218,23 +228,39 @@ fn expand_sections( #(#writes)* Err(#crate_path::FieldWriteError::UnknownField(id)) } + + fn validate(&self) -> Result<(), #crate_path::ConfigViolation> { + if !#crate_path::validation::__unique_field_ids::() { + return Err(#crate_path::ConfigViolation::global( + #crate_path::ValidationRule::Inconsistent, + )); + } + if !#crate_path::validation::__unique_display_keys::() { + 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 = ::FIELD_COUNT; } }) } struct FieldMetadata { - descriptor: Option, + descriptor: TokenStream, checks: Vec, } fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result { let mut checks = Vec::new(); - let Some(id) = field.id else { - return Ok(FieldMetadata { - descriptor: None, - checks, - }); - }; + let id = field.id.expect("scalar configuration fields have IDs"); let minimum = field.checks.iter().find_map(|check| match check { Check::Minimum(value) => Some(value), _ => None, @@ -248,23 +274,33 @@ fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Res } let key = field.display_key.as_ref().expect("id fields have a key"); let unit = field.unit.as_ref().expect("id fields have a unit"); - let default = field.default.as_ref().expect("id fields have a default"); - let (kind, value) = config_value_tokens(crate_path, &field.ty)?; + let ty = &field.ty; + let (kind, enum_options) = if !field.enum_backed { + ( + quote! { <#ty as #crate_path::ConfigScalar>::KIND }, + quote! { &[] }, + ) + } else { + ( + quote! { #crate_path::ValueKind::U32 }, + quote! { <#ty>::CONFIG_ENUM_OPTIONS }, + ) + }; + let value = config_value_constructor_tokens(crate_path, &field.ty)?; let minimum = option_value_tokens(value.clone(), minimum); let maximum = option_value_tokens(value.clone(), maximum); Ok(FieldMetadata { - descriptor: Some(quote! { + descriptor: quote! { #crate_path::FieldDescriptor { id: #crate_path::FieldId::new(#id), display_key: #key, kind: #kind, minimum: #minimum, maximum: #maximum, - default: #value(#default), + enum_options: #enum_options, unit: #unit, - apply_policy: #crate_path::ApplyPolicy::RestartRequired, } - }), + }, checks, }) } @@ -276,10 +312,7 @@ fn option_value_tokens(value: TokenStream, expression: Option<&syn::Expr>) -> To } } -fn config_value_tokens( - crate_path: &syn::Path, - ty: &syn::Type, -) -> Result<(TokenStream, TokenStream)> { +fn config_value_constructor_tokens(crate_path: &syn::Path, ty: &syn::Type) -> Result { let syn::Type::Path(path) = ty else { return Err(syn::Error::new_spanned( ty, @@ -292,39 +325,18 @@ fn config_value_tokens( "configuration metadata requires a scalar type", )); }; - let (kind, value) = match segment.ident.to_string().as_str() { - "bool" => ( - quote! { #crate_path::ValueKind::Bool }, - quote! { #crate_path::ConfigValue::Bool }, - ), - "i32" => ( - quote! { #crate_path::ValueKind::I32 }, - quote! { #crate_path::ConfigValue::I32 }, - ), - "u32" => ( - quote! { #crate_path::ValueKind::U32 }, - quote! { #crate_path::ConfigValue::U32 }, - ), - "u64" => ( - quote! { #crate_path::ValueKind::U64 }, - quote! { #crate_path::ConfigValue::U64 }, - ), - "f32" => ( - quote! { #crate_path::ValueKind::F32 }, - quote! { #crate_path::ConfigValue::F32 }, - ), - "f64" => ( - quote! { #crate_path::ValueKind::F64 }, - quote! { #crate_path::ConfigValue::F64 }, - ), + let value = match segment.ident.to_string().as_str() { + "bool" => quote! { #crate_path::ConfigValue::Bool }, + "i32" => quote! { #crate_path::ConfigValue::I32 }, + "u8" | "u16" | "u32" => quote! { #crate_path::ConfigValue::U32 }, + "u64" => quote! { #crate_path::ConfigValue::U64 }, + "f32" => quote! { #crate_path::ConfigValue::F32 }, + "f64" => quote! { #crate_path::ConfigValue::F64 }, _ => { - return Err(syn::Error::new_spanned( - ty, - "configuration metadata supports bool, i32, u32, u64, f32, and f64", - )); + return Ok(quote! { #crate_path::ConfigValue::U32 }); } }; - Ok((kind, value)) + Ok(value) } fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Check) -> TokenStream { diff --git a/firmware/libs/kaptan-config-derive/src/lib.rs b/firmware/libs/kaptan-config-derive/src/lib.rs index 3b97579..2c6a95a 100644 --- a/firmware/libs/kaptan-config-derive/src/lib.rs +++ b/firmware/libs/kaptan-config-derive/src/lib.rs @@ -1,17 +1,36 @@ -//! Procedural validation derive for Kaptan configuration records. +//! Procedural derive for recursively composable Kaptan configuration records. +mod config_enum; mod expand; mod parse; use proc_macro::TokenStream; use syn::parse_macro_input; -/// Implements `kaptan_config::ValidateFields` from `#[config(...)]` helpers. -#[proc_macro_derive(ConfigValidate, attributes(config))] -pub fn derive_config_validate(input: TokenStream) -> TokenStream { +/// Generates validation, metadata, routing, and schema identity. +/// +/// Primitive fields retain their native scalar type. Enum-backed fields must +/// opt in with `#[config(enum)]` and derive [`ConfigEnum`]. +#[proc_macro_derive(Config, attributes(config))] +pub fn derive_config(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as syn::DeriveInput); match parse::Model::parse(input).and_then(expand::expand) { Ok(tokens) => tokens.into(), Err(error) => error.into_compile_error().into(), } } + +/// Generates the canonical table and conversions for a field configured as a +/// compact enum. +/// +/// The enum must use `#[repr(u8)]`, have explicit integer discriminants, and +/// contain only fieldless variants. Variant identifiers are the default GCS +/// labels; `#[config_enum(label = "...")]` overrides one label. +#[proc_macro_derive(ConfigEnum, attributes(config_enum))] +pub fn derive_config_enum(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as syn::DeriveInput); + match config_enum::expand(input) { + Ok(tokens) => tokens.into(), + Err(error) => error.into_compile_error().into(), + } +} diff --git a/firmware/libs/kaptan-config-derive/src/parse.rs b/firmware/libs/kaptan-config-derive/src/parse.rs index 6f1b8d5..9080390 100644 --- a/firmware/libs/kaptan-config-derive/src/parse.rs +++ b/firmware/libs/kaptan-config-derive/src/parse.rs @@ -3,6 +3,9 @@ use syn::{ spanned::Spanned, }; +const MAX_DISPLAY_KEY_BYTES: usize = 48; +const MAX_UNIT_BYTES: usize = 16; + pub(crate) struct Model { pub(crate) ident: Ident, pub(crate) generics: syn::Generics, @@ -24,9 +27,8 @@ pub(crate) struct FieldModel { pub(crate) id: Option, pub(crate) display_key: Option, pub(crate) unit: Option, - pub(crate) default: Option, pub(crate) checks: Vec, - pub(crate) section: bool, + pub(crate) enum_backed: bool, } #[derive(Clone)] @@ -43,19 +45,19 @@ impl Model { let Data::Struct(data) = input.data else { return Err(syn::Error::new( input.ident.span(), - "ConfigValidate can only be derived for structs", + "Config can only be derived for structs", )); }; let Fields::Named(fields) = data.fields else { return Err(syn::Error::new( input.ident.span(), - "ConfigValidate requires named struct fields", + "Config requires named struct fields", )); }; if fields.named.is_empty() { return Err(syn::Error::new( input.ident.span(), - "ConfigValidate requires at least one configured field or section", + "Config requires at least one field", )); } let mut models = Vec::with_capacity(fields.named.len()); @@ -73,38 +75,63 @@ impl Model { } models.push(model); } - let kind = if models.iter().all(|field| field.section) { - ModelKind::Sections - } else if models.iter().all(|field| field.id.is_some()) { + let kind = if models.iter().all(|field| field.id.is_some()) { ModelKind::Scalars + } else if models.iter().all(|field| field.id.is_none()) { + ModelKind::Sections } else { let field = models .iter() - .find(|field| field.section || field.id.is_none()) + .find(|field| field.id.is_none()) .expect("mixed configuration model has one invalid field"); return Err(syn::Error::new( field.ident.span(), - "ConfigValidate records must contain either scalar config fields or config(section) fields exclusively", + "Config records must contain either scalar fields or child configuration records exclusively", )); }; if kind == ModelKind::Scalars { + let mut display_keys = Vec::with_capacity(models.len()); for field in &models { - if field.display_key.is_none() { + let Some(display_key) = &field.display_key else { return Err(syn::Error::new( field.ident.span(), "configuration fields require config(key = \"...\")", )); + }; + validate_metadata_text( + display_key, + MAX_DISPLAY_KEY_BYTES, + "configuration display key", + false, + )?; + if display_keys + .iter() + .any(|previous: &&syn::LitStr| previous.value() == display_key.value()) + { + return Err(syn::Error::new( + display_key.span(), + "duplicate configuration display key", + )); } - if field.unit.is_none() { + display_keys.push(display_key); + let Some(unit) = &field.unit else { return Err(syn::Error::new( field.ident.span(), "configuration fields require config(unit = \"...\")", )); - } - if field.default.is_none() { + }; + validate_metadata_text(unit, MAX_UNIT_BYTES, "configuration unit", true)?; + let primitive = scalar_type_name(&field.ty).is_some(); + if field.enum_backed && primitive { return Err(syn::Error::new( - field.ident.span(), - "configuration fields require config(default = ...)", + field.ty.span(), + "config(enum) requires a ConfigEnum-derived enum type", + )); + } + if !field.enum_backed && !primitive { + return Err(syn::Error::new( + field.ty.span(), + "non-primitive configuration fields require config(enum)", )); } validate_static_constraints(field)?; @@ -121,6 +148,34 @@ impl Model { } } +fn validate_metadata_text( + literal: &syn::LitStr, + maximum_bytes: usize, + name: &str, + allow_empty: bool, +) -> Result<()> { + let value = literal.value(); + if !allow_empty && value.is_empty() { + return Err(syn::Error::new( + literal.span(), + format!("{name} must not be empty"), + )); + } + if value.contains('\0') { + return Err(syn::Error::new( + literal.span(), + format!("{name} cannot contain NUL bytes"), + )); + } + if value.len() > maximum_bytes { + return Err(syn::Error::new( + literal.span(), + format!("{name} exceeds the {maximum_bytes}-byte wire limit"), + )); + } + Ok(()) +} + fn parse_container_attributes(attributes: &[Attribute]) -> Result<(Path, Option)> { let mut crate_path = syn::parse_quote!(::kaptan_config); let mut has_crate_path = false; @@ -160,40 +215,17 @@ fn parse_field(field: Field) -> Result { let mut id = None; let mut display_key = None; let mut unit = None; - let mut default = None; let mut finite = false; let mut nonzero = false; let mut range_minimum = false; let mut range_maximum = false; - let mut section = false; + let mut enum_backed = false; for attribute in field .attrs .iter() .filter(|attribute| attribute.path().is_ident("config")) { attribute.parse_nested_meta(|meta| { - if section && !meta.path.is_ident("section") { - return Err(meta.error( - "config(section) cannot be combined with scalar id, metadata, or validation rules", - )); - } - if meta.path.is_ident("section") { - if section { - return Err(meta.error("duplicate config(section)")); - } - if id.is_some() - || display_key.is_some() - || unit.is_some() - || default.is_some() - || !checks.is_empty() - { - return Err(meta.error( - "config(section) cannot be combined with scalar id, metadata, or validation rules", - )); - } - section = true; - return Ok(()); - } if meta.path.is_ident("id") { if id.is_some() { return Err(meta.error("duplicate configuration field id")); @@ -220,13 +252,11 @@ fn parse_field(field: Field) -> Result { unit = Some(meta.value()?.parse()?); return Ok(()); } - if meta.path.is_ident("default") { - if default.is_some() { - return Err(meta.error("duplicate configuration default")); + if meta.path.is_ident("enum") { + if enum_backed { + return Err(meta.error("duplicate enum configuration marker")); } - let expression = meta.value()?.parse()?; - validate_default_literal(&expression, &field.ty)?; - default = Some(expression); + enum_backed = true; return Ok(()); } if meta.path.is_ident("finite") { @@ -273,26 +303,16 @@ fn parse_field(field: Field) -> Result { return Ok(()); } Err(meta.error( - "supported field config rules are id, key, unit, default, finite, nonzero, and range", + "supported field config rules are id, key, unit, enum, finite, nonzero, and range", )) })?; } - if !section && !checks.is_empty() && id.is_none() { - return Err(syn::Error::new( - ident.span(), - "validated configuration fields require config(id = N)", - )); - } - if section - && (id.is_some() - || display_key.is_some() - || unit.is_some() - || default.is_some() - || !checks.is_empty()) + if id.is_none() + && (display_key.is_some() || unit.is_some() || enum_backed || !checks.is_empty()) { return Err(syn::Error::new( ident.span(), - "config(section) cannot be combined with scalar id, metadata, or validation rules", + "scalar configuration fields require config(id = N)", )); } Ok(FieldModel { @@ -301,9 +321,8 @@ fn parse_field(field: Field) -> Result { id, display_key, unit, - default, checks, - section, + enum_backed, }) } @@ -353,33 +372,19 @@ fn validate_numeric_literal(expression: &Expr) -> Result<()> { } } -fn validate_default_literal(expression: &Expr, ty: &Type) -> Result<()> { - if scalar_type_name(ty) == Some("bool") { - return match expression { - Expr::Lit(expression) if matches!(expression.lit, Lit::Bool(_)) => Ok(()), - _ => Err(syn::Error::new( - expression.span(), - "bool configuration defaults must be bool literals", - )), - }; - } - validate_numeric_literal(expression) -} - fn validate_static_constraints(field: &FieldModel) -> Result<()> { - let ty = scalar_type_name(&field.ty).ok_or_else(|| { - syn::Error::new( + let Some(ty) = scalar_type_name(&field.ty) else { + if field.checks.is_empty() { + return Ok(()); + } + return Err(syn::Error::new( field.ty.span(), - "configuration metadata requires a supported scalar type", - ) - })?; + "validation rules require a supported primitive scalar type", + )); + }; if ty == "bool" { return Ok(()); } - let default = scalar_literal( - field.default.as_ref().expect("scalar fields have defaults"), - ty, - )?; let minimum = field.checks.iter().find_map(|check| match check { Check::Minimum(value) => Some(value), _ => None, @@ -406,70 +411,6 @@ fn validate_static_constraints(field: &FieldModel) -> Result<()> { "configuration range minimum exceeds maximum", )); } - if field - .checks - .iter() - .any(|check| matches!(check, Check::Finite)) - && !default.is_finite() - { - return Err(syn::Error::new( - field - .default - .as_ref() - .expect("scalar fields have defaults") - .span(), - "configuration default violates finite", - )); - } - if !default.is_finite() { - return Err(syn::Error::new( - field - .default - .as_ref() - .expect("scalar fields have defaults") - .span(), - "configuration default is not finite for its field type", - )); - } - if field - .checks - .iter() - .any(|check| matches!(check, Check::Nonzero)) - && default.is_zero() - { - return Err(syn::Error::new( - field - .default - .as_ref() - .expect("scalar fields have defaults") - .span(), - "configuration default violates nonzero", - )); - } - if let Some(minimum) = minimum - && compare_scalar(default, minimum).is_lt() - { - return Err(syn::Error::new( - field - .default - .as_ref() - .expect("scalar fields have defaults") - .span(), - "configuration default is below its declared minimum", - )); - } - if let Some(maximum) = maximum - && compare_scalar(default, maximum).is_gt() - { - return Err(syn::Error::new( - field - .default - .as_ref() - .expect("scalar fields have defaults") - .span(), - "configuration default is above its declared maximum", - )); - } Ok(()) } @@ -487,14 +428,6 @@ impl ScalarLiteral { Self::Float(value) => value.is_finite(), } } - - fn is_zero(self) -> bool { - match self { - Self::Signed(value) => value == 0, - Self::Unsigned(value) => value == 0, - Self::Float(value) => value == 0.0, - } - } } fn scalar_literal(expression: &Expr, ty: &str) -> Result { @@ -519,6 +452,26 @@ fn scalar_literal(expression: &Expr, ty: &str) -> Result { })?; Ok(ScalarLiteral::Unsigned(value)) } + "u8" => { + let value = unsigned_literal(expression)?; + u8::try_from(value).map_err(|_| { + syn::Error::new( + expression.span(), + "configuration literal is outside u8 range", + ) + })?; + Ok(ScalarLiteral::Unsigned(value)) + } + "u16" => { + let value = unsigned_literal(expression)?; + u16::try_from(value).map_err(|_| { + syn::Error::new( + expression.span(), + "configuration literal is outside u16 range", + ) + })?; + Ok(ScalarLiteral::Unsigned(value)) + } "u64" => { let value = unsigned_literal(expression)?; u64::try_from(value).map_err(|_| { @@ -535,7 +488,7 @@ fn scalar_literal(expression: &Expr, ty: &str) -> Result { "f64" => float_literal(expression).map(ScalarLiteral::Float), _ => Err(syn::Error::new( expression.span(), - "configuration metadata supports bool, i32, u32, u64, f32, and f64", + "configuration metadata supports bool, i32, u8, u16, u32, u64, f32, and f64", )), } } diff --git a/firmware/libs/kaptan-config-derive/tests/pass/aggregate_sections.rs b/firmware/libs/kaptan-config-derive/tests/pass/aggregate_sections.rs index f318a55..4880e5e 100644 --- a/firmware/libs/kaptan-config-derive/tests/pass/aggregate_sections.rs +++ b/firmware/libs/kaptan-config-derive/tests/pass/aggregate_sections.rs @@ -1,27 +1,27 @@ -use kaptan_config::{ConfigAggregate, ConfigSection, ConfigViolation, ValidateFields}; -use kaptan_config_derive::ConfigValidate; +use kaptan_config::{Config, ConfigViolation}; -#[derive(ConfigValidate)] +#[derive(Config)] struct Child { - #[config(id = 1, key = "child.value", unit = "count", default = 1, range(min = 0, max = 2))] + #[config(id = 1, key = "child.value", unit = "count", range(min = 0, max = 2))] value: u32, } -#[derive(ConfigValidate)] +#[derive(Config)] #[config(post = Self::validate_complete)] struct Root { - #[config(section)] child: Child, } impl Root { fn validate_complete(&self) -> Result<(), ConfigViolation> { - self.child.validate_fields() + self.child.validate() } } fn main() { - let root = Root::default_aggregate().unwrap(); + let root = Root { + child: Child { value: 1 }, + }; assert_eq!(Root::FIELD_COUNT, 1); assert_eq!(root.child.field_value(kaptan_config::FieldId::new(1)), Some(kaptan_config::ConfigValue::U32(1))); } diff --git a/firmware/libs/kaptan-config-derive/tests/pass/config_enum.rs b/firmware/libs/kaptan-config-derive/tests/pass/config_enum.rs new file mode 100644 index 0000000..9c30e20 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/pass/config_enum.rs @@ -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 + ); +} diff --git a/firmware/libs/kaptan-config-derive/tests/pass/root_bool.rs b/firmware/libs/kaptan-config-derive/tests/pass/root_bool.rs index cf6e187..e2a55fb 100644 --- a/firmware/libs/kaptan-config-derive/tests/pass/root_bool.rs +++ b/firmware/libs/kaptan-config-derive/tests/pass/root_bool.rs @@ -1,8 +1,8 @@ -use kaptan_config_derive::ConfigValidate; +use kaptan_config_derive::Config; -#[derive(ConfigValidate)] +#[derive(Config)] struct RootBooleanConfig { - #[config(id = 1, key = "enabled", unit = "bool", default = true)] + #[config(id = 1, key = "enabled", unit = "bool")] enabled: bool, } diff --git a/firmware/libs/kaptan-config-derive/tests/section.rs b/firmware/libs/kaptan-config-derive/tests/section.rs index bfc4d35..a39ae65 100644 --- a/firmware/libs/kaptan-config-derive/tests/section.rs +++ b/firmware/libs/kaptan-config-derive/tests/section.rs @@ -1,37 +1,22 @@ use kaptan_config::{ - ConfigAggregate, ConfigSection, ConfigValue, ConfigViolation, FieldId, FieldWriteError, - ValidateFields, ValidationRule, + Config, ConfigEnum, ConfigValue, ConfigViolation, FieldId, FieldWriteError, ValidationRule, }; -use kaptan_config_derive::ConfigValidate; -#[derive(ConfigValidate)] +#[derive(Config)] struct LocalSection { - #[config( - id = 1, - key = "local.first", - unit = "count", - default = 2, - range(min = 1, max = 9) - )] + #[config(id = 1, key = "local.first", unit = "count", range(min = 1, max = 9))] first: u32, - #[config( - id = 2, - key = "local.second", - unit = "count", - default = 4, - range(min = 1, max = 9) - )] + #[config(id = 2, key = "local.second", unit = "count", range(min = 1, max = 9))] second: u32, } -#[derive(ConfigValidate)] +#[derive(Config)] #[config(post = Self::validate_order)] struct OrderedSection { #[config( id = 3, key = "ordered.minimum", unit = "count", - default = 1, range(min = 0, max = 9) )] minimum: u32, @@ -39,7 +24,6 @@ struct OrderedSection { id = 4, key = "ordered.maximum", unit = "count", - default = 2, range(min = 0, max = 9) )] maximum: u32, @@ -58,17 +42,54 @@ impl OrderedSection { } } -#[derive(ConfigValidate)] +#[derive(Config)] struct Aggregate { - #[config(section)] local: LocalSection, - #[config(section)] ordered: OrderedSection, } +#[derive(Config)] +struct BoardOwnedSection { + #[config( + id = 10, + key = "board.endpoint", + unit = "id", + range(min = 1, max = 255) + )] + endpoint: u32, +} + +#[derive(Config)] +struct BoardOwnedAggregate { + endpoint: BoardOwnedSection, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ConfigEnum)] +#[repr(u8)] +enum TestMode { + Quiet = 1, + Loud = 3, +} + +#[derive(Config)] +struct CompactSection { + #[config( + id = 20, + key = "compact.endpoint", + unit = "id", + range(min = 1, max = 255) + )] + endpoint: u8, + #[config(enum, id = 21, key = "compact.mode", unit = "mode")] + mode: TestMode, +} + #[test] fn generated_section_owns_every_declared_scalar_path() { - let mut section = LocalSection::default_section().unwrap(); + let mut section = LocalSection { + first: 2, + second: 4, + }; assert_eq!(LocalSection::FIELD_COUNT, 2); assert_eq!( section.field_value(FieldId::new(2)), @@ -94,9 +115,52 @@ fn generated_section_owns_every_declared_scalar_path() { ); } +#[test] +fn narrow_scalars_and_compact_enums_keep_native_types_and_generic_metadata() { + let mut section = CompactSection { + endpoint: 7, + mode: TestMode::Quiet, + }; + assert_eq!( + section.field_value(FieldId::new(20)), + Some(ConfigValue::U32(7)) + ); + assert_eq!( + CompactSection::DESCRIPTORS[1].enum_options, + TestMode::CONFIG_ENUM_OPTIONS + ); + assert_eq!(TestMode::Quiet.wire_value(), 1); + assert_eq!(TestMode::try_from_wire(3), Some(TestMode::Loud)); + assert_eq!(TestMode::try_from_wire(2), None); + + section + .set_field(FieldId::new(21), ConfigValue::U32(3)) + .unwrap(); + assert_eq!(section.mode, TestMode::Loud); + assert!(matches!( + section.set_field(FieldId::new(21), ConfigValue::U32(2)), + Err(FieldWriteError::Violation(issue)) + if issue.rule() == ValidationRule::InvalidEnumValue + )); + assert!(matches!( + section.set_field(FieldId::new(20), ConfigValue::U32(256)), + Err(FieldWriteError::Violation(issue)) + if issue.rule() == ValidationRule::AboveMaximum + )); +} + #[test] fn aggregate_routes_writes_and_defers_declared_post_validation() { - let mut aggregate = Aggregate::default_aggregate().unwrap(); + let mut aggregate = Aggregate { + local: LocalSection { + first: 2, + second: 4, + }, + ordered: OrderedSection { + minimum: 1, + maximum: 2, + }, + }; assert_eq!(Aggregate::FIELD_COUNT, 4); assert_eq!( aggregate.field_value(FieldId::new(2)), @@ -106,7 +170,7 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() { .set_field(FieldId::new(3), ConfigValue::U32(3)) .unwrap(); assert!(matches!( - aggregate.validate_fields(), + aggregate.validate(), Err(issue) if issue.primary() == Some(FieldId::new(3)) && issue.related_field() == Some(FieldId::new(4)) @@ -115,5 +179,101 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() { aggregate .set_field(FieldId::new(4), ConfigValue::U32(3)) .unwrap(); - aggregate.validate_fields().unwrap(); + aggregate.validate().unwrap(); +} + +#[test] +fn aggregate_derives_its_flattened_descriptor_order() { + let descriptors: Vec<_> = (0..Aggregate::FIELD_COUNT) + .map(|index| Aggregate::descriptor(index).unwrap()) + .collect(); + assert_eq!( + descriptors + .iter() + .map(|descriptor| descriptor.id.get()) + .collect::>(), + [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 + )); } diff --git a/firmware/libs/kaptan-config-derive/tests/ui/config_enum_implicit_discriminant.rs b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_implicit_discriminant.rs new file mode 100644 index 0000000..9f01f3b --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_implicit_discriminant.rs @@ -0,0 +1,10 @@ +use kaptan_config_derive::ConfigEnum; + +#[derive(ConfigEnum)] +#[repr(u8)] +enum ImplicitDiscriminant { + First = 1, + Second, +} + +fn main() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/config_enum_implicit_discriminant.stderr b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_implicit_discriminant.stderr new file mode 100644 index 0000000..5fd151a --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_implicit_discriminant.stderr @@ -0,0 +1,5 @@ +error: ConfigEnum variants require explicit u8 discriminants + --> tests/ui/config_enum_implicit_discriminant.rs:7:5 + | +7 | Second, + | ^^^^^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_nul.rs b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_nul.rs new file mode 100644 index 0000000..d0c8dcf --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_nul.rs @@ -0,0 +1,10 @@ +use kaptan_config_derive::ConfigEnum; + +#[derive(ConfigEnum)] +#[repr(u8)] +enum LabelWithNul { + #[config_enum(label = "invalid\0label")] + Value = 1, +} + +fn main() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_nul.stderr b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_nul.stderr new file mode 100644 index 0000000..86ec7f0 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_nul.stderr @@ -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")] + | ^^^^^^^^^^^^^^^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_too_many_bytes.rs b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_too_many_bytes.rs new file mode 100644 index 0000000..1a3ccc0 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_too_many_bytes.rs @@ -0,0 +1,10 @@ +use kaptan_config_derive::ConfigEnum; + +#[derive(ConfigEnum)] +#[repr(u8)] +enum LabelTooManyBytes { + #[config_enum(label = "şşşşşşşşşşşşşşşşş")] + Value = 1, +} + +fn main() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_too_many_bytes.stderr b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_too_many_bytes.stderr new file mode 100644 index 0000000..b743d0a --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_label_too_many_bytes.stderr @@ -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 = "şşşşşşşşşşşşşşşşş")] + | ^^^^^^^^^^^^^^^^^^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/config_enum_missing_repr.rs b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_missing_repr.rs new file mode 100644 index 0000000..69ef0b5 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_missing_repr.rs @@ -0,0 +1,8 @@ +use kaptan_config_derive::ConfigEnum; + +#[derive(ConfigEnum)] +enum MissingRepresentation { + First = 1, +} + +fn main() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/config_enum_missing_repr.stderr b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_missing_repr.stderr new file mode 100644 index 0000000..c14d207 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_missing_repr.stderr @@ -0,0 +1,5 @@ +error: ConfigEnum requires exactly #[repr(u8)] + --> tests/ui/config_enum_missing_repr.rs:4:6 + | +4 | enum MissingRepresentation { + | ^^^^^^^^^^^^^^^^^^^^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/config_enum_payload.rs b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_payload.rs new file mode 100644 index 0000000..ead83b0 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_payload.rs @@ -0,0 +1,9 @@ +use kaptan_config_derive::ConfigEnum; + +#[derive(ConfigEnum)] +#[repr(u8)] +enum Payload { + Value(u8) = 1, +} + +fn main() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/config_enum_payload.stderr b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_payload.stderr new file mode 100644 index 0000000..01a4554 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/config_enum_payload.stderr @@ -0,0 +1,5 @@ +error: ConfigEnum variants cannot contain fields + --> tests/ui/config_enum_payload.rs:6:10 + | +6 | Value(u8) = 1, + | ^^^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/default_below_minimum.rs b/firmware/libs/kaptan-config-derive/tests/ui/default_below_minimum.rs deleted file mode 100644 index 14006ac..0000000 --- a/firmware/libs/kaptan-config-derive/tests/ui/default_below_minimum.rs +++ /dev/null @@ -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() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/default_below_minimum.stderr b/firmware/libs/kaptan-config-derive/tests/ui/default_below_minimum.stderr deleted file mode 100644 index de700e1..0000000 --- a/firmware/libs/kaptan-config-derive/tests/ui/default_below_minimum.stderr +++ /dev/null @@ -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))] - | ^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/default_violates_finite.rs b/firmware/libs/kaptan-config-derive/tests/ui/default_violates_finite.rs deleted file mode 100644 index b09ab85..0000000 --- a/firmware/libs/kaptan-config-derive/tests/ui/default_violates_finite.rs +++ /dev/null @@ -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() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/default_violates_finite.stderr b/firmware/libs/kaptan-config-derive/tests/ui/default_violates_finite.stderr deleted file mode 100644 index 7629742..0000000 --- a/firmware/libs/kaptan-config-derive/tests/ui/default_violates_finite.stderr +++ /dev/null @@ -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)] - | ^^^^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.rs b/firmware/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.rs deleted file mode 100644 index 235f682..0000000 --- a/firmware/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.rs +++ /dev/null @@ -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() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.stderr b/firmware/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.stderr deleted file mode 100644 index a274a22..0000000 --- a/firmware/libs/kaptan-config-derive/tests/ui/default_violates_nonzero.stderr +++ /dev/null @@ -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)] - | ^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/display_key_too_long.rs b/firmware/libs/kaptan-config-derive/tests/ui/display_key_too_long.rs new file mode 100644 index 0000000..e74f749 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/display_key_too_long.rs @@ -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() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/display_key_too_long.stderr b/firmware/libs/kaptan-config-derive/tests/ui/display_key_too_long.stderr new file mode 100644 index 0000000..2a8f559 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/display_key_too_long.stderr @@ -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", + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/duplicate_display_key.rs b/firmware/libs/kaptan-config-derive/tests/ui/duplicate_display_key.rs new file mode 100644 index 0000000..85fd0bf --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/duplicate_display_key.rs @@ -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() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/duplicate_display_key.stderr b/firmware/libs/kaptan-config-derive/tests/ui/duplicate_display_key.stderr new file mode 100644 index 0000000..94e65a2 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/duplicate_display_key.stderr @@ -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")] + | ^^^^^^^^^^^^^^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/duplicate_id.rs b/firmware/libs/kaptan-config-derive/tests/ui/duplicate_id.rs index 9f0bdfe..3eaf229 100644 --- a/firmware/libs/kaptan-config-derive/tests/ui/duplicate_id.rs +++ b/firmware/libs/kaptan-config-derive/tests/ui/duplicate_id.rs @@ -1,6 +1,6 @@ -use kaptan_config_derive::ConfigValidate; +use kaptan_config_derive::Config; -#[derive(ConfigValidate)] +#[derive(Config)] struct DuplicateId { #[config(id = 7, nonzero)] first: u32, diff --git a/firmware/libs/kaptan-config-derive/tests/ui/duplicate_range.rs b/firmware/libs/kaptan-config-derive/tests/ui/duplicate_range.rs index 6774236..96b986d 100644 --- a/firmware/libs/kaptan-config-derive/tests/ui/duplicate_range.rs +++ b/firmware/libs/kaptan-config-derive/tests/ui/duplicate_range.rs @@ -1,6 +1,6 @@ -use kaptan_config_derive::ConfigValidate; +use kaptan_config_derive::Config; -#[derive(ConfigValidate)] +#[derive(Config)] struct DuplicateRange { #[config(id = 7, range(min = 1), range(min = 2))] value: u32, diff --git a/firmware/libs/kaptan-config-derive/tests/ui/empty.rs b/firmware/libs/kaptan-config-derive/tests/ui/empty.rs index a6fe384..5380682 100644 --- a/firmware/libs/kaptan-config-derive/tests/ui/empty.rs +++ b/firmware/libs/kaptan-config-derive/tests/ui/empty.rs @@ -1,6 +1,6 @@ -use kaptan_config_derive::ConfigValidate; +use kaptan_config_derive::Config; -#[derive(ConfigValidate)] +#[derive(Config)] struct Empty {} fn main() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/empty.stderr b/firmware/libs/kaptan-config-derive/tests/ui/empty.stderr index 2d04d41..bc724c1 100644 --- a/firmware/libs/kaptan-config-derive/tests/ui/empty.stderr +++ b/firmware/libs/kaptan-config-derive/tests/ui/empty.stderr @@ -1,4 +1,4 @@ -error: ConfigValidate requires at least one configured field or section +error: Config requires at least one field --> tests/ui/empty.rs:4:8 | 4 | struct Empty {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/enum_marker_on_primitive.rs b/firmware/libs/kaptan-config-derive/tests/ui/enum_marker_on_primitive.rs new file mode 100644 index 0000000..4a0ede1 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/enum_marker_on_primitive.rs @@ -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() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/enum_marker_on_primitive.stderr b/firmware/libs/kaptan-config-derive/tests/ui/enum_marker_on_primitive.stderr new file mode 100644 index 0000000..7e0fd5c --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/enum_marker_on_primitive.stderr @@ -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, + | ^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/missing_enum_marker.rs b/firmware/libs/kaptan-config-derive/tests/ui/missing_enum_marker.rs new file mode 100644 index 0000000..31f4e7b --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/missing_enum_marker.rs @@ -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() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/missing_enum_marker.stderr b/firmware/libs/kaptan-config-derive/tests/ui/missing_enum_marker.stderr new file mode 100644 index 0000000..6945765 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/missing_enum_marker.stderr @@ -0,0 +1,5 @@ +error: non-primitive configuration fields require config(enum) + --> tests/ui/missing_enum_marker.rs:12:11 + | +12 | mode: Mode, + | ^^^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/missing_id.rs b/firmware/libs/kaptan-config-derive/tests/ui/missing_id.rs index 7998675..eb35e4b 100644 --- a/firmware/libs/kaptan-config-derive/tests/ui/missing_id.rs +++ b/firmware/libs/kaptan-config-derive/tests/ui/missing_id.rs @@ -1,6 +1,6 @@ -use kaptan_config_derive::ConfigValidate; +use kaptan_config_derive::Config; -#[derive(ConfigValidate)] +#[derive(Config)] struct MissingId { #[config(nonzero)] value: u32, diff --git a/firmware/libs/kaptan-config-derive/tests/ui/missing_id.stderr b/firmware/libs/kaptan-config-derive/tests/ui/missing_id.stderr index 5b1287b..00f1eaf 100644 --- a/firmware/libs/kaptan-config-derive/tests/ui/missing_id.stderr +++ b/firmware/libs/kaptan-config-derive/tests/ui/missing_id.stderr @@ -1,4 +1,4 @@ -error: validated configuration fields require config(id = N) +error: scalar configuration fields require config(id = N) --> tests/ui/missing_id.rs:6:5 | 6 | value: u32, diff --git a/firmware/libs/kaptan-config-derive/tests/ui/missing_metadata.rs b/firmware/libs/kaptan-config-derive/tests/ui/missing_metadata.rs index ac97ca3..1609da5 100644 --- a/firmware/libs/kaptan-config-derive/tests/ui/missing_metadata.rs +++ b/firmware/libs/kaptan-config-derive/tests/ui/missing_metadata.rs @@ -1,8 +1,8 @@ -use kaptan_config_derive::ConfigValidate; +use kaptan_config_derive::Config; -#[derive(ConfigValidate)] +#[derive(Config)] struct MissingMetadata { - #[config(id = 7, unit = "count", default = 1, range(min = 0, max = 10))] + #[config(id = 7, unit = "count", range(min = 0, max = 10))] value: u32, } diff --git a/firmware/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.rs b/firmware/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.rs index 5a81b85..d3b2555 100644 --- a/firmware/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.rs +++ b/firmware/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.rs @@ -1,10 +1,9 @@ -use kaptan_config_derive::ConfigValidate; +use kaptan_config_derive::Config; -#[derive(ConfigValidate)] +#[derive(Config)] struct MixedSectionAndScalar { - #[config(section)] child: u32, - #[config(id = 1, key = "scalar", unit = "count", default = 1, range(min = 0, max = 2))] + #[config(id = 1, key = "scalar", unit = "count", range(min = 0, max = 2))] scalar: u32, } diff --git a/firmware/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.stderr b/firmware/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.stderr index 6374074..23985ea 100644 --- a/firmware/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.stderr +++ b/firmware/libs/kaptan-config-derive/tests/ui/mixed_section_scalar.stderr @@ -1,5 +1,5 @@ -error: ConfigValidate records must contain either scalar config fields or config(section) fields exclusively - --> tests/ui/mixed_section_scalar.rs:6:5 +error: Config records must contain either scalar fields or child configuration records exclusively + --> tests/ui/mixed_section_scalar.rs:5:5 | -6 | child: u32, +5 | child: u32, | ^^^^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/range_minimum_exceeds_maximum.rs b/firmware/libs/kaptan-config-derive/tests/ui/range_minimum_exceeds_maximum.rs index 94f0239..5d983f6 100644 --- a/firmware/libs/kaptan-config-derive/tests/ui/range_minimum_exceeds_maximum.rs +++ b/firmware/libs/kaptan-config-derive/tests/ui/range_minimum_exceeds_maximum.rs @@ -1,8 +1,8 @@ -use kaptan_config_derive::ConfigValidate; +use kaptan_config_derive::Config; -#[derive(ConfigValidate)] +#[derive(Config)] struct InvalidRange { - #[config(id = 1, key = "value", unit = "count", default = 2, range(min = 3, max = 1))] + #[config(id = 1, key = "value", unit = "count", range(min = 3, max = 1))] value: u32, } diff --git a/firmware/libs/kaptan-config-derive/tests/ui/unit_too_long.rs b/firmware/libs/kaptan-config-derive/tests/ui/unit_too_long.rs new file mode 100644 index 0000000..d5c0d94 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/unit_too_long.rs @@ -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() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/unit_too_long.stderr b/firmware/libs/kaptan-config-derive/tests/ui/unit_too_long.stderr new file mode 100644 index 0000000..8c796f0 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/unit_too_long.stderr @@ -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___")] + | ^^^^^^^^^^^^^^^^^^^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/unsupported_default.rs b/firmware/libs/kaptan-config-derive/tests/ui/unsupported_default.rs new file mode 100644 index 0000000..f375325 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/unsupported_default.rs @@ -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() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/unsupported_default.stderr b/firmware/libs/kaptan-config-derive/tests/ui/unsupported_default.stderr new file mode 100644 index 0000000..6a69c1b --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/unsupported_default.stderr @@ -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)] + | ^^^^^^^ diff --git a/firmware/libs/kaptan-config-derive/tests/ui/unsupported_external_defaults.rs b/firmware/libs/kaptan-config-derive/tests/ui/unsupported_external_defaults.rs new file mode 100644 index 0000000..765cfe4 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/unsupported_external_defaults.rs @@ -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() {} diff --git a/firmware/libs/kaptan-config-derive/tests/ui/unsupported_external_defaults.stderr b/firmware/libs/kaptan-config-derive/tests/ui/unsupported_external_defaults.stderr new file mode 100644 index 0000000..83f07a5 --- /dev/null +++ b/firmware/libs/kaptan-config-derive/tests/ui/unsupported_external_defaults.stderr @@ -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)] + | ^^^^^^^^^^^^^^^^^ diff --git a/firmware/libs/kaptan-config-mavlink/Cargo.toml b/firmware/libs/kaptan-config-mavlink/Cargo.toml index 6c12236..22796f2 100644 --- a/firmware/libs/kaptan-config-mavlink/Cargo.toml +++ b/firmware/libs/kaptan-config-mavlink/Cargo.toml @@ -8,11 +8,6 @@ version.workspace = true [lints] workspace = true -[features] -# Explicitly opt-in host/SITL test authority. Production cannot construct an -# authorization token from a CRC-valid raw MAVLink frame. -host-authority = [] - [dependencies] kaptan-config = { path = "../kaptan-config" } kaptan-log = { path = "../kaptan-log" } diff --git a/firmware/libs/kaptan-config-mavlink/src/command.rs b/firmware/libs/kaptan-config-mavlink/src/command.rs index 2874c54..9d69d2e 100644 --- a/firmware/libs/kaptan-config-mavlink/src/command.rs +++ b/firmware/libs/kaptan-config-mavlink/src/command.rs @@ -1,4 +1,4 @@ -//! Authorized, transport-neutral configuration commands. +//! Transport-neutral configuration commands. use kaptan_config::{ConfigValue, FieldId}; @@ -11,6 +11,30 @@ pub struct Target { component: u8, } +/// Exact MAVLink endpoint that owns a configuration transaction lease. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Principal(u16); + +impl Principal { + /// Creates a principal from one exact source system/component pair. + pub const fn new(system: u8, component: u8) -> Self { + assert!( + system != 0 && component != 0, + "configuration principal must be exact" + ); + Self(u16::from_be_bytes([system, component])) + } + + /// Parses an exact source principal from an untrusted transport. + pub const fn try_new(system: u8, component: u8) -> Option { + if system == 0 || component == 0 { + None + } else { + Some(Self(u16::from_be_bytes([system, component]))) + } + } +} + /// Selects the configuration snapshot read by a value request. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ValueSource { @@ -45,21 +69,30 @@ impl Target { /// One command accepted by the Kaptan configuration microservice. #[derive(Clone, Copy, Debug, PartialEq)] pub enum ConfigCommand { - /// Reads protocol/schema metadata before any descriptor or upload request. + /// Reads protocol/schema metadata before descriptor and value requests. InfoRequest { /// Client correlation identity. request_id: u32, /// Exact addressed endpoint. target: Target, }, - /// Enumerates a bounded page of schema descriptors. + /// Reads one schema descriptor by stable index. DescriptorRequest { /// Client correlation identity. request_id: u32, - /// First stable descriptor index. - first: u16, - /// Requested number of descriptors, clamped to the protocol bound. - count: u16, + /// Stable descriptor index. + index: u16, + /// Exact addressed endpoint. + target: Target, + }, + /// Reads one named choice for one descriptor. + EnumOptionRequest { + /// Client correlation identity. + request_id: u32, + /// Stable index of the owning descriptor. + descriptor_index: u16, + /// Stable enum-option index. + option_index: u8, /// Exact addressed endpoint. target: Target, }, @@ -76,59 +109,10 @@ pub enum ConfigCommand { /// Exact addressed endpoint. target: Target, }, - /// Starts one bounded CBOR snapshot import into the caller's transaction. - ImportBegin { - /// Client correlation identity. - request_id: u32, - /// Lease-bound transaction identity. - session_id: u32, - /// Optimistic transaction revision. - expected_revision: u32, - /// Complete CBOR byte length. - length: u16, - /// Exact schema identifier advertised by `KAPTAN_CONFIG_INFO`. - schema_id: [u8; 16], - /// CRC-32C over exactly `length` CBOR bytes. - crc32c: u32, - /// Exact addressed endpoint. - target: Target, - }, - /// Supplies one fixed-bounded CBOR import chunk. - ImportChunk { - /// Client correlation identity. - request_id: u32, - /// Lease-bound transaction identity. - session_id: u32, - /// Server-issued ID returned by the matching import-begin result. - transfer_id: u32, - /// Byte offset into the complete import. - offset: u16, - /// Used bytes of `data`. - length: u8, - /// Fixed MAVLink payload data. - data: [u8; 192], - /// Exact addressed endpoint. - target: Target, - }, - /// Checks the completed import and replaces only the RAM transaction. - ImportFinish { - /// Client correlation identity. - request_id: u32, - /// Lease-bound transaction identity. - session_id: u32, - /// Server-issued ID returned by the matching import-begin result. - transfer_id: u32, - /// Optimistic transaction revision. - expected_revision: u32, - /// Exact addressed endpoint. - target: Target, - }, - /// Starts an edit transaction from the expected committed generation. + /// Starts an edit transaction from the latest committed configuration. Begin { /// Client correlation identity. request_id: u32, - /// Optimistic concurrency generation. - expected_generation: u64, /// Exact addressed endpoint. target: Target, }, @@ -164,8 +148,6 @@ pub enum ConfigCommand { request_id: u32, /// Lease-bound transaction identity. session_id: u32, - /// Optimistic committed generation. - expected_generation: u64, /// Optimistic transaction revision. expected_revision: u32, /// Exact addressed endpoint. @@ -188,10 +170,8 @@ impl ConfigCommand { match self { Self::InfoRequest { request_id, .. } | Self::DescriptorRequest { request_id, .. } + | Self::EnumOptionRequest { request_id, .. } | Self::ValueRequest { request_id, .. } - | Self::ImportBegin { request_id, .. } - | Self::ImportChunk { request_id, .. } - | Self::ImportFinish { request_id, .. } | Self::Begin { request_id, .. } | Self::Set { request_id, .. } | Self::Validate { request_id, .. } @@ -205,10 +185,8 @@ impl ConfigCommand { match self { Self::InfoRequest { target, .. } | Self::DescriptorRequest { target, .. } + | Self::EnumOptionRequest { target, .. } | Self::ValueRequest { target, .. } - | Self::ImportBegin { target, .. } - | Self::ImportChunk { target, .. } - | Self::ImportFinish { target, .. } | Self::Begin { target, .. } | Self::Set { target, .. } | Self::Validate { target, .. } @@ -218,54 +196,25 @@ impl ConfigCommand { } } -/// A capability token produced only by the future signing/replay authority. -/// -/// The token carries a stable principal identity so the service can bind a -/// transaction lease to one authenticated source. It is intentionally opaque: -/// an unverified raw MAVLink frame cannot create one. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct VerifiedAuthority { - principal: u64, - _private: (), -} - -impl VerifiedAuthority { - /// Returns the authenticated principal identity for ownership checks. - pub const fn principal(self) -> u64 { - self.principal - } - - #[cfg(any(test, feature = "host-authority"))] - /// Creates an explicit host/SITL authority token. - /// - /// This exists only for host integration and protocol tests. Flight code - /// must receive the equivalent token from the reviewed signing/replay path. - pub const fn host(principal: u64) -> Self { - Self { - principal, - _private: (), - } - } -} - -/// A command whose authority was verified before it reached this adapter. +/// Test command paired with its lease principal. +#[cfg(test)] #[derive(Clone, Copy, Debug, PartialEq)] pub struct Authorized { - authority: VerifiedAuthority, + principal: Principal, command: T, } +#[cfg(test)] impl Authorized { - #[cfg(any(test, feature = "host-authority"))] - /// Wraps a host/SITL command in an explicit test-only authority token. - pub const fn host(principal: u64, command: T) -> Self { + /// Pairs a test command with component `principal` in test system 1. + pub const fn host(principal: u8, command: T) -> Self { Self { - authority: VerifiedAuthority::host(principal), + principal: Principal::new(1, principal), command, } } - pub(crate) fn into_parts(self) -> (VerifiedAuthority, T) { - (self.authority, self.command) + pub(crate) fn into_parts(self) -> (Principal, T) { + (self.principal, self.command) } } diff --git a/firmware/libs/kaptan-config-mavlink/src/lib.rs b/firmware/libs/kaptan-config-mavlink/src/lib.rs index c0e5b06..6da8617 100644 --- a/firmware/libs/kaptan-config-mavlink/src/lib.rs +++ b/firmware/libs/kaptan-config-mavlink/src/lib.rs @@ -2,19 +2,34 @@ //! //! This crate translates generated `kaptan.xml` records to the transport- //! neutral configuration lifecycle. It does not parse raw frames, authenticate -//! peers, deserialize CBOR, access storage directly, or mutate active flight +//! peers, access storage directly, or mutate active flight //! consumers. #![no_std] +#[cfg(test)] +extern crate std; + mod command; mod protocol; pub mod service; pub mod wire; -pub use command::{Authorized, ConfigCommand, Target, ValueSource, VerifiedAuthority}; +#[cfg(test)] +pub use command::Authorized; +pub use command::{ConfigCommand, Principal, Target, ValueSource}; pub use protocol::{ConfigReply, Operation, ProtocolError, ResultKind}; pub use service::{ - CAPABILITY_CBOR_IMPORT, ConfigInfo, ConfigProtocol, DescriptorPage, NoSnapshotImporter, - ServiceResponse, SnapshotImporter, ValueResponse, + ConfigInfo, ConfigProtocol, DescriptorResponse, EnumOptionResponse, ServiceResponse, + ValueResponse, }; +pub use wire::{ResponseVisitor, visit_response}; + +/// Wire protocol implemented by the descriptor/value configuration service. +pub const PROTOCOL_MAJOR: u8 = 2; +pub const PROTOCOL_MINOR: u8 = 0; + +/// `KAPTAN_CONFIG_DESCRIPTOR.flags` bit: `minimum_bits` is present. +pub const DESCRIPTOR_HAS_MINIMUM: u8 = 1 << 0; +/// `KAPTAN_CONFIG_DESCRIPTOR.flags` bit: `maximum_bits` is present. +pub const DESCRIPTOR_HAS_MAXIMUM: u8 = 1 << 1; diff --git a/firmware/libs/kaptan-config-mavlink/src/protocol.rs b/firmware/libs/kaptan-config-mavlink/src/protocol.rs index 99d3158..06849a8 100644 --- a/firmware/libs/kaptan-config-mavlink/src/protocol.rs +++ b/firmware/libs/kaptan-config-mavlink/src/protocol.rs @@ -1,7 +1,8 @@ //! Stable typed outcomes for the Kaptan configuration microservice. use kaptan_config::{ConfigState, ConfigViolation, FieldId}; -use kaptan_log::{EventHub, KaptanError, LogFlags, PublishOutcome}; +use kaptan_log::KaptanError; + /// Operation reported in an authoritative result record. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Operation { @@ -17,8 +18,6 @@ pub enum Operation { Commit, /// Transaction abort. Abort, - /// CBOR snapshot import into the isolated transaction. - Import, } /// Stable result classification. @@ -38,11 +37,7 @@ pub enum ResultKind { pub enum ProtocolError { /// The request target did not match this component. BadTarget, - /// Authorization was absent or insufficient. - AuthorizationDenied, - /// The optimistic durable generation did not match. - GenerationConflict, - /// The single transaction slot is occupied. + /// The configuration lease or single transaction slot is occupied. SessionBusy, /// The session is unknown, expired, or belongs to a different principal. InvalidSession, @@ -54,22 +49,14 @@ pub enum ProtocolError { BadState, /// The field is not present in this schema. UnknownField(FieldId), + /// A query requested an empty or out-of-range descriptor span. + InvalidQuery, /// The typed setter does not match the field descriptor. TypeMismatch(FieldId), /// A local or complete validation issue. Violation(ConfigViolation), /// Persistence failed after a complete validation pass. StorageFailure, - /// A CBOR snapshot could not be decoded into this schema. - CborMalformed, - /// A CBOR snapshot exceeded the fixed import budget. - CborTooLarge, - /// The complete CBOR snapshot did not match the supplied CRC-32C. - BlobCrc, - /// The uploaded CBOR blob was prepared for a different schema. - SchemaMismatch, - /// This deployment does not expose bounded CBOR transaction import. - ImportUnavailable, /// The adapter reached an impossible internal state. Internal, } @@ -81,12 +68,8 @@ pub struct ConfigReply { pub request_id: u32, /// The current session, or zero when none exists. pub session_id: u32, - /// Server-assigned import transfer identity, or zero when not importing. - pub transfer_id: u32, /// The transaction revision, or zero when no transaction exists. pub revision: u32, - /// Latest committed generation, or zero for the factory profile. - pub generation: u64, /// Completed operation. pub operation: Operation, /// High-level result. @@ -130,66 +113,4 @@ impl ConfigReply { _ => 0, } } - - /// Enqueues a critical diagnostic companion for a rejected structured - /// result. The `KAPTAN_CONFIG_RESULT` remains the correlated, - /// field/rule-precise authority; this sends only the derive-generated - /// high-level `ProtocolError` display identity through `KAPTAN_ERROR`. - /// - /// The helper deliberately carries no request/session/generation fields - /// into the error channel. A composition owner may call it after it has - /// emitted or retained the authoritative result. - pub fn publish_rejection( - self, - hub: &mut EventHub, - ) -> Option { - 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); - } } diff --git a/firmware/libs/kaptan-config-mavlink/src/service.rs b/firmware/libs/kaptan-config-mavlink/src/service.rs index 8793966..fe7b372 100644 --- a/firmware/libs/kaptan-config-mavlink/src/service.rs +++ b/firmware/libs/kaptan-config-mavlink/src/service.rs @@ -1,14 +1,14 @@ //! Transport-neutral façade for the custom configuration service. //! //! Public callers see one small protocol API here. Its bounded query, -//! session, replay, and CBOR-import mechanics stay in private sibling modules +//! session and replay mechanics stay in private sibling modules //! so no one concern becomes a second service owner. -mod import; mod query; mod replay; mod session; -pub use import::{CAPABILITY_CBOR_IMPORT, NoSnapshotImporter, SnapshotImporter}; -pub use query::{ConfigInfo, DescriptorPage, ServiceResponse, ValueResponse}; +pub use query::{ + ConfigInfo, DescriptorResponse, EnumOptionResponse, ServiceResponse, ValueResponse, +}; pub use session::ConfigProtocol; diff --git a/firmware/libs/kaptan-config-mavlink/src/service/import.rs b/firmware/libs/kaptan-config-mavlink/src/service/import.rs deleted file mode 100644 index ed49c6a..0000000 --- a/firmware/libs/kaptan-config-mavlink/src/service/import.rs +++ /dev/null @@ -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 { - /// 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; -} - -/// 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 SnapshotImporter for NoSnapshotImporter { - const IMPORT_SUPPORTED: bool = false; - const PROFILE_FORMAT: u16 = 0; - - fn decode(&self, _: &[u8]) -> Result { - 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 ConfigProtocol { - pub(super) fn begin_import( - &mut self, - owner: u64, - command: ConfigCommand, - session: Session, - ) -> ConfigReply - where - C: ConfigModel, - I: SnapshotImporter, - { - 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( - &mut self, - manager: &mut ConfigManager, - command: ConfigCommand, - session: Session, - request_id: u32, - transfer_id: u32, - ) -> ConfigReply - where - C: ConfigModel, - S: ProfileStore, - I: SnapshotImporter, - { - 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 -} diff --git a/firmware/libs/kaptan-config-mavlink/src/service/query.rs b/firmware/libs/kaptan-config-mavlink/src/service/query.rs index c56030b..20081fe 100644 --- a/firmware/libs/kaptan-config-mavlink/src/service/query.rs +++ b/firmware/libs/kaptan-config-mavlink/src/service/query.rs @@ -1,24 +1,39 @@ //! Read-only protocol discovery and value-query responses. use kaptan_config::{ - ConfigManager, ConfigModel, ConfigState, ConfigValue, FieldDescriptor, FieldId, Generation, - ProfileStore, + ConfigManager, ConfigModel, ConfigState, ConfigStore, ConfigValue, EnumOption, FieldDescriptor, + FieldId, }; -use super::{import::SnapshotImporter, session::ConfigProtocol}; -use crate::{ProtocolError, ValueSource}; +use super::session::ConfigProtocol; +use crate::{Principal, ProtocolError, ValueSource}; -/// Bounded descriptor page returned by a schema discovery request. +/// One descriptor returned by a schema discovery request. #[derive(Clone, Copy, Debug, PartialEq)] -pub struct DescriptorPage { +pub struct DescriptorResponse { /// Request being answered. pub request_id: u32, - /// First requested descriptor index. - pub first: u16, - /// Number of descriptors present in `descriptors`. - pub len: u8, - /// Fixed-capacity response page; `None` slots are unused. - pub descriptors: [Option<&'static FieldDescriptor>; 4], + /// Stable index of this descriptor in the advertised schema. + pub index: u16, + /// Self-describing field metadata. + pub descriptor: FieldDescriptor, + /// Factory value selected by the concrete board/image composition. + pub default: ConfigValue, +} + +/// One named enum choice associated with a descriptor response. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EnumOptionResponse { + /// Request being answered. + pub request_id: u32, + /// Stable schema index of the owning descriptor. + pub descriptor_index: u16, + /// Stable field identity of the owning descriptor. + pub field: FieldId, + /// Stable zero-based order within the descriptor's choices. + pub option_index: u8, + /// Self-describing named choice. + pub option: EnumOption, } /// One typed value read from an explicitly selected configuration snapshot. @@ -41,20 +56,12 @@ pub struct ValueResponse { pub struct ConfigInfo { /// Request being answered. pub request_id: u32, - /// Boot-active generation, or `None` for the factory profile. - pub active_generation: Option, - /// Last committed generation, or `None` for the factory profile. - pub committed_generation: Option, - /// Exact schema identifier expected by profile import. + /// Stable schema identity for this target-described field registry. pub schema_id: [u8; 16], - /// Version carried inside the one raw CBOR profile blob. - pub profile_format: u16, /// Number of editable fields in the registry. pub field_count: u16, /// Current configuration lifecycle state. pub state: ConfigState, - /// Deployment capabilities such as bounded CBOR import. - pub capability_flags: u8, } /// Exact response produced by the custom configuration service. @@ -64,13 +71,15 @@ pub enum ServiceResponse { Info(ConfigInfo), /// Completion for a mutating operation or rejected query. Result(crate::ConfigReply), - /// Bounded self-describing schema page. - Descriptors(DescriptorPage), + /// One self-describing schema field. + Descriptor(DescriptorResponse), + /// One named choice for its explicitly identified enum descriptor. + EnumOption(EnumOptionResponse), /// One typed scalar value. Value(ValueResponse), } -impl ConfigProtocol { +impl ConfigProtocol { /// Returns static descriptor metadata by stable schema index. pub fn descriptor( &self, @@ -79,7 +88,7 @@ impl ConfigProtocol { ) -> Option<&'static FieldDescriptor> where C: ConfigModel, - S: ProfileStore, + S: ConfigStore, { manager.descriptor(index) } @@ -93,7 +102,7 @@ impl ConfigProtocol { ) -> Option where C: ConfigModel, - S: ProfileStore, + S: ConfigStore, { match source { ValueSource::Active => manager.active_value(field), @@ -102,60 +111,65 @@ impl ConfigProtocol { } } - pub(super) fn descriptors( + pub(super) fn descriptor_response( &self, manager: &ConfigManager, request_id: u32, - first: u16, - requested: u16, - ) -> DescriptorPage + index: u16, + ) -> Option where C: ConfigModel, - S: ProfileStore, + S: ConfigStore, { - let mut descriptors = [None; 4]; - let count = requested.min(descriptors.len() as u16); - let mut len = 0; - for offset in 0..count { - let Some(index) = first.checked_add(offset) else { - break; - }; - let Some(descriptor) = manager.descriptor(index) else { - break; - }; - descriptors[usize::from(len)] = Some(descriptor); - len += 1; - } - DescriptorPage { + let descriptor = *manager.descriptor(index)?; + let default = manager.factory_value(descriptor.id)?; + Some(DescriptorResponse { request_id, - first, - len, - descriptors, - } + index, + descriptor, + default, + }) + } + + pub(super) fn enum_option_response( + &self, + manager: &ConfigManager, + request_id: u32, + descriptor_index: u16, + option_index: u8, + ) -> Option + where + C: ConfigModel, + S: ConfigStore, + { + 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(&self, manager: &ConfigManager, request_id: u32) -> ConfigInfo where C: ConfigModel, - S: ProfileStore, - I: SnapshotImporter, + S: ConfigStore, { ConfigInfo { request_id, - active_generation: manager.active_generation(), - committed_generation: manager.committed_generation(), schema_id: C::SCHEMA_ID, - profile_format: I::PROFILE_FORMAT, field_count: manager.field_count(), state: self.state, - capability_flags: self.capability_flags_for::(), } } pub(super) fn value_response( &self, manager: &ConfigManager, - owner: u64, + owner: Principal, request_id: u32, session_id: u32, field: FieldId, @@ -163,7 +177,7 @@ impl ConfigProtocol { ) -> ServiceResponse where C: ConfigModel, - S: ProfileStore, + S: ConfigStore, { let (value, reply_session, revision) = match source { ValueSource::Active => (manager.active_value(field), 0, 0), @@ -176,7 +190,7 @@ impl ConfigProtocol { ProtocolError::InvalidSession, )); }; - if session.id != session_id || session.owner != owner { + if session.id != session_id || self.principal != Some(owner) { return ServiceResponse::Result(self.rejected_for( request_id, session_id, diff --git a/firmware/libs/kaptan-config-mavlink/src/service/replay.rs b/firmware/libs/kaptan-config-mavlink/src/service/replay.rs index 08643c7..7ee15f5 100644 --- a/firmware/libs/kaptan-config-mavlink/src/service/replay.rs +++ b/firmware/libs/kaptan-config-mavlink/src/service/replay.rs @@ -3,34 +3,42 @@ use kaptan_config::ConfigValue; use super::session::ConfigProtocol; -use crate::{ConfigCommand, ConfigReply, ProtocolError}; - -pub(super) const REQUEST_CACHE_CAPACITY: usize = 8; +use crate::{ConfigCommand, ConfigReply, Operation, Principal, ProtocolError}; #[derive(Clone, Copy)] pub(super) struct RememberedResult { - pub(super) owner: u64, pub(super) command: ConfigCommand, pub(super) reply: ConfigReply, + pub(super) owner: Principal, } -impl ConfigProtocol { - pub(super) fn cached(&self, owner: u64, command: ConfigCommand) -> Option { - for cached in self.remembered.iter().flatten() { - if cached.owner != owner || cached.command.request_id() != command.request_id() { - continue; - } - if same_command(cached.command, command) { - return Some(cached.reply); - } - return Some(self.rejected(command, ProtocolError::RequestConflict)); +impl ConfigProtocol { + pub(super) fn cached(&self, owner: Principal, command: ConfigCommand) -> Option { + let cached = self.remembered?; + if cached.owner != owner || cached.command.request_id() != command.request_id() { + return None; + } + if same_command(cached.command, command) { + Some(cached.reply) + } else { + Some(self.rejected(command, ProtocolError::RequestConflict)) } - None } - pub(super) fn remember(&mut self, result: RememberedResult) { - self.remembered[self.next_remembered] = Some(result); - self.next_remembered = (self.next_remembered + 1) % REQUEST_CACHE_CAPACITY; + pub(super) fn remember( + &mut self, + owner: Principal, + command: ConfigCommand, + reply: ConfigReply, + ) { + if reply.operation == Operation::Query { + return; + } + self.remembered = Some(RememberedResult { + command, + reply, + owner, + }); } } @@ -49,20 +57,36 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool { ( ConfigCommand::DescriptorRequest { request_id: left_request, - first: left_first, - count: left_count, + index: left_index, target: left_target, }, ConfigCommand::DescriptorRequest { request_id: right_request, - first: right_first, - count: right_count, + index: right_index, target: right_target, }, ) => { left_request == right_request - && left_first == right_first - && left_count == right_count + && left_index == right_index + && left_target == right_target + } + ( + ConfigCommand::EnumOptionRequest { + request_id: left_request, + descriptor_index: left_descriptor, + option_index: left_option, + target: left_target, + }, + ConfigCommand::EnumOptionRequest { + request_id: right_request, + descriptor_index: right_descriptor, + option_index: right_option, + target: right_target, + }, + ) => { + left_request == right_request + && left_descriptor == right_descriptor + && left_option == right_option && left_target == right_target } ( @@ -90,19 +114,13 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool { ( ConfigCommand::Begin { request_id: left_request, - expected_generation: left_generation, target: left_target, }, ConfigCommand::Begin { request_id: right_request, - expected_generation: right_generation, target: right_target, }, - ) => { - left_request == right_request - && left_generation == right_generation - && left_target == right_target - } + ) => left_request == right_request && left_target == right_target, ( ConfigCommand::Set { request_id: left_request, @@ -151,21 +169,18 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool { ConfigCommand::Commit { request_id: left_request, session_id: left_session, - expected_generation: left_generation, expected_revision: left_revision, target: left_target, }, ConfigCommand::Commit { request_id: right_request, session_id: right_session, - expected_generation: right_generation, expected_revision: right_revision, target: right_target, }, ) => { left_request == right_request && left_session == right_session - && left_generation == right_generation && left_revision == right_revision && left_target == right_target } @@ -185,84 +200,6 @@ fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool { && left_session == right_session && left_target == right_target } - ( - ConfigCommand::ImportBegin { - request_id: left_request, - session_id: left_session, - expected_revision: left_revision, - length: left_length, - schema_id: left_schema, - crc32c: left_crc, - target: left_target, - }, - ConfigCommand::ImportBegin { - request_id: right_request, - session_id: right_session, - expected_revision: right_revision, - length: right_length, - schema_id: right_schema, - crc32c: right_crc, - target: right_target, - }, - ) => { - left_request == right_request - && left_session == right_session - && left_revision == right_revision - && left_length == right_length - && left_schema == right_schema - && left_crc == right_crc - && left_target == right_target - } - ( - ConfigCommand::ImportChunk { - request_id: left_request, - session_id: left_session, - transfer_id: left_transfer, - offset: left_offset, - length: left_length, - data: left_data, - target: left_target, - }, - ConfigCommand::ImportChunk { - request_id: right_request, - session_id: right_session, - transfer_id: right_transfer, - offset: right_offset, - length: right_length, - data: right_data, - target: right_target, - }, - ) => { - left_request == right_request - && left_session == right_session - && left_transfer == right_transfer - && left_offset == right_offset - && left_length == right_length - && left_data == right_data - && left_target == right_target - } - ( - ConfigCommand::ImportFinish { - request_id: left_request, - session_id: left_session, - transfer_id: left_transfer, - expected_revision: left_revision, - target: left_target, - }, - ConfigCommand::ImportFinish { - request_id: right_request, - session_id: right_session, - transfer_id: right_transfer, - expected_revision: right_revision, - target: right_target, - }, - ) => { - left_request == right_request - && left_session == right_session - && left_transfer == right_transfer - && left_revision == right_revision - && left_target == right_target - } _ => false, } } diff --git a/firmware/libs/kaptan-config-mavlink/src/service/session.rs b/firmware/libs/kaptan-config-mavlink/src/service/session.rs index 33e63bf..9592986 100644 --- a/firmware/libs/kaptan-config-mavlink/src/service/session.rs +++ b/firmware/libs/kaptan-config-mavlink/src/service/session.rs @@ -3,152 +3,166 @@ use core::num::NonZeroU32; use kaptan_config::{ - BeginError, CommitError, ConfigManager, ConfigModel, ConfigState, FieldWriteError, Generation, - ProfileStore, SetError, + BeginError, CommitError, ConfigManager, ConfigModel, ConfigState, ConfigStore, FieldWriteError, + SetError, }; -use super::{ - import::{CAPABILITY_CBOR_IMPORT, ImportBuffer, NoSnapshotImporter, SnapshotImporter}, - query::ServiceResponse, - replay::{REQUEST_CACHE_CAPACITY, RememberedResult}, -}; #[cfg(test)] -use super::{ - import::{IMPORT_CHUNK_BYTES, crc32c}, - query::{ConfigInfo, DescriptorPage, ValueResponse}, -}; +use super::query::{ConfigInfo, DescriptorResponse, EnumOptionResponse, ValueResponse}; +use super::{query::ServiceResponse, replay::RememberedResult}; +#[cfg(test)] +use crate::command::Authorized; use crate::{ - command::{Authorized, ConfigCommand, Target}, + command::{ConfigCommand, Principal, Target}, protocol::{ConfigReply, Operation, ProtocolError, ResultKind}, }; #[derive(Clone, Copy)] pub(super) struct Session { pub(super) id: u32, - pub(super) owner: u64, pub(super) revision: u32, } /// Owns only the one GCS transaction lease and bounded protocol state. /// -/// A future signing/replay dispatcher creates [`Authorized`] commands. This -/// protocol never accepts raw frames and can be expired by the composition -/// owner on lease or link loss. The same composition owner retains the one -/// [`ConfigManager`] and one runtime log hub. -pub struct ConfigProtocol { +/// This protocol never accepts raw frames or interprets transport authority +/// evidence. The composition owner admits a command, binds its principal, and +/// may expire the protocol on lease or link loss. The same owner retains the +/// one [`ConfigManager`] and one runtime log sink queue. +pub struct ConfigProtocol { pub(super) session: Option, + /// Sole configuration principal admitted until explicit lease expiry. + pub(super) principal: Option, pub(super) next_session_id: Option, - pub(super) next_transfer_id: Option, pub(super) target: Target, - pub(super) remembered: [Option; REQUEST_CACHE_CAPACITY], - pub(super) next_remembered: usize, - pub(super) importer: I, - pub(super) import: Option, - pub(super) generation: u64, + /// Sole most-recent result retained for the admitted stop-and-wait client. + pub(super) remembered: Option, pub(super) state: ConfigState, } -impl ConfigProtocol { - /// Creates a custom-protocol state machine without CBOR import support. +impl ConfigProtocol { + /// Creates a custom configuration-protocol state machine. pub const fn new(target: Target) -> Self { - Self::with_importer(target, NoSnapshotImporter) - } -} - -impl ConfigProtocol { - /// Creates a custom-protocol state machine with a vehicle-specific CBOR - /// importer. Profile/store ownership stays outside this adapter. - pub const fn with_importer(target: Target, importer: I) -> Self { Self { session: None, + principal: None, next_session_id: Some(NonZeroU32::MIN), - next_transfer_id: Some(NonZeroU32::MIN), target, - remembered: [None; REQUEST_CACHE_CAPACITY], - next_remembered: 0, - importer, - import: None, - generation: 0, + remembered: None, state: ConfigState::Active, } } -} -impl ConfigProtocol { - /// Feature bits for `KAPTAN_CONFIG_INFO` for a concrete candidate type. - pub const fn capability_flags_for(&self) -> u8 - where - I: SnapshotImporter, - { - if I::IMPORT_SUPPORTED { - CAPABILITY_CBOR_IMPORT - } else { - 0 - } - } - - /// Dispatches one authorized configuration command. The authoritative + /// Dispatches one composition-admitted configuration command. The + /// `principal` isolates transaction ownership; the first accepted `Begin` + /// binds this protocol to that exact source until [`Self::expire`]. This + /// adapter neither authenticates the source nor decides whether the + /// transport may configure the board. The authoritative /// `KAPTAN_CONFIG_RESULT` response remains the complete configuration /// audit; generic human logging is intentionally independent. - pub fn dispatch( + /// + /// An emitter error is a delivery failure, not an operation rollback. A + /// mutating command may already be durable and its sole most-recent result + /// is remembered before `emit` returns an error. Binding one stop-and-wait + /// client prevents another source from evicting that result; expiry clears + /// both the binding and replay state. Descriptor and enum-option queries + /// each emit exactly one response and are safe to retry by index. + pub fn dispatch_from_composition( + &mut self, + manager: &mut ConfigManager, + principal: Principal, + command: ConfigCommand, + mut emit: F, + ) -> Result<(), E> + where + C: ConfigModel, + S: ConfigStore, + F: FnMut(ServiceResponse) -> Result<(), E>, + { + self.refresh(manager); + self.dispatch_inner(manager, principal, command, &mut emit) + } + + /// Dispatches a host/SITL test command through the same protocol path. + #[cfg(test)] + pub fn dispatch( &mut self, manager: &mut ConfigManager, authorized: Authorized, - ) -> ServiceResponse + emit: F, + ) -> Result<(), E> where C: ConfigModel, - S: ProfileStore, - I: SnapshotImporter, + S: ConfigStore, + F: FnMut(ServiceResponse) -> Result<(), E>, { - self.refresh(manager); - let (authority, command) = authorized.into_parts(); - self.dispatch_inner(manager, authority.principal(), command) + let (principal, command) = authorized.into_parts(); + self.dispatch_from_composition(manager, principal, command, emit) } - /// Expires the bounded session and drops only its RAM transaction. + /// Expires the admitted principal and drops its RAM transaction, if any. pub fn expire(&mut self, manager: &mut ConfigManager) -> bool where C: ConfigModel, - S: ProfileStore, + S: ConfigStore, { - let expired = self.session.take().is_some(); - if expired { + let had_session = self.session.take().is_some(); + if had_session { manager.abort(); - self.import = None; - self.remembered = [None; REQUEST_CACHE_CAPACITY]; - self.next_remembered = 0; } + let expired = self.principal.take().is_some(); + self.remembered = None; expired } - fn dispatch_inner( + fn dispatch_inner( &mut self, manager: &mut ConfigManager, - owner: u64, + owner: Principal, command: ConfigCommand, - ) -> ServiceResponse + emit: &mut F, + ) -> Result<(), E> where C: ConfigModel, - S: ProfileStore, - I: SnapshotImporter, + S: ConfigStore, + F: FnMut(ServiceResponse) -> Result<(), E>, { - self.refresh(manager); if command.target() != self.target { - return ServiceResponse::Result(self.rejected(command, ProtocolError::BadTarget)); + return emit(ServiceResponse::Result( + self.rejected(command, ProtocolError::BadTarget), + )); + } + if self.principal.is_some_and(|principal| principal != owner) { + return emit(ServiceResponse::Result( + self.rejected(command, ProtocolError::SessionBusy), + )); } if let Some(cached) = self.cached(owner, command) { - return ServiceResponse::Result(cached); + return emit(ServiceResponse::Result(cached)); } let response = match command { ConfigCommand::InfoRequest { request_id, .. } => { ServiceResponse::Info(self.info(manager, request_id)) } ConfigCommand::DescriptorRequest { + request_id, index, .. + } => self + .descriptor_response(manager, request_id, index) + .map_or_else( + || ServiceResponse::Result(self.rejected(command, ProtocolError::InvalidQuery)), + ServiceResponse::Descriptor, + ), + ConfigCommand::EnumOptionRequest { request_id, - first, - count, + descriptor_index, + option_index, .. - } => ServiceResponse::Descriptors(self.descriptors(manager, request_id, first, count)), + } => self + .enum_option_response(manager, request_id, descriptor_index, option_index) + .map_or_else( + || ServiceResponse::Result(self.rejected(command, ProtocolError::InvalidQuery)), + ServiceResponse::EnumOption, + ), ConfigCommand::ValueRequest { request_id, session_id, @@ -156,11 +170,6 @@ impl ConfigProtocol { source, .. } => self.value_response(manager, owner, request_id, session_id, field, source), - ConfigCommand::ImportBegin { .. } - | ConfigCommand::ImportChunk { .. } - | ConfigCommand::ImportFinish { .. } => { - ServiceResponse::Result(self.in_session(manager, owner, command)) - } ConfigCommand::Begin { .. } => { ServiceResponse::Result(self.begin(manager, owner, command)) } @@ -172,53 +181,46 @@ impl ConfigProtocol { } }; self.refresh(manager); - match response { + let response = match response { ServiceResponse::Result(reply) => { - self.remember(RememberedResult { - owner, - command, - reply, - }); + self.remember(owner, command, reply); ServiceResponse::Result(reply) } response => response, - } + }; + emit(response) } fn begin( &mut self, manager: &mut ConfigManager, - owner: u64, + owner: Principal, command: ConfigCommand, ) -> ConfigReply where C: ConfigModel, - S: ProfileStore, + S: ConfigStore, { - let ConfigCommand::Begin { - request_id, - expected_generation, - .. - } = command - else { + let ConfigCommand::Begin { request_id, .. } = command else { return self.rejected(command, ProtocolError::Internal); }; - if self.session.is_some() { - return self.rejected(command, ProtocolError::SessionBusy); - } - if expected_generation != self.generation() { - return self.rejected(command, ProtocolError::GenerationConflict); + if let Some(session) = self.session { + return self.reply( + request_id, + session.id, + session.revision, + Operation::Begin, + ResultKind::Rejected, + Some(ProtocolError::SessionBusy), + ); } let Some(id) = self.allocate_session_id() else { return self.rejected(command, ProtocolError::Internal); }; match manager.begin() { Ok(()) => { - self.session = Some(Session { - id, - owner, - revision: 0, - }); + self.principal = Some(owner); + self.session = Some(Session { id, revision: 0 }); self.reply( request_id, id, @@ -237,19 +239,18 @@ impl ConfigProtocol { fn in_session( &mut self, manager: &mut ConfigManager, - owner: u64, + owner: Principal, command: ConfigCommand, ) -> ConfigReply where C: ConfigModel, - S: ProfileStore, - I: SnapshotImporter, + S: ConfigStore, { let Some(session) = self.session else { return self.rejected(command, ProtocolError::InvalidSession); }; let session_id = command_session_id(command); - if session.id != session_id || session.owner != owner { + if session.id != session_id || self.principal != Some(owner) { return self.rejected(command, ProtocolError::InvalidSession); } if let Some(expected_revision) = command_expected_revision(command) @@ -258,13 +259,6 @@ impl ConfigProtocol { return self.rejected(command, ProtocolError::StaleRevision); } match command { - ConfigCommand::ImportBegin { .. } => self.begin_import::(owner, command, session), - ConfigCommand::ImportChunk { .. } => self.import_chunk(command, session), - ConfigCommand::ImportFinish { - request_id, - transfer_id, - .. - } => self.finish_import(manager, command, session, request_id, transfer_id), ConfigCommand::Set { request_id, field, @@ -309,43 +303,28 @@ impl ConfigProtocol { } Err(CommitError::Store(_)) => self.rejected(command, ProtocolError::Internal), }, - ConfigCommand::Commit { - request_id, - expected_generation, - .. - } => { - if expected_generation != self.generation() { - return self.rejected(command, ProtocolError::GenerationConflict); + ConfigCommand::Commit { request_id, .. } => match manager.commit() { + Ok(()) => { + self.session = None; + self.refresh(manager); + self.reply( + request_id, + 0, + session.revision, + Operation::Commit, + ResultKind::CommittedRestartRequired, + None, + ) } - match manager.commit() { - Ok(_) => { - self.session = None; - self.import = None; - self.refresh(manager); - self.reply( - request_id, - 0, - session.revision, - Operation::Commit, - ResultKind::CommittedRestartRequired, - 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::StorageFailure) - } + 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::StorageFailure), + }, ConfigCommand::Abort { request_id, .. } => { manager.abort(); self.session = None; - self.import = None; self.refresh(manager); self.reply( request_id, @@ -358,6 +337,7 @@ impl ConfigProtocol { } ConfigCommand::InfoRequest { .. } | ConfigCommand::DescriptorRequest { .. } + | ConfigCommand::EnumOptionRequest { .. } | ConfigCommand::ValueRequest { .. } | ConfigCommand::Begin { .. } => self.rejected(command, ProtocolError::Internal), } @@ -369,25 +349,14 @@ impl ConfigProtocol { Some(id.get()) } - pub(super) fn allocate_transfer_id(&mut self) -> Option { - let id = self.next_transfer_id?; - self.next_transfer_id = id.get().checked_add(1).and_then(NonZeroU32::new); - Some(id.get()) - } - fn refresh(&mut self, manager: &ConfigManager) where C: ConfigModel, - S: ProfileStore, + S: ConfigStore, { - self.generation = manager.generation().map_or(0, Generation::get); self.state = manager.state(); } - fn generation(&self) -> u64 { - self.generation - } - pub(super) fn reply( &self, request_id: u32, @@ -400,9 +369,7 @@ impl ConfigProtocol { ConfigReply { request_id, session_id, - transfer_id: 0, revision, - generation: self.generation, operation, result, error, @@ -442,12 +409,10 @@ fn command_session_id(command: ConfigCommand) -> u32 { match command { ConfigCommand::InfoRequest { .. } | ConfigCommand::DescriptorRequest { .. } + | ConfigCommand::EnumOptionRequest { .. } | ConfigCommand::Begin { .. } => 0, ConfigCommand::ValueRequest { session_id, .. } => session_id, ConfigCommand::Set { session_id, .. } - | ConfigCommand::ImportBegin { session_id, .. } - | ConfigCommand::ImportChunk { session_id, .. } - | ConfigCommand::ImportFinish { session_id, .. } | ConfigCommand::Validate { session_id, .. } | ConfigCommand::Commit { session_id, .. } | ConfigCommand::Abort { session_id, .. } => session_id, @@ -459,12 +424,6 @@ fn command_expected_revision(command: ConfigCommand) -> Option { ConfigCommand::Set { expected_revision, .. } - | ConfigCommand::ImportBegin { - expected_revision, .. - } - | ConfigCommand::ImportFinish { - expected_revision, .. - } | ConfigCommand::Validate { expected_revision, .. } @@ -473,8 +432,8 @@ fn command_expected_revision(command: ConfigCommand) -> Option { } => Some(expected_revision), ConfigCommand::InfoRequest { .. } | ConfigCommand::DescriptorRequest { .. } + | ConfigCommand::EnumOptionRequest { .. } | ConfigCommand::ValueRequest { .. } - | ConfigCommand::ImportChunk { .. } | ConfigCommand::Begin { .. } | ConfigCommand::Abort { .. } => None, } @@ -484,10 +443,8 @@ const fn operation(command: ConfigCommand) -> Operation { match command { ConfigCommand::InfoRequest { .. } | ConfigCommand::DescriptorRequest { .. } + | ConfigCommand::EnumOptionRequest { .. } | ConfigCommand::ValueRequest { .. } => Operation::Query, - ConfigCommand::ImportBegin { .. } - | ConfigCommand::ImportChunk { .. } - | ConfigCommand::ImportFinish { .. } => Operation::Import, ConfigCommand::Begin { .. } => Operation::Begin, ConfigCommand::Set { .. } => Operation::Set, ConfigCommand::Validate { .. } => Operation::Validate, @@ -506,24 +463,26 @@ fn field_error(error: FieldWriteError) -> ProtocolError { #[cfg(test)] mod tests { + use std::{cell::Cell, rc::Rc}; + use kaptan_config::{ - ConfigManager, ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, - FieldWriteError, Generation, ProfileStore, ValidationRule, + ConfigManager, ConfigModel, ConfigStore, ConfigValue, ConfigViolation, EnumOption, + FieldDescriptor, FieldId, FieldWriteError, ValidationRule, }; use super::*; use crate::{Authorized, ConfigCommand, ProtocolError, ResultKind, Target, ValueSource}; const VALUE: FieldId = FieldId::new(1); + const ENUM_OPTIONS: [EnumOption; 3] = [(0, "Zero"), (1, "One"), (2, "Two")]; const DESCRIPTOR: FieldDescriptor = FieldDescriptor { id: VALUE, display_key: "test.value", kind: kaptan_config::ValueKind::U32, minimum: Some(ConfigValue::U32(0)), maximum: Some(ConfigValue::U32(10)), - default: ConfigValue::U32(2), + enum_options: &ENUM_OPTIONS, unit: "count", - apply_policy: kaptan_config::ApplyPolicy::RestartRequired, }; #[derive(Clone)] @@ -572,76 +531,155 @@ mod tests { } struct Store; - impl ProfileStore for Store { + impl ConfigStore for Store { type Error = (); - fn load(&mut self) -> Result, Self::Error> { + fn load(&mut self) -> Result, Self::Error> { Ok(None) } - fn persist(&mut self, _: &Candidate) -> Result { - Ok(Generation::new(1)) + fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> { + Ok(()) } } - struct Importer; + const fn multi_descriptor(id: u16) -> FieldDescriptor { + FieldDescriptor { + id: FieldId::new(id), + display_key: "test.multi", + kind: kaptan_config::ValueKind::U32, + minimum: None, + maximum: None, + enum_options: &[], + unit: "count", + } + } - impl SnapshotImporter for Importer { - const IMPORT_SUPPORTED: bool = true; - const PROFILE_FORMAT: u16 = 1; + const MULTI_DESCRIPTORS: [FieldDescriptor; 6] = [ + multi_descriptor(1), + multi_descriptor(2), + multi_descriptor(3), + multi_descriptor(4), + multi_descriptor(5), + multi_descriptor(6), + ]; - fn decode(&self, bytes: &[u8]) -> Result { - if bytes != [7] - && !(bytes.len() == IMPORT_CHUNK_BYTES + 1 - && bytes[0] == 7 - && bytes[IMPORT_CHUNK_BYTES] == 9) - { - return Err(ProtocolError::CborMalformed); - } - Ok(Candidate(7)) + #[derive(Clone)] + struct MultiCandidate([u32; 6]); + + impl ConfigModel for MultiCandidate { + type Active = [u32; 6]; + type Context = (); + const SCHEMA_ID: [u8; 16] = *b"CONFIGSERVICE-02"; + + fn descriptor(index: u16) -> Option<&'static FieldDescriptor> { + MULTI_DESCRIPTORS.get(usize::from(index)) + } + + fn field_count() -> u16 { + MULTI_DESCRIPTORS.len() as u16 + } + + fn field_value(&self, id: FieldId) -> Option { + id.get() + .checked_sub(1) + .and_then(|index| self.0.get(usize::from(index))) + .copied() + .map(ConfigValue::U32) + } + + fn active_field_value(active: &Self::Active, id: FieldId) -> Option { + id.get() + .checked_sub(1) + .and_then(|index| active.get(usize::from(index))) + .copied() + .map(ConfigValue::U32) + } + + fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> { + let Some(index) = id.get().checked_sub(1).map(usize::from) else { + return Err(FieldWriteError::UnknownField(id)); + }; + let Some(field) = self.0.get_mut(index) else { + return Err(FieldWriteError::UnknownField(id)); + }; + let ConfigValue::U32(value) = value else { + return Err(FieldWriteError::WrongType { + field: id, + expected: kaptan_config::ValueKind::U32, + actual: value.kind(), + }); + }; + *field = value; + Ok(()) + } + + fn validate(&self, _: &Self::Context) -> Result { + Ok(self.0) + } + } + + struct MultiStore; + + impl ConfigStore for MultiStore { + type Error = (); + + fn load(&mut self) -> Result, Self::Error> { + Ok(None) + } + + fn persist(&mut self, _: &MultiCandidate) -> Result<(), Self::Error> { + Ok(()) + } + } + + struct CountingStore(Rc>); + + impl ConfigStore for CountingStore { + type Error = (); + + fn load(&mut self) -> Result, Self::Error> { + Ok(None) + } + + fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> { + self.0.set(self.0.get() + 1); + Ok(()) } } /// Test-only composition root. Production code must retain these two /// objects separately; this wrapper keeps protocol examples compact while /// exercising the same externally-owned-manager dispatch API. - struct TestService { + struct TestService { manager: ConfigManager, - protocol: ConfigProtocol, + protocol: ConfigProtocol, } - impl TestService { + impl TestService { fn new(manager: ConfigManager, target: Target) -> Self { Self { manager, protocol: ConfigProtocol::new(target), } } - } - impl TestService - where - I: SnapshotImporter, - { - fn with_importer( - manager: ConfigManager, - target: Target, - importer: I, - ) -> Self { - Self { - manager, - protocol: ConfigProtocol::with_importer(target, importer), - } + fn dispatch_all( + &mut self, + command: Authorized, + ) -> std::vec::Vec { + let mut responses = std::vec::Vec::new(); + self.protocol + .dispatch(&mut self.manager, command, |response| { + responses.push(response); + Ok::<(), core::convert::Infallible>(()) + }) + .unwrap(); + responses } fn dispatch(&mut self, command: Authorized) -> ServiceResponse { - self.protocol.dispatch(&mut self.manager, command) - } - - fn manager(&self) -> &ConfigManager { - &self.manager - } - - fn capability_flags(&self) -> u8 { - self.protocol.capability_flags_for::() + let mut responses = self.dispatch_all(command); + assert_eq!(responses.len(), 1, "test expected one service response"); + responses.pop().unwrap() } } @@ -649,7 +687,8 @@ mod tests { match response { ServiceResponse::Result(reply) => reply, ServiceResponse::Info(_) - | ServiceResponse::Descriptors(_) + | ServiceResponse::Descriptor(_) + | ServiceResponse::EnumOption(_) | ServiceResponse::Value(_) => { panic!("test expected a result response") } @@ -662,7 +701,6 @@ mod tests { let mut service = TestService::new(manager, Target::new(1, 1)); let begin = ConfigCommand::Begin { request_id: 1, - expected_generation: 0, target: Target::new(1, 1), }; let started = result(service.dispatch(Authorized::host(7, begin))); @@ -678,6 +716,22 @@ mod tests { let accepted = result(service.dispatch(Authorized::host(7, set))); assert_eq!(accepted.result, ResultKind::Accepted); assert_eq!(result(service.dispatch(Authorized::host(7, set))), accepted); + let conflict = result(service.dispatch(Authorized::host( + 7, + ConfigCommand::Set { + request_id: 2, + session_id: started.session_id, + expected_revision: 0, + field: VALUE, + value: ConfigValue::U32(4), + target: Target::new(1, 1), + }, + ))); + assert_eq!(conflict.error, Some(ProtocolError::RequestConflict)); + assert_eq!( + service.manager.transaction_value(VALUE), + Some(ConfigValue::U32(3)) + ); let foreign = result(service.dispatch(Authorized::host( 8, ConfigCommand::Set { @@ -689,7 +743,80 @@ mod tests { target: Target::new(1, 1), }, ))); - assert_eq!(foreign.error, Some(ProtocolError::InvalidSession)); + assert_eq!(foreign.error, Some(ProtocolError::SessionBusy)); + } + + #[test] + fn only_the_most_recent_stop_and_wait_result_is_replayed() { + let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); + let mut service = TestService::new(manager, Target::new(1, 1)); + let begin = ConfigCommand::Begin { + request_id: 1, + target: Target::new(1, 1), + }; + let started = result(service.dispatch(Authorized::host(7, begin))); + let set = ConfigCommand::Set { + request_id: 2, + session_id: started.session_id, + expected_revision: 0, + field: VALUE, + value: ConfigValue::U32(3), + target: Target::new(1, 1), + }; + assert_eq!( + result(service.dispatch(Authorized::host(7, set))).result, + ResultKind::Accepted + ); + + // Advancing a stop-and-wait client replaces the preceding result. + // Reusing that older command is handled from current state instead of + // retaining an unbounded request history. + assert_eq!( + result(service.dispatch(Authorized::host(7, begin))).error, + Some(ProtocolError::SessionBusy) + ); + } + + #[test] + fn busy_owner_can_identify_and_abort_its_abandoned_session() { + let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); + let mut service = TestService::new(manager, Target::new(1, 1)); + let started = result(service.dispatch(Authorized::host( + 7, + ConfigCommand::Begin { + request_id: 1, + target: Target::new(1, 1), + }, + ))); + let owned_busy = result(service.dispatch(Authorized::host( + 7, + ConfigCommand::Begin { + request_id: 2, + target: Target::new(1, 1), + }, + ))); + assert_eq!(owned_busy.error, Some(ProtocolError::SessionBusy)); + assert_eq!(owned_busy.session_id, started.session_id); + + let foreign_busy = result(service.dispatch(Authorized::host( + 8, + ConfigCommand::Begin { + request_id: 3, + target: Target::new(1, 1), + }, + ))); + assert_eq!(foreign_busy.error, Some(ProtocolError::SessionBusy)); + assert_eq!(foreign_busy.session_id, 0); + + let aborted = result(service.dispatch(Authorized::host( + 7, + ConfigCommand::Abort { + request_id: 4, + session_id: owned_busy.session_id, + target: Target::new(1, 1), + }, + ))); + assert_eq!(aborted.result, ResultKind::Accepted); } #[test] @@ -700,7 +827,6 @@ mod tests { 7, ConfigCommand::Begin { request_id: 1, - expected_generation: 0, target: Target::new(1, 1), }, ))); @@ -728,16 +854,15 @@ mod tests { 7, ConfigCommand::DescriptorRequest { request_id: 1, - first: 0, - count: 4, + index: 0, target: Target::new(1, 1), }, )); assert!(matches!( descriptors, - ServiceResponse::Descriptors(DescriptorPage { - len: 1, - descriptors: [Some(&DESCRIPTOR), None, None, None], + ServiceResponse::Descriptor(DescriptorResponse { + index: 0, + descriptor: DESCRIPTOR, .. }) )); @@ -771,235 +896,297 @@ mod tests { ServiceResponse::Info(ConfigInfo { request_id: 3, field_count: 1, - profile_format: 0, - capability_flags: 0, .. }) )); } #[test] - fn complete_cbor_import_replaces_only_the_open_transaction() { - let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut service = TestService::with_importer(manager, Target::new(1, 1), Importer); - let begin = result(service.dispatch(Authorized::host( + fn descriptors_advertise_the_composition_factory_not_a_schema_fallback() { + let manager = ConfigManager::new(Candidate(7), (), Store).unwrap(); + let mut service = TestService::new(manager, Target::new(1, 1)); + let response = service.dispatch(Authorized::host( 7, - ConfigCommand::Begin { + ConfigCommand::DescriptorRequest { request_id: 1, - expected_generation: 0, + index: 0, target: Target::new(1, 1), }, - ))); - let bytes = [7]; - let mut chunk = [0; IMPORT_CHUNK_BYTES]; - chunk[0] = 7; - let import_begin = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::ImportBegin { - request_id: 2, - session_id: begin.session_id, - expected_revision: 0, - length: 1, - schema_id: Candidate::SCHEMA_ID, - crc32c: crc32c(&bytes), - target: Target::new(1, 1), - }, - ))); - assert_eq!(import_begin.result, ResultKind::Accepted); - assert_ne!(import_begin.transfer_id, 0); + )); + let ServiceResponse::Descriptor(response) = response else { + panic!("expected descriptor response"); + }; + assert_eq!(response.default, ConfigValue::U32(7)); + } + + #[test] + fn enum_options_are_explicitly_indexed_and_retry_the_rejected_request() { + let mut manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); + let mut protocol = ConfigProtocol::new(Target::new(1, 1)); + let command = ConfigCommand::EnumOptionRequest { + request_id: 10, + descriptor_index: 0, + option_index: 0, + target: Target::new(1, 1), + }; + + let mut rejected = None; assert_eq!( - result(service.dispatch(Authorized::host( - 7, - ConfigCommand::ImportChunk { - request_id: 3, - session_id: begin.session_id, - transfer_id: import_begin.transfer_id, - offset: 0, - length: 1, - data: chunk, - target: Target::new(1, 1), + protocol.dispatch(&mut manager, Authorized::host(7, command), |response| { + rejected = Some(response); + Err(()) + }), + Err(()) + ); + assert!(matches!( + rejected, + Some(ServiceResponse::EnumOption(EnumOptionResponse { + option_index: 0, + option: (0, "Zero"), + .. + })) + )); + + let mut retry = std::vec::Vec::new(); + protocol + .dispatch(&mut manager, Authorized::host(7, command), |response| { + retry.push(response); + Ok::<(), core::convert::Infallible>(()) + }) + .unwrap(); + assert_eq!(retry.len(), 1, "one request identifies one option"); + assert!(matches!( + retry[0], + ServiceResponse::EnumOption(EnumOptionResponse { + option_index: 0, + option: (0, "Zero"), + .. + }) + )); + + let next = ConfigCommand::EnumOptionRequest { + request_id: 11, + descriptor_index: 0, + option_index: 1, + target: Target::new(1, 1), + }; + let mut second = None; + protocol + .dispatch(&mut manager, Authorized::host(7, next), |response| { + second = Some(response); + Ok::<(), core::convert::Infallible>(()) + }) + .unwrap(); + assert!(matches!( + second, + Some(ServiceResponse::EnumOption(EnumOptionResponse { + option_index: 1, + option: (1, "One"), + .. + })) + )); + } + + #[test] + fn descriptor_queries_are_bounded_ordered_and_stop_at_the_schema_end() { + let mut manager = ConfigManager::new(MultiCandidate([0; 6]), (), MultiStore).unwrap(); + let mut protocol = ConfigProtocol::new(Target::new(1, 1)); + + let mut indices = std::vec::Vec::new(); + protocol + .dispatch( + &mut manager, + Authorized::host( + 7, + ConfigCommand::DescriptorRequest { + request_id: 1, + index: 1, + target: Target::new(1, 1), + }, + ), + |response| { + let ServiceResponse::Descriptor(response) = response else { + panic!("expected a descriptor response"); + }; + indices.push(response.index); + Ok::<(), core::convert::Infallible>(()) }, - ))) - .result, - ResultKind::Accepted - ); - let complete = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::ImportFinish { - request_id: 4, - session_id: begin.session_id, - transfer_id: import_begin.transfer_id, - expected_revision: 0, - target: Target::new(1, 1), - }, - ))); - assert_eq!(complete.result, ResultKind::Accepted); - assert_eq!(complete.revision, 1); - assert_eq!( - service.manager().active_value(VALUE), - Some(ConfigValue::U32(2)) - ); - assert_eq!( - service.manager().transaction_value(VALUE), - Some(ConfigValue::U32(7)) - ); + ) + .unwrap(); + assert_eq!(indices, [1]); + + indices.clear(); + protocol + .dispatch( + &mut manager, + Authorized::host( + 7, + ConfigCommand::DescriptorRequest { + request_id: 2, + index: 4, + target: Target::new(1, 1), + }, + ), + |response| { + let ServiceResponse::Descriptor(response) = response else { + panic!("expected a descriptor response"); + }; + indices.push(response.index); + Ok::<(), core::convert::Infallible>(()) + }, + ) + .unwrap(); + assert_eq!(indices, [4]); } #[test] - fn stale_import_transfer_cannot_feed_a_new_import() { - let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut service = TestService::with_importer(manager, Target::new(1, 1), Importer); - let session = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::Begin { - request_id: 1, - expected_generation: 0, - target: Target::new(1, 1), - }, - ))); - let first = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::ImportBegin { - request_id: 2, - session_id: session.session_id, - expected_revision: 0, - length: 1, - schema_id: Candidate::SCHEMA_ID, - crc32c: crc32c(&[7]), - target: Target::new(1, 1), - }, - ))); - let second = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::ImportBegin { - request_id: 3, - session_id: session.session_id, - expected_revision: 0, - length: 1, - schema_id: Candidate::SCHEMA_ID, - crc32c: crc32c(&[7]), - target: Target::new(1, 1), - }, - ))); - assert_ne!(first.transfer_id, second.transfer_id); - let mut chunk = [0; IMPORT_CHUNK_BYTES]; - chunk[0] = 7; - let stale = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::ImportChunk { - request_id: 4, - session_id: session.session_id, - transfer_id: first.transfer_id, - offset: 0, - length: 1, - data: chunk, - target: Target::new(1, 1), - }, - ))); - assert_eq!(stale.error, Some(ProtocolError::InvalidSession)); + fn descriptor_request_retries_the_same_index_after_delivery_fails() { + let mut manager = ConfigManager::new(MultiCandidate([0; 6]), (), MultiStore).unwrap(); + let mut protocol = ConfigProtocol::new(Target::new(1, 1)); + let command = ConfigCommand::DescriptorRequest { + request_id: 1, + index: 0, + target: Target::new(1, 1), + }; + let mut indices = std::vec::Vec::new(); + let failure = protocol.dispatch(&mut manager, Authorized::host(7, command), |response| { + let ServiceResponse::Descriptor(response) = response else { + panic!("expected a descriptor response"); + }; + indices.push(response.index); + Err(()) + }); + assert_eq!(failure, Err(())); + assert_eq!(indices, [0]); + + indices.clear(); + protocol + .dispatch(&mut manager, Authorized::host(7, command), |response| { + let ServiceResponse::Descriptor(response) = response else { + panic!("expected a descriptor response"); + }; + indices.push(response.index); + Ok::<(), core::convert::Infallible>(()) + }) + .unwrap(); + assert_eq!(indices, [0]); + + indices.clear(); + protocol + .dispatch( + &mut manager, + Authorized::host( + 7, + ConfigCommand::DescriptorRequest { + request_id: 2, + index: 1, + target: Target::new(1, 1), + }, + ), + |response| { + let ServiceResponse::Descriptor(response) = response else { + panic!("expected a descriptor response"); + }; + indices.push(response.index); + Ok::<(), core::convert::Infallible>(()) + }, + ) + .unwrap(); + assert_eq!(indices, [1]); } #[test] - fn import_accepts_aligned_out_of_order_idempotent_chunks_and_rejects_conflicts() { + fn invalid_descriptor_index_is_a_structured_query_error() { let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut service = TestService::with_importer(manager, Target::new(1, 1), Importer); - let session = result(service.dispatch(Authorized::host( + let mut service = TestService::new(manager, Target::new(1, 1)); + let reply = result(service.dispatch(Authorized::host( 7, - ConfigCommand::Begin { + ConfigCommand::DescriptorRequest { request_id: 1, - expected_generation: 0, + index: 1, target: Target::new(1, 1), }, ))); - let mut bytes = [0_u8; IMPORT_CHUNK_BYTES + 1]; - bytes[0] = 7; - bytes[IMPORT_CHUNK_BYTES] = 9; - let imported = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::ImportBegin { - request_id: 2, - session_id: session.session_id, - expected_revision: 0, - length: bytes.len() as u16, - schema_id: Candidate::SCHEMA_ID, - crc32c: crc32c(&bytes), - target: Target::new(1, 1), - }, - ))); - let mut tail = [0_u8; IMPORT_CHUNK_BYTES]; - tail[0] = 9; - let tail_command = ConfigCommand::ImportChunk { + assert_eq!(reply.error, Some(ProtocolError::InvalidQuery)); + } + + #[test] + fn a_lost_commit_response_does_not_repeat_flash_persistence() { + let persists = Rc::new(Cell::new(0)); + let store = CountingStore(Rc::clone(&persists)); + let mut manager = ConfigManager::new(Candidate(2), (), store).unwrap(); + let mut protocol = ConfigProtocol::new(Target::new(1, 1)); + let mut begin_reply = None; + protocol + .dispatch( + &mut manager, + Authorized::host( + 7, + ConfigCommand::Begin { + request_id: 1, + target: Target::new(1, 1), + }, + ), + |response| { + begin_reply = Some(result(response)); + Ok::<(), core::convert::Infallible>(()) + }, + ) + .unwrap(); + let begin_reply = begin_reply.unwrap(); + let commit = ConfigCommand::Commit { + request_id: 2, + session_id: begin_reply.session_id, + expected_revision: 0, + target: Target::new(1, 1), + }; + + assert_eq!( + protocol.dispatch(&mut manager, Authorized::host(7, commit), |_| Err(())), + Err(()) + ); + assert_eq!(persists.get(), 1); + + let foreign = ConfigCommand::Begin { request_id: 3, - session_id: session.session_id, - transfer_id: imported.transfer_id, - offset: IMPORT_CHUNK_BYTES as u16, - length: 1, - data: tail, target: Target::new(1, 1), }; + let mut foreign_reply = None; + protocol + .dispatch(&mut manager, Authorized::host(8, foreign), |response| { + foreign_reply = Some(result(response)); + Ok::<(), core::convert::Infallible>(()) + }) + .unwrap(); assert_eq!( - result(service.dispatch(Authorized::host(7, tail_command))).result, - ResultKind::Accepted + foreign_reply.unwrap().error, + Some(ProtocolError::SessionBusy) ); - let mut head = [0_u8; IMPORT_CHUNK_BYTES]; - head[0] = 7; - let head_command = ConfigCommand::ImportChunk { - request_id: 4, - session_id: session.session_id, - transfer_id: imported.transfer_id, - offset: 0, - length: IMPORT_CHUNK_BYTES as u8, - data: head, - target: Target::new(1, 1), - }; + let mut retry_reply = None; + protocol + .dispatch(&mut manager, Authorized::host(7, commit), |response| { + retry_reply = Some(result(response)); + Ok::<(), core::convert::Infallible>(()) + }) + .unwrap(); + assert_eq!(persists.get(), 1); assert_eq!( - result(service.dispatch(Authorized::host(7, head_command))).result, - ResultKind::Accepted - ); - let retry = ConfigCommand::ImportChunk { - request_id: 5, - session_id: session.session_id, - transfer_id: imported.transfer_id, - offset: 0, - length: IMPORT_CHUNK_BYTES as u8, - data: head, - target: Target::new(1, 1), - }; - assert_eq!( - result(service.dispatch(Authorized::host(7, retry))).result, - ResultKind::Accepted - ); - let mut conflicting = head; - conflicting[0] = 8; - let conflict = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::ImportChunk { - request_id: 6, - data: conflicting, - session_id: session.session_id, - transfer_id: imported.transfer_id, - offset: 0, - length: IMPORT_CHUNK_BYTES as u8, - target: Target::new(1, 1), - }, - ))); - assert_eq!(conflict.error, Some(ProtocolError::RequestConflict)); - let complete = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::ImportFinish { - request_id: 7, - session_id: session.session_id, - transfer_id: imported.transfer_id, - expected_revision: 0, - target: Target::new(1, 1), - }, - ))); - assert_eq!(complete.result, ResultKind::Accepted); - assert_eq!( - service.manager().transaction_value(VALUE), - Some(ConfigValue::U32(7)) + retry_reply.unwrap().result, + ResultKind::CommittedRestartRequired ); + + // Expiry releases the principal even though COMMIT already closed its + // RAM transaction, allowing the next direct-link configurator in. + assert!(protocol.expire(&mut manager)); + let mut next_owner = None; + protocol + .dispatch(&mut manager, Authorized::host(8, foreign), |response| { + next_owner = Some(result(response)); + Ok::<(), core::convert::Infallible>(()) + }) + .unwrap(); + assert_eq!(next_owner.unwrap().result, ResultKind::Accepted); } #[test] @@ -1010,7 +1197,6 @@ mod tests { 7, ConfigCommand::Begin { request_id: 1, - expected_generation: 0, target: Target::new(1, 1), }, ))); @@ -1032,63 +1218,4 @@ mod tests { assert_eq!(rejected.request_id, 2); assert_eq!(rejected.primary_field(), FieldId::new(2).get()); } - - #[test] - fn import_capability_truthfully_reflects_the_installed_importer() { - let default_manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut default_service = TestService::new(default_manager, Target::new(1, 1)); - assert_eq!(default_service.capability_flags(), 0); - let begin = result(default_service.dispatch(Authorized::host( - 7, - ConfigCommand::Begin { - request_id: 1, - expected_generation: 0, - target: Target::new(1, 1), - }, - ))); - let unavailable = result(default_service.dispatch(Authorized::host( - 7, - ConfigCommand::ImportBegin { - request_id: 2, - session_id: begin.session_id, - expected_revision: 0, - length: 1, - schema_id: [0; 16], - crc32c: 0, - target: Target::new(1, 1), - }, - ))); - assert_eq!(unavailable.error, Some(ProtocolError::ImportUnavailable)); - let importing_manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let importing_service = - TestService::with_importer(importing_manager, Target::new(1, 1), Importer); - assert_eq!(importing_service.capability_flags(), CAPABILITY_CBOR_IMPORT); - } - - #[test] - fn import_requires_the_discovered_schema_id_before_receiving_bytes() { - let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut service = TestService::with_importer(manager, Target::new(1, 1), Importer); - let begin = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::Begin { - request_id: 1, - expected_generation: 0, - target: Target::new(1, 1), - }, - ))); - let rejected = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::ImportBegin { - request_id: 2, - session_id: begin.session_id, - expected_revision: 0, - length: 1, - schema_id: [0; 16], - crc32c: crc32c(&[7]), - target: Target::new(1, 1), - }, - ))); - assert_eq!(rejected.error, Some(ProtocolError::SchemaMismatch)); - } } diff --git a/firmware/libs/kaptan-config-mavlink/src/wire.rs b/firmware/libs/kaptan-config-mavlink/src/wire.rs index 614bc7a..88240d7 100644 --- a/firmware/libs/kaptan-config-mavlink/src/wire.rs +++ b/firmware/libs/kaptan-config-mavlink/src/wire.rs @@ -1,23 +1,68 @@ //! Generated Kaptan-dialect message translation. use kaptan_config::{ - ApplyPolicy, ConfigState, ConfigValue, FieldDescriptor, FieldId, Generation, ValidationRule, - ValueKind, + ConfigState, ConfigValue, FieldDescriptor, FieldId, ValidationRule, ValueKind, }; use kaptan_log::KaptanError; use kaptan_mavlink::message::Message; use kaptan_mavlink::message::kaptan::{ - KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_INFO_DATA, KAPTAN_CONFIG_RESULT_DATA, - KAPTAN_CONFIG_VALUE_DATA, KaptanConfigError, KaptanConfigOperation, KaptanConfigPolicy, + KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_ENUM_OPTION_DATA, KAPTAN_CONFIG_INFO_DATA, + KAPTAN_CONFIG_RESULT_DATA, KAPTAN_CONFIG_VALUE_DATA, KaptanConfigError, KaptanConfigOperation, KaptanConfigResult, KaptanConfigState, KaptanConfigValueKind, KaptanConfigValueSource, MavMessage, }; use crate::{ - ConfigCommand, ConfigReply, Operation, ProtocolError, ResultKind, Target, ValueSource, - service::{ConfigInfo, ValueResponse}, + ConfigCommand, ConfigReply, DESCRIPTOR_HAS_MAXIMUM, DESCRIPTOR_HAS_MINIMUM, Operation, + PROTOCOL_MAJOR, PROTOCOL_MINOR, ProtocolError, ResultKind, Target, ValueSource, + service::{ConfigInfo, EnumOptionResponse, ServiceResponse, ValueResponse}, }; +/// Transport-owned consumer of generated configuration response payloads. +/// +/// Implementors retain ownership of MAVLink framing, sender sequence, queue +/// classification, and physical transmission. +pub trait ResponseVisitor { + /// Transport-specific admission or serialization failure. + type Error; + + /// Visits one protocol/schema information response. + fn info(&mut self, response: &KAPTAN_CONFIG_INFO_DATA) -> Result<(), Self::Error>; + + /// Visits one authoritative operation result. + fn result(&mut self, response: &KAPTAN_CONFIG_RESULT_DATA) -> Result<(), Self::Error>; + + /// Visits one field descriptor. + fn descriptor(&mut self, response: &KAPTAN_CONFIG_DESCRIPTOR_DATA) -> Result<(), Self::Error>; + + /// Visits one named choice for an enum-backed field descriptor. + fn enum_option(&mut self, response: &KAPTAN_CONFIG_ENUM_OPTION_DATA) + -> Result<(), Self::Error>; + + /// Visits one typed configuration value. + fn value(&mut self, response: &KAPTAN_CONFIG_VALUE_DATA) -> Result<(), Self::Error>; +} + +/// Projects one transport-neutral service response into generated MAVLink +/// payloads without allocating or assuming a board, schema, or field set. +pub fn visit_response(response: ServiceResponse, visitor: &mut V) -> Result<(), V::Error> +where + V: ResponseVisitor, +{ + match response { + ServiceResponse::Info(info) => visitor.info(&encode_info(info)), + ServiceResponse::Result(result) => visitor.result(&encode_result(result)), + ServiceResponse::Descriptor(response) => visitor.descriptor(&encode_descriptor( + response.request_id, + response.index, + response.descriptor, + response.default, + )), + ServiceResponse::EnumOption(response) => visitor.enum_option(&encode_enum_option(response)), + ServiceResponse::Value(value) => visitor.value(&encode_value(value)), + } +} + /// Rejects a generated message that is not a Kaptan configuration command. #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum DecodeError { @@ -35,8 +80,9 @@ pub enum DecodeError { /// Converts one generated custom request into a transport-neutral command. /// -/// This only decodes a validated MAVLink payload. An authenticated ingress must -/// still wrap the result in [`crate::Authorized`] before dispatch. +/// This only decodes a validated MAVLink payload. CRC validity is not peer +/// authentication; the platform composition must separately decide whether +/// the ingress may dispatch the command and bind its transaction principal. pub fn decode_command(message: &MavMessage) -> Result { if matches!(message.target_system_id(), Some(0)) || matches!(message.target_component_id(), Some(0)) @@ -51,8 +97,15 @@ pub fn decode_command(message: &MavMessage) -> Result { Ok(ConfigCommand::DescriptorRequest { request_id: data.request_id, - first: data.first, - count: data.count, + index: data.descriptor_index, + target: Target::new(data.target_system, data.target_component), + }) + } + MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(data) => { + Ok(ConfigCommand::EnumOptionRequest { + request_id: data.request_id, + descriptor_index: data.descriptor_index, + option_index: data.option_index, target: Target::new(data.target_system, data.target_component), }) } @@ -63,34 +116,8 @@ pub fn decode_command(message: &MavMessage) -> Result Ok(ConfigCommand::ImportBegin { - request_id: data.request_id, - session_id: data.session_id, - expected_revision: data.expected_revision, - length: data.length, - schema_id: data.schema_id, - crc32c: data.crc32c, - target: Target::new(data.target_system, data.target_component), - }), - MavMessage::KAPTAN_CONFIG_IMPORT_CHUNK(data) => Ok(ConfigCommand::ImportChunk { - request_id: data.request_id, - session_id: data.session_id, - transfer_id: data.transfer_id, - offset: data.offset, - length: data.length, - data: data.data, - target: Target::new(data.target_system, data.target_component), - }), - MavMessage::KAPTAN_CONFIG_IMPORT_FINISH(data) => Ok(ConfigCommand::ImportFinish { - request_id: data.request_id, - session_id: data.session_id, - transfer_id: data.transfer_id, - expected_revision: data.expected_revision, - target: Target::new(data.target_system, data.target_component), - }), MavMessage::KAPTAN_CONFIG_BEGIN(data) => Ok(ConfigCommand::Begin { request_id: data.request_id, - expected_generation: data.expected_generation, target: Target::new(data.target_system, data.target_component), }), MavMessage::KAPTAN_CONFIG_SET_F32(data) => Ok(ConfigCommand::Set { @@ -150,7 +177,6 @@ pub fn decode_command(message: &MavMessage) -> Result Ok(ConfigCommand::Commit { request_id: data.request_id, session_id: data.session_id, - expected_generation: data.expected_generation, expected_revision: data.expected_revision, target: Target::new(data.target_system, data.target_component), }), @@ -191,10 +217,8 @@ const fn boolean(value: u8) -> Result { /// Encodes one authoritative service completion. pub fn encode_result(reply: ConfigReply) -> KAPTAN_CONFIG_RESULT_DATA { KAPTAN_CONFIG_RESULT_DATA { - generation: reply.generation, request_id: reply.request_id, session_id: reply.session_id, - transfer_id: reply.transfer_id, revision: reply.revision, primary_field: reply.primary_field(), related_field: reply.related_field(), @@ -209,16 +233,12 @@ pub fn encode_result(reply: ConfigReply) -> KAPTAN_CONFIG_RESULT_DATA { /// Encodes configuration protocol/schema status for a Kaptan control station. pub fn encode_info(info: ConfigInfo) -> KAPTAN_CONFIG_INFO_DATA { KAPTAN_CONFIG_INFO_DATA { - active_generation: info.active_generation.map_or(0, Generation::get), - committed_generation: info.committed_generation.map_or(0, Generation::get), request_id: info.request_id, schema_id: info.schema_id, - profile_format: info.profile_format, field_count: info.field_count, - protocol_major: 1, - protocol_minor: 0, + protocol_major: PROTOCOL_MAJOR, + protocol_minor: PROTOCOL_MINOR, config_state: state(info.state), - capability_flags: info.capability_flags, } } @@ -227,19 +247,34 @@ pub fn encode_descriptor( request_id: u32, descriptor_index: u16, descriptor: FieldDescriptor, + default: ConfigValue, ) -> KAPTAN_CONFIG_DESCRIPTOR_DATA { KAPTAN_CONFIG_DESCRIPTOR_DATA { minimum_bits: descriptor.minimum.map_or(0, ConfigValue::raw_bits), maximum_bits: descriptor.maximum.map_or(0, ConfigValue::raw_bits), - default_bits: descriptor.default.raw_bits(), + default_bits: default.raw_bits(), request_id, descriptor_index, field_id: descriptor.id.get(), + enum_option_count: u8::try_from(descriptor.enum_options.len()) + .expect("validated enum option count fits u8"), display_key: descriptor.display_key.into(), unit: descriptor.unit.into(), value_kind: value_kind(descriptor.kind), - apply_policy: policy(descriptor.apply_policy), - flags: 0, + flags: descriptor.minimum.map_or(0, |_| DESCRIPTOR_HAS_MINIMUM) + | descriptor.maximum.map_or(0, |_| DESCRIPTOR_HAS_MAXIMUM), + } +} + +/// Encodes one generic named enum choice. +pub fn encode_enum_option(response: EnumOptionResponse) -> KAPTAN_CONFIG_ENUM_OPTION_DATA { + KAPTAN_CONFIG_ENUM_OPTION_DATA { + value: response.option.0, + request_id: response.request_id, + descriptor_index: response.descriptor_index, + field_id: response.field.get(), + option_index: response.option_index, + label: response.option.1.into(), } } @@ -266,14 +301,6 @@ const fn value_kind(kind: ValueKind) -> KaptanConfigValueKind { } } -const fn policy(policy: ApplyPolicy) -> KaptanConfigPolicy { - match policy { - ApplyPolicy::Live => KaptanConfigPolicy::KAPTAN_CONFIG_POLICY_LIVE, - ApplyPolicy::DisarmedReset => KaptanConfigPolicy::KAPTAN_CONFIG_POLICY_DISARMED_RESET, - ApplyPolicy::RestartRequired => KaptanConfigPolicy::KAPTAN_CONFIG_POLICY_RESTART_REQUIRED, - } -} - const fn operation(operation: Operation) -> KaptanConfigOperation { match operation { Operation::Query => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_QUERY, @@ -282,7 +309,6 @@ const fn operation(operation: Operation) -> KaptanConfigOperation { Operation::Validate => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE, Operation::Commit => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT, Operation::Abort => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT, - Operation::Import => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_IMPORT, } } @@ -308,12 +334,6 @@ const fn error(error: Option) -> KaptanConfigError { match error { None => KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE, Some(ProtocolError::BadTarget) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BAD_TARGET, - Some(ProtocolError::AuthorizationDenied) => { - KaptanConfigError::KAPTAN_CONFIG_ERROR_AUTHORIZATION_DENIED - } - Some(ProtocolError::GenerationConflict) => { - KaptanConfigError::KAPTAN_CONFIG_ERROR_GENERATION_CONFLICT - } Some(ProtocolError::SessionBusy) => KaptanConfigError::KAPTAN_CONFIG_ERROR_SESSION_BUSY, Some(ProtocolError::InvalidSession) => { KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_SESSION @@ -326,6 +346,7 @@ const fn error(error: Option) -> KaptanConfigError { Some(ProtocolError::UnknownField(_)) => { KaptanConfigError::KAPTAN_CONFIG_ERROR_UNKNOWN_FIELD } + Some(ProtocolError::InvalidQuery) => KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_QUERY, Some(ProtocolError::TypeMismatch(_)) => { KaptanConfigError::KAPTAN_CONFIG_ERROR_TYPE_MISMATCH } @@ -339,36 +360,88 @@ const fn error(error: Option) -> KaptanConfigError { ValidationRule::UnsupportedCapability => { KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_CAPABILITY } - ValidationRule::UnsupportedFormat => { - KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_FORMAT + ValidationRule::UnsupportedFormat => KaptanConfigError::KAPTAN_CONFIG_ERROR_INTERNAL, + ValidationRule::InvalidEnumValue => { + KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_ENUM_VALUE } }, Some(ProtocolError::StorageFailure) => { KaptanConfigError::KAPTAN_CONFIG_ERROR_STORAGE_FAILURE } - Some(ProtocolError::CborMalformed) => KaptanConfigError::KAPTAN_CONFIG_ERROR_CBOR_MALFORMED, - Some(ProtocolError::CborTooLarge) => KaptanConfigError::KAPTAN_CONFIG_ERROR_CBOR_TOO_LARGE, - Some(ProtocolError::BlobCrc) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BLOB_CRC, - Some(ProtocolError::SchemaMismatch) => { - KaptanConfigError::KAPTAN_CONFIG_ERROR_SCHEMA_MISMATCH - } - Some(ProtocolError::ImportUnavailable) => { - KaptanConfigError::KAPTAN_CONFIG_ERROR_IMPORT_UNAVAILABLE - } Some(ProtocolError::Internal) => KaptanConfigError::KAPTAN_CONFIG_ERROR_INTERNAL, } } #[cfg(test)] mod tests { - use kaptan_config::{ConfigState, ConfigViolation, ValidationRule}; + use kaptan_config::{ + ConfigState, ConfigValue, ConfigViolation, FieldDescriptor, ValidationRule, ValueKind, + }; use kaptan_mavlink::message::kaptan::{ - KAPTAN_CONFIG_INFO_REQUEST_DATA, KAPTAN_CONFIG_SET_F32_DATA, MavMessage, + KAPTAN_CONFIG_ENUM_OPTION_REQUEST_DATA, KAPTAN_CONFIG_INFO_REQUEST_DATA, + KAPTAN_CONFIG_SET_F32_DATA, MavMessage, }; use super::*; - use crate::CAPABILITY_CBOR_IMPORT; + #[derive(Debug, PartialEq)] + enum Emitted { + Info(KAPTAN_CONFIG_INFO_DATA), + Result(KAPTAN_CONFIG_RESULT_DATA), + Descriptor(KAPTAN_CONFIG_DESCRIPTOR_DATA), + EnumOption(KAPTAN_CONFIG_ENUM_OPTION_DATA), + Value(KAPTAN_CONFIG_VALUE_DATA), + } + + #[derive(Default)] + struct RecordingVisitor { + emitted: std::vec::Vec, + } + + impl ResponseVisitor for RecordingVisitor { + type Error = core::convert::Infallible; + + fn info(&mut self, response: &KAPTAN_CONFIG_INFO_DATA) -> Result<(), Self::Error> { + self.emitted.push(Emitted::Info(response.clone())); + Ok(()) + } + + fn result(&mut self, response: &KAPTAN_CONFIG_RESULT_DATA) -> Result<(), Self::Error> { + self.emitted.push(Emitted::Result(response.clone())); + Ok(()) + } + + fn descriptor( + &mut self, + response: &KAPTAN_CONFIG_DESCRIPTOR_DATA, + ) -> Result<(), Self::Error> { + self.emitted.push(Emitted::Descriptor(response.clone())); + Ok(()) + } + + fn enum_option( + &mut self, + response: &KAPTAN_CONFIG_ENUM_OPTION_DATA, + ) -> Result<(), Self::Error> { + self.emitted.push(Emitted::EnumOption(response.clone())); + Ok(()) + } + + fn value(&mut self, response: &KAPTAN_CONFIG_VALUE_DATA) -> Result<(), Self::Error> { + self.emitted.push(Emitted::Value(response.clone())); + Ok(()) + } + } + + const FIRST_DESCRIPTOR: FieldDescriptor = FieldDescriptor { + id: FieldId::new(17), + display_key: "future.sensor.rate_hz", + kind: ValueKind::U32, + minimum: Some(ConfigValue::U32(1)), + maximum: Some(ConfigValue::U32(200)), + enum_options: &[], + unit: "Hz", + }; #[test] fn generated_set_message_preserves_typed_value_and_identifiers() { let message = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA { @@ -409,6 +482,27 @@ mod tests { ); } + #[test] + fn generated_enum_option_request_preserves_the_explicit_index() { + let message = + MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(KAPTAN_CONFIG_ENUM_OPTION_REQUEST_DATA { + request_id: 41, + descriptor_index: 3, + option_index: 6, + target_system: 1, + target_component: 2, + }); + assert_eq!( + decode_command(&message), + Ok(ConfigCommand::EnumOptionRequest { + request_id: 41, + descriptor_index: 3, + option_index: 6, + target: Target::new(1, 2), + }) + ); + } + #[test] fn generated_config_requests_reject_broadcast_targets() { let zero_system = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA { @@ -444,9 +538,7 @@ mod tests { let reply = ConfigReply { request_id: 1, session_id: 2, - transfer_id: 0, revision: 3, - generation: 4, operation: Operation::Set, result: ResultKind::Rejected, error: Some(ProtocolError::Violation(ConfigViolation::field( @@ -465,37 +557,118 @@ mod tests { } #[test] - fn unavailable_import_has_its_own_structured_error_code() { - let reply = ConfigReply { - request_id: 1, - session_id: 0, - transfer_id: 0, - revision: 0, - generation: 0, - operation: Operation::Import, - result: ResultKind::Rejected, - error: Some(ProtocolError::ImportUnavailable), + fn info_preserves_schema_and_protocol_identity() { + let info = encode_info(ConfigInfo { + request_id: 7, + schema_id: [0; 16], + field_count: 23, state: ConfigState::Active, - }; + }); + assert_eq!(info.request_id, 7); + assert_eq!(info.schema_id, [0; 16]); assert_eq!( - encode_result(reply).error, - KaptanConfigError::KAPTAN_CONFIG_ERROR_IMPORT_UNAVAILABLE + (info.protocol_major, info.protocol_minor), + (PROTOCOL_MAJOR, PROTOCOL_MINOR) ); } #[test] - fn info_preserves_service_capabilities() { - let info = encode_info(ConfigInfo { + fn descriptor_flags_identify_present_bounds() { + let descriptor = FieldDescriptor { + id: FieldId::new(9), + display_key: "test.value", + kind: ValueKind::U32, + minimum: Some(ConfigValue::U32(1)), + maximum: Some(ConfigValue::U32(10)), + enum_options: &[], + unit: "count", + }; + assert_eq!( + encode_descriptor(1, 0, descriptor, ConfigValue::U32(2)).flags, + DESCRIPTOR_HAS_MINIMUM | DESCRIPTOR_HAS_MAXIMUM + ); + assert_eq!( + encode_descriptor(1, 0, descriptor, ConfigValue::U32(2)).enum_option_count, + 0 + ); + } + + #[test] + fn response_visitor_projects_every_singleton_response_variant() { + let info = ConfigInfo { request_id: 7, - active_generation: None, - committed_generation: None, - schema_id: [0; 16], - profile_format: 1, - field_count: 23, - state: ConfigState::Active, - capability_flags: CAPABILITY_CBOR_IMPORT, - }); - assert_eq!(info.request_id, 7); - assert_eq!(info.capability_flags, CAPABILITY_CBOR_IMPORT); + schema_id: *b"UNKNOWN-SCHEMA01", + field_count: 2, + state: ConfigState::RestartRequired, + }; + let result = ConfigReply { + request_id: 8, + session_id: 9, + revision: 2, + operation: Operation::Commit, + result: ResultKind::CommittedRestartRequired, + error: None, + state: ConfigState::RestartRequired, + }; + let value = ValueResponse { + request_id: 10, + session_id: 0, + revision: 0, + field: FieldId::new(401), + value: ConfigValue::Bool(true), + }; + let enum_option = crate::EnumOptionResponse { + request_id: 9, + descriptor_index: 12, + field: FieldId::new(17), + option_index: 1, + option: (3, "Error"), + }; + let mut visitor = RecordingVisitor::default(); + + assert_eq!( + visit_response(ServiceResponse::Info(info), &mut visitor), + Ok(()) + ); + assert_eq!( + visit_response(ServiceResponse::Result(result), &mut visitor), + Ok(()) + ); + assert_eq!( + visit_response( + ServiceResponse::Descriptor(crate::DescriptorResponse { + request_id: 9, + index: 12, + descriptor: FIRST_DESCRIPTOR, + default: ConfigValue::U32(20), + }), + &mut visitor, + ), + Ok(()) + ); + assert_eq!( + visit_response(ServiceResponse::EnumOption(enum_option), &mut visitor), + Ok(()) + ); + assert_eq!( + visit_response(ServiceResponse::Value(value), &mut visitor), + Ok(()) + ); + + assert_eq!( + visitor.emitted, + std::vec![ + Emitted::Info(encode_info(info)), + Emitted::Result(encode_result(result)), + Emitted::Descriptor(encode_descriptor( + 9, + 12, + FIRST_DESCRIPTOR, + ConfigValue::U32(20), + )), + Emitted::EnumOption(encode_enum_option(enum_option)), + Emitted::Value(encode_value(value)), + ] + ); } } diff --git a/firmware/libs/kaptan-config/src/error.rs b/firmware/libs/kaptan-config/src/error.rs index f48f978..408f27a 100644 --- a/firmware/libs/kaptan-config/src/error.rs +++ b/firmware/libs/kaptan-config/src/error.rs @@ -2,7 +2,7 @@ use crate::FieldId; -/// The invariant violated by a configuration field or complete profile. +/// The invariant violated by a configuration field or complete candidate. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(u16)] pub enum ValidationRule { @@ -20,6 +20,8 @@ pub enum ValidationRule { UnsupportedCapability = 6, /// The encoded snapshot format cannot be used by this firmware. UnsupportedFormat = 7, + /// A scalar does not name a declared variant of its typed enum field. + InvalidEnumValue = 8, } impl ValidationRule { @@ -59,7 +61,7 @@ impl ConfigViolation { } } - /// Constructs a profile-level violation with no scalar identity. + /// Constructs a configuration-wide violation with no scalar identity. pub const fn global(rule: ValidationRule) -> Self { Self { primary: None, diff --git a/firmware/libs/kaptan-config/src/field.rs b/firmware/libs/kaptan-config/src/field.rs index fb0e1ec..535ddd5 100644 --- a/firmware/libs/kaptan-config/src/field.rs +++ b/firmware/libs/kaptan-config/src/field.rs @@ -3,9 +3,18 @@ use crate::{ConfigViolation, ValidationRule}; use kaptan_log::KaptanError; +/// Maximum choices representable by one enum-backed field. +/// +/// Enum values and indexed discovery use their native `u8` domain. Options +/// are immutable flash metadata, so no smaller runtime resource limit is +/// necessary. +pub const MAX_ENUM_OPTIONS_PER_FIELD: usize = u8::MAX as usize; +/// Maximum UTF-8 bytes advertised for one enum variant name. +pub const MAX_ENUM_OPTION_LABEL_BYTES: usize = 32; + /// A stable, schema-local identity for one editable configuration field. /// -/// IDs are never reused within a schema generation. They are compact wire +/// IDs are never reused within a schema version. They are compact wire /// identities; display strings belong to metadata and diagnostics only. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)] #[repr(transparent)] @@ -56,6 +65,88 @@ pub enum ConfigValue { F64(f64), } +/// One named value advertised for an enum-backed configuration field. +/// +/// Enum metadata belongs to the compiled schema. Generic transports can +/// therefore render choices without knowing field keys or firmware-specific +/// enum types. The tuple is `(stable u8 representation, display label)`. +pub type EnumOption = (u8, &'static str); + +/// Failure to project a generic configuration scalar into its native type. +#[doc(hidden)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ScalarValueError { + /// The generic scalar kind does not match the native field type. + WrongKind(ValueKind), + /// The representation has the correct scalar kind but no native value. + OutOfDomain, +} + +/// Native representation used by generated configuration fields. +/// +/// The generic manager and transports retain their compact fixed scalar +/// protocol while board and runtime structures keep the narrow or enum type +/// that actually models the value. +#[doc(hidden)] +pub trait ConfigScalar: Copy { + /// Generic scalar kind used at schema and transport boundaries. + const KIND: ValueKind; + /// Projects the native value into the generic scalar boundary. + fn into_config_value(self) -> ConfigValue; + /// Validates and projects one generic scalar into the native type. + fn try_from_config_value(value: ConfigValue) -> Result; +} + +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 { + 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 { + match value { + ConfigValue::U32(value) => { + Self::try_from(value).map_err(|_| ScalarValueError::OutOfDomain) + } + value => Err(ScalarValueError::WrongKind(value.kind())), + } + } + } + }; +} + +narrow_unsigned_config_scalar!(u8); +narrow_unsigned_config_scalar!(u16); + impl ConfigValue { /// The declared value kind. pub const fn kind(self) -> ValueKind { @@ -80,6 +171,26 @@ impl ConfigValue { Self::F64(value) => value.to_bits(), } } + + /// Decodes the canonical scalar representation used by snapshots and the + /// configuration wire protocol. + pub const fn try_from_raw_bits(kind: ValueKind, bits: u64) -> Option { + match kind { + ValueKind::Bool => match bits { + 0 => Some(Self::Bool(false)), + 1 => Some(Self::Bool(true)), + _ => None, + }, + ValueKind::I32 if bits <= u32::MAX as u64 => Some(Self::I32(bits as u32 as i32)), + ValueKind::U32 if bits <= u32::MAX as u64 => Some(Self::U32(bits as u32)), + ValueKind::U64 => Some(Self::U64(bits)), + ValueKind::F32 if bits <= u32::MAX as u64 => { + Some(Self::F32(f32::from_bits(bits as u32))) + } + ValueKind::F64 => Some(Self::F64(f64::from_bits(bits))), + ValueKind::I32 | ValueKind::U32 | ValueKind::F32 => None, + } + } } /// The scalar kind carried in a descriptor or typed protocol message. @@ -100,18 +211,6 @@ pub enum ValueKind { F64 = 6, } -/// When a committed field may affect the active runtime. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[repr(u8)] -pub enum ApplyPolicy { - /// Reserved for individually proven live-safe changes. - Live = 1, - /// Reserved for controlled output-inhibited reconfiguration. - DisarmedReset = 2, - /// P1 policy: the field takes effect only at the next boot. - RestartRequired = 3, -} - /// Static metadata for one configuration field. #[derive(Clone, Copy, Debug, PartialEq)] pub struct FieldDescriptor { @@ -125,12 +224,10 @@ pub struct FieldDescriptor { pub minimum: Option, /// Inclusive upper bound when the value is ordered. pub maximum: Option, - /// Firmware-build default. - pub default: ConfigValue, + /// Named choices for an enum-backed field, otherwise empty. + pub enum_options: &'static [EnumOption], /// Human-readable unit for schema metadata. pub unit: &'static str, - /// Activation policy advertised to a control station. - pub apply_policy: ApplyPolicy, } /// Failure while changing one transaction field. @@ -168,7 +265,7 @@ impl FieldWriteError { /// Vehicle-specific editable candidate model. /// -/// Implementations expose every operator-editable scalar through the static +/// Implementations expose every user-editable scalar through the static /// descriptor registry. A field write performs only local validation; complete /// relational and capability validation happens through [`Self::validate`]. pub trait ConfigModel: Clone { diff --git a/firmware/libs/kaptan-config/src/journal.rs b/firmware/libs/kaptan-config/src/journal.rs new file mode 100644 index 0000000..af2ef57 --- /dev/null +++ b/firmware/libs/kaptan-config/src/journal.rs @@ -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 { + /// 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 { + flash: F, + factory: C, + _payload: PhantomData<[u8; MAX_PAYLOAD]>, +} + +impl DualSlotJournal { + /// 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 { + slot: usize, + sequence: u64, + candidate: C, +} + +impl DualSlotJournal +where + C: ConfigModel, + F: DualSlotNor, +{ + fn geometry(&self) -> Result<(), JournalError> { + 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>, JournalError> { + 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>, JournalError> { + 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> { + 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 ConfigStore for DualSlotJournal +where + C: ConfigModel, + F: DualSlotNor, +{ + type Error = JournalError; + + fn load(&mut self) -> Result, Self::Error> { + self.geometry()?; + Ok(self.newest()?.map(|record| record.candidate)) + } + + fn load_matching

(&mut self, mut compatible: P) -> Result, 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 { + (id == VALUE).then_some(ConfigValue::U32(self.0)) + } + + fn active_field_value(active: &Self::Active, id: FieldId) -> Option { + 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 { + 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, + } + + 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; + + #[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 + ); + } +} diff --git a/firmware/libs/kaptan-config/src/lib.rs b/firmware/libs/kaptan-config/src/lib.rs index 03b66c5..914c3d3 100644 --- a/firmware/libs/kaptan-config/src/lib.rs +++ b/firmware/libs/kaptan-config/src/lib.rs @@ -3,7 +3,9 @@ //! This crate has no knowledge of MAVLink, filesystems, board routing, motors, //! or a particular airframe. It owns the staged-versus-active lifecycle and //! parameter metadata; vehicle crates provide the typed schema and complete -//! validation. CBOR storage and MAVLink transport are separate adapters. +//! validation. Persistence framing and transports remain separate adapters; +//! this crate provides only the bounded scalar snapshot payload shared by +//! storage backends. #![no_std] @@ -11,15 +13,24 @@ extern crate self as kaptan_config; pub mod error; pub mod field; +pub mod journal; pub mod lifecycle; +pub mod snapshot; pub mod storage; pub mod validation; pub use error::{ConfigViolation, ValidationRule}; pub use field::{ - ApplyPolicy, ConfigModel, ConfigValue, FieldDescriptor, FieldId, FieldWriteError, ValueKind, + ConfigModel, ConfigScalar, ConfigValue, EnumOption, FieldDescriptor, FieldId, FieldWriteError, + MAX_ENUM_OPTION_LABEL_BYTES, MAX_ENUM_OPTIONS_PER_FIELD, ScalarValueError, ValueKind, }; -pub use kaptan_config_derive::ConfigValidate; +pub use journal::{DualSlotJournal, DualSlotNor, JournalError}; +pub use kaptan_config_derive::{Config, ConfigEnum}; pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError}; -pub use storage::{Generation, ProfileStore}; -pub use validation::{ConfigAggregate, ConfigSection, ValidateFields}; +pub use snapshot::{ + SCALAR_SNAPSHOT_ENTRY_LENGTH, SCALAR_SNAPSHOT_HEADER_LENGTH, SCALAR_SNAPSHOT_VERSION, + ScalarSnapshotDecodeError, ScalarSnapshotEncodeError, SnapshotModelError, + decode_scalar_snapshot, encode_scalar_snapshot, scalar_snapshot_length, +}; +pub use storage::ConfigStore; +pub use validation::Config; diff --git a/firmware/libs/kaptan-config/src/lifecycle.rs b/firmware/libs/kaptan-config/src/lifecycle.rs index 28a6933..e7695e1 100644 --- a/firmware/libs/kaptan-config/src/lifecycle.rs +++ b/firmware/libs/kaptan-config/src/lifecycle.rs @@ -1,8 +1,8 @@ //! Transactional committed-versus-active configuration lifecycle. use crate::{ - ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, - Generation, ProfileStore, + ConfigModel, ConfigStore, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, + FieldWriteError, }; use kaptan_log::KaptanError; @@ -13,7 +13,7 @@ pub enum ConfigState { Active, /// One isolated RAM transaction is open. Editing, - /// A newer committed snapshot will take effect after reboot. + /// A newer committed snapshot will take effect after restart. RestartRequired, } @@ -53,7 +53,7 @@ pub enum CommitError { Store(E), } -/// Owns the immutable boot-active profile, the latest durable candidate, and +/// Owns the immutable boot-active configuration, the latest durable candidate, and /// exactly one RAM edit transaction. /// /// P1 deliberately has no activation operation: [`Self::active`] never @@ -61,11 +61,10 @@ pub enum CommitError { /// candidate, then reports [`ConfigState::RestartRequired`]. pub struct ConfigManager { active: C::Active, + factory: C, committed: C, transaction: Option, context: C::Context, - active_generation: Option, - committed_generation: Option, store: S, restart_required: bool, } @@ -73,7 +72,7 @@ pub struct ConfigManager { impl ConfigManager where C: ConfigModel, - S: ProfileStore, + S: ConfigStore, { /// Starts from a validated factory candidate when no persistent record is /// available. Flight callers separately decide whether setup/calibration @@ -82,11 +81,10 @@ where let active = factory.validate(&context)?; Ok(Self { active, + factory: factory.clone(), committed: factory, transaction: None, context, - active_generation: None, - committed_generation: None, store, restart_required: false, }) @@ -99,7 +97,7 @@ where context: C::Context, mut store: S, ) -> Result> { - let Some((generation, committed)) = store.load().map_err(StartupError::Store)? else { + let Some(committed) = store.load().map_err(StartupError::Store)? else { return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted); }; let active = committed @@ -107,11 +105,41 @@ where .map_err(StartupError::InvalidPersisted)?; Ok(Self { active, + factory, + committed, + transaction: None, + context, + store, + restart_required: false, + }) + } + + /// Recovers the newest candidate compatible with the current context, + /// falling back to the supplied factory when no compatible record exists. + /// + /// This is an explicit recovery policy for output-inhibited setup images. + /// Safety-critical compositions should use [`Self::recover`] if an + /// incompatible durable record must prevent initialization. + pub fn recover_compatible_or_factory( + factory: C, + context: C::Context, + mut store: S, + ) -> Result> { + let Some(committed) = store + .load_matching(|candidate| candidate.validate(&context).is_ok()) + .map_err(StartupError::Store)? + else { + return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted); + }; + let active = committed + .validate(&context) + .map_err(StartupError::InvalidPersisted)?; + Ok(Self { + active, + factory, committed, transaction: None, context, - active_generation: Some(generation), - committed_generation: Some(generation), store, restart_required: false, }) @@ -127,6 +155,11 @@ where self.committed.field_value(id) } + /// Returns the selected board/image factory value for descriptor metadata. + pub fn factory_value(&self, id: FieldId) -> Option { + self.factory.field_value(id) + } + /// Returns a value from the immutable boot-active snapshot. pub fn active_value(&self, id: FieldId) -> Option { 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 { - self.active_generation - } - - /// Returns the most recently recovered or committed generation. - pub const fn committed_generation(&self) -> Option { - 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 { - self.committed_generation - } - /// Opens the only transaction slot from the last durably committed state. pub fn begin(&mut self) -> Result<(), BeginError> { if self.transaction.is_some() { @@ -209,25 +225,8 @@ where .map_err(CommitError::InvalidCandidate) } - /// Replaces the open RAM transaction with one complete imported candidate. - /// - /// Unlike scalar edits, imports are accepted only after complete - /// relational and capability validation. Neither the boot-active nor the - /// committed snapshot is changed here. - pub fn replace_transaction(&mut self, candidate: C) -> Result<(), CommitError> { - candidate - .validate(&self.context) - .map_err(CommitError::InvalidCandidate)?; - let transaction = self - .transaction - .as_mut() - .ok_or(CommitError::NoTransaction)?; - *transaction = candidate; - Ok(()) - } - /// Validates and durably commits the complete open transaction. - pub fn commit(&mut self) -> Result> { + pub fn commit(&mut self) -> Result<(), CommitError> { let candidate = self .transaction .as_ref() @@ -235,29 +234,23 @@ where candidate .validate(&self.context) .map_err(CommitError::InvalidCandidate)?; - let generation = self.store.persist(candidate).map_err(CommitError::Store)?; + self.store.persist(candidate).map_err(CommitError::Store)?; self.committed = candidate.clone(); self.transaction = None; - self.committed_generation = Some(generation); self.restart_required = true; - Ok(generation) + Ok(()) } /// Drops the RAM transaction without changing active or committed state. pub fn abort(&mut self) -> bool { self.transaction.take().is_some() } - - /// Releases the owned store when the configuration owner shuts down. - pub fn into_store(self) -> S { - self.store - } } #[cfg(test)] mod tests { use super::*; - use crate::{ApplyPolicy, ConfigValue, ValidationRule, ValueKind}; + use crate::{ConfigValue, ValidationRule, ValueKind}; const VALUE: FieldId = FieldId::new(1); const OTHER: FieldId = FieldId::new(2); @@ -268,9 +261,8 @@ mod tests { kind: ValueKind::U32, minimum: Some(ConfigValue::U32(1)), maximum: Some(ConfigValue::U32(10)), - default: ConfigValue::U32(2), + enum_options: &[], unit: "count", - apply_policy: ApplyPolicy::RestartRequired, }, FieldDescriptor { id: OTHER, @@ -278,9 +270,8 @@ mod tests { kind: ValueKind::U32, minimum: Some(ConfigValue::U32(1)), maximum: Some(ConfigValue::U32(10)), - default: ConfigValue::U32(4), + enum_options: &[], unit: "count", - apply_policy: ApplyPolicy::RestartRequired, }, ]; @@ -364,15 +355,29 @@ mod tests { struct MemoryStore; - impl ProfileStore for MemoryStore { + impl ConfigStore for MemoryStore { type Error = (); - fn load(&mut self) -> Result, Self::Error> { + fn load(&mut self) -> Result, Self::Error> { Ok(None) } - fn persist(&mut self, _: &Candidate) -> Result { - Ok(Generation::new(7)) + fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> { + Ok(()) + } + } + + struct IncompatibleStore; + + impl ConfigStore for IncompatibleStore { + type Error = (); + + fn load(&mut self) -> Result, Self::Error> { + Ok(Some(Candidate { value: 9, other: 1 })) + } + + fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> { + Ok(()) } } @@ -405,11 +410,9 @@ mod tests { ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap(); manager.begin().unwrap(); manager.set(VALUE, ConfigValue::U32(3)).unwrap(); - assert_eq!(manager.commit(), Ok(Generation::new(7))); + assert_eq!(manager.commit(), Ok(())); assert_eq!(manager.active(), &Active { value: 2, other: 4 }); assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3))); - assert_eq!(manager.active_generation(), None); - assert_eq!(manager.committed_generation(), Some(Generation::new(7))); assert_eq!(manager.state(), ConfigState::RestartRequired); manager.begin().unwrap(); manager.set(VALUE, ConfigValue::U32(4)).unwrap(); @@ -417,4 +420,17 @@ mod tests { assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3))); assert_eq!(manager.state(), ConfigState::RestartRequired); } + + #[test] + fn explicit_recovery_policy_uses_factory_when_no_record_matches_context() { + let factory = Candidate { value: 2, other: 4 }; + assert!(matches!( + ConfigManager::recover(factory.clone(), (), IncompatibleStore), + Err(StartupError::InvalidPersisted(_)) + )); + + let manager = + ConfigManager::recover_compatible_or_factory(factory, (), IncompatibleStore).unwrap(); + assert_eq!(manager.active(), &Active { value: 2, other: 4 }); + } } diff --git a/firmware/libs/kaptan-config/src/snapshot.rs b/firmware/libs/kaptan-config/src/snapshot.rs new file mode 100644 index 0000000..7c8baa7 --- /dev/null +++ b/firmware/libs/kaptan-config/src/snapshot.rs @@ -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::().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( + input: &[u8], + factory: &C, +) -> Result { + validate_model::().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::(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() -> 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( + 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::(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(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 { + 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 { + 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 { + 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 { + 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 { + 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 { + None + } + + fn active_field_value(_: &Self::Active, _: FieldId) -> Option { + None + } + + fn set_field(&mut self, id: FieldId, _: ConfigValue) -> Result<(), FieldWriteError> { + Err(FieldWriteError::UnknownField(id)) + } + + fn validate(&self, _: &Self::Context) -> Result { + 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) + )) + )); + } +} diff --git a/firmware/libs/kaptan-config/src/storage.rs b/firmware/libs/kaptan-config/src/storage.rs index 75be0dc..5b7e6f0 100644 --- a/firmware/libs/kaptan-config/src/storage.rs +++ b/firmware/libs/kaptan-config/src/storage.rs @@ -1,48 +1,31 @@ //! Persistence interface for complete validated candidate snapshots. -use core::num::NonZeroU64; - -/// Storage-owned monotonically increasing committed snapshot generation. -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub struct Generation(NonZeroU64); - -impl Generation { - /// Creates a generation supplied only by a persistence backend. - pub const fn new(value: u64) -> Self { - match NonZeroU64::new(value) { - Some(value) => Self(value), - None => panic!("configuration generation must be nonzero"), - } - } - - /// Parses an on-disk or wire generation, rejecting the factory sentinel. - pub const fn try_new(value: u64) -> Option { - match NonZeroU64::new(value) { - Some(value) => Some(Self(value)), - None => None, - } - } - - /// Returns the committed generation value. - pub const fn get(self) -> u64 { - self.0.get() - } -} - /// Durable storage of complete configuration snapshots. /// /// Flight implementations use a two-slot journal: a record contains a header, -/// bounded CBOR payload, integrity check, and a commit marker written last. +/// bounded payload, integrity check, and a commit marker written last. /// A backend must retain its previous valid record until the replacement is /// fully verified. Host file stores may implement the same interface without /// claiming flash power-loss semantics. -pub trait ProfileStore { +pub trait ConfigStore { /// Backend-specific I/O or integrity failure. type Error; /// Loads the newest valid committed candidate, if one exists. - fn load(&mut self) -> Result, Self::Error>; + fn load(&mut self) -> Result, 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(&mut self, mut compatible: F) -> Result, Self::Error> + where + F: FnMut(&C) -> bool, + { + Ok(self.load()?.filter(|candidate| compatible(candidate))) + } /// Persists one complete already-validated candidate atomically. - fn persist(&mut self, candidate: &C) -> Result; + fn persist(&mut self, candidate: &C) -> Result<(), Self::Error>; } diff --git a/firmware/libs/kaptan-config/src/validation.rs b/firmware/libs/kaptan-config/src/validation.rs index e7ee338..2011cf6 100644 --- a/firmware/libs/kaptan-config/src/validation.rs +++ b/firmware/libs/kaptan-config/src/validation.rs @@ -1,63 +1,199 @@ -//! Local scalar configuration sections implemented by the derive macro. +//! Generated configuration trees. -use crate::{ConfigValue, ConfigViolation, FieldId, FieldWriteError}; +use crate::{ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError}; -/// Validates local scalar invariants of one record. +/// One recursively composable configuration tree. /// -/// The derive macro handles finite, nonzero, and inclusive range checks. -/// Cross-field, capability, calibration, and lifecycle invariants belong in a -/// handwritten post-validation hook on the owning vehicle schema. -pub trait ValidateFields { - /// Rejects the first violated scalar or declared post-validation rule. - fn validate_fields(&self) -> Result<(), ConfigViolation>; -} - -/// A complete scalar configuration section generated from `#[config(...)]` -/// field attributes. -/// -/// Its metadata, default construction, reads, local writes, and scalar -/// validation are all produced from the same field declarations. A section -/// setter never commits a locally invalid value. Cross-section and capability -/// invariants deliberately remain the aggregate candidate's responsibility. -pub trait ConfigSection: ValidateFields { - /// Number of scalar descriptors generated for this section. +/// `#[derive(kaptan_config::Config)]` implements this contract for both scalar +/// records and records composed from other configuration trees. Validation, +/// metadata, field routing, and schema identity therefore come from one source. +pub trait Config { + /// Number of scalar fields in the flattened tree. const FIELD_COUNT: usize; - /// Builds the section from derive-generated defaults after validating it. - fn default_section() -> Result - where - Self: Sized; + /// Fingerprint generated from the complete declared schema. + const SCHEMA_ID: [u8; 16]; + + /// Returns a descriptor from the flattened declaration order. + fn descriptor(index: usize) -> Option<&'static FieldDescriptor>; /// Returns one scalar by stable field identity. fn field_value(&self, id: FieldId) -> Option; - /// Applies one scalar only when this section owns the ID and its local - /// generated validation accepts the new value. + /// Applies one scalar only when its owning record accepts the local value. + /// Whole-tree relational validation is intentionally deferred until the + /// candidate is validated or committed. fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>; - /// Validates only generated scalar invariants. This intentionally omits - /// a derive `post` hook so staged multi-field edits can remain temporarily - /// cross-field-invalid until aggregate validation/commit. - fn validate_local_fields(&self) -> Result<(), ConfigViolation>; + /// Validates every generated rule and declared post-validation hook. + fn validate(&self) -> Result<(), ConfigViolation>; } -/// An ordered aggregate of generated configuration sections. +/// Detects duplicate flattened field identities in a generated tree. +#[doc(hidden)] +pub fn __unique_field_ids() -> 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() -> 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) -> [u64; 2] { + let Some(value) = value else { + return hash_byte(state, 0); + }; + state = hash_byte(state, value.kind() as u8); + hash_u64(state, value.raw_bits()) +} + +const fn finish_fingerprint(state: [u64; 2]) -> [u8; 16] { + let left = state[0].to_le_bytes(); + let right = state[1].to_le_bytes(); + [ + left[0], left[1], left[2], left[3], left[4], left[5], left[6], left[7], right[0], right[1], + right[2], right[3], right[4], right[5], right[6], right[7], + ] +} + +/// Produces the stable schema fingerprint for one scalar record. +#[doc(hidden)] +pub const fn __schema_id(descriptors: &[FieldDescriptor]) -> [u8; 16] { + let mut state = [FINGERPRINT_LEFT_OFFSET, FINGERPRINT_RIGHT_OFFSET]; + state = hash_u64(state, descriptors.len() as u64); + let mut index = 0; + while index < descriptors.len() { + let descriptor = descriptors[index]; + state = hash_u64(state, descriptor.id.get() as u64); + state = hash_str(state, descriptor.display_key); + state = hash_byte(state, descriptor.kind as u8); + state = hash_value(state, descriptor.minimum); + state = hash_value(state, descriptor.maximum); + state = hash_u64(state, descriptor.enum_options.len() as u64); + let mut option = 0; + while option < descriptor.enum_options.len() { + state = hash_u64(state, descriptor.enum_options[option].0 as u64); + state = hash_str(state, descriptor.enum_options[option].1); + option += 1; + } + state = hash_str(state, descriptor.unit); + index += 1; + } + finish_fingerprint(state) +} + +/// Produces a parent fingerprint from ordered child fingerprints. /// -/// The derive owns routing/default construction so adding a field to an -/// existing section cannot silently bypass local validation. The aggregate -/// owner still defines capability checks and immutable active construction. -pub trait ConfigAggregate: ValidateFields { - /// Builds and fully validates all nested sections from their derived - /// defaults. - fn default_aggregate() -> Result - where - Self: Sized; - - /// Returns one nested scalar by stable field identity. - fn field_value(&self, id: FieldId) -> Option; - - /// Routes one scalar write to its owning generated section. The setter - /// applies local validation only, so a transaction may temporarily hold a - /// cross-field-invalid multi-edit candidate. - fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>; +/// A one-child wrapper is deliberately transparent, so adding or removing a +/// redundant structural wrapper does not invalidate persisted values. +#[doc(hidden)] +pub const fn __combine_schema_ids(children: &[[u8; 16]]) -> [u8; 16] { + if children.len() == 1 { + return children[0]; + } + let mut state = [FINGERPRINT_LEFT_OFFSET, FINGERPRINT_RIGHT_OFFSET]; + state = hash_u64(state, children.len() as u64); + let mut child = 0; + while child < children.len() { + let mut byte = 0; + while byte < children[child].len() { + state = hash_byte(state, children[child][byte]); + byte += 1; + } + child += 1; + } + finish_fingerprint(state) } diff --git a/firmware/libs/kaptan-control/src/lib.rs b/firmware/libs/kaptan-control/src/lib.rs index 434ee1b..a91e78f 100644 --- a/firmware/libs/kaptan-control/src/lib.rs +++ b/firmware/libs/kaptan-control/src/lib.rs @@ -292,7 +292,7 @@ impl RatePid { /// Position, velocity, and acceleration reference in the local NED frame. /// /// The reference is generated continuously. A caller must not replace it with -/// a position step merely because an operator requested a distant goal. +/// a position step merely because a user requested a distant goal. #[derive(Clone, Copy, Debug, PartialEq)] pub struct KinematicReference { /// Local NED position in metres. diff --git a/firmware/libs/kaptan-data-channel/Cargo.toml b/firmware/libs/kaptan-data-channel/Cargo.toml index bbd8173..025c70d 100644 --- a/firmware/libs/kaptan-data-channel/Cargo.toml +++ b/firmware/libs/kaptan-data-channel/Cargo.toml @@ -9,7 +9,6 @@ version.workspace = true workspace = true [dependencies] -kaptan-board = { path = "../kaptan-board" } kaptan-log = { path = "../kaptan-log" } kaptan-dma = { path = "../kaptan-dma" } kaptan-mavlink = { path = "../kaptan-mavlink" } diff --git a/firmware/libs/kaptan-data-channel/src/lib.rs b/firmware/libs/kaptan-data-channel/src/lib.rs index 0d2d994..52cc7e5 100644 --- a/firmware/libs/kaptan-data-channel/src/lib.rs +++ b/firmware/libs/kaptan-data-channel/src/lib.rs @@ -7,16 +7,14 @@ #![no_std] -use kaptan_board::{BoardConfig, DataChannelRates}; use kaptan_dma::TxCompletion; use kaptan_log::KaptanError; use kaptan_mavlink::message::MessageData; use kaptan_mavlink::{ MavlinkFrame, encoder::{DistanceSensorConfig, MavlinkEncoder}, - message::{MavCmd, MavResult, MavState}, - transmitter::FrameClass, - transmitter::TransmitFaultKind, + message::MavState, + transmitter::{EnqueueResult, FrameClass}, }; use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot}; @@ -24,84 +22,36 @@ mod mailbox; pub use mailbox::{LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox}; +/// Validated publication schedule consumed by the outbound data channel. +/// +/// This is a derived runtime input, not a persisted configuration root. Board +/// packages own the factory values and the platform constructs this view only +/// after validating the active user configuration against immutable hardware. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct DataChannelRates { + /// Frequency of the platform service tick. + pub service_hz: usize, + /// Raw-IMU publication rate. + pub raw_imu_hz: usize, + /// Range publication rate; `None` disables the stream. + pub range_hz: Option, + /// Optical-flow publication rate; `None` disables the stream. + pub optical_flow_hz: Option, + /// Heartbeat publication rate. + pub heartbeat_hz: usize, +} + /// Platform transport used by a data channel. /// /// The channel decides exactly which encoded frame is admitted and with which /// delivery class. The platform retains only DMA endpoint ownership. pub trait FrameSink { /// Admits one fully encoded frame to the platform's bounded transport. - fn send(&mut self, class: FrameClass, frame: MavlinkFrame) -> FrameAdmission; + fn send(&mut self, class: FrameClass, frame: MavlinkFrame) -> EnqueueResult; - /// Releases or faults the active DMA frame after a terminal interrupt. - fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion; - - /// Returns unconfigurable sole-link loss counters. - fn health(&self) -> TxHealth; - - /// Removes one exact terminal transport-fault category for the central - /// observability owner. It is never encoded from an ISR or transport - /// driver directly. - fn take_transport_fault(&mut self) -> Option; -} - -/// Outcome returned to the one platform telemetry owner for each attempted -/// frame admission. It never triggers logging recursively. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum FrameAdmission { - /// The queue retained this encoded frame, possibly after replacing a - /// pending lower-priority frame. - Retained(kaptan_mavlink::transmitter::EnqueueResult), -} - -impl FrameAdmission { - /// Whether the frame is represented by the lower transport and the log - /// hub may acknowledge its source record. - pub const fn is_retained(self) -> bool { - matches!( - self, - Self::Retained( - kaptan_mavlink::transmitter::EnqueueResult::Enqueued - | kaptan_mavlink::transmitter::EnqueueResult::ReplacedBestEffort - | kaptan_mavlink::transmitter::EnqueueResult::ReplacedCritical - ) - ) - } - - /// Whether no pending queue slot accepted the source frame. - pub const fn is_queue_full(self) -> bool { - matches!( - self, - Self::Retained( - kaptan_mavlink::transmitter::EnqueueResult::VitalQueueFull - | kaptan_mavlink::transmitter::EnqueueResult::CriticalQueueFull - | kaptan_mavlink::transmitter::EnqueueResult::BestEffortDropped - ) - ) - } -} - -/// Terminal outcome from the sole DMA owner. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum DmaCompletion { - /// No new transport fault was observed. - Complete, - /// A DMA terminal or restart fault was counted by the platform. - Fault, -} - -/// Unconfigurable loss counters from sole-link admission/DMA ownership. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct TxHealth { - /// Dropped or displaced pending frames, including vital admission loss. - pub admission_loss_count: u32, - /// DMA preparation/start/terminal transfer faults. - pub dma_fault_count: u32, - /// DMA-visible memory preparation faults. - pub memory_fault_count: u32, - /// DMA-engine transfer-start faults. - pub engine_fault_count: u32, - /// Active-DMA terminal faults or halts. - pub terminal_fault_count: u32, + /// Applies one terminal DMA interrupt and starts the next retained frame + /// when possible. + fn on_dma_complete(&mut self, completion: TxCompletion); } /// Mailboxes read by one data-channel worker. @@ -129,8 +79,6 @@ pub struct VerticalDataConfig { /// Data-channel construction failure. #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum DataChannelError { - /// Rejected profiles must not construct a physical telemetry channel. - NotTelemetryProfile, /// A configured channel must be due at least once per second. ZeroRate, /// A configured channel exceeds the service frequency. @@ -159,20 +107,19 @@ impl<'a, Sink, const SERVICE_HZ: usize> DataChannel<'a, Sink, SERVICE_HZ> where Sink: FrameSink, { - /// Creates an outbound data flow from validated portable board metadata. + /// Creates an outbound data flow from already-validated runtime inputs. pub fn new( sink: Sink, - config: BoardConfig, + system_id: u8, + component_id: u8, + rates: DataChannelRates, vertical: VerticalDataConfig, sources: DataSources<'a>, ) -> Result { - let telemetry = config - .telemetry() - .ok_or(DataChannelError::NotTelemetryProfile)?; - let schedule = Schedule::from_rates(telemetry.data_channel)?; + let schedule = Schedule::from_rates(rates)?; Ok(Self { sink, - encoder: MavlinkEncoder::new(config.mavlink.system_id, config.mavlink.component_id), + encoder: MavlinkEncoder::new(system_id, component_id), schedule, sources, vertical, @@ -234,41 +181,16 @@ where } /// Completes the active DMA lifecycle for this channel's transport. - pub fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion { + pub fn on_dma_complete(&mut self, completion: TxCompletion) { self.sink.on_dma_complete(completion) } - /// Emits a critical acknowledgement for a received MAVLink command. - pub fn acknowledge_command(&mut self, command: MavCmd, result: MavResult) { - let _ = self.sink.send( - FrameClass::Critical, - self.encoder.command_ack(command, result), - ); - } - /// Encodes an adapter-owned generated MAVLink data record through the /// channel's sole encoder and bounded transport sink. - pub fn send_custom( - &mut self, - class: FrameClass, - message: &M, - ) -> FrameAdmission { + pub fn send_custom(&mut self, class: FrameClass, message: &M) -> EnqueueResult { self.sink.send(class, self.encoder.custom(message)) } - /// Returns local sole-link accounting for platform diagnostics. It is not - /// serialized through Kaptan's bounded diagnostic messages. - pub fn transport_health(&self) -> TxHealth { - self.sink.health() - } - - /// Removes one exact sole-link transport fault category after the platform - /// has counted it. The platform maps it to a typed log record on its - /// bounded telemetry worker. - pub fn take_transport_fault(&mut self) -> Option { - self.sink.take_transport_fault() - } - fn refresh_vertical_data(&mut self) { if let Some(range) = self.sources.range.take_newer(&mut self.observed_range) { self.last_range = Some(range); @@ -371,17 +293,15 @@ impl Schedule { #[cfg(test)] mod tests { use super::{ - DataChannel, DataSources, DmaCompletion, FrameAdmission, FrameSink, LatestImuMailbox, - LatestOpticalFlowMailbox, LatestRangeMailbox, TransmitFaultKind, TxHealth, - VerticalDataConfig, - }; - use kaptan_board::{ - BoardConfig, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort, - VerticalSensingKind, + DataChannel, DataChannelRates, DataSources, FrameSink, LatestImuMailbox, + LatestOpticalFlowMailbox, LatestRangeMailbox, VerticalDataConfig, }; use kaptan_dma::TxCompletion; - use kaptan_log::Severity; - use kaptan_mavlink::{MavlinkFrame, message::MavState, transmitter::FrameClass}; + use kaptan_mavlink::{ + MavlinkFrame, + message::MavState, + transmitter::{EnqueueResult, FrameClass}, + }; use kaptan_sensor::{ imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot, }; @@ -394,50 +314,26 @@ mod tests { } impl FrameSink for Sink { - fn send(&mut self, class: FrameClass, _: MavlinkFrame) -> FrameAdmission { + fn send(&mut self, class: FrameClass, _: MavlinkFrame) -> EnqueueResult { match class { FrameClass::Vital => self.critical += 1, FrameClass::BestEffort => self.best_effort += 1, FrameClass::Critical => self.critical += 1, } - FrameAdmission::Retained(kaptan_mavlink::transmitter::EnqueueResult::Enqueued) + EnqueueResult::Enqueued } - fn on_dma_complete(&mut self, _: TxCompletion) -> DmaCompletion { + fn on_dma_complete(&mut self, _: TxCompletion) { self.completions += 1; - DmaCompletion::Complete - } - - fn health(&self) -> TxHealth { - TxHealth::default() - } - - fn take_transport_fault(&mut self) -> Option { - None } } - const CONFIG: BoardConfig = BoardConfig { - name: "test", - mavlink: MavlinkEndpoint { - system_id: 1, - component_id: 1, - port: MavlinkPort::Serial1, - baud: 115_200, - }, - profile: kaptan_board::BoardProfile::TelemetryOnly(kaptan_board::TelemetryProfile { - imu: ImuDevice::Mpu6000, - imu_mount: ImuMount::BodyAligned, - vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow, - data_channel: DataChannelRates { - service_hz: 100, - raw_imu_hz: 20, - range_hz: Some(20), - optical_flow_hz: Some(20), - heartbeat_hz: 2, - }, - maximum_log_level: Severity::Info, - }), + const RATES: DataChannelRates = DataChannelRates { + service_hz: 100, + raw_imu_hz: 20, + range_hz: Some(20), + optical_flow_hz: Some(20), + heartbeat_hz: 2, }; #[test] @@ -451,7 +347,9 @@ mod tests { critical: 0, completions: 0, }, - CONFIG, + 1, + 1, + RATES, VerticalDataConfig { distance: Some(kaptan_mavlink::encoder::DistanceSensorConfig { sensor_id: 0, @@ -488,7 +386,6 @@ mod tests { channel.on_service_tick(MavState::MAV_STATE_STANDBY, false); } channel.on_dma_complete(TxCompletion::Complete); - let sink = channel.sink; assert_eq!(sink.best_effort, 60); assert_eq!(sink.critical, 2); diff --git a/firmware/libs/kaptan-drivers/src/mpu6000/config.rs b/firmware/libs/kaptan-drivers/src/mpu6000/config.rs index f207fd4..33cd869 100644 --- a/firmware/libs/kaptan-drivers/src/mpu6000/config.rs +++ b/firmware/libs/kaptan-drivers/src/mpu6000/config.rs @@ -13,17 +13,6 @@ pub struct Mpu6000Config { pub sample_rate_divider: u8, } -impl Default for Mpu6000Config { - fn default() -> Self { - Self { - accelerometer: AccelerometerRange::PlusMinus4G, - gyroscope: GyroscopeRange::PlusMinus1000DegreesPerSecond, - digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz, - sample_rate_divider: 9, - } - } -} - /// MPU6000 digital low-pass filter bandwidth selection. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DigitalLowPassFilter { diff --git a/firmware/libs/kaptan-drivers/src/mpu6000/mod.rs b/firmware/libs/kaptan-drivers/src/mpu6000/mod.rs index f3cc0d4..6e38f7d 100644 --- a/firmware/libs/kaptan-drivers/src/mpu6000/mod.rs +++ b/firmware/libs/kaptan-drivers/src/mpu6000/mod.rs @@ -232,10 +232,20 @@ impl Mpu6000 { #[cfg(test)] mod tests { - use super::{Error, Mpu6000, Mpu6000Config, Mpu6000Event, TRANSFER_CAPACITY}; + use super::{ + AccelerometerRange, DigitalLowPassFilter, Error, GyroscopeRange, Mpu6000, Mpu6000Config, + Mpu6000Event, TRANSFER_CAPACITY, + }; use kaptan_spi::{SpiCompletion, SpiDevice}; use kaptan_time::Timestamp; + const TEST_CONFIG: Mpu6000Config = Mpu6000Config { + accelerometer: AccelerometerRange::PlusMinus4G, + gyroscope: GyroscopeRange::PlusMinus1000DegreesPerSecond, + digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz, + sample_rate_divider: 9, + }; + #[derive(Debug, Eq, PartialEq)] enum FakeError { Start, @@ -277,7 +287,7 @@ mod tests { fn configures_a_verified_device_without_waiting() { let mut bus = FakeBus::ready(); bus.receive[1] = 0x68; - let mut driver = Mpu6000::new(bus, Mpu6000Config::default()); + let mut driver = Mpu6000::new(bus, TEST_CONFIG); driver.begin_initialization().unwrap(); assert_eq!( @@ -302,7 +312,7 @@ mod tests { fn rejects_an_unexpected_identity() { let mut bus = FakeBus::ready(); bus.receive[1] = 0x70; - let mut driver = Mpu6000::new(bus, Mpu6000Config::default()); + let mut driver = Mpu6000::new(bus, TEST_CONFIG); driver.begin_initialization().unwrap(); let _ = driver.on_transfer_complete(SpiCompletion::Complete); @@ -322,7 +332,7 @@ mod tests { ]; let mut bus = FakeBus::ready(); bus.receive[1] = 0x68; - let mut driver = Mpu6000::new(bus, Mpu6000Config::default()); + let mut driver = Mpu6000::new(bus, TEST_CONFIG); driver.begin_initialization().unwrap(); let _ = driver.on_transfer_complete(SpiCompletion::Complete); @@ -352,7 +362,7 @@ mod tests { fn reports_a_route_transfer_fault() { let mut bus = FakeBus::ready(); bus.receive[1] = 0x68; - let mut driver = Mpu6000::new(bus, Mpu6000Config::default()); + let mut driver = Mpu6000::new(bus, TEST_CONFIG); driver.begin_initialization().unwrap(); assert!(matches!( diff --git a/firmware/libs/kaptan-drivers/src/mpu6000/service.rs b/firmware/libs/kaptan-drivers/src/mpu6000/service.rs index 66b6488..9278f87 100644 --- a/firmware/libs/kaptan-drivers/src/mpu6000/service.rs +++ b/firmware/libs/kaptan-drivers/src/mpu6000/service.rs @@ -89,10 +89,19 @@ where #[cfg(test)] mod tests { use super::{Mpu6000Service, Mpu6000ServiceEvent}; - use crate::mpu6000::{Mpu6000, Mpu6000Config}; + use crate::mpu6000::{ + AccelerometerRange, DigitalLowPassFilter, GyroscopeRange, Mpu6000, Mpu6000Config, + }; use kaptan_spi::{SpiCompletion, SpiDevice}; use kaptan_time::{MonotonicClock, Timestamp}; + const TEST_CONFIG: Mpu6000Config = Mpu6000Config { + accelerometer: AccelerometerRange::PlusMinus4G, + gyroscope: GyroscopeRange::PlusMinus1000DegreesPerSecond, + digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz, + sample_rate_divider: 9, + }; + struct Clock; impl MonotonicClock for Clock { @@ -120,10 +129,7 @@ mod tests { fn accepts_a_sample_only_after_initialization() { let mut response = [0; 15]; response[1] = 0x68; - let mut service = Mpu6000Service::new( - Clock, - Mpu6000::new(Bus { response }, Mpu6000Config::default()), - ); + let mut service = Mpu6000Service::new(Clock, Mpu6000::new(Bus { response }, TEST_CONFIG)); service.begin().unwrap(); assert!(matches!(service.on_data_ready(), Mpu6000ServiceEvent::Idle)); diff --git a/firmware/libs/kaptan-flight-config/src/cbor.rs b/firmware/libs/kaptan-flight-config/src/cbor.rs index 51f3a16..870b219 100644 --- a/firmware/libs/kaptan-flight-config/src/cbor.rs +++ b/firmware/libs/kaptan-flight-config/src/cbor.rs @@ -1,4 +1,4 @@ -//! Bounded CBOR import/export for one explicit current record. +//! Bounded CBOR persistence codec for one explicit current record. use kaptan_config::{ ConfigModel, ConfigValue, ConfigViolation, FieldWriteError, ValidationRule, ValueKind, @@ -13,7 +13,7 @@ pub const MAXIMUM_CANDIDATE_BYTES: usize = 4096; const FORMAT_KEY: u16 = 0; const SCHEMA_KEY: u16 = u16::MAX; -/// CBOR import failure. +/// CBOR snapshot decoding failure. #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum CborError { /// The supplied payload exceeded the bounded configuration storage budget. @@ -126,17 +126,14 @@ fn decode_current(bytes: &[u8]) -> Result { } } _ => { - let index = CandidateProfile::descriptors() - .iter() - .position(|descriptor| descriptor.id.get() == key) + let (index, descriptor) = CandidateProfile::descriptors() + .enumerate() + .find(|(_, descriptor)| descriptor.id.get() == key) .ok_or(CborError::Malformed)?; if values[index].is_some() { return Err(CborError::Malformed); } - values[index] = Some(decode_value( - &mut decoder, - CandidateProfile::descriptors()[index].kind, - )?); + values[index] = Some(decode_value(&mut decoder, descriptor.kind)?); } } } @@ -151,10 +148,10 @@ fn decode_current(bytes: &[u8]) -> Result { if values.iter().any(Option::is_none) { return Err(CborError::Malformed); } - let mut candidate = CandidateProfile::default(); + let mut candidate = CandidateProfile::factory(); for (index, value) in values.into_iter().enumerate() { let descriptor = CandidateProfile::descriptors() - .get(index) + .nth(index) .ok_or(CborError::Malformed)?; candidate .set_field(descriptor.id, value.ok_or(CborError::Malformed)?) @@ -210,7 +207,7 @@ mod tests { fn round_trip_and_trailing_rejection_are_exact() { let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]); let capabilities = VehicleCapabilities::quad_x(); - encode_candidate(CandidateProfile::default(), &capabilities, &mut writer).unwrap(); + encode_candidate(CandidateProfile::factory(), &capabilities, &mut writer).unwrap(); let length = writer.position(); let bytes = writer.into_inner(); assert!(decode_candidate(&bytes[..length], &capabilities).is_ok()); @@ -225,7 +222,7 @@ mod tests { #[test] fn encoding_rejects_a_cross_field_invalid_transaction_before_writing() { let capabilities = VehicleCapabilities::quad_x(); - let mut candidate = CandidateProfile::default(); + let mut candidate = CandidateProfile::factory(); candidate .set_field(kaptan_config::FieldId::new(2), ConfigValue::U64(50_001)) .expect("the scalar is locally valid"); @@ -295,8 +292,8 @@ mod tests { .u16(FORMAT_VERSION) .unwrap(); encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap(); - let candidate = CandidateProfile::default(); - for descriptor in CandidateProfile::descriptors().iter().skip(1) { + let candidate = CandidateProfile::factory(); + for descriptor in CandidateProfile::descriptors().skip(1) { encoder.u16(descriptor.id.get()).unwrap(); encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap(); } @@ -312,7 +309,6 @@ mod tests { fn cbor_scalar_write_preserves_the_generated_field_and_rule() { let capabilities = VehicleCapabilities::quad_x(); let mass = CandidateProfile::descriptors() - .iter() .find(|descriptor| descriptor.display_key == "thrust.mass_kg") .expect("declared mass descriptor"); let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]); @@ -326,7 +322,7 @@ mod tests { .u16(FORMAT_VERSION) .unwrap(); encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap(); - let candidate = CandidateProfile::default(); + let candidate = CandidateProfile::factory(); for descriptor in CandidateProfile::descriptors() { encoder.u16(descriptor.id.get()).unwrap(); let value = if descriptor.id == mass.id { @@ -367,7 +363,7 @@ mod tests { .u16(format_version) .unwrap(); encoder.u16(SCHEMA_KEY).unwrap().bytes(&schema_id).unwrap(); - let candidate = CandidateProfile::default(); + let candidate = CandidateProfile::factory(); for descriptor in CandidateProfile::descriptors() { encoder.u16(remap_key(descriptor.id.get())).unwrap(); encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap(); diff --git a/firmware/libs/kaptan-flight-config/src/lib.rs b/firmware/libs/kaptan-flight-config/src/lib.rs index 6189e66..7e9f687 100644 --- a/firmware/libs/kaptan-flight-config/src/lib.rs +++ b/firmware/libs/kaptan-flight-config/src/lib.rs @@ -10,11 +10,8 @@ pub use cbor::{ }; pub use profile::{CandidateProfile, FORMAT_VERSION, VehicleCapabilities, VehicleProfile}; -/// Exact identifier of the one unreleased Quad-X profile schema. -/// -/// This is an identifier, not a cryptographic fingerprint. A GCS includes it -/// with an import so a profile prepared for another schema cannot be applied. -pub const SCHEMA_ID: [u8; 16] = *b"KAPTANPROFILE-01"; +/// Generated fingerprint of the complete vehicle configuration schema. +pub const SCHEMA_ID: [u8; 16] = ::SCHEMA_ID; #[cfg(test)] extern crate std; diff --git a/firmware/libs/kaptan-flight-config/src/profile/active.rs b/firmware/libs/kaptan-flight-config/src/profile/active.rs index f3a1f3e..3ecc5f7 100644 --- a/firmware/libs/kaptan-flight-config/src/profile/active.rs +++ b/firmware/libs/kaptan-flight-config/src/profile/active.rs @@ -201,7 +201,7 @@ pub struct VehicleProfile { pub(crate) thrust: ThrustSettings, pub(crate) rate_pid: PidSettings, pub(crate) actuator_limits: ActuatorLimits, - pub(crate) observability: super::ObservabilitySettings, + pub(crate) mavlink_logging: super::MavlinkLoggingSettings, pub(crate) mixer: [[f32; 4]; 4], } @@ -227,9 +227,9 @@ impl VehicleProfile { pub const fn actuator_limits(self) -> ActuatorLimits { self.actuator_limits } - /// Immutable boot-active best-effort diagnostic policy. - pub const fn observability(self) -> super::ObservabilitySettings { - self.observability + /// Immutable boot-active policy for the MAVLink diagnostic sink. + pub const fn mavlink_logging(self) -> super::MavlinkLoggingSettings { + self.mavlink_logging } pub const fn mixer(self) -> [[f32; 4]; 4] { self.mixer diff --git a/firmware/libs/kaptan-flight-config/src/profile/candidate.rs b/firmware/libs/kaptan-flight-config/src/profile/candidate.rs index b04f64a..3235b07 100644 --- a/firmware/libs/kaptan-flight-config/src/profile/candidate.rs +++ b/firmware/libs/kaptan-flight-config/src/profile/candidate.rs @@ -1,60 +1,100 @@ //! Private vehicle candidate and whole-profile validation boundary. use kaptan_config::{ - ConfigAggregate, ConfigModel, ConfigValidate, ConfigValue, ConfigViolation, FieldDescriptor, - FieldId, FieldWriteError, ValidateFields, ValidationRule, + Config, ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, + ValidationRule, }; +use kaptan_log::Severity; use super::{ - ActuatorLimits, AidingSettings, ControlSettings, ObservabilitySettings, PidSettings, + ActuatorLimits, AidingSettings, ControlSettings, MavlinkLoggingSettings, PidSettings, SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile, control::{ ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate, }, - observability::ObservabilityCandidate, - registry, + mavlink_logging::MavlinkLoggingCandidate, safety::SafetyCandidate, timing::TimingCandidate, }; /// Current persisted CBOR schema for the vehicle candidate. -pub const FORMAT_VERSION: u16 = 1; +pub const FORMAT_VERSION: u16 = 2; /// Complete editable vehicle candidate. /// -/// Fields are private outside this crate. Operators, CBOR import, and MAVLink -/// transport all use the same descriptor/mutation and full-validation path. -#[derive(Clone, Copy, ConfigValidate)] +/// Fields are private outside this crate. Local snapshot recovery and MAVLink +/// scalar writes both use the same descriptor/mutation and full-validation +/// path. +#[derive(Clone, Copy, Config)] pub struct CandidateProfile { - #[config(section)] pub(super) safety: SafetyCandidate, - #[config(section)] pub(super) timing: TimingCandidate, - #[config(section)] pub(super) aiding: AidingCandidate, - #[config(section)] pub(super) control: ControlCandidate, - #[config(section)] pub(super) thrust: ThrustCandidate, - #[config(section)] pub(super) rate_pid: PidCandidate, - #[config(section)] pub(super) actuator_limits: ActuatorLimitsCandidate, - #[config(section)] - pub(super) observability: ObservabilityCandidate, -} - -impl Default for CandidateProfile { - fn default() -> Self { - ::default_aggregate() - .expect("compiled configuration defaults must validate") - } + pub(super) mavlink_logging: MavlinkLoggingCandidate, } impl CandidateProfile { + /// Explicit factory candidate used by the vehicle-profile examples. + /// + /// Board firmware supplies its own factory [`Config`] through the board + /// composition root; configuration types never synthesize values through + /// [`Default`]. + pub const fn factory() -> Self { + Self { + safety: SafetyCandidate { + prearm_dwell_microseconds: 100_000, + }, + timing: TimingCandidate { + maximum_imu_interval_microseconds: 50_000, + imu_freshness_microseconds: 50_000, + local_state_freshness_microseconds: 150_000, + command_link_freshness_microseconds: 500_000, + }, + aiding: AidingCandidate { + position_variance_m2: 0.04, + velocity_variance_m2_per_second_squared: 0.04, + }, + control: ControlCandidate { + position_gain_per_second_squared: 0.8, + velocity_gain_per_second: 1.2, + maximum_horizontal_acceleration_mps2: 3.0, + maximum_upward_acceleration_mps2: 2.0, + maximum_downward_acceleration_mps2: 2.0, + }, + thrust: ThrustCandidate { + mass_kg: 1.2, + maximum_collective_thrust_newtons: 18.0, + maximum_tilt_tangent: 0.700_207_53, + }, + rate_pid: PidCandidate { + proportional: 0.035, + integral: 0.006, + derivative: 0.0, + output_limit: 0.10, + integral_limit: 0.025, + }, + actuator_limits: ActuatorLimitsCandidate { + minimum: 0.0, + maximum: 1.0, + slew_per_second: 8.0, + }, + mavlink_logging: MavlinkLoggingCandidate { + enabled: true, + maximum_severity: Severity::Info, + }, + } + } + /// Returns the complete transport-neutral descriptor registry. - pub const fn descriptors() -> &'static [FieldDescriptor; Self::FIELD_COUNT] { - registry::descriptors() + pub fn descriptors() -> impl ExactSizeIterator + Clone { + (0..Self::FIELD_COUNT).map(|index| { + ::descriptor(index) + .expect("derived aggregate descriptor count must be exact") + }) } fn from_active(active: &VehicleProfile) -> Self { @@ -65,7 +105,7 @@ impl CandidateProfile { let thrust = active.thrust(); let rate_pid = active.rate_pid(); let actuator_limits = active.actuator_limits(); - let observability = active.observability(); + let mavlink_logging = active.mavlink_logging(); Self { safety: SafetyCandidate { prearm_dwell_microseconds: safety.prearm_dwell_microseconds(), @@ -106,8 +146,9 @@ impl CandidateProfile { maximum: actuator_limits.maximum(), slew_per_second: actuator_limits.slew_per_second(), }, - observability: ObservabilityCandidate { - maximum_log_level: observability.maximum_log_level(), + mavlink_logging: MavlinkLoggingCandidate { + enabled: mavlink_logging.enabled(), + maximum_severity: mavlink_logging.maximum_severity(), }, } } @@ -116,7 +157,7 @@ impl CandidateProfile { &self, capabilities: &VehicleCapabilities, ) -> Result { - self.validate_fields()?; + ::validate(self)?; self.validate_capabilities(capabilities)?; Ok(VehicleProfile { safety: SafetySettings { @@ -162,7 +203,10 @@ impl CandidateProfile { maximum: self.actuator_limits.maximum, slew_per_second: self.actuator_limits.slew_per_second, }, - observability: ObservabilitySettings::new(self.observability.maximum_log_level), + mavlink_logging: MavlinkLoggingSettings::new( + self.mavlink_logging.enabled, + self.mavlink_logging.maximum_severity, + ), mixer: capabilities.mixer(), }) } @@ -183,10 +227,10 @@ impl CandidateProfile { impl ConfigModel for CandidateProfile { type Active = VehicleProfile; type Context = VehicleCapabilities; - const SCHEMA_ID: [u8; 16] = crate::SCHEMA_ID; + const SCHEMA_ID: [u8; 16] = ::SCHEMA_ID; fn descriptor(index: u16) -> Option<&'static FieldDescriptor> { - registry::descriptor(index) + ::descriptor(usize::from(index)) } fn field_count() -> u16 { @@ -194,15 +238,15 @@ impl ConfigModel for CandidateProfile { } fn field_value(&self, id: FieldId) -> Option { - ::field_value(self, id) + ::field_value(self, id) } fn active_field_value(active: &Self::Active, id: FieldId) -> Option { - ::field_value(&Self::from_active(active), id) + ::field_value(&Self::from_active(active), id) } fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> { - ::set_field(self, id, value) + ::set_field(self, id, value) } fn validate(&self, context: &Self::Context) -> Result { @@ -214,36 +258,43 @@ impl ConfigModel for CandidateProfile { mod tests { use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule}; - use super::{CandidateProfile, VehicleCapabilities}; + use super::{CandidateProfile, Severity, VehicleCapabilities}; use crate::profile::ids; const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x(); + #[test] + fn generated_schema_fingerprint_is_stable() { + assert_eq!( + crate::SCHEMA_ID, + [ + 0xc4, 0xf6, 0x36, 0xc0, 0x29, 0xd2, 0x3e, 0x29, 0xa3, 0x85, 0x5b, 0x36, 0xb7, 0xb3, + 0x59, 0x9c, + ] + ); + } + fn descriptor_id(display_key: &str) -> FieldId { CandidateProfile::descriptors() - .iter() .find(|descriptor| descriptor.display_key == display_key) .expect("declared field descriptor") .id } #[test] - fn every_descriptor_has_a_readable_default_value() { - let candidate = CandidateProfile::default(); + fn every_descriptor_has_a_readable_factory_value() { + let candidate = CandidateProfile::factory(); for descriptor in CandidateProfile::descriptors() { - assert_eq!( - candidate.field_value(descriptor.id), - Some(descriptor.default) - ); + assert!(candidate.field_value(descriptor.id).is_some()); } } #[test] fn aggregated_descriptor_ids_are_unique() { - for (index, descriptor) in CandidateProfile::descriptors().iter().enumerate() { + for (index, descriptor) in CandidateProfile::descriptors().enumerate() { assert!( - CandidateProfile::descriptors()[index + 1..] - .iter() + CandidateProfile::descriptors() + .skip(index + 1) .all(|other| other.id != descriptor.id) ); } @@ -251,7 +302,7 @@ mod tests { #[test] fn cross_field_invalid_edit_is_retained_only_in_the_candidate() { - let mut candidate = CandidateProfile::default(); + let mut candidate = CandidateProfile::factory(); candidate .set_field( ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS, @@ -266,7 +317,7 @@ mod tests { #[test] fn local_write_error_retains_exact_field_and_rule() { - let mut candidate = CandidateProfile::default(); + let mut candidate = CandidateProfile::factory(); assert_eq!( candidate.set_field(ids::MASS_KG, ConfigValue::F32(f32::NAN)), Err(FieldWriteError::non_finite(ids::MASS_KG)) @@ -280,7 +331,7 @@ mod tests { .into_iter() .flatten() { - let mut candidate = CandidateProfile::default(); + let mut candidate = CandidateProfile::factory(); candidate .set_field(descriptor.id, bound) .expect("descriptor bound must pass the descriptor-backed setter"); @@ -297,20 +348,26 @@ mod tests { } #[test] - fn observability_filter_is_persisted_restart_only_and_cannot_hide_errors() { - let mut candidate = CandidateProfile::default(); + fn mavlink_logging_policy_is_persisted_and_restart_only() { + let mut candidate = CandidateProfile::factory(); + let severity_descriptor = CandidateProfile::descriptors() + .find(|descriptor| descriptor.display_key == "mavlink.logging.maximum_severity") + .unwrap(); + assert_eq!( + severity_descriptor.enum_options, + Severity::CONFIG_ENUM_OPTIONS + ); candidate .set_field( - descriptor_id("observability.maximum_log_level"), - ConfigValue::U32(3), + descriptor_id("mavlink.logging.enabled"), + ConfigValue::Bool(false), ) .unwrap(); let active = candidate.validate(&CAPABILITIES).unwrap(); - assert_eq!(active.observability().maximum_log_level(), 3); - assert_eq!(active.observability().maximum_log_level(), 3); + assert!(!active.mavlink_logging().enabled()); candidate .set_field( - descriptor_id("observability.maximum_log_level"), + descriptor_id("mavlink.logging.maximum_severity"), ConfigValue::U32(5), ) .unwrap(); @@ -318,27 +375,27 @@ mod tests { candidate .validate(&CAPABILITIES) .unwrap() - .observability() - .maximum_log_level(), - 5 + .mavlink_logging() + .maximum_severity(), + Severity::Notice ); assert!(matches!( candidate.set_field( - descriptor_id("observability.maximum_log_level"), - ConfigValue::U32(2), + descriptor_id("mavlink.logging.maximum_severity"), + ConfigValue::U32(8), ), Err(FieldWriteError::Violation(issue)) - if issue.rule() == ValidationRule::BelowMinimum + if issue.rule() == ValidationRule::InvalidEnumValue )); } #[test] fn every_persisted_log_severity_is_retained_exactly() { - for raw in [3, 4, 5, 6, 7] { - let mut candidate = CandidateProfile::default(); + for raw in 0..=7 { + let mut candidate = CandidateProfile::factory(); candidate .set_field( - descriptor_id("observability.maximum_log_level"), + descriptor_id("mavlink.logging.maximum_severity"), ConfigValue::U32(raw), ) .expect("descriptor range accepts the stable severity"); @@ -346,9 +403,9 @@ mod tests { candidate .validate(&CAPABILITIES) .expect("valid complete profile") - .observability() - .maximum_log_level(), - raw + .mavlink_logging() + .maximum_severity(), + Severity::try_from_wire(u8::try_from(raw).unwrap()).unwrap() ); } } diff --git a/firmware/libs/kaptan-flight-config/src/profile/control.rs b/firmware/libs/kaptan-flight-config/src/profile/control.rs index b46b079..de201aa 100644 --- a/firmware/libs/kaptan-flight-config/src/profile/control.rs +++ b/firmware/libs/kaptan-flight-config/src/profile/control.rs @@ -1,14 +1,13 @@ -use kaptan_config::{ConfigValidate, ConfigViolation, ValidationRule}; +use kaptan_config::{Config, ConfigViolation, ValidationRule}; use super::ids; -#[derive(Clone, Copy, ConfigValidate)] +#[derive(Clone, Copy, Config)] pub(super) struct AidingCandidate { #[config( id = 6, key = "aiding.position_variance_m2", unit = "m2", - default = 0.04, finite, range(min = 0.000_001, max = 10_000.0) )] @@ -17,20 +16,18 @@ pub(super) struct AidingCandidate { id = 7, key = "aiding.velocity_variance_m2ps2", unit = "m2/s2", - default = 0.04, finite, range(min = 0.000_001, max = 10_000.0) )] pub(super) velocity_variance_m2_per_second_squared: f32, } -#[derive(Clone, Copy, ConfigValidate)] +#[derive(Clone, Copy, Config)] pub(super) struct ControlCandidate { #[config( id = 8, key = "control.position_gain_per_s2", unit = "1/s2", - default = 0.8, finite, range(min = 0.0, max = 100.0) )] @@ -39,7 +36,6 @@ pub(super) struct ControlCandidate { id = 9, key = "control.velocity_gain_per_s", unit = "1/s", - default = 1.2, finite, range(min = 0.0, max = 100.0) )] @@ -48,7 +44,6 @@ pub(super) struct ControlCandidate { id = 10, key = "control.maximum_horizontal_acceleration_mps2", unit = "m/s2", - default = 3.0, finite, range(min = 0.001, max = 100.0) )] @@ -57,7 +52,6 @@ pub(super) struct ControlCandidate { id = 11, key = "control.maximum_upward_acceleration_mps2", unit = "m/s2", - default = 2.0, finite, range(min = 0.001, max = 100.0) )] @@ -66,20 +60,18 @@ pub(super) struct ControlCandidate { id = 12, key = "control.maximum_downward_acceleration_mps2", unit = "m/s2", - default = 2.0, finite, range(min = 0.001, max = 100.0) )] pub(super) maximum_downward_acceleration_mps2: f32, } -#[derive(Clone, Copy, ConfigValidate)] +#[derive(Clone, Copy, Config)] #[config(post = Self::validate_thrust)] pub(super) struct ThrustCandidate { #[config( id = 13, key = "thrust.mass_kg", unit = "kg", - default = 1.2, finite, range(min = 0.01, max = 100.0) )] @@ -88,7 +80,6 @@ pub(super) struct ThrustCandidate { id = 14, key = "thrust.maximum_collective_thrust_n", unit = "N", - default = 18.0, finite, range(min = 0.1, max = 10_000.0) )] @@ -97,7 +88,6 @@ pub(super) struct ThrustCandidate { id = 15, key = "thrust.maximum_tilt_tangent", unit = "", - default = 0.700_207_53, finite, range(min = 0.001, max = 10.0) )] @@ -116,14 +106,13 @@ impl ThrustCandidate { } } -#[derive(Clone, Copy, ConfigValidate)] +#[derive(Clone, Copy, Config)] #[config(post = Self::validate_pid)] pub(super) struct PidCandidate { #[config( id = 16, key = "control.rate_pid.proportional", unit = "", - default = 0.035, finite, range(min = 0.0, max = 10.0) )] @@ -132,7 +121,6 @@ pub(super) struct PidCandidate { id = 17, key = "control.rate_pid.integral", unit = "", - default = 0.006, finite, range(min = 0.0, max = 10.0) )] @@ -141,7 +129,6 @@ pub(super) struct PidCandidate { id = 18, key = "control.rate_pid.derivative", unit = "", - default = 0.0, finite, range(min = 0.0, max = 10.0) )] @@ -150,7 +137,6 @@ pub(super) struct PidCandidate { id = 19, key = "control.rate_pid.output_limit", unit = "", - default = 0.10, finite, range(min = 0.001, max = 1.0) )] @@ -159,7 +145,6 @@ pub(super) struct PidCandidate { id = 20, key = "control.rate_pid.integral_limit", unit = "", - default = 0.025, finite, range(min = 0.0, max = 1.0) )] @@ -178,14 +163,13 @@ impl PidCandidate { } } -#[derive(Clone, Copy, ConfigValidate)] +#[derive(Clone, Copy, Config)] #[config(post = Self::validate_actuator_limits)] pub(super) struct ActuatorLimitsCandidate { #[config( id = 21, key = "actuator.minimum", unit = "", - default = 0.0, finite, range(min = 0.0, max = 1.0) )] @@ -194,7 +178,6 @@ pub(super) struct ActuatorLimitsCandidate { id = 22, key = "actuator.maximum", unit = "", - default = 1.0, finite, range(min = 0.0, max = 1.0) )] @@ -203,7 +186,6 @@ pub(super) struct ActuatorLimitsCandidate { id = 23, key = "actuator.slew_per_second", unit = "1/s", - default = 8.0, finite, range(min = 0.001, max = 100.0) )] diff --git a/firmware/libs/kaptan-flight-config/src/profile/mavlink_logging.rs b/firmware/libs/kaptan-flight-config/src/profile/mavlink_logging.rs new file mode 100644 index 0000000..552cf1b --- /dev/null +++ b/firmware/libs/kaptan-flight-config/src/profile/mavlink_logging.rs @@ -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 + } +} diff --git a/firmware/libs/kaptan-flight-config/src/profile/mod.rs b/firmware/libs/kaptan-flight-config/src/profile/mod.rs index 7b29ffb..b199a56 100644 --- a/firmware/libs/kaptan-flight-config/src/profile/mod.rs +++ b/firmware/libs/kaptan-flight-config/src/profile/mod.rs @@ -4,8 +4,7 @@ mod active; mod candidate; mod control; mod ids; -mod observability; -mod registry; +mod mavlink_logging; mod safety; mod timing; @@ -14,4 +13,4 @@ pub use active::{ TimingSettings, VehicleCapabilities, VehicleProfile, }; pub use candidate::{CandidateProfile, FORMAT_VERSION}; -pub use observability::ObservabilitySettings; +pub use mavlink_logging::MavlinkLoggingSettings; diff --git a/firmware/libs/kaptan-flight-config/src/profile/observability.rs b/firmware/libs/kaptan-flight-config/src/profile/observability.rs deleted file mode 100644 index 3c85779..0000000 --- a/firmware/libs/kaptan-flight-config/src/profile/observability.rs +++ /dev/null @@ -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 - } -} diff --git a/firmware/libs/kaptan-flight-config/src/profile/registry.rs b/firmware/libs/kaptan-flight-config/src/profile/registry.rs deleted file mode 100644 index b61131b..0000000 --- a/firmware/libs/kaptan-flight-config/src/profile/registry.rs +++ /dev/null @@ -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)) -} diff --git a/firmware/libs/kaptan-flight-config/src/profile/safety.rs b/firmware/libs/kaptan-flight-config/src/profile/safety.rs index 5974f3b..57a86f9 100644 --- a/firmware/libs/kaptan-flight-config/src/profile/safety.rs +++ b/firmware/libs/kaptan-flight-config/src/profile/safety.rs @@ -1,12 +1,11 @@ -use kaptan_config::ConfigValidate; +use kaptan_config::Config; -#[derive(Clone, Copy, ConfigValidate)] +#[derive(Clone, Copy, Config)] pub(super) struct SafetyCandidate { #[config( id = 1, key = "safety.prearm_dwell_us", unit = "us", - default = 100_000, nonzero, range(min = 1, max = 10_000_000) )] diff --git a/firmware/libs/kaptan-flight-config/src/profile/timing.rs b/firmware/libs/kaptan-flight-config/src/profile/timing.rs index e97909d..571b0df 100644 --- a/firmware/libs/kaptan-flight-config/src/profile/timing.rs +++ b/firmware/libs/kaptan-flight-config/src/profile/timing.rs @@ -1,15 +1,14 @@ -use kaptan_config::{ConfigValidate, ConfigViolation, ValidationRule}; +use kaptan_config::{Config, ConfigViolation, ValidationRule}; use super::ids; -#[derive(Clone, Copy, ConfigValidate)] +#[derive(Clone, Copy, Config)] #[config(post = Self::validate_timing)] pub(super) struct TimingCandidate { #[config( id = 2, key = "timing.imu_max_interval_us", unit = "us", - default = 50_000, nonzero, range(min = 1, max = 1_000_000) )] @@ -18,7 +17,6 @@ pub(super) struct TimingCandidate { id = 3, key = "timing.imu_freshness_us", unit = "us", - default = 50_000, nonzero, range(min = 1, max = 1_000_000) )] @@ -27,7 +25,6 @@ pub(super) struct TimingCandidate { id = 4, key = "timing.local_state_freshness_us", unit = "us", - default = 150_000, nonzero, range(min = 1, max = 10_000_000) )] @@ -36,7 +33,6 @@ pub(super) struct TimingCandidate { id = 5, key = "timing.link_freshness_us", unit = "us", - default = 500_000, nonzero, range(min = 1, max = 10_000_000) )] diff --git a/firmware/libs/kaptan-flight-runtime/src/lib.rs b/firmware/libs/kaptan-flight-runtime/src/lib.rs index b63fb60..171cdec 100644 --- a/firmware/libs/kaptan-flight-runtime/src/lib.rs +++ b/firmware/libs/kaptan-flight-runtime/src/lib.rs @@ -1,45 +1,48 @@ //! Central, transport-neutral flight-runtime composition. //! -//! This crate owns the one configuration manager and one bounded log hub for -//! an image. MAVLink adapters receive mutable borrows for bounded dispatch or -//! draining, but neither can own configuration lifecycle or link state. +//! This crate owns the one configuration manager and the bounded queue for the +//! image's MAVLink diagnostic sink. MAVLink adapters receive mutable borrows +//! for bounded dispatch or draining, but cannot own configuration lifecycle or +//! link state. #![no_std] -use kaptan_config::{ConfigManager, ProfileStore, StartupError}; +use kaptan_config::{ConfigManager, ConfigStore, StartupError}; use kaptan_flight_config::{CandidateProfile, VehicleCapabilities}; -use kaptan_log::{EventHub, LogFlags, LogRecord, LogText, PublishOutcome, Severity}; +use kaptan_log::{LogRecord, LogSinkQueue}; -/// Default bounded allocation for central runtime text diagnostics. Error -/// records are partitioned from optional detail. -pub type RuntimeLogHub = EventHub<8, 16>; +/// Default bounded allocation for the MAVLink diagnostic sink. Error records +/// are partitioned from optional detail. +pub type RuntimeMavlinkLogQueue = LogSinkQueue<8, 16>; /// One boot-active vehicle runtime. It deliberately has no MAVLink dependency. pub struct FlightRuntime { configuration: ConfigManager, - logs: RuntimeLogHub, + mavlink_logs: RuntimeMavlinkLogQueue, } impl FlightRuntime where - S: ProfileStore, + S: ConfigStore, { /// Recovers the central manager, validates its boot-active snapshot, and - /// copies the restart-only observability policy into the bounded hub. + /// copies the restart-only MAVLink sink policy into its bounded queue. pub fn recover( + factory: CandidateProfile, capabilities: VehicleCapabilities, store: S, ) -> Result> { - let configuration = - ConfigManager::recover(CandidateProfile::default(), capabilities, store)?; - let maximum_log_level = configuration.active().observability().maximum_log_level(); - let logs = RuntimeLogHub::new(kaptan_log::LogFilter::new( - Severity::try_from_wire(maximum_log_level as u8) - .expect("validated flight profile has a MAVLink log severity"), - )); + let configuration = ConfigManager::recover(factory, capabilities, store)?; + let logging = configuration.active().mavlink_logging(); + let filter = if logging.enabled() { + kaptan_log::LogFilter::up_to(logging.maximum_severity()) + } else { + kaptan_log::LogFilter::disabled() + }; + let mavlink_logs = RuntimeMavlinkLogQueue::new(filter); Ok(Self { configuration, - logs, + mavlink_logs, }) } @@ -48,82 +51,108 @@ where &self.configuration } - /// Borrows the manager and hub together for a state-only protocol adapter. - /// This makes adapter ownership inversion unrepresentable in composition. - pub fn configuration_and_logs_mut( - &mut self, - ) -> (&mut ConfigManager, &mut RuntimeLogHub) { - (&mut self.configuration, &mut self.logs) + /// Mutably borrows the sole configuration manager for protocol dispatch. + pub fn configuration_mut(&mut self) -> &mut ConfigManager { + &mut self.configuration } - /// Publishes one preformatted bounded task record through the sole hub. - pub fn log(&mut self, severity: Severity, flags: LogFlags, message: LogText) -> PublishOutcome { - self.logs.publish_text(severity, flags, message) + /// Composition-owned fan-out boundary for one preformatted event. + /// A future blackbox adds its independent sink admission here. + pub fn publish_log_event(&mut self, record: LogRecord) { + let _ = self.mavlink_logs.publish_record(record); } /// Peeks the highest-priority pending text log for the sole telemetry link. - pub fn pending_report(&self) -> Option { - self.logs.peek() + pub fn pending_mavlink_report(&self) -> Option { + self.mavlink_logs.peek() } /// Submits the next record and removes it only after transport admission. - pub fn submit_next( + pub fn submit_next_mavlink( &mut self, submit: impl FnOnce(&LogRecord) -> (kaptan_log::TransportAdmission, R), ) -> Option { - self.logs.submit_next(submit) + self.mavlink_logs.submit_next(submit) } - /// Immutable boot-active log policy copied from the validated profile. - pub const fn log_filter(&self) -> kaptan_log::LogFilter { - self.logs.filter() + /// Immutable boot-active MAVLink log policy copied from the validated profile. + pub const fn mavlink_log_filter(&self) -> kaptan_log::LogFilter { + self.mavlink_logs.filter() } } #[cfg(test)] mod tests { - use kaptan_config::{Generation, ProfileStore}; - use kaptan_log::{LogFlags, LogText, Severity}; + use kaptan_config::{ConfigModel, ConfigStore, ConfigValue}; + use kaptan_log::{LogFlags, LogRecord, LogText, Severity}; use super::FlightRuntime; struct Store; - impl ProfileStore for Store { + impl ConfigStore for Store { type Error = (); - fn load( - &mut self, - ) -> Result, Self::Error> - { + fn load(&mut self) -> Result, Self::Error> { Ok(None) } fn persist( &mut self, _: &kaptan_flight_config::CandidateProfile, - ) -> Result { - Ok(Generation::new(1)) + ) -> Result<(), Self::Error> { + Ok(()) } } #[test] - fn central_runtime_uses_the_active_profile_filter_and_owns_one_hub() { - let mut runtime = - FlightRuntime::recover(kaptan_flight_config::VehicleCapabilities::quad_x(), Store) - .expect("factory profile validates"); + fn runtime_uses_the_active_mavlink_policy_and_owns_one_sink_queue() { + let mut runtime = FlightRuntime::recover( + kaptan_flight_config::CandidateProfile::factory(), + kaptan_flight_config::VehicleCapabilities::quad_x(), + Store, + ) + .expect("factory configuration validates"); assert_eq!( runtime .configuration() .active() - .observability() - .maximum_log_level(), - runtime.log_filter().maximum_severity() as u32 + .mavlink_logging() + .maximum_severity(), + runtime + .mavlink_log_filter() + .maximum_severity() + .expect("factory enables MAVLink logs") ); - let _ = runtime.log( + runtime.publish_log_event(LogRecord::new( Severity::Info, LogFlags::NONE, LogText::from_message("runtime ready"), - ); - assert!(runtime.pending_report().is_some()); + )); + assert!(runtime.pending_mavlink_report().is_some()); + } + + #[test] + fn disabled_mavlink_sink_filters_every_severity() { + let mut factory = kaptan_flight_config::CandidateProfile::factory(); + let enabled = kaptan_flight_config::CandidateProfile::descriptors() + .find(|descriptor| descriptor.display_key == "mavlink.logging.enabled") + .expect("MAVLink logging enable descriptor"); + factory + .set_field(enabled.id, ConfigValue::Bool(false)) + .unwrap(); + let mut runtime = FlightRuntime::recover( + factory, + kaptan_flight_config::VehicleCapabilities::quad_x(), + Store, + ) + .expect("disabled MAVLink sink is valid"); + + assert_eq!(runtime.mavlink_log_filter().maximum_severity(), None); + runtime.publish_log_event(LogRecord::new( + Severity::Error, + LogFlags::NONE, + LogText::from_message("not routed to MAVLink"), + )); + assert!(runtime.pending_mavlink_report().is_none()); } } diff --git a/firmware/libs/kaptan-log-mavlink/src/lib.rs b/firmware/libs/kaptan-log-mavlink/src/lib.rs index 0ef82ba..9a93aa3 100644 --- a/firmware/libs/kaptan-log-mavlink/src/lib.rs +++ b/firmware/libs/kaptan-log-mavlink/src/lib.rs @@ -9,18 +9,21 @@ #[cfg(test)] extern crate std; -use kaptan_log::{LogRecord, LogText, RecordChannel, ShutdownReason, ShutdownReport}; +use kaptan_log::{ + LogRecord, MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES, MAXIMUM_ERROR_PATH_BYTES, + MAXIMUM_LOG_TEXT_BYTES, RecordChannel, ShutdownReason, ShutdownReport, +}; use kaptan_mavlink::message::kaptan::{ KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KAPTAN_SHUTDOWN_DATA, KaptanErrorFlags, KaptanLogFlags, KaptanShutdownReason, }; /// Exact capacity of the fixed text field in [`KAPTAN_LOG_DATA`]. -pub const LOG_MESSAGE_BYTES: usize = 160; +pub const LOG_MESSAGE_BYTES: usize = MAXIMUM_LOG_TEXT_BYTES; /// Exact capacity of the fixed error identity path field. -pub const ERROR_PATH_BYTES: usize = 96; +pub const ERROR_PATH_BYTES: usize = MAXIMUM_ERROR_PATH_BYTES; /// Exact capacity of the fixed error additional-information field. -pub const ERROR_ADDITIONAL_INFORMATION_BYTES: usize = 64; +pub const ERROR_ADDITIONAL_INFORMATION_BYTES: usize = MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES; /// One generated diagnostic payload. A platform-owned link remains the sole /// MAVLink framing, admission, DMA, and retry owner. @@ -45,8 +48,8 @@ pub fn encode_diagnostic(record: &LogRecord) -> DiagnosticPayload { .expect("log channel must contain ordinary message text"); debug_assert!(usize::from(message.len()) <= LOG_MESSAGE_BYTES); DiagnosticPayload::Log(KAPTAN_LOG_DATA { - level: record.severity() as u8, - flags: KaptanLogFlags::from_bits_retain(record.flags().bits()), + level: record.severity().wire_value(), + flags: KaptanLogFlags::from_bits_retain(record.flags().log_bits()), message_length: message.len(), message: *message.bytes(), }) @@ -58,47 +61,29 @@ pub fn encode_diagnostic(record: &LogRecord) -> DiagnosticPayload { let additional_information = record .additional_information() .expect("error channel must contain additional-information storage"); - let (path, path_length, path_clipped) = project_text::(path); - let (additional_information, additional_information_length, information_clipped) = - project_text::(additional_information); - let mut flags = record.flags().error_bits(); - if path_clipped || information_clipped { - flags |= KaptanErrorFlags::KAPTAN_ERROR_FLAG_TRUNCATED.bits(); - } + debug_assert_eq!(path.bytes().len(), ERROR_PATH_BYTES); + debug_assert_eq!( + additional_information.bytes().len(), + ERROR_ADDITIONAL_INFORMATION_BYTES + ); DiagnosticPayload::Error(KAPTAN_ERROR_DATA { - flags: KaptanErrorFlags::from_bits_retain(flags), - path_length, - additional_information_length, - path, - additional_information, + level: record.severity().wire_value(), + flags: KaptanErrorFlags::from_bits_retain(record.flags().error_bits()), + path_length: path.len(), + additional_information_length: additional_information.len(), + path: *path.bytes(), + additional_information: *additional_information.bytes(), }) } } } -fn project_text(text: &LogText) -> ([u8; N], u8, bool) { - let source = text.as_str(); - let retained = source - .char_indices() - .take_while(|(index, character)| index + character.len_utf8() <= N) - .map(|(index, character)| index + character.len_utf8()) - .last() - .unwrap_or(0); - let mut bytes = [0; N]; - bytes[..retained].copy_from_slice(&source.as_bytes()[..retained]); - ( - bytes, - retained as u8, - text.is_truncated() || retained != source.len(), - ) -} - /// Encodes one typed shutdown event. The caller owns when a shutdown is safe /// to report; this pure adapter cannot run a shutdown state machine. pub const fn encode_shutdown(report: ShutdownReport) -> KAPTAN_SHUTDOWN_DATA { KAPTAN_SHUTDOWN_DATA { reason: match report.reason() { - ShutdownReason::Operator => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_OPERATOR, + ShutdownReason::User => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_USER, ShutdownReason::Safety => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_SAFETY, }, } @@ -113,8 +98,8 @@ mod tests { encode_diagnostic, encode_shutdown, }; use kaptan_log::{ - ErrorAdditionalInformation, ErrorPath, EventHub, FacadeIngress, GlobalLogger, LogFilter, - LogFlags, LogRecord, LogText, Severity, ShutdownReason, ShutdownReport, + ErrorAdditionalInformation, ErrorPath, FacadeIngress, GlobalLogger, LogFlags, LogRecord, + LogText, Severity, ShutdownReason, ShutdownReport, }; use kaptan_mavlink::{ encoder::MavlinkEncoder, @@ -124,7 +109,7 @@ mod tests { }, }; - static ERROR_INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info)); + static ERROR_INGRESS: FacadeIngress<1> = FacadeIngress::new(); static ERROR_LOGGER: GlobalLogger<1> = GlobalLogger::new(&ERROR_INGRESS, never_forbidden); static INSTALL_ERROR_LOGGER: Once = Once::new(); @@ -136,13 +121,13 @@ mod tests { fn ordinary_record_preserves_level_flags_and_bounded_text() { let record = LogRecord::new( Severity::Info, - LogFlags::TRUNCATED, + LogFlags::TRUNCATED.with(LogFlags::COALESCED), LogText::from_message("imu unavailable"), ); let DiagnosticPayload::Log(data) = encode_diagnostic(&record) else { panic!("info record must use KAPTAN_LOG"); }; - assert_eq!(data.level, Severity::Info as u8); + assert_eq!(data.level, Severity::Info.wire_value()); assert_eq!(data.message_length, 15); assert_eq!(&data.message[..15], b"imu unavailable"); assert!(data.message[15..].iter().all(|byte| *byte == 0)); @@ -161,9 +146,9 @@ mod tests { kaptan_log::log::info!("optional ingress is occupied"); kaptan_log::log::error!("critical error survives"); - let mut hub = EventHub::<1, 1>::new(LogFilter::default()); - assert!(ERROR_INGRESS.drain_one_into(&mut hub).is_some()); - let record = hub.peek().expect("critical macro record retained"); + let record = ERROR_INGRESS + .take() + .expect("critical macro record retained"); let DiagnosticPayload::Error(payload) = encode_diagnostic(&record) else { panic!("log::error! must not produce KAPTAN_LOG"); }; @@ -211,6 +196,7 @@ mod tests { panic!("error record must use KAPTAN_ERROR"); }; assert_eq!(error_data.flags.bits(), LogFlags::COALESCED.bits()); + assert_eq!(error_data.level, Severity::Error.wire_value()); assert_eq!(error_data.path_length, 23); assert_eq!(error_data.additional_information_length, 0); assert_eq!(&error_data.path[..23], b"driver::Error::Transfer"); @@ -220,10 +206,10 @@ mod tests { .iter() .all(|byte| *byte == 0) ); - assert_eq!(KAPTAN_ERROR_DATA::ENCODED_LEN, 163); + assert_eq!(KAPTAN_ERROR_DATA::ENCODED_LEN, 164); let error_frame = encoder.custom(&error_data); assert_eq!(error_frame.message_id(), 51_021); - assert!(error_frame.raw_bytes().len() <= 175); + assert!(error_frame.raw_bytes().len() <= 176); assert!(matches!( MavMessage::parse( mavlink::MavlinkVersion::V2, @@ -235,7 +221,7 @@ mod tests { } #[test] - fn error_wire_projection_clips_each_component_independently() { + fn error_record_bounds_each_component_before_wire_projection() { let error = LogRecord::error( LogFlags::NONE, ErrorPath::from_message(&"p".repeat(LOG_MESSAGE_BYTES)), @@ -257,6 +243,41 @@ mod tests { ); } + #[test] + fn ordinary_error_constructor_preserves_narrowing_as_truncation() { + let error = LogRecord::new( + Severity::Error, + LogFlags::NONE, + LogText::from_message(&"p".repeat(ERROR_PATH_BYTES + 1)), + ); + let DiagnosticPayload::Error(payload) = encode_diagnostic(&error) else { + panic!("error record must use KAPTAN_ERROR"); + }; + assert_eq!(usize::from(payload.path_length), ERROR_PATH_BYTES); + assert_eq!(&payload.path, &[b'p'; ERROR_PATH_BYTES]); + assert_eq!(payload.flags.bits(), LogFlags::TRUNCATED.bits()); + } + + #[test] + fn error_wire_preserves_every_error_severity() { + for severity in [ + Severity::Emergency, + Severity::Alert, + Severity::Critical, + Severity::Error, + ] { + let error = LogRecord::new( + severity, + LogFlags::NONE, + LogText::from_message("flight fault"), + ); + let DiagnosticPayload::Error(payload) = encode_diagnostic(&error) else { + panic!("error severity must use KAPTAN_ERROR"); + }; + assert_eq!(payload.level, severity.wire_value()); + } + } + #[test] fn error_path_remains_decodable_when_only_additional_information_clips() { let error = LogRecord::error( diff --git a/firmware/libs/kaptan-log/Cargo.toml b/firmware/libs/kaptan-log/Cargo.toml index 3de45d8..e8c4a1d 100644 --- a/firmware/libs/kaptan-log/Cargo.toml +++ b/firmware/libs/kaptan-log/Cargo.toml @@ -15,5 +15,6 @@ default = [] facade = ["dep:log"] [dependencies] +kaptan-config-derive = { path = "../kaptan-config-derive" } kaptan-log-derive = { path = "../kaptan-log-derive" } log = { version = "0.4.33", default-features = false, optional = true } diff --git a/firmware/libs/kaptan-log/src/facade.rs b/firmware/libs/kaptan-log/src/facade.rs index ff43f6c..6dfb3bf 100644 --- a/firmware/libs/kaptan-log/src/facade.rs +++ b/firmware/libs/kaptan-log/src/facade.rs @@ -6,15 +6,12 @@ use core::{ cell::UnsafeCell, - sync::atomic::{AtomicBool, AtomicU8, Ordering}, + sync::atomic::{AtomicBool, Ordering}, }; use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError}; -use crate::{ - ErrorAdditionalInformation, ErrorPath, EventHub, LogFilter, LogFlags, LogText, PublishOutcome, - Severity, -}; +use crate::{ErrorAdditionalInformation, ErrorPath, LogFlags, LogRecord, LogText, Severity}; /// Saturating, process-local accounting for the optional global facade. /// @@ -24,8 +21,6 @@ use crate::{ pub struct FacadeHealth { /// Calls rejected before formatting because their context is forbidden. pub forbidden_context: u32, - /// Optional records excluded by the active verbosity cap. - pub filtered: u32, /// Calls that lost the one-attempt ingress ownership race. pub contended: u32, /// Calls dropped because their bounded ingress lane was full. @@ -36,7 +31,6 @@ pub struct FacadeHealth { struct FacadeCounters { forbidden_context: core::sync::atomic::AtomicU32, - filtered: core::sync::atomic::AtomicU32, contended: core::sync::atomic::AtomicU32, full: core::sync::atomic::AtomicU32, truncated: core::sync::atomic::AtomicU32, @@ -46,7 +40,6 @@ impl FacadeCounters { const fn new() -> Self { Self { forbidden_context: core::sync::atomic::AtomicU32::new(0), - filtered: core::sync::atomic::AtomicU32::new(0), contended: core::sync::atomic::AtomicU32::new(0), full: core::sync::atomic::AtomicU32::new(0), truncated: core::sync::atomic::AtomicU32::new(0), @@ -71,7 +64,6 @@ impl FacadeCounters { fn snapshot(&self) -> FacadeHealth { FacadeHealth { forbidden_context: self.forbidden_context.load(Ordering::Relaxed), - filtered: self.filtered.load(Ordering::Relaxed), contended: self.contended.load(Ordering::Relaxed), full: self.full.load(Ordering::Relaxed), truncated: self.truncated.load(Ordering::Relaxed), @@ -146,8 +138,8 @@ impl IngressQueue { } } -/// Fixed global-facade ingress. The normal task/runtime owner drains it into -/// an [`EventHub`] after startup. +/// Fixed global-facade ingress. The normal task/runtime owner takes each +/// transport-neutral record and fans it out to independently filtered sinks. pub struct FacadeIngress { /// One dedicated critical slot keeps error records independent from the /// bounded ordinary FIFO. It is deliberately one slot: producers never @@ -157,7 +149,6 @@ pub struct FacadeIngress { critical_busy: AtomicBool, queue: UnsafeCell>, queue_busy: AtomicBool, - maximum_severity: AtomicU8, counters: FacadeCounters, } @@ -167,29 +158,17 @@ pub struct FacadeIngress { unsafe impl Sync for FacadeIngress {} impl FacadeIngress { - /// Creates empty fixed ingress with the boot-active verbosity cap. - pub const fn new(filter: LogFilter) -> Self { + /// Creates an empty fixed ingress. + pub const fn new() -> Self { Self { critical: UnsafeCell::new(None), critical_busy: AtomicBool::new(false), queue: UnsafeCell::new(IngressQueue::new()), queue_busy: AtomicBool::new(false), - maximum_severity: AtomicU8::new(filter.maximum_severity() as u8), counters: FacadeCounters::new(), } } - /// Updates the runtime-selected maximum non-error verbosity. - pub fn set_filter(&self, filter: LogFilter) { - self.maximum_severity - .store(filter.maximum_severity() as u8, Ordering::Release); - } - - fn allows(&self, severity: Severity) -> bool { - severity <= Severity::Error - || (severity as u8) <= self.maximum_severity.load(Ordering::Acquire) - } - fn push(&self, record: IngressRecord) { if record.severity <= Severity::Error { self.push_critical(record); @@ -222,24 +201,23 @@ impl FacadeIngress { self.counters.snapshot() } - /// Moves at most one preformatted facade record into the central hub. - pub fn drain_one_into( - &self, - hub: &mut EventHub, - ) -> Option { + /// Takes at most one preformatted event for composition-level fan-out. + /// + /// The ingress deliberately applies no sink policy. A caller may submit + /// the returned copy to MAVLink, blackbox, or any later sink without one + /// sink's verbosity selection suppressing another's event. + pub fn take(&self) -> Option { let record = match self.with_critical(Option::take) { Some(Some(record)) => record, Some(None) => self.with_queue(IngressQueue::pop).flatten()?, None => return None, }; Some(match record.body { - IngressBody::Log { message } => { - hub.publish_text(record.severity, record.flags, message) - } + IngressBody::Log { message } => LogRecord::new(record.severity, record.flags, message), IngressBody::Error { path, additional_information, - } => hub.publish_error_text(record.flags, path, additional_information), + } => LogRecord::error(record.flags, path, additional_information), }) } @@ -278,6 +256,12 @@ impl FacadeIngress { } } +impl Default for FacadeIngress { + fn default() -> Self { + Self::new() + } +} + /// Installed standard logger. The context probe must return true for /// ISR/panic/hard-fault/shutdown contexts; those calls are rejected before /// [`Record::args`] is formatted. @@ -296,7 +280,8 @@ impl GlobalLogger { } /// Installs this logger once for the process/image and enables all Rust - /// levels compiled into the image. Kaptan's own filter applies afterward. + /// levels compiled into the image. Each sink applies its policy after it + /// receives a record from the ingress. pub fn install(&'static self) -> Result<(), SetLoggerError> { log::set_logger(self)?; log::set_max_level(LevelFilter::Trace); @@ -305,18 +290,11 @@ impl GlobalLogger { } impl Log for GlobalLogger { - fn enabled(&self, metadata: &Metadata<'_>) -> bool { + fn enabled(&self, _: &Metadata<'_>) -> bool { if (self.forbidden_context)() { FacadeCounters::increment(&self.ingress.counters.forbidden_context); return false; } - let Some(severity) = severity(metadata.level()) else { - return false; - }; - if !self.ingress.allows(severity) { - FacadeCounters::increment(&self.ingress.counters.filtered); - return false; - } true } @@ -325,13 +303,7 @@ impl Log for GlobalLogger { FacadeCounters::increment(&self.ingress.counters.forbidden_context); return; } - let Some(severity) = severity(record.level()) else { - return; - }; - if !self.ingress.allows(severity) { - FacadeCounters::increment(&self.ingress.counters.filtered); - return; - } + let severity = severity(record.level()); let flags = if record.level() == Level::Trace { LogFlags::TRACE } else { @@ -367,12 +339,12 @@ impl Log for GlobalLogger { fn flush(&self) {} } -const fn severity(level: Level) -> Option { +const fn severity(level: Level) -> Severity { match level { - Level::Error => Some(Severity::Error), - Level::Warn => Some(Severity::Warning), - Level::Info => Some(Severity::Info), - Level::Debug | Level::Trace => Some(Severity::Debug), + Level::Error => Severity::Error, + Level::Warn => Severity::Warning, + Level::Info => Severity::Info, + Level::Debug | Level::Trace => Severity::Debug, } } @@ -382,10 +354,10 @@ mod tests { use std::sync::Once; use super::{FacadeIngress, GlobalLogger}; - use crate::{EventHub, LogFilter, RecordChannel, Severity}; + use crate::{RecordChannel, Severity}; use log::{Level, Log, Metadata, Record}; - static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug)); + static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new(); static GLOBAL_LOGGER: GlobalLogger<2> = GlobalLogger::new(&GLOBAL_INGRESS, never_forbidden); static INSTALL_GLOBAL: Once = Once::new(); @@ -399,7 +371,7 @@ mod tests { #[test] fn rejects_forbidden_context_before_formatting() { - static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug)); + static INGRESS: FacadeIngress<2> = FacadeIngress::new(); let logger = GlobalLogger::new(&INGRESS, always_forbidden); let metadata = Metadata::builder() .level(Level::Error) @@ -408,19 +380,31 @@ mod tests { assert!(!logger.enabled(&metadata)); assert_eq!(INGRESS.health().forbidden_context, 1); - static FILTERED: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Info)); - let filtered_logger = GlobalLogger::new(&FILTERED, never_forbidden); + static UNFILTERED: FacadeIngress<2> = FacadeIngress::new(); + let unfiltered_logger = GlobalLogger::new(&UNFILTERED, never_forbidden); let debug = Metadata::builder() .level(Level::Debug) .target("test") .build(); - assert!(!filtered_logger.enabled(&debug)); - assert_eq!(FILTERED.health().filtered, 1); + assert!(unfiltered_logger.enabled(&debug)); + unfiltered_logger.log( + &Record::builder() + .metadata(debug) + .args(format_args!("sink decides whether to keep this")) + .build(), + ); + assert_eq!( + UNFILTERED + .take() + .expect("debug event reaches the unfiltered ingress") + .severity(), + Severity::Debug + ); } #[test] - fn formats_once_then_drains_into_priority_hub() { - static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug)); + fn formats_once_then_exposes_a_transport_neutral_record() { + static INGRESS: FacadeIngress<2> = FacadeIngress::new(); let logger = GlobalLogger::new(&INGRESS, never_forbidden); let record = Record::builder() .metadata( @@ -432,9 +416,7 @@ mod tests { .args(format_args!("link failed")) .build(); logger.log(&record); - let mut hub = EventHub::<1, 1>::new(LogFilter::default()); - assert!(INGRESS.drain_one_into(&mut hub).is_some()); - let pending = hub.peek().unwrap(); + let pending = INGRESS.take().expect("facade event retained"); assert_eq!(pending.error_path().unwrap().as_str(), "test"); assert_eq!( pending.additional_information().unwrap().as_str(), @@ -456,11 +438,10 @@ mod tests { fn standard_log_macros_enqueue_bounded_targeted_text() { INSTALL_GLOBAL.call_once(|| GLOBAL_LOGGER.install().expect("one global test logger")); log::info!("armed configuration loaded"); - let mut hub = EventHub::<1, 2>::new(LogFilter::default()); - assert!(GLOBAL_INGRESS.drain_one_into(&mut hub).is_some()); assert!( - hub.peek() - .unwrap() + GLOBAL_INGRESS + .take() + .expect("macro event retained") .message() .unwrap() .as_str() @@ -470,7 +451,7 @@ mod tests { #[test] fn forbidden_context_skips_argument_formatting() { - static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug)); + static INGRESS: FacadeIngress<2> = FacadeIngress::new(); let logger = GlobalLogger::new(&INGRESS, always_forbidden); logger.log( &Record::builder() @@ -488,7 +469,7 @@ mod tests { #[test] fn bounded_ingress_exposes_local_drop_and_truncation_accounting() { - static INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info)); + static INGRESS: FacadeIngress<1> = FacadeIngress::new(); let logger = GlobalLogger::new(&INGRESS, never_forbidden); logger.log( &Record::builder() @@ -531,7 +512,7 @@ mod tests { #[test] fn full_optional_ingress_retains_an_error_for_the_error_channel() { - static INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info)); + static INGRESS: FacadeIngress<1> = FacadeIngress::new(); let logger = GlobalLogger::new(&INGRESS, never_forbidden); logger.log( &Record::builder() @@ -556,9 +537,9 @@ mod tests { .build(), ); - let mut hub = EventHub::<1, 1>::new(LogFilter::default()); - assert!(INGRESS.drain_one_into(&mut hub).is_some()); - let error = hub.peek().expect("error retained ahead of optional text"); + let error = INGRESS + .take() + .expect("error retained ahead of optional text"); assert_eq!(error.channel(), RecordChannel::Error); assert_eq!(error.error_path().unwrap().as_str(), "test"); assert_eq!(error.additional_information().unwrap().as_str(), "critical"); diff --git a/firmware/libs/kaptan-log/src/filter.rs b/firmware/libs/kaptan-log/src/filter.rs index 0d408be..fa4644a 100644 --- a/firmware/libs/kaptan-log/src/filter.rs +++ b/firmware/libs/kaptan-log/src/filter.rs @@ -2,37 +2,77 @@ use crate::Severity; -/// Immutable policy for best-effort log admission. +/// Immutable policy for one log sink. /// -/// Error/critical/vital records are never filtered by this type; callers use -/// their dedicated delivery lanes for those records. +/// Filtering belongs to a sink rather than to event creation: MAVLink and a +/// future blackbox may therefore select different policies without either +/// sink suppressing the other's events. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct LogFilter { - maximum_severity: Severity, +pub enum LogFilter { + /// The sink receives no log events, including errors. + Disabled, + /// The sink receives this level and every more severe level. + UpTo(Severity), } impl LogFilter { - /// Creates a best-effort verbosity cap. - pub const fn new(maximum_severity: Severity) -> Self { - Self { maximum_severity } + /// Creates a disabled sink policy. + pub const fn disabled() -> Self { + Self::Disabled } - /// The selected MAVLink-compatible severity cap. - /// - /// The value is exact on the wire: in particular, `NOTICE` does not - /// silently permit `INFO` records. - pub const fn maximum_severity(self) -> Severity { - self.maximum_severity + /// Creates a sink policy with one inclusive verbosity cap. + pub const fn up_to(maximum_severity: Severity) -> Self { + Self::UpTo(maximum_severity) } - /// Returns true if a best-effort record should be retained. + /// The selected MAVLink-compatible severity cap, or `None` when disabled. + pub const fn maximum_severity(self) -> Option { + match self { + Self::Disabled => None, + Self::UpTo(maximum_severity) => Some(maximum_severity), + } + } + + /// Returns true if this sink accepts the event. pub const fn allows(self, severity: Severity) -> bool { - (severity as u8) <= (self.maximum_severity as u8) + match self { + Self::Disabled => false, + Self::UpTo(maximum_severity) => severity.wire_value() <= maximum_severity.wire_value(), + } } } -impl Default for LogFilter { - fn default() -> Self { - Self::new(Severity::Info) +#[cfg(test)] +mod tests { + use super::LogFilter; + use crate::Severity; + + #[test] + fn disabled_policy_rejects_every_severity() { + let filter = LogFilter::disabled(); + for severity in [ + Severity::Emergency, + Severity::Alert, + Severity::Critical, + Severity::Error, + Severity::Warning, + Severity::Notice, + Severity::Info, + Severity::Debug, + ] { + assert!(!filter.allows(severity)); + } + assert_eq!(filter.maximum_severity(), None); + } + + #[test] + fn level_policy_is_inclusive_and_rejects_more_verbose_events() { + let filter = LogFilter::up_to(Severity::Info); + assert!(filter.allows(Severity::Emergency)); + assert!(filter.allows(Severity::Error)); + assert!(filter.allows(Severity::Info)); + assert!(!filter.allows(Severity::Debug)); + assert_eq!(filter.maximum_severity(), Some(Severity::Info)); } } diff --git a/firmware/libs/kaptan-log/src/hub.rs b/firmware/libs/kaptan-log/src/hub.rs index cb2516b..94616f3 100644 --- a/firmware/libs/kaptan-log/src/hub.rs +++ b/firmware/libs/kaptan-log/src/hub.rs @@ -4,7 +4,7 @@ use core::fmt; use crate::{ DeliveryClass, ErrorAdditionalInformation, ErrorPath, ErrorReport, LogFilter, LogFlags, - LogRecord, LogText, Severity, StaticError, + LogRecord, LogText, Severity, StaticError, StaticErrorMailbox, }; /// Outcome from one bounded diagnostic admission attempt. @@ -12,9 +12,7 @@ use crate::{ pub enum PublishOutcome { /// The record was retained. Queued, - /// An adjacent equivalent record absorbed this occurrence. - Coalesced, - /// An ordinary log record was excluded by the configured verbosity cap. + /// A record was excluded by this sink's configured policy. Filtered, /// A best-effort record was replaced by newer optional information. ReplacedOldest, @@ -26,37 +24,10 @@ pub enum PublishOutcome { /// record to its lower transport. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TransportAdmission { - /// The lower transport retained the record, so it may leave the hub. + /// The lower transport retained the record, so it may leave the queue. Accepted, /// The lower transport did not accept it yet; retain it for retry. Retry, - /// The lower transport will not accept optional detail. This can only - /// discard a best-effort record; critical records remain queued. - DropBestEffort, -} - -/// Saturating, process-local accounting for bounded diagnostic admission. -/// -/// These counters intentionally are not part of `KAPTAN_LOG` or -/// `KAPTAN_ERROR`: neither wire message is a metrics protocol. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct HubHealth { - /// Optional records rejected by the active verbosity cap. - pub filtered: u32, - /// Adjacent records folded into an existing entry. - pub coalesced: u32, - /// Optional entries replaced because their bounded lane was full. - pub best_effort_replaced: u32, - /// Error entries rejected because their bounded critical lane was full. - pub critical_rejected: u32, - /// Optional entries the lower transport refused permanently. - pub best_effort_transport_dropped: u32, -} - -impl HubHealth { - fn increment(value: &mut u32) { - *value = value.saturating_add(1); - } } struct Lane { @@ -76,15 +47,6 @@ impl Lane { } fn publish(&mut self, record: LogRecord, replace_oldest: bool) -> PublishOutcome { - if self.len != 0 { - let tail = (self.head + self.len - 1) % N; - if let Some(previous) = self.entries[tail].as_mut() - && previous.is_equivalent_to(&record) - { - previous.increment_occurrences(); - return PublishOutcome::Coalesced; - } - } if self.len == N { if replace_oldest { self.entries[self.head] = Some(record); @@ -99,6 +61,10 @@ impl Lane { PublishOutcome::Queued } + const fn is_full(&self) -> bool { + self.len == N + } + fn peek(&self) -> Option { self.entries.get(self.head).copied().flatten() } @@ -114,39 +80,78 @@ impl Lane { } } -/// One bounded priority hub. It owns no global logger, MAVLink state, or DMA. -pub struct EventHub { +/// One bounded priority queue owned by a log sink. +/// +/// It owns no global logger, MAVLink state, DMA, filesystem, or other sink. +pub struct LogSinkQueue { filter: LogFilter, critical: Lane, best_effort: Lane, - health: HubHealth, } -impl EventHub { - /// Creates one hub with its immutable boot-active verbosity cap. +impl LogSinkQueue { + /// Creates one sink queue with its immutable boot-active policy. pub const fn new(filter: LogFilter) -> Self { Self { filter, critical: Lane::new(), best_effort: Lane::new(), - health: HubHealth { - filtered: 0, - coalesced: 0, - best_effort_replaced: 0, - critical_rejected: 0, - best_effort_transport_dropped: 0, - }, } } - /// Immutable best-effort verbosity policy. + /// Immutable sink policy. pub const fn filter(&self) -> LogFilter { self.filter } - /// Process-local bounded-admission accounting. - pub const fn health(&self) -> HubHealth { - self.health + /// Whether an error source may be consumed without losing an enabled + /// critical record. Filtered errors may be consumed intentionally. + pub const fn can_publish_error(&self) -> bool { + !self.filter.allows(Severity::Error) || !self.critical.is_full() + } + + /// Consumes at most one source-local static-error mailbox in round-robin + /// order. An enabled source remains untouched while the critical lane is + /// full; a filtered source is consumed intentionally. + pub fn drain_next_error_mailbox( + &mut self, + mailboxes: [&StaticErrorMailbox; SOURCES], + cursor: &mut u8, + ) -> bool { + assert!( + SOURCES != 0 && SOURCES <= u8::MAX as usize, + "error mailbox fan-in must fit its u8 cursor" + ); + if !self.can_publish_error() { + return false; + } + if usize::from(*cursor) >= SOURCES { + *cursor = 0; + } + + let mut checked = 0; + while checked < SOURCES { + let index = usize::from(*cursor); + *cursor = if index + 1 == SOURCES { 0 } else { *cursor + 1 }; + checked += 1; + + let (error, coalesced) = mailboxes[index].take(); + let Some(error) = error else { + continue; + }; + let flags = if coalesced { + LogFlags::COALESCED + } else { + LogFlags::NONE + }; + let outcome = self.publish_error_static(flags, error); + debug_assert!(matches!( + outcome, + PublishOutcome::Queued | PublishOutcome::Filtered + )); + return true; + } + false } /// Formats and retains one ordinary task/worker diagnostic. Error-level @@ -164,7 +169,7 @@ impl EventHub(&mut self, flags: LogFlags, error: &E) -> PublishOutcome { - self.publish_error_static(flags, error.static_error()) + self.publish_record(LogRecord::from_error(flags, error)) } /// Retains one static mailbox error. This is the ISR-safe counterpart to @@ -174,16 +179,13 @@ impl EventHub PublishOutcome { - self.publish_error_text( - flags, - ErrorPath::from_message(error.path()), - ErrorAdditionalInformation::from_message(error.additional_information()), - ) + self.publish_record(LogRecord::from_static_error(flags, error)) } /// Retains a preformatted error identity and optional detail. Both - /// components stay transport-neutral here; the MAVLink adapter enforces - /// its independent on-wire capacities. + /// transport-neutral components already have the exact common diagnostic + /// envelope capacities, so a sink adapter need not retain or clip them + /// again. pub fn publish_error_text( &mut self, flags: LogFlags, @@ -194,38 +196,29 @@ impl EventHub PublishOutcome { - let delivery = delivery_for(severity); - if delivery == DeliveryClass::BestEffort && !self.filter.allows(severity) { - HubHealth::increment(&mut self.health.filtered); - return PublishOutcome::Filtered; - } self.publish_record(LogRecord::new(severity, flags, text)) } - fn publish_record(&mut self, record: LogRecord) -> PublishOutcome { + /// Applies this sink's policy and retains one already-constructed event. + /// + /// Composition may submit the same transport-neutral record to multiple + /// sink queues without coupling event creation to any sink policy. + pub fn publish_record(&mut self, record: LogRecord) -> PublishOutcome { + if !self.filter.allows(record.severity()) { + return PublishOutcome::Filtered; + } let delivery = record.delivery(); - let outcome = match delivery { + match delivery { DeliveryClass::Critical => self.critical.publish(record, false), DeliveryClass::BestEffort => self.best_effort.publish(record, true), - }; - match outcome { - PublishOutcome::Coalesced => HubHealth::increment(&mut self.health.coalesced), - PublishOutcome::ReplacedOldest => { - HubHealth::increment(&mut self.health.best_effort_replaced); - } - PublishOutcome::RejectedFull => { - HubHealth::increment(&mut self.health.critical_rejected); - } - PublishOutcome::Queued | PublishOutcome::Filtered => {} } - outcome } /// Highest-priority record without relinquishing it. @@ -233,9 +226,9 @@ impl EventHub( &mut self, submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R), @@ -246,13 +239,7 @@ impl EventHub { let _ = self.pop(); } - TransportAdmission::DropBestEffort - if record.delivery() == DeliveryClass::BestEffort => - { - let _ = self.best_effort.pop(); - HubHealth::increment(&mut self.health.best_effort_transport_dropped); - } - TransportAdmission::Retry | TransportAdmission::DropBestEffort => {} + TransportAdmission::Retry => {} } Some(result) } @@ -262,27 +249,30 @@ impl EventHub DeliveryClass { - if severity <= Severity::Error { - DeliveryClass::Critical - } else { - DeliveryClass::BestEffort - } -} - #[cfg(test)] mod tests { - use super::{EventHub, PublishOutcome, TransportAdmission}; + use super::{LogSinkQueue, PublishOutcome, TransportAdmission}; use crate::{ - DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogText, - RecordChannel, Severity, StaticError, + DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogRecord, + LogText, RecordChannel, Severity, StaticError, }; #[test] - fn errors_bypass_filter_but_debug_does_not() { - let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info)); + fn sink_policy_applies_to_errors_and_ordinary_logs() { + let mut disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled()); assert_eq!( - hub.publish_text( + disabled.publish_text( + Severity::Error, + LogFlags::NONE, + LogText::from_message("error") + ), + PublishOutcome::Filtered + ); + assert!(disabled.peek().is_none()); + + let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info)); + assert_eq!( + queue.publish_text( Severity::Debug, LogFlags::NONE, LogText::from_message("debug") @@ -290,43 +280,110 @@ mod tests { PublishOutcome::Filtered ); assert_eq!( - hub.publish_text( + queue.publish_text( Severity::Error, LogFlags::NONE, LogText::from_message("error") ), PublishOutcome::Queued ); - let record = hub.peek().unwrap(); + let record = queue.peek().unwrap(); assert_eq!(record.error_path().unwrap().as_str(), "error"); assert!(record.additional_information().unwrap().is_empty()); assert_eq!(record.channel(), RecordChannel::Error); } #[test] - fn static_messages_and_repeats_are_bounded() { - static FAULT: StaticError = StaticError::new("driver::Fault"); - let mut hub = EventHub::<1, 1>::new(LogFilter::default()); + fn full_critical_lane_backpressures_only_an_enabled_error_source() { + let mut enabled = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info)); + assert!(enabled.can_publish_error()); assert_eq!( - hub.publish_error_static(LogFlags::NONE, &FAULT), + enabled.publish_text( + Severity::Error, + LogFlags::NONE, + LogText::from_message("pending") + ), + PublishOutcome::Queued + ); + assert!(!enabled.can_publish_error()); + + let disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled()); + assert!(disabled.can_publish_error()); + } + + #[test] + fn mailbox_fan_in_survives_backpressure_and_is_round_robin() { + static FIRST: StaticError = StaticError::new("first"); + static SECOND: StaticError = StaticError::new("second"); + static THIRD: StaticError = StaticError::new("third"); + let first = crate::StaticErrorMailbox::new(); + let second = crate::StaticErrorMailbox::new(); + let third = crate::StaticErrorMailbox::new(); + let mailboxes = [&first, &second, &third]; + first.record(&FIRST); + second.record(&SECOND); + third.record(&THIRD); + + let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info)); + let mut cursor = 0; + assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor)); + assert_eq!( + queue.peek().unwrap().error_path().unwrap().as_str(), + "first" + ); + + first.record(&FIRST); + assert!(!queue.drain_next_error_mailbox(mailboxes, &mut cursor)); + assert_eq!( + queue.peek().unwrap().error_path().unwrap().as_str(), + "first" + ); + + let mut expect = |path| { + assert_eq!(queue.peek().unwrap().error_path().unwrap().as_str(), path); + assert_eq!( + queue.submit_next(|_| (TransportAdmission::Accepted, ())), + Some(()) + ); + assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor)); + }; + expect("first"); + expect("second"); + expect("third"); + assert_eq!( + queue.peek().unwrap().error_path().unwrap().as_str(), + "first" + ); + } + + #[test] + fn identical_events_remain_distinct_records() { + static FAULT: StaticError = StaticError::new("driver::Fault"); + let mut queue = LogSinkQueue::<2, 1>::new(LogFilter::up_to(Severity::Debug)); + assert_eq!( + queue.publish_error_static(LogFlags::NONE, &FAULT), PublishOutcome::Queued ); assert_eq!( - hub.publish_error_static(LogFlags::NONE, &FAULT), - PublishOutcome::Coalesced + queue.publish_error_static(LogFlags::NONE, &FAULT), + PublishOutcome::Queued ); - let record = hub.peek().unwrap(); - assert_eq!(record.occurrences, 2); - assert!(record.flags().contains(LogFlags::COALESCED)); + let record = queue.peek().unwrap(); + assert!(!record.flags().contains(LogFlags::COALESCED)); assert_eq!(record.error_path().unwrap().as_str(), "driver::Fault"); assert!(record.additional_information().unwrap().is_empty()); + assert_eq!( + queue.submit_next(|_| (TransportAdmission::Accepted, ())), + Some(()) + ); + assert_eq!(queue.peek(), Some(record)); } #[test] fn static_error_detail_needs_no_producer_formatting() { static FAULT: StaticError = StaticError::with_additional_information("driver::Dma::Fault", "stream 7"); - let mut hub = EventHub::<1, 1>::new(LogFilter::default()); + let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug)); assert_eq!( hub.publish_error_static(LogFlags::NONE, &FAULT), PublishOutcome::Queued @@ -341,7 +398,7 @@ mod tests { #[test] fn error_identity_and_detail_are_independent_record_components() { - let mut hub = EventHub::<1, 1>::new(LogFilter::default()); + let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug)); let _ = hub.publish_error_text( LogFlags::NONE, ErrorPath::from_message("driver::Icm42688::Transfer"), @@ -360,15 +417,15 @@ mod tests { } #[test] - fn retry_retains_critical_and_only_best_effort_can_be_discarded() { - let mut hub = EventHub::<1, 1>::new(LogFilter::default()); + fn retry_retains_every_severity() { + let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug)); let _ = hub.publish_text( Severity::Error, LogFlags::NONE, LogText::from_message("fault"), ); assert_eq!( - hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())), + hub.submit_next(|_| (TransportAdmission::Retry, ())), Some(()) ); assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::Critical); @@ -384,50 +441,30 @@ mod tests { LogText::from_message("detail"), ); assert_eq!( - hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())), + hub.submit_next(|_| (TransportAdmission::Retry, ())), + Some(()) + ); + assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::BestEffort); + assert_eq!( + hub.submit_next(|_| (TransportAdmission::Accepted, ())), Some(()) ); assert!(hub.peek().is_none()); - assert_eq!(hub.health().best_effort_transport_dropped, 1); } #[test] - fn admission_losses_are_retained_as_local_saturating_accounting() { - let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info)); - let _ = hub.publish_text( - Severity::Debug, - LogFlags::NONE, - LogText::from_message("filtered"), - ); - let _ = hub.publish_text( + fn independent_sink_policies_do_not_suppress_each_other() { + let record = LogRecord::new( Severity::Info, LogFlags::NONE, - LogText::from_message("first optional"), - ); - let _ = hub.publish_text( - Severity::Info, - LogFlags::NONE, - LogText::from_message("newer optional"), - ); - let _ = hub.publish_text( - Severity::Error, - LogFlags::NONE, - LogText::from_message("first error"), - ); - let _ = hub.publish_text( - Severity::Error, - LogFlags::NONE, - LogText::from_message("second error"), - ); - assert_eq!( - hub.health(), - super::HubHealth { - filtered: 1, - coalesced: 0, - best_effort_replaced: 1, - critical_rejected: 1, - best_effort_transport_dropped: 0, - } + LogText::from_message("flight ready"), ); + let mut mavlink = LogSinkQueue::<1, 1>::new(LogFilter::disabled()); + let mut blackbox = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug)); + + assert_eq!(mavlink.publish_record(record), PublishOutcome::Filtered); + assert_eq!(blackbox.publish_record(record), PublishOutcome::Queued); + assert!(mavlink.peek().is_none()); + assert_eq!(blackbox.peek(), Some(record)); } } diff --git a/firmware/libs/kaptan-log/src/level.rs b/firmware/libs/kaptan-log/src/level.rs index 1d30547..1548012 100644 --- a/firmware/libs/kaptan-log/src/level.rs +++ b/firmware/libs/kaptan-log/src/level.rs @@ -2,7 +2,7 @@ /// MAVLink-compatible severity. Numeric values deliberately match /// `MAV_SEVERITY`. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, kaptan_config_derive::ConfigEnum)] #[repr(u8)] pub enum Severity { /// System is unusable. @@ -23,19 +23,14 @@ pub enum Severity { Debug = 7, } -impl Severity { - /// Converts the stable MAVLink numeric representation. - pub const fn try_from_wire(value: u8) -> Option { - match value { - 0 => Some(Self::Emergency), - 1 => Some(Self::Alert), - 2 => Some(Self::Critical), - 3 => Some(Self::Error), - 4 => Some(Self::Warning), - 5 => Some(Self::Notice), - 6 => Some(Self::Info), - 7 => Some(Self::Debug), - _ => None, - } +#[cfg(test)] +mod tests { + use core::mem::size_of; + + use super::Severity; + + #[test] + fn severity_is_one_byte() { + assert_eq!(size_of::(), 1); } } diff --git a/firmware/libs/kaptan-log/src/lib.rs b/firmware/libs/kaptan-log/src/lib.rs index 0fc7414..6c1bab4 100644 --- a/firmware/libs/kaptan-log/src/lib.rs +++ b/firmware/libs/kaptan-log/src/lib.rs @@ -1,8 +1,9 @@ //! Bounded, transport-neutral runtime diagnostics for Kaptan. //! //! This crate deliberately contains no MAVLink, board, filesystem, clock, or -//! configuration dependency. Flight code reports bounded text records to a -//! hub; a platform-owned adapter may later encode them for a link. +//! configuration dependency. Flight code reports bounded events to +//! independently filtered sink queues; a platform-owned adapter may later +//! encode one queue for a link. //! Reporting is observational and must never influence flight control or //! actuator authority. @@ -15,8 +16,8 @@ extern crate std; pub mod crash; #[cfg(feature = "facade")] pub mod facade; -pub mod filter; -pub mod hub; +mod filter; +mod hub; pub mod level; pub mod mailbox; pub mod report; @@ -25,13 +26,16 @@ pub mod text; pub use crash::{CrashKind, CrashRecord, CrashRecordError}; pub use filter::LogFilter; -pub use hub::{EventHub, HubHealth, PublishOutcome, TransportAdmission}; +pub use hub::{LogSinkQueue, PublishOutcome, TransportAdmission}; pub use kaptan_log_derive::KaptanError; pub use level::Severity; pub use mailbox::StaticErrorMailbox; pub use report::{DeliveryClass, ErrorReport, LogFlags, LogRecord, RecordChannel, StaticError}; pub use shutdown::{ShutdownReason, ShutdownReport}; -pub use text::{ErrorAdditionalInformation, ErrorPath, LogText, MAXIMUM_LOG_TEXT_BYTES}; +pub use text::{ + ErrorAdditionalInformation, ErrorPath, LogText, MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES, + MAXIMUM_ERROR_PATH_BYTES, MAXIMUM_LOG_TEXT_BYTES, +}; #[cfg(feature = "facade")] pub use facade::FacadeHealth; diff --git a/firmware/libs/kaptan-log/src/mailbox.rs b/firmware/libs/kaptan-log/src/mailbox.rs index c1f04a7..5008edf 100644 --- a/firmware/libs/kaptan-log/src/mailbox.rs +++ b/firmware/libs/kaptan-log/src/mailbox.rs @@ -2,74 +2,71 @@ use core::{ ptr, - sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering}, + sync::atomic::{AtomicPtr, Ordering}, }; use crate::StaticError; -/// One source-local, single-attempt static-fault mailbox. +/// One source-local static-fault mailbox with fixed bounded atomic attempts. /// /// Producer contexts retain the first pending message and mark later ones as /// coalesced. They never loop, format, allocate, lock, or encode a frame. A /// worker drains the exact message pointer and flag later. pub struct StaticErrorMailbox { - error: AtomicPtr, - coalesced: AtomicBool, - coalesced_total: AtomicU32, + /// Pending pointer with its low alignment bit used as the coalesced flag. + state: AtomicPtr, } +const COALESCED_TAG: usize = 1; +const _: () = assert!(core::mem::align_of::() > COALESCED_TAG); + impl StaticErrorMailbox { /// Creates an empty mailbox. pub const fn new() -> Self { Self { - error: AtomicPtr::new(ptr::null_mut()), - coalesced: AtomicBool::new(false), - coalesced_total: AtomicU32::new(0), + state: AtomicPtr::new(ptr::null_mut()), } } - /// Records one static message with one bounded atomic attempt. + /// Records one static message with at most two fixed atomic attempts: one + /// to retain it, then one to tag an already pending observation. pub fn record(&self, error: &'static StaticError) { - if self - .error - .compare_exchange( - ptr::null_mut(), - core::ptr::from_ref(error).cast_mut(), - Ordering::AcqRel, - Ordering::Acquire, - ) - .is_err() + let error = core::ptr::from_ref(error).cast_mut(); + if let Err(pending) = + self.state + .compare_exchange(ptr::null_mut(), error, Ordering::AcqRel, Ordering::Acquire) { - self.coalesced.store(true, Ordering::Relaxed); - increment_saturating(&self.coalesced_total); + self.mark_coalesced(pending); } } + /// Marks only the exact pending pointer observed by `record`. If a worker + /// drained it concurrently, the single CAS fails without leaving a flag + /// that could attach to a later error. + fn mark_coalesced(&self, pending: *mut StaticError) { + if pending.addr() & COALESCED_TAG != 0 { + return; + } + let tagged = pending.map_addr(|address| address | COALESCED_TAG); + let _ = self + .state + .compare_exchange(pending, tagged, Ordering::AcqRel, Ordering::Acquire); + } + /// Removes the retained first message and its coalesced marker. pub fn take(&self) -> (Option<&'static StaticError>, bool) { - let error = self.error.swap(ptr::null_mut(), Ordering::AcqRel); - let coalesced = self.coalesced.swap(false, Ordering::AcqRel); + let state = self.state.swap(ptr::null_mut(), Ordering::AcqRel); + if state.is_null() { + return (None, false); + } + let coalesced = state.addr() & COALESCED_TAG != 0; + let error = state.map_addr(|address| address & !COALESCED_TAG); // SAFETY: `record` stores only pointers to static messages and the - // atomic swap transfers the exact pointer to this one worker. + // atomic swap transfers the exact pointer to this one worker. The low + // tag bit is cleared before dereferencing the aligned pointer. let error = unsafe { error.as_ref() }; (error, coalesced) } - - /// Total later-fault coalesces since startup. This is local producer - /// accounting; wire diagnostics remain human-facing envelopes. - pub fn coalesced_total(&self) -> u32 { - self.coalesced_total.load(Ordering::Relaxed) - } -} - -/// One bounded atomic increment: diagnostics may undercount a simultaneous -/// producer collision but never loop, block, or wrap back to zero. -fn increment_saturating(counter: &AtomicU32) { - let current = counter.load(Ordering::Relaxed); - if current != u32::MAX { - let _ = - counter.compare_exchange(current, current + 1, Ordering::Relaxed, Ordering::Relaxed); - } } impl Default for StaticErrorMailbox { @@ -80,6 +77,8 @@ impl Default for StaticErrorMailbox { #[cfg(test)] mod tests { + use core::sync::atomic::Ordering; + use super::StaticErrorMailbox; use crate::StaticError; @@ -91,7 +90,25 @@ mod tests { mailbox.record(&FIRST); mailbox.record(&SECOND); assert_eq!(mailbox.take(), (Some(&FIRST), true)); - assert_eq!(mailbox.coalesced_total(), 1); + assert_eq!(mailbox.take(), (None, false)); + } + + #[test] + fn a_late_coalesced_mark_cannot_attach_to_the_next_error() { + static FIRST: StaticError = StaticError::new("first"); + static NEXT: StaticError = StaticError::new("next"); + let mailbox = StaticErrorMailbox::new(); + mailbox.record(&FIRST); + + // Reproduce the losing producer's observed pointer, then let the + // worker drain it and a different error arrive before that producer's + // bounded marking CAS. + let observed = mailbox.state.load(Ordering::Acquire); + assert_eq!(mailbox.take(), (Some(&FIRST), false)); + mailbox.record(&NEXT); + mailbox.mark_coalesced(observed); + + assert_eq!(mailbox.take(), (Some(&NEXT), false)); assert_eq!(mailbox.take(), (None, false)); } } diff --git a/firmware/libs/kaptan-log/src/report.rs b/firmware/libs/kaptan-log/src/report.rs index c37aa9d..af19f0c 100644 --- a/firmware/libs/kaptan-log/src/report.rs +++ b/firmware/libs/kaptan-log/src/report.rs @@ -61,7 +61,7 @@ impl LogFlags { pub const TRACE: Self = Self(1 << 0); /// Text did not fit the fixed record buffer. pub const TRUNCATED: Self = Self(1 << 1); - /// Adjacent equivalent records were coalesced. + /// Multiple ISR producer observations were folded into one mailbox event. pub const COALESCED: Self = Self(1 << 2); /// Raw MAVLink bitmask. @@ -69,6 +69,11 @@ impl LogFlags { self.0 } + /// Bits valid for an ordinary `KAPTAN_LOG` record. + pub const fn log_bits(self) -> u8 { + self.0 & (Self::TRACE.0 | Self::TRUNCATED.0) + } + /// Bits valid for an error-only record. Trace is a property of ordinary /// `KAPTAN_LOG` diagnostics and is intentionally never emitted on /// `KAPTAN_ERROR`. @@ -122,8 +127,6 @@ pub struct LogRecord { severity: Severity, flags: LogFlags, body: RecordBody, - /// Adjacent equivalent diagnostics retained as one record. - pub occurrences: u32, } impl LogRecord { @@ -144,7 +147,6 @@ impl LogRecord { severity, flags: with_truncation(flags, message), body: RecordBody::Log { message }, - occurrences: 1, } } @@ -158,6 +160,20 @@ impl LogRecord { Self::error_with_severity(Severity::Error, flags, path, additional_information) } + /// Creates one record from a typed derived error without choosing a sink. + pub fn from_error(flags: LogFlags, error: &E) -> Self { + Self::from_static_error(flags, error.static_error()) + } + + /// Creates one record from an ISR-safe static error without choosing a sink. + pub fn from_static_error(flags: LogFlags, error: &'static StaticError) -> Self { + Self::error( + flags, + ErrorPath::from_message(error.path()), + ErrorAdditionalInformation::from_message(error.additional_information()), + ) + } + fn error_with_severity( severity: Severity, flags: LogFlags, @@ -172,7 +188,6 @@ impl LogRecord { path, additional_information, }, - occurrences: 1, } } @@ -228,17 +243,6 @@ impl LogRecord { RecordBody::Error { .. } => DeliveryClass::Critical, } } - - /// Tests whether two pending records can coalesce without hiding a new - /// payload, severity, or delivery class. - pub fn is_equivalent_to(&self, other: &Self) -> bool { - self.severity == other.severity && self.flags == other.flags && self.body == other.body - } - - pub(crate) fn increment_occurrences(&mut self) { - self.occurrences = self.occurrences.saturating_add(1); - self.flags = self.flags.with(LogFlags::COALESCED); - } } fn with_truncation(mut flags: LogFlags, value: LogText) -> LogFlags { diff --git a/firmware/libs/kaptan-log/src/shutdown.rs b/firmware/libs/kaptan-log/src/shutdown.rs index 2ca0bfb..7e04643 100644 --- a/firmware/libs/kaptan-log/src/shutdown.rs +++ b/firmware/libs/kaptan-log/src/shutdown.rs @@ -4,8 +4,8 @@ #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(u8)] pub enum ShutdownReason { - /// An operator initiated the controlled stop. - Operator = 1, + /// A user initiated the controlled stop. + User = 1, /// Safety initiated the controlled stop. Safety = 2, } diff --git a/firmware/libs/kaptan-log/src/text.rs b/firmware/libs/kaptan-log/src/text.rs index ee5c4ee..d26ab16 100644 --- a/firmware/libs/kaptan-log/src/text.rs +++ b/firmware/libs/kaptan-log/src/text.rs @@ -2,29 +2,35 @@ use core::fmt::{self, Write}; -/// Maximum bytes retained in one transport-neutral diagnostic text component. +/// Maximum bytes retained in one ordinary log message. pub const MAXIMUM_LOG_TEXT_BYTES: usize = 160; +/// Maximum bytes retained in one error identity path. +pub const MAXIMUM_ERROR_PATH_BYTES: usize = 96; +/// Maximum bytes retained in one error additional-information value. +pub const MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES: usize = 64; /// Fixed UTF-8 diagnostic text with an explicit truncation marker. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct LogText { - bytes: [u8; MAXIMUM_LOG_TEXT_BYTES], +pub struct BoundedText { + bytes: [u8; N], length: u8, truncated: bool, } -/// Transport-neutral error identity path. The MAVLink adapter applies its -/// separate 96-byte wire bound. -pub type ErrorPath = LogText; -/// Transport-neutral optional error detail. The MAVLink adapter applies its -/// separate 64-byte wire bound. -pub type ErrorAdditionalInformation = LogText; +/// Transport-neutral ordinary log message. +pub type LogText = BoundedText; +/// Transport-neutral error identity path. +pub type ErrorPath = BoundedText; +/// Transport-neutral optional error detail. +pub type ErrorAdditionalInformation = BoundedText; -impl LogText { +impl BoundedText { /// Creates empty fixed text storage. pub const fn new() -> Self { + assert!(N > 0, "diagnostic text storage must be nonempty"); + assert!(N <= u8::MAX as usize, "diagnostic text length must fit u8"); Self { - bytes: [0; MAXIMUM_LOG_TEXT_BYTES], + bytes: [0; N], length: 0, truncated: false, } @@ -46,10 +52,15 @@ impl LogText { } /// Raw bounded wire bytes. - pub const fn bytes(&self) -> &[u8; MAXIMUM_LOG_TEXT_BYTES] { + pub const fn bytes(&self) -> &[u8; N] { &self.bytes } + /// Maximum number of UTF-8 bytes retained by this text type. + pub const fn capacity(&self) -> usize { + N + } + /// Number of meaningful UTF-8 bytes. pub const fn len(&self) -> u8 { self.length @@ -73,16 +84,16 @@ impl LogText { } } -impl Default for LogText { +impl Default for BoundedText { fn default() -> Self { Self::new() } } -impl fmt::Write for LogText { +impl fmt::Write for BoundedText { fn write_str(&mut self, message: &str) -> fmt::Result { let used = usize::from(self.length); - let remaining = MAXIMUM_LOG_TEXT_BYTES - used; + let remaining = N - used; if message.len() <= remaining { self.bytes[used..used + message.len()].copy_from_slice(message.as_bytes()); self.length = (used + message.len()) as u8; @@ -104,7 +115,10 @@ impl fmt::Write for LogText { #[cfg(test)] mod tests { - use super::{LogText, MAXIMUM_LOG_TEXT_BYTES}; + use super::{ + ErrorAdditionalInformation, ErrorPath, LogText, MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES, + MAXIMUM_ERROR_PATH_BYTES, MAXIMUM_LOG_TEXT_BYTES, + }; #[test] fn truncates_only_at_utf8_boundaries() { @@ -114,4 +128,40 @@ mod tests { assert_eq!(text.as_str().len(), MAXIMUM_LOG_TEXT_BYTES - 1); assert!(core::str::from_utf8(&text.bytes()[..usize::from(text.len())]).is_ok()); } + + #[test] + fn each_semantic_component_owns_only_its_exact_capacity() { + assert_eq!(LogText::new().capacity(), MAXIMUM_LOG_TEXT_BYTES); + assert_eq!(ErrorPath::new().capacity(), MAXIMUM_ERROR_PATH_BYTES); + assert_eq!( + ErrorAdditionalInformation::new().capacity(), + MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES + ); + + assert_eq!(core::mem::size_of::(), MAXIMUM_LOG_TEXT_BYTES + 2); + assert_eq!( + core::mem::size_of::(), + MAXIMUM_ERROR_PATH_BYTES + 2 + ); + assert_eq!( + core::mem::size_of::(), + MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES + 2 + ); + } + + #[test] + fn error_components_truncate_at_their_own_utf8_boundaries() { + let path = ErrorPath::from_message(&("p".repeat(MAXIMUM_ERROR_PATH_BYTES - 1) + "é")); + assert!(path.is_truncated()); + assert_eq!(path.as_str().len(), MAXIMUM_ERROR_PATH_BYTES - 1); + + let information = ErrorAdditionalInformation::from_message( + &("i".repeat(MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES - 1) + "é"), + ); + assert!(information.is_truncated()); + assert_eq!( + information.as_str().len(), + MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES - 1 + ); + } } diff --git a/firmware/libs/kaptan-mavlink/definitions/kaptan/config.xml b/firmware/libs/kaptan-mavlink/definitions/kaptan/config.xml index 71bb8e8..2aa81c0 100644 --- a/firmware/libs/kaptan-mavlink/definitions/kaptan/config.xml +++ b/firmware/libs/kaptan-mavlink/definitions/kaptan/config.xml @@ -13,11 +13,6 @@ - - - - - @@ -35,7 +30,6 @@ - @@ -45,9 +39,9 @@ - - - + + The target configuration lease or transaction slot belongs to a client. + @@ -58,30 +52,22 @@ - - - - - - + + - Correlated configuration protocol and active snapshot metadata. - - + Correlated configuration protocol and schema metadata. - - Requests correlated configuration protocol and schema metadata. @@ -90,10 +76,9 @@ - Requests a bounded page of field descriptors. + Requests one field descriptor by stable schema index. - - + @@ -105,11 +90,28 @@ + Number of named enum choices available for indexed retrieval. - - + Bit 0 marks minimum_bits present; bit 1 marks maximum_bits present. Other bits are reserved. + + + One named choice for the explicitly identified enum-backed descriptor. + + + + + + + + + Requests one named choice by descriptor and option index. + + + + + Reads one active, committed, or transaction value. @@ -131,7 +133,6 @@ Opens the one lease-bound edit transaction. - @@ -186,7 +187,6 @@ Validates then atomically persists the open transaction. - @@ -202,10 +202,8 @@ Authoritative typed completion for a configuration request. - - @@ -235,36 +233,5 @@ - - Starts a fixed-bounded CBOR import inside an open transaction. - - - - - - - - - - - One aligned, idempotent 192-byte-or-final CBOR import chunk. Chunks may arrive out of order. - - - - - - - - - - - Validates a complete received CBOR import into the transaction. - - - - - - - diff --git a/firmware/libs/kaptan-mavlink/definitions/kaptan/error.xml b/firmware/libs/kaptan-mavlink/definitions/kaptan/error.xml index c7e4c71..8a8ae59 100644 --- a/firmware/libs/kaptan-mavlink/definitions/kaptan/error.xml +++ b/firmware/libs/kaptan-mavlink/definitions/kaptan/error.xml @@ -9,13 +9,14 @@ The path or additional-information UTF-8 prefix was truncated to its bounded on-wire field. - Error represents repeated adjacent observations with the same path and additional information. + One or more later observations from the same source mailbox were folded while this first error was pending. Bounded derived or explicit runtime error. This is distinct from ordinary KAPTAN_LOG records. The display identity is generated from the Rust error type and variant when a KaptanError-derived value is available; structured configuration failures remain authoritative in KAPTAN_CONFIG_RESULT. + MAV_SEVERITY-compatible error level from Emergency=0 through Error=3. Known bits are TRUNCATED=2 and COALESCED=4. Receivers ignore unknown future bits. Number of valid UTF-8 bytes in path; no more than 96. Number of valid UTF-8 bytes in additional_information; no more than 64. diff --git a/firmware/libs/kaptan-mavlink/definitions/kaptan/log.xml b/firmware/libs/kaptan-mavlink/definitions/kaptan/log.xml index 70da5d9..1b84394 100644 --- a/firmware/libs/kaptan-mavlink/definitions/kaptan/log.xml +++ b/firmware/libs/kaptan-mavlink/definitions/kaptan/log.xml @@ -11,16 +11,13 @@ Message text was truncated to the bounded on-wire buffer. - - Message represents repeated adjacent observations. - - Bounded human-readable runtime log. The receiver needs only level, flags, and the valid UTF-8 text prefix. MAVLink framing, bounded admission, DMA, retries, and local loss accounting remain platform-owned. + Bounded human-readable runtime log. The receiver needs only level, flags, and the valid UTF-8 text prefix. MAVLink framing, bounded admission, DMA, and retries remain platform-owned. MAV_SEVERITY-compatible level. Rust TRACE uses DEBUG plus the TRACE flag. - Known bits are TRACE=1, TRUNCATED=2, and COALESCED=4. Receivers ignore unknown future bits. + Known bits are TRACE=1 and TRUNCATED=2. Receivers ignore unknown future bits. Number of valid UTF-8 bytes in message; at most 160. Bounded UTF-8 message bytes. Bytes after message_length are zero. diff --git a/firmware/libs/kaptan-mavlink/definitions/kaptan/shutdown.xml b/firmware/libs/kaptan-mavlink/definitions/kaptan/shutdown.xml index a269f90..a625418 100644 --- a/firmware/libs/kaptan-mavlink/definitions/kaptan/shutdown.xml +++ b/firmware/libs/kaptan-mavlink/definitions/kaptan/shutdown.xml @@ -5,8 +5,8 @@ - - An operator initiated the completed controlled firmware shutdown. + + A user initiated the completed controlled firmware shutdown. The safety owner initiated the completed controlled firmware shutdown. diff --git a/firmware/libs/kaptan-mavlink/src/encoder.rs b/firmware/libs/kaptan-mavlink/src/encoder.rs index d111ab4..9b91d17 100644 --- a/firmware/libs/kaptan-mavlink/src/encoder.rs +++ b/firmware/libs/kaptan-mavlink/src/encoder.rs @@ -81,15 +81,6 @@ impl MavlinkEncoder { self.serialize(&heartbeat) } - /// Acknowledges a supported MAVLink command. - pub fn command_ack( - &mut self, - command: message::MavCmd, - result: message::MavResult, - ) -> MavlinkFrame { - self.serialize(&message::COMMAND_ACK_DATA { command, result }) - } - /// Encodes a standard `RAW_IMU` frame. #[cfg(feature = "sensor-telemetry")] pub fn raw_imu_snapshot(&mut self, snapshot: &ImuSnapshot) -> MavlinkFrame { diff --git a/firmware/libs/kaptan-mavlink/src/lib.rs b/firmware/libs/kaptan-mavlink/src/lib.rs index 77c0319..e81e10c 100644 --- a/firmware/libs/kaptan-mavlink/src/lib.rs +++ b/firmware/libs/kaptan-mavlink/src/lib.rs @@ -13,6 +13,8 @@ pub mod transmitter; /// An owned MAVLink 2 frame suitable for UART transmission. pub type MavlinkFrame = message::MAVLinkV2MessageRaw; +/// MAVLink framing version used when parsing an integrity-checked payload. +pub use mavlink_core::MavlinkVersion; /// Supported MAVLink message types for Kaptan. pub mod message { diff --git a/firmware/libs/kaptan-mavlink/src/receiver.rs b/firmware/libs/kaptan-mavlink/src/receiver.rs index 4f77402..eb2082b 100644 --- a/firmware/libs/kaptan-mavlink/src/receiver.rs +++ b/firmware/libs/kaptan-mavlink/src/receiver.rs @@ -2,58 +2,28 @@ use crate::{MavlinkFrame, message}; -/// Bounded MAVLink 2 receiver for a board's serial-byte interrupt. -/// -/// Boards pass UART bytes to [`Self::receive_byte`] and take complete frames -/// from [`Self::take_received`]. Production has no signing verifier or replay -/// state, so it never exposes an inbound MAVLink frame as an authority record. -pub struct MavlinkReceiver { +struct IntegrityFramer { frame: MavlinkFrame, received: usize, expected: usize, - frames: [Option; N], - front: usize, - length: usize, } -impl Default for MavlinkReceiver { - fn default() -> Self { - Self::new() - } -} - -impl MavlinkReceiver { - /// Creates a production receiver with space for `N` complete frames. - /// - /// TODO(signing): accept a frame only after verified MAVLink-v2 signing, - /// configured sender/target binding, persistent anti-replay state, and - /// provisioning/rotation policy exist together. A V2 CRC only detects - /// corruption; it is not authentication. - pub const fn new() -> Self { +impl IntegrityFramer { + const fn new() -> Self { Self { frame: MavlinkFrame::new(), received: 0, expected: 0, - frames: [None; N], - front: 0, - length: 0, } } - /// Receives one UART byte and returns whether it queued a complete frame. - /// - /// Bytes outside MAVLink 2 frames and frames with invalid CRCs are - /// discarded. Signed frames are also discarded because this receiver has - /// no verifier or replay state. Until the TODO(signing) work is complete, - /// unsigned frames are deliberately not promoted to received records - /// either. - pub fn receive_byte(&mut self, byte: u8) -> bool { + fn receive_byte(&mut self, byte: u8) -> Option { if self.received == 0 { if byte == 0xfd { self.frame.as_mut_slice()[0] = byte; self.received = 1; } - return false; + return None; } self.frame.as_mut_slice()[self.received] = byte; @@ -61,43 +31,80 @@ impl MavlinkReceiver { if self.received == 10 { let header = self.frame.as_slice(); - // MAVLink's bit 0 means the packet carries a signature. Reject all - // nonzero incompatibility flags until a reviewed signature verifier - // and persistent replay policy exist. + // MAVLink incompatibility bit zero adds a signature. This framer + // has neither a signing verifier nor persistent replay state, so + // every nonzero incompatibility flag is rejected at the header. if header[2] != 0 { self.reset(); - return false; + return None; } self.expected = 1 + 9 + usize::from(header[1]) + 2; } - if self.expected != 0 && self.received == self.expected { - let frame = core::mem::replace(&mut self.frame, MavlinkFrame::new()); - self.received = 0; - self.expected = 0; - // The production receiver still drops every inbound frame until - // signing/replay verification exists. Validate either known - // dialect here so a future verifier receives no CRC-corrupt - // custom configuration message. - if !has_valid_known_crc(&frame) { - return false; - } + if self.expected == 0 || self.received != self.expected { + return None; } - false + let frame = core::mem::replace(&mut self.frame, MavlinkFrame::new()); + self.received = 0; + self.expected = 0; + has_valid_known_crc(&frame).then_some(frame) } - /// Discards the in-progress frame after a UART transport fault. - pub fn reset(&mut self) { + fn reset(&mut self) { self.frame = MavlinkFrame::new(); self.received = 0; self.expected = 0; } +} - /// Takes the oldest complete received frame, if one is available. - /// - /// The production receiver returns no frame until signing verification is - /// implemented, but retaining this API keeps dispatch ownership explicit. +/// Bounded integrity-only MAVLink 2 receiver. +/// +/// It rejects signed, incompatible, corrupt, and MAVLink 1 frames, but an +/// accepted frame is still unauthenticated. The composition decides whether +/// and how a received command may be admitted. +pub struct IntegrityMavlinkReceiver { + framer: IntegrityFramer, + frames: [Option; N], + front: usize, + length: usize, +} + +impl IntegrityMavlinkReceiver { + /// Creates an empty integrity-only receiver. + pub const fn new() -> Self { + assert!(N != 0, "integrity receiver capacity must be nonzero"); + Self { + framer: IntegrityFramer::new(), + frames: [None; N], + front: 0, + length: 0, + } + } + + /// Queues one complete CRC-valid unsigned frame when capacity remains. + pub fn receive_byte(&mut self, byte: u8) -> bool { + let Some(frame) = self.framer.receive_byte(byte) else { + return false; + }; + if self.length == N { + return false; + } + let index = (self.front + self.length) % N; + self.frames[index] = Some(frame); + self.length += 1; + true + } + + /// Discards partial framing state and all queued unauthenticated frames. + pub fn reset(&mut self) { + self.framer.reset(); + self.frames = [None; N]; + self.front = 0; + self.length = 0; + } + + /// Takes the oldest integrity-checked frame. pub fn take_received(&mut self) -> Option { if self.length == 0 { return None; @@ -107,10 +114,11 @@ impl MavlinkReceiver { self.length -= 1; frame } +} - /// Returns whether at least one complete frame awaits dispatch. - pub const fn has_received(&self) -> bool { - self.length != 0 +impl Default for IntegrityMavlinkReceiver { + fn default() -> Self { + Self::new() } } @@ -128,7 +136,7 @@ fn has_valid_known_crc(frame: &MavlinkFrame) -> bool { #[cfg(test)] mod tests { - use super::MavlinkReceiver; + use super::IntegrityMavlinkReceiver; use crate::{MavlinkFrame, message}; fn heartbeat(sequence: u8) -> MavlinkFrame { @@ -173,7 +181,7 @@ mod tests { #[test] fn discards_a_crc_valid_mavlink_v1_frame() { - let mut receiver = MavlinkReceiver::<1>::new(); + let mut receiver = IntegrityMavlinkReceiver::<1>::new(); let v1 = heartbeat_v1(3); assert_eq!(v1.raw_bytes()[0], 0xfe); @@ -185,26 +193,26 @@ mod tests { } #[test] - fn production_rejects_unsigned_frames_without_a_signing_verifier() { - let frame = heartbeat(0); - let mut receiver = MavlinkReceiver::<1>::new(); + fn queues_a_complete_crc_valid_unsigned_frame() { + let frame = heartbeat(9); + let mut receiver = IntegrityMavlinkReceiver::<1>::new(); for byte in frame.raw_bytes() { - assert!(!receiver.receive_byte(*byte)); + receiver.receive_byte(*byte); } - assert!(!receiver.has_received()); + assert_eq!(receiver.take_received(), Some(frame)); assert_eq!(receiver.take_received(), None); } #[test] fn rejects_signed_v2_before_accepting_any_payload() { - let mut receiver = MavlinkReceiver::<1>::new(); + let mut receiver = IntegrityMavlinkReceiver::<1>::new(); // The first ten bytes contain the complete MAVLink 2 header. This // frame advertises a signature (incompatibility bit 0), so rejection // happens before its payload can be considered. for byte in [0xfd, 0, 1, 0, 0, 1, 1, 0, 0, 0] { assert!(!receiver.receive_byte(byte)); } - assert!(!receiver.has_received()); + assert_eq!(receiver.take_received(), None); } #[test] @@ -213,11 +221,11 @@ mod tests { let mut bytes = frame.into_inner(); bytes[10] ^= 1; let corrupt = MavlinkFrame::from_bytes_unparsed(bytes); - let mut receiver = MavlinkReceiver::<1>::new(); + let mut receiver = IntegrityMavlinkReceiver::<1>::new(); for byte in corrupt.raw_bytes() { assert!(!receiver.receive_byte(*byte)); } - assert!(!receiver.has_received()); + assert_eq!(receiver.take_received(), None); } } diff --git a/firmware/libs/kaptan-mavlink/src/transmitter.rs b/firmware/libs/kaptan-mavlink/src/transmitter.rs index c13d56a..03cb62e 100644 --- a/firmware/libs/kaptan-mavlink/src/transmitter.rs +++ b/firmware/libs/kaptan-mavlink/src/transmitter.rs @@ -18,32 +18,10 @@ pub enum MavlinkTransmitFault { Engine(EngineError), } -/// Transport-neutral class of a terminal link fault. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum TransmitFaultKind { - /// DMA-visible frame preparation failed. - Memory, - /// The UART DMA engine rejected a transfer start. - Engine, - /// An active DMA transfer ended with a terminal fault or halt. - Terminal, -} - -impl MavlinkTransmitFault { - /// Returns the bounded category a platform may convert to a static log - /// message without exposing a generic HAL error value on the wire. - pub const fn kind(&self) -> TransmitFaultKind { - match self { - Self::Memory(_) => TransmitFaultKind::Memory, - Self::Engine(_) => TransmitFaultKind::Engine, - } - } -} - /// Combined bounded-admission and DMA-start outcome. /// -/// The two outcomes stay distinct so a platform can account for a failed DMA -/// start without falsely treating a queue-full source frame as retained. +/// The outcomes stay distinct because admission decides whether the source may +/// acknowledge its frame, while a failed start keeps reserved work for retry. #[must_use] pub struct AdmissionStart { /// Queue admission for the source frame. @@ -94,10 +72,9 @@ where } /// Admits one frame and attempts one bounded DMA start while preserving - /// both outcomes. Platform policy uses this to account for a failed DMA - /// start without mistaking an admission rejection for a retained frame. - /// A start failure retains the selected frame in the queue reservation for - /// a later service-turn retry. + /// both outcomes. A start failure retains the selected frame in the queue + /// reservation for a later service-turn retry; it does not change whether + /// the newly submitted source frame was admitted. pub fn admit_and_start( &mut self, class: FrameClass, diff --git a/firmware/libs/kaptan-tx/src/lib.rs b/firmware/libs/kaptan-tx/src/lib.rs index d9c2058..5fe2ff8 100644 --- a/firmware/libs/kaptan-tx/src/lib.rs +++ b/firmware/libs/kaptan-tx/src/lib.rs @@ -44,6 +44,16 @@ pub enum EnqueueResult { BestEffortDropped, } +impl EnqueueResult { + /// Whether the submitted frame remains represented by the queue. + pub const fn is_retained(self) -> bool { + matches!( + self, + Self::Enqueued | Self::ReplacedBestEffort | Self::ReplacedCritical + ) + } +} + /// Result of attempting to begin a transfer. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum StartOutcome { diff --git a/firmware/platforms/stm32f7/Cargo.toml b/firmware/platforms/stm32f7/Cargo.toml index 7b49fd3..6b5c996 100644 --- a/firmware/platforms/stm32f7/Cargo.toml +++ b/firmware/platforms/stm32f7/Cargo.toml @@ -14,11 +14,9 @@ workspace = true # generated lib-test harness would link `std` and a second panic handler. test = false -[features] -default = [] - [dependencies] -kaptan-board = { path = "../../libs/kaptan-board" } +kaptan-config = { path = "../../libs/kaptan-config" } +kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink" } kaptan-data-channel = { path = "../../libs/kaptan-data-channel" } kaptan-dma = { path = "../../libs/kaptan-dma" } kaptan-drivers = { path = "../../libs/kaptan-drivers" } diff --git a/firmware/platforms/stm32f7/src/f722/board.rs b/firmware/platforms/stm32f7/src/f722/board.rs index 259dbcc..21038bf 100644 --- a/firmware/platforms/stm32f7/src/f722/board.rs +++ b/firmware/platforms/stm32f7/src/f722/board.rs @@ -9,24 +9,39 @@ use super::peripherals::{ configure_uart4_pins_pa0_pa1, configure_usart1_rx_pin_pa10, configure_usart1_tx_pin_pa9, configure_usart2_pins_pa2_pa3, }; -use kaptan_board::{BoardConfig, VerticalSensingKind}; +use kaptan_config::Config; use kaptan_data_channel::{ - DataChannel, DataSources, LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox, + DataChannel, DataChannelRates, DataSources, LatestImuMailbox, LatestOpticalFlowMailbox, + LatestRangeMailbox, }; use kaptan_drivers::{ lidar_lite_v3::{AcquisitionMode, LidarLiteV3, LidarLiteV3Service, LidarLiteV3ServiceEvent}, mtf01::Mtf01Service, }; use kaptan_log::{ - DeliveryClass, ErrorReport, EventHub, KaptanError, LogFilter, LogFlags, StaticError, + DeliveryClass, ErrorReport, KaptanError, LogFilter, LogSinkQueue, StaticError, StaticErrorMailbox, TransportAdmission, }; use kaptan_log_mavlink::{DiagnosticPayload, encode_diagnostic}; use kaptan_spi::SpiCompletion; use stm32f7::stm32f722; -use crate::{BoardError, F722BoardRoutes, F722Profile}; +use kaptan_config_mavlink::{ + ConfigProtocol, Operation, ResponseVisitor, ResultKind, ServiceResponse, Target, + visit_response, wire, +}; +use kaptan_mavlink::{ + MavlinkVersion, + message::{Message, kaptan}, + receiver::IntegrityMavlinkReceiver, +}; +use crate::{BoardError, F7ImmutableConfig, F722Hardware, F722Profile, F722StatusLedRoute}; + +use super::config::{ + F722ConfigManager, F722ConfigStore, F722Nor, UserConfigModel, project_runtime, + validate_runtime_values, +}; use super::{ VerticalRoute, clock, data_channel::{ @@ -38,44 +53,26 @@ use super::{ }; const UART4_CLOCK_HZ: u32 = 54_000_000; -const MTF01_BAUD: u32 = 115_200; const I2C1_CONFIG: I2c1Config = I2c1Config::from_timing_register(0x2040_4768); const LIDAR_MODE: AcquisitionMode = AcquisitionMode::WithReceiverBiasCorrection; const LIDAR_STARTUP_TICKS: u8 = 3; -const ERROR_RECORDS_PER_SECOND: u8 = 10; -const LOG_RECORDS_PER_SECOND: u8 = 5; -const SCHEDULED_FRAMES_PER_SECOND: usize = 62; +const CONFIG_SESSION_LEASE_TICKS: u16 = (SERVICE_HZ * 10) as u16; const SCHEDULED_FRAME_MAXIMUM_BYTES: usize = 48; -const MAVLINK_V2_UNSIGNED_OVERHEAD_BYTES: usize = 12; -const KAPTAN_ERROR_FRAME_MAXIMUM_BYTES: usize = - kaptan_mavlink::message::kaptan::KAPTAN_ERROR_DATA::ENCODED_LEN - + MAVLINK_V2_UNSIGNED_OVERHEAD_BYTES; -const KAPTAN_LOG_FRAME_MAXIMUM_BYTES: usize = - kaptan_mavlink::message::kaptan::KAPTAN_LOG_DATA::ENCODED_LEN - + MAVLINK_V2_UNSIGNED_OVERHEAD_BYTES; -// The static F722 telemetry profile is bounded at 62 scheduled frames/s. -// It permits ten `KAPTAN_ERROR` and five `KAPTAN_LOG` frames/s. Both maxima -// are 163 payload bytes plus 12 unsigned MAVLink V2 framing bytes, so -// 62×48 + 15×175 = 5,601 bytes/s. MAVLink signing is not enabled in this -// telemetry-only image; any future signing profile must re-budget its -// signature overhead before it can enable the endpoint. -pub(crate) const WORST_CASE_OBSERVABILITY_BYTES_PER_SECOND: usize = SCHEDULED_FRAMES_PER_SECOND - * SCHEDULED_FRAME_MAXIMUM_BYTES - + ERROR_RECORDS_PER_SECOND as usize * KAPTAN_ERROR_FRAME_MAXIMUM_BYTES - + LOG_RECORDS_PER_SECOND as usize * KAPTAN_LOG_FRAME_MAXIMUM_BYTES; +// User/immutable validation permits at most one scheduled frame per 100 Hz +// service tick. Configuration replies and diagnostics are event-driven and +// use bounded queue admission; they do not invent a periodic link reservation. +pub(crate) const SCHEDULED_TELEMETRY_BYTES_PER_SECOND: usize = + SERVICE_HZ * SCHEDULED_FRAME_MAXIMUM_BYTES; -/// Returns whether the immutable board UART configuration can carry the fixed -/// telemetry and bounded diagnostic schedule at ten UART bits per byte. -pub(crate) const fn link_budget_fits(baud: u32) -> bool { - WORST_CASE_OBSERVABILITY_BYTES_PER_SECOND < baud as usize / 10 +/// Returns whether the immutable board UART configuration can carry its +/// deterministic telemetry schedule at ten UART bits per byte. +pub(crate) const fn scheduled_link_budget_fits(baud: u32) -> bool { + SCHEDULED_TELEMETRY_BYTES_PER_SECOND < baud as usize / 10 } static UART_COMPLETION: AtomicU8 = AtomicU8::new(0); #[derive(Clone, Copy, Debug, KaptanError)] enum F722TelemetryError { Mtf01Uart, - TransmitMemory, - TransmitEngine, - TransmitTerminal, } // High-priority/ISR-adjacent producers never encode, lock, allocate, or send // MAVLink. They retain the first fault text and coalesce later reports for @@ -99,12 +96,18 @@ pub type F722ServiceTimer = Tim2Periodic; /// /// Only the MAVLink worker accesses this state, so its priority ceiling is 9 /// rather than the IMU or vertical-sensor IRQ ceiling. -pub struct F722MavlinkState { +pub struct F722MavlinkState +where + C: Clone + Config, + I: F7ImmutableConfig, +{ data_channel: DataChannel<'static, F722MavlinkTransmitter, SERVICE_HZ>, - logs: EventHub<2, 4>, - budget_ticks: u8, - error_budget: u8, - log_budget: u8, + configuration: F722ConfigManager, + config_protocol: ConfigProtocol, + receiver: IntegrityMavlinkReceiver<2>, + config_lease_ticks: u16, + mavlink_logs: LogSinkQueue<1, 1>, + fault_mailbox_cursor: u8, led: F722StatusLed, } @@ -121,81 +124,103 @@ pub struct F722LidarState { /// /// This is not runtime state indirection: each field is moved exactly once /// into the single RTIC owner that accesses it. -pub struct F722InitResources { - pub(crate) imu: F722Imu, - pub(crate) mtf01: Option, - pub(crate) mtf01_uart: Option, - pub(crate) timer: F722ServiceTimer, - pub(crate) lidar: F722LidarState, - pub(crate) mavlink: F722MavlinkState, +pub struct F722InitResources +where + C: Clone + Config, + I: F7ImmutableConfig, +{ + #[doc(hidden)] + pub imu: F722Imu, + #[doc(hidden)] + pub mtf01: Option, + #[doc(hidden)] + pub mtf01_uart: Option, + #[doc(hidden)] + pub timer: F722ServiceTimer, + #[doc(hidden)] + pub lidar: F722LidarState, + #[doc(hidden)] + pub mavlink: F722MavlinkState, } -/// Validates the relationship between portable metadata and selected sensor -/// route before platform initialization claims a peripheral. -pub(crate) fn validate_board_routes( - config: BoardConfig, - routes: F722BoardRoutes, -) -> Result<(), BoardError> { - let telemetry = config.telemetry().ok_or(BoardError::Initialization)?; - if telemetry.data_channel.service_hz != SERVICE_HZ { +/// Validates user-controlled runtime values against immutable electrical and +/// platform constraints before initialization claims a non-safety peripheral. +pub(crate) fn validate_board_config( + config: &C, + immutable: &I, + hardware: F722Hardware, +) -> Result<(), BoardError> +where + C: Clone + Config, + I: F7ImmutableConfig, +{ + ::validate(config).map_err(|_| BoardError::Configuration)?; + validate_runtime_values(config, immutable).map_err(|_| BoardError::Configuration)?; + let immutable = immutable.f722(); + if immutable.service_hz != SERVICE_HZ { return Err(BoardError::UnsupportedServiceFrequency); } - let expected = match routes.vertical { - VerticalRoute::None => VerticalSensingKind::None, - VerticalRoute::Mtf01 => VerticalSensingKind::RangeAndOpticalFlow, - VerticalRoute::LidarLiteV3 => VerticalSensingKind::Range, + let vertical_baud_is_valid = match (hardware.vertical(), hardware.vertical_sensor_baud()) { + (VerticalRoute::Mtf01, Some(baud)) => baud != 0, + (VerticalRoute::None | VerticalRoute::LidarLiteV3, None) => true, + _ => false, }; - if telemetry.vertical_sensing != expected { + if !vertical_baud_is_valid { return Err(BoardError::VerticalSensorCapabilityMismatch); } - if telemetry.imu != routes.imu() { - return Err(BoardError::ImuRouteMismatch); - } - if telemetry.imu_mount != routes.imu_mount() { - return Err(BoardError::ImuMountMismatch); - } - if config.mavlink.port != routes.mavlink_port() { - return Err(BoardError::MavlinkRouteMismatch); - } - if !link_budget_fits(config.mavlink.baud) { + if !scheduled_link_budget_fits(hardware.mavlink_baud()) { return Err(BoardError::TelemetryLinkOvercommitted); } Ok(()) } -pub(crate) fn initialize( +pub(crate) fn initialize( mut core: cortex_m::Peripherals, device: stm32f722::Peripherals, - config: BoardConfig, - routes: F722BoardRoutes, -) -> Result { + factory: C, + immutable: I, + hardware: F722Hardware, +) -> Result, BoardError> +where + C: Clone + Config, + I: F7ImmutableConfig, +{ let clock = clock::initialize(&mut core, &device).map_err(|_| BoardError::Initialization)?; // The profile is written once before any interrupt is enabled. ISR helpers // read it with Acquire ordering and only access the initialized route. - interrupts::activate_profile(routes.profile()); + interrupts::activate_profile(hardware.profile()); - match routes.profile() { - F722Profile::Mpu6000Spi1Usart1 => { - initialize_mpu6000(device, clock, config, routes.vertical) - } - F722Profile::Icm42688Spi3Usart2 => { - initialize_icm42688(device, clock, config, routes.vertical) - } + match hardware.profile() { + F722Profile::Mpu6000Spi1Usart1 => initialize_mpu6000::( + device, clock, factory, immutable, hardware, + ), + F722Profile::Icm42688Spi3Usart2 => initialize_icm42688::( + device, clock, factory, immutable, hardware, + ), } } -fn initialize_mpu6000( +fn initialize_mpu6000( device: stm32f722::Peripherals, clock: DwtMicrosecondClock, - config: BoardConfig, - vertical: VerticalRoute, -) -> Result { + factory: C, + immutable: I, + hardware: F722Hardware, +) -> Result, BoardError> +where + C: Clone + Config, + I: F7ImmutableConfig, +{ + let configuration = + recover_configuration::(device.FLASH, factory, immutable)?; + let config = configuration.active().clone(); configure_spi1_pins_pa5_pa6_pa7(&device.RCC, &device.GPIOA); configure_exti4_pin_pa4(&device.GPIOA); configure_exti4(&device.RCC, device.SYSCFG, device.EXTI); configure_usart1_tx_pin_pa9(&device.GPIOA); configure_usart1_rx_pin_pa10(&device.GPIOA); + let vertical = hardware.vertical(); configure_vertical_pins(&device.RCC, &device.GPIOA, &device.GPIOB, vertical); let (range, mtf01, mtf01_uart) = initialize_vertical( @@ -205,19 +230,25 @@ fn initialize_mpu6000( clock.fork(), clock.fork(), vertical, + hardware.vertical_sensor_baud(), )?; - let telemetry = config.telemetry().ok_or(BoardError::Initialization)?; let imu = F722Imu::mpu6000( &device.RCC, device.SPI1, device.DMA2, device.GPIOB, clock.fork(), - telemetry.imu_mount, + hardware.imu_mount(), + hardware + .mpu6000_config() + .ok_or(BoardError::Initialization)?, ); let transmitter = - F722MavlinkTransmitter::usart1(&device.RCC, device.USART1, config.mavlink.baud) + F722MavlinkTransmitter::usart1(&device.RCC, device.USART1, hardware.mavlink_baud()) .map_err(|_| BoardError::Initialization)?; + if hardware.status_led() != F722StatusLedRoute::Pd2ActiveLow { + return Err(BoardError::Initialization); + } let led = F722StatusLed::Kakute(super::peripherals::ActiveLowLedPd2::new( &device.RCC, device.GPIOD, @@ -226,6 +257,7 @@ fn initialize_mpu6000( &device.RCC, device.TIM2, config, + immutable, vertical, imu, range, @@ -233,15 +265,24 @@ fn initialize_mpu6000( mtf01_uart, transmitter, led, + configuration, ) } -fn initialize_icm42688( +fn initialize_icm42688( device: stm32f722::Peripherals, clock: DwtMicrosecondClock, - config: BoardConfig, - vertical: VerticalRoute, -) -> Result { + factory: C, + immutable: I, + hardware: F722Hardware, +) -> Result, BoardError> +where + C: Clone + Config, + I: F7ImmutableConfig, +{ + let configuration = + recover_configuration::(device.FLASH, factory, immutable)?; + let config = configuration.active().clone(); configure_spi3_pins_pc10_pc11_pc12(&device.RCC, &device.GPIOC); configure_exti3_pin_pc3(&device.GPIOC); configure_exti3(&device.RCC, device.SYSCFG, device.EXTI); @@ -253,6 +294,7 @@ fn initialize_icm42688( .ahb1enr() .modify(|_, write| write.gpioaen().set_bit()); configure_usart2_pins_pa2_pa3(&device.GPIOA); + let vertical = hardware.vertical(); configure_vertical_pins(&device.RCC, &device.GPIOA, &device.GPIOB, vertical); let (range, mtf01, mtf01_uart) = initialize_vertical( @@ -262,24 +304,28 @@ fn initialize_icm42688( clock.fork(), clock.fork(), vertical, + hardware.vertical_sensor_baud(), )?; - let telemetry = config.telemetry().ok_or(BoardError::Initialization)?; let imu = F722Imu::icm42688( &device.RCC, device.SPI3, device.DMA1, &device.GPIOA, clock.fork(), - telemetry.imu_mount, + hardware.imu_mount(), ); let transmitter = - F722MavlinkTransmitter::usart2(&device.RCC, device.USART2, config.mavlink.baud) + F722MavlinkTransmitter::usart2(&device.RCC, device.USART2, hardware.mavlink_baud()) .map_err(|_| BoardError::Initialization)?; + if hardware.status_led() != F722StatusLedRoute::Pa13ActiveHigh { + return Err(BoardError::Initialization); + } let led = F722StatusLed::Geprc(GeprcStatusLed::new(&device.RCC, device.GPIOA)); finish( &device.RCC, device.TIM2, config, + immutable, vertical, imu, range, @@ -287,14 +333,16 @@ fn initialize_icm42688( mtf01_uart, transmitter, led, + configuration, ) } #[allow(clippy::too_many_arguments)] -fn finish( +fn finish( rcc: &stm32f722::RCC, tim2: stm32f722::TIM2, - config: BoardConfig, + config: C, + immutable: I, vertical: VerticalRoute, imu: F722Imu, range: Option, @@ -302,10 +350,33 @@ fn finish( mtf01_uart: Option, transmitter: F722MavlinkTransmitter, led: F722StatusLed, -) -> Result { + configuration: F722ConfigManager, +) -> Result, BoardError> +where + C: Clone + Config, + I: F7ImmutableConfig, +{ + let runtime = project_runtime(&config, &immutable).map_err(|_| BoardError::Configuration)?; + let immutable = immutable.f722(); + let system_id = runtime.system_id; + let component_id = runtime.component_id; + let rates = DataChannelRates { + service_hz: immutable.service_hz, + raw_imu_hz: usize::from(runtime.raw_imu_hz), + range_hz: runtime.range_hz.map(usize::from), + optical_flow_hz: runtime.optical_flow_hz.map(usize::from), + heartbeat_hz: usize::from(runtime.heartbeat_hz), + }; + let mavlink_log_filter = if runtime.mavlink_logging_enabled { + LogFilter::up_to(runtime.mavlink_maximum_log_severity) + } else { + LogFilter::disabled() + }; let data_channel = DataChannel::new( transmitter, - config, + system_id, + component_id, + rates, vertical_data_config(vertical), DataSources { imu: &SAMPLES, @@ -325,20 +396,35 @@ fn finish( }, mavlink: F722MavlinkState { data_channel, - logs: EventHub::new(LogFilter::new( - config - .telemetry() - .ok_or(BoardError::Initialization)? - .maximum_log_level, - )), - budget_ticks: 0, - error_budget: ERROR_RECORDS_PER_SECOND, - log_budget: LOG_RECORDS_PER_SECOND, + configuration, + config_protocol: ConfigProtocol::new(Target::new(system_id, component_id)), + receiver: IntegrityMavlinkReceiver::new(), + config_lease_ticks: 0, + mavlink_logs: LogSinkQueue::new(mavlink_log_filter), + fault_mailbox_cursor: 0, led, }, }) } +fn recover_configuration( + flash: stm32f722::FLASH, + factory: C, + immutable: I, +) -> Result, BoardError> +where + C: Clone + Config, + I: F7ImmutableConfig, +{ + let candidate = UserConfigModel::::new(factory); + let store = + F722ConfigStore::::new(F722Nor::new(flash), candidate.clone()); + F722ConfigManager::::recover_compatible_or_factory( + candidate, immutable, store, + ) + .map_err(|_| BoardError::Configuration) +} + fn configure_vertical_pins( rcc: &stm32f722::RCC, gpioa: &stm32f722::GPIOA, @@ -361,17 +447,21 @@ fn initialize_vertical( mtf01_clock: DwtMicrosecondClock, range_clock: DwtMicrosecondClock, vertical: VerticalRoute, + vertical_sensor_baud: Option, ) -> Result { match vertical { VerticalRoute::None => Ok((None, None, None)), - VerticalRoute::Mtf01 => Ok(( - None, - Some(Mtf01Service::new(mtf01_clock)), - Some( - Uart4::new(rcc, uart4, UART4_CLOCK_HZ, MTF01_BAUD) - .map_err(|_| BoardError::Initialization)?, - ), - )), + VerticalRoute::Mtf01 => { + let baud = vertical_sensor_baud.ok_or(BoardError::Initialization)?; + Ok(( + None, + Some(Mtf01Service::new(mtf01_clock)), + Some( + Uart4::new(rcc, uart4, UART4_CLOCK_HZ, baud) + .map_err(|_| BoardError::Initialization)?, + ), + )) + } VerticalRoute::LidarLiteV3 => Ok(( Some(LidarLiteV3Service::new( range_clock, @@ -410,7 +500,7 @@ fn configure_exti3(rcc: &stm32f722::RCC, syscfg: stm32f722::SYSCFG, exti: stm32f /// Services the MTF-01 UART route. It is a task-local resource, so it needs /// no RTIC lock and cannot extend another resource's priority ceiling. -pub(crate) fn on_uart4_interrupt(mtf01: &mut Option) { +pub fn on_uart4_interrupt(mtf01: &mut Option) { let Some(mtf01) = mtf01.as_mut() else { return; }; @@ -434,7 +524,7 @@ pub(crate) fn on_uart4_interrupt(mtf01: &mut Option) { } impl F722LidarState { - pub(crate) fn on_i2c_event(&mut self, error: bool) { + pub fn on_i2c_event(&mut self, error: bool) { let Some(range) = self.range.as_mut() else { return; }; @@ -448,7 +538,7 @@ impl F722LidarState { publish_lidar_event(event); } - pub(crate) fn on_timer(&mut self) { + pub fn on_timer(&mut self) { if let Some(range) = self.range.as_mut() { if self.startup_ticks < LIDAR_STARTUP_TICKS { self.startup_ticks += 1; @@ -465,7 +555,7 @@ impl F722LidarState { } /// Advances the IMU state machine in its dedicated worker-local resource. -pub(crate) fn process_imu(imu: &mut F722Imu, events: u8) { +pub fn process_imu(imu: &mut F722Imu, events: u8) { const START: u8 = 1 << 4; const TRANSFER_FAULT: u8 = 1 << 2; const TRANSFER_COMPLETE: u8 = 1 << 1; @@ -499,21 +589,31 @@ fn accept_imu_event(event: ImuEvent) { } } -impl F722MavlinkState { +impl F722MavlinkState +where + C: Clone + Config, + I: F7ImmutableConfig, +{ /// Handles bounded DMA and timer work for the telemetry image. - pub(crate) fn process_worker_events(&mut self, events: u8) { + pub fn process_worker_events(&mut self, events: u8) { const TIMER: u8 = 1 << 0; + const RECEIVED: u8 = 1 << 1; const DMA: u8 = 1 << 2; + if events & RECEIVED != 0 { + self.drain_config_frames(); + } + if events & DMA != 0 { let completion = take_mavlink_completion(); - let _ = self.data_channel.on_dma_complete(completion); + self.data_channel.on_dma_complete(completion); if completion == kaptan_dma::TxCompletion::Complete { self.led.toggle(); } } if events & TIMER != 0 { + self.tick_config_lease(); // The board is initialized and transmitting physical telemetry, // but has no flight runtime or output authority. `STANDBY` with // an unarmed base mode communicates that accurately; `UNINIT` @@ -521,66 +621,81 @@ impl F722MavlinkState { self.data_channel .on_service_tick(kaptan_mavlink::message::MavState::MAV_STATE_STANDBY, false); self.drain_driver_faults(); - self.budget_ticks = self.budget_ticks.saturating_add(1); - if self.budget_ticks == SERVICE_HZ as u8 { - self.budget_ticks = 0; - self.error_budget = ERROR_RECORDS_PER_SECOND; - self.log_budget = LOG_RECORDS_PER_SECOND; + self.drain_one_mavlink_log_record(); + } + } + + pub fn receive_config_interrupt(&mut self, event: ReceiveInterrupt) -> bool { + match event { + ReceiveInterrupt::Byte(byte) => self.receiver.receive_byte(byte), + ReceiveInterrupt::Fault => { + self.receiver.reset(); + self.config_lease_ticks = 0; + self.config_protocol.expire(&mut self.configuration); + false } - self.drain_one_log_record(); + ReceiveInterrupt::NotPending => false, + } + } + + fn drain_config_frames(&mut self) { + while let Some(frame) = self.receiver.take_received() { + if frame.system_id() == 0 || frame.component_id() == 0 { + continue; + } + let Ok(message) = + kaptan::MavMessage::parse(MavlinkVersion::V2, frame.message_id(), frame.payload()) + else { + continue; + }; + let Ok(command) = wire::decode_command(&message) else { + continue; + }; + let principal = + kaptan_config_mavlink::Principal::new(frame.system_id(), frame.component_id()); + let mut sender = ConfigResponseSender { + data_channel: &mut self.data_channel, + }; + let mut refresh_lease = false; + let _: Result<(), ()> = self.config_protocol.dispatch_from_composition( + &mut self.configuration, + principal, + command, + |response| { + if let ServiceResponse::Result(reply) = response + && reply.result != ResultKind::Rejected + { + refresh_lease |= reply.operation != Operation::Query; + } + visit_response(response, &mut sender) + }, + ); + if refresh_lease { + self.config_lease_ticks = CONFIG_SESSION_LEASE_TICKS; + } + } + } + + fn tick_config_lease(&mut self) { + if self.config_lease_ticks == 0 { + return; + } + self.config_lease_ticks -= 1; + if self.config_lease_ticks == 0 { + self.config_protocol.expire(&mut self.configuration); } } fn drain_driver_faults(&mut self) { - self.drain_fault_mailbox(&IMU_FAULTS); - self.drain_fault_mailbox(&LIDAR_FAULTS); - self.drain_fault_mailbox(&MTF01_FAULTS); - self.drain_transport_faults(); + let _ = self.mavlink_logs.drain_next_error_mailbox( + [&IMU_FAULTS, &LIDAR_FAULTS, &MTF01_FAULTS], + &mut self.fault_mailbox_cursor, + ); } - fn drain_transport_faults(&mut self) { - while let Some(fault) = self.data_channel.take_transport_fault() { - let message = match fault { - kaptan_mavlink::transmitter::TransmitFaultKind::Memory => { - F722TelemetryError::TransmitMemory.static_error() - } - kaptan_mavlink::transmitter::TransmitFaultKind::Engine => { - F722TelemetryError::TransmitEngine.static_error() - } - kaptan_mavlink::transmitter::TransmitFaultKind::Terminal => { - F722TelemetryError::TransmitTerminal.static_error() - } - }; - let _ = self.logs.publish_error_static(LogFlags::NONE, message); - } - } - - fn drain_fault_mailbox(&mut self, mailbox: &StaticErrorMailbox) { - let (report, coalesced) = mailbox.take(); - let Some(message) = report else { - return; - }; - let flags = if coalesced { - LogFlags::COALESCED - } else { - LogFlags::NONE - }; - let _ = self.logs.publish_error_static(flags, message); - } - - fn drain_one_log_record(&mut self) { - let Some(record) = self.logs.peek() else { - return; - }; - let delivery = record.delivery(); - if (delivery == DeliveryClass::Critical && self.error_budget == 0) - || (delivery == DeliveryClass::BestEffort && self.log_budget == 0) - { - return; - } + fn drain_one_mavlink_log_record(&mut self) { let data_channel = &mut self.data_channel; - let mut accepted = None; - let _ = self.logs.submit_next(|record| { + let _ = self.mavlink_logs.submit_next(|record| { let class = match record.delivery() { DeliveryClass::Critical => kaptan_mavlink::transmitter::FrameClass::Critical, DeliveryClass::BestEffort => kaptan_mavlink::transmitter::FrameClass::BestEffort, @@ -591,24 +706,56 @@ impl F722MavlinkState { DiagnosticPayload::Error(payload) => data_channel.send_custom(class, payload), }; let outcome = if admission.is_retained() { - accepted = Some(record.delivery()); TransportAdmission::Accepted - } else if record.delivery() == DeliveryClass::BestEffort { - TransportAdmission::DropBestEffort } else { TransportAdmission::Retry }; (outcome, ()) }); - match accepted { - Some(DeliveryClass::Critical) => { - self.error_budget = self.error_budget.saturating_sub(1); - } - Some(DeliveryClass::BestEffort) => { - self.log_budget = self.log_budget.saturating_sub(1); - } - None => {} - } + } +} + +struct ConfigResponseSender<'a> { + data_channel: &'a mut DataChannel<'static, F722MavlinkTransmitter, SERVICE_HZ>, +} + +impl ResponseVisitor for ConfigResponseSender<'_> { + type Error = (); + + fn info(&mut self, response: &kaptan::KAPTAN_CONFIG_INFO_DATA) -> Result<(), Self::Error> { + self.send(response) + } + + fn result(&mut self, response: &kaptan::KAPTAN_CONFIG_RESULT_DATA) -> Result<(), Self::Error> { + self.send(response) + } + + fn descriptor( + &mut self, + response: &kaptan::KAPTAN_CONFIG_DESCRIPTOR_DATA, + ) -> Result<(), Self::Error> { + self.send(response) + } + + fn enum_option( + &mut self, + response: &kaptan::KAPTAN_CONFIG_ENUM_OPTION_DATA, + ) -> Result<(), Self::Error> { + self.send(response) + } + + fn value(&mut self, response: &kaptan::KAPTAN_CONFIG_VALUE_DATA) -> Result<(), Self::Error> { + self.send(response) + } +} + +impl ConfigResponseSender<'_> { + fn send(&mut self, response: &M) -> Result<(), ()> { + self.data_channel + .send_custom(kaptan_mavlink::transmitter::FrameClass::Critical, response) + .is_retained() + .then_some(()) + .ok_or(()) } } diff --git a/firmware/platforms/stm32f7/src/f722/config.rs b/firmware/platforms/stm32f7/src/f722/config.rs new file mode 100644 index 0000000..016b170 --- /dev/null +++ b/firmware/platforms/stm32f7/src/f722/config.rs @@ -0,0 +1,421 @@ +//! Board-owned user configuration support and the generic F722 flash journal. + +use core::marker::PhantomData; +use kaptan_config::{ + Config, ConfigManager, ConfigModel, ConfigValue, ConfigViolation, DualSlotJournal, DualSlotNor, + FieldDescriptor, FieldId, FieldWriteError, ValidationRule, +}; +use stm32f7::stm32f722; + +use crate::{F7ImmutableConfig, Severity}; + +const MAVLINK_SYSTEM_ID: FieldId = FieldId::new(101); +const MAVLINK_COMPONENT_ID: FieldId = FieldId::new(102); +const RAW_IMU_HZ: FieldId = FieldId::new(103); +const HEARTBEAT_HZ: FieldId = FieldId::new(104); +const MAVLINK_MAXIMUM_LOG_SEVERITY: FieldId = FieldId::new(105); +const RANGE_HZ: FieldId = FieldId::new(106); +const OPTICAL_FLOW_HZ: FieldId = FieldId::new(107); +const MAVLINK_LOGGING_ENABLED: FieldId = FieldId::new(108); + +/// Narrow boot projection consumed only by fixed F722 services. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct F722UserRuntime { + pub(super) system_id: u8, + pub(super) component_id: u8, + pub(super) mavlink_logging_enabled: bool, + pub(super) mavlink_maximum_log_severity: Severity, + pub(super) raw_imu_hz: u8, + pub(super) range_hz: Option, + pub(super) optical_flow_hz: Option, + pub(super) heartbeat_hz: u8, +} + +/// Generic [`ConfigModel`] adapter for one board-composed aggregate. +#[derive(Clone)] +pub(crate) struct UserConfigModel { + candidate: C, + _immutable: PhantomData, +} + +impl UserConfigModel { + pub(crate) const fn new(candidate: C) -> Self { + Self { + candidate, + _immutable: PhantomData, + } + } +} + +impl ConfigModel for UserConfigModel +where + C: Clone + Config, + I: F7ImmutableConfig, +{ + type Active = C; + type Context = I; + + const SCHEMA_ID: [u8; 16] = ::SCHEMA_ID; + + fn descriptor(index: u16) -> Option<&'static FieldDescriptor> { + C::descriptor(usize::from(index)) + } + + fn field_count() -> u16 { + assert!( + C::FIELD_COUNT <= u16::MAX as usize, + "configuration schema exceeds wire field-count domain" + ); + C::FIELD_COUNT as u16 + } + + fn field_value(&self, id: FieldId) -> Option { + self.candidate.field_value(id) + } + + fn active_field_value(active: &Self::Active, id: FieldId) -> Option { + active.field_value(id) + } + + fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> { + self.candidate.set_field(id, value) + } + + fn validate(&self, context: &Self::Context) -> Result { + ::validate(&self.candidate)?; + validate_runtime_values(&self.candidate, context)?; + Ok(self.candidate.clone()) + } +} + +pub(super) fn validate_runtime_values( + candidate: &C, + immutable: &I, +) -> Result<(), ConfigViolation> +where + C: Clone + Config, + I: F7ImmutableConfig, +{ + project_runtime(candidate, immutable).map(|_| ()) +} + +pub(super) fn project_runtime( + candidate: &C, + immutable: &I, +) -> Result +where + C: Config, + I: F7ImmutableConfig, +{ + let immutable = immutable.f722(); + let system_id = required_u8(candidate, MAVLINK_SYSTEM_ID)?; + if system_id == 0 { + return Err(ConfigViolation::field( + MAVLINK_SYSTEM_ID, + ValidationRule::Zero, + )); + } + let component_id = required_u8(candidate, MAVLINK_COMPONENT_ID)?; + if component_id == 0 { + return Err(ConfigViolation::field( + MAVLINK_COMPONENT_ID, + ValidationRule::Zero, + )); + } + let raw_imu_hz = required_u8(candidate, RAW_IMU_HZ)?; + let heartbeat_hz = required_u8(candidate, HEARTBEAT_HZ)?; + let mavlink_logging_enabled = required_bool(candidate, MAVLINK_LOGGING_ENABLED)?; + let severity = required_u8(candidate, MAVLINK_MAXIMUM_LOG_SEVERITY)?; + let mavlink_maximum_log_severity = + Severity::try_from_wire(severity).ok_or(ConfigViolation::field( + MAVLINK_MAXIMUM_LOG_SEVERITY, + ValidationRule::InvalidEnumValue, + ))?; + let range_hz = if immutable.hardware.has_range() { + Some(required_u8(candidate, RANGE_HZ)?) + } else { + None + }; + let optical_flow_hz = if immutable.hardware.has_optical_flow() { + Some(required_u8(candidate, OPTICAL_FLOW_HZ)?) + } else { + None + }; + + let service_hz = immutable.service_hz; + let mut scheduled_hz = checked_rate(raw_imu_hz, RAW_IMU_HZ, service_hz)? + .checked_add(checked_rate(heartbeat_hz, HEARTBEAT_HZ, service_hz)?) + .ok_or(ConfigViolation::global(ValidationRule::Inconsistent))?; + scheduled_hz = add_optional_rate(range_hz, RANGE_HZ, service_hz, scheduled_hz)?; + scheduled_hz = add_optional_rate(optical_flow_hz, OPTICAL_FLOW_HZ, service_hz, scheduled_hz)?; + if scheduled_hz > service_hz { + return Err(ConfigViolation::global(ValidationRule::Inconsistent)); + } + + Ok(F722UserRuntime { + system_id, + component_id, + mavlink_logging_enabled, + mavlink_maximum_log_severity, + raw_imu_hz, + range_hz, + optical_flow_hz, + heartbeat_hz, + }) +} + +fn required_u8(candidate: &C, field: FieldId) -> Result { + match candidate.field_value(field) { + Some(ConfigValue::U32(value)) => u8::try_from(value) + .map_err(|_| ConfigViolation::field(field, ValidationRule::AboveMaximum)), + Some(_) | None => Err(ConfigViolation::field(field, ValidationRule::Inconsistent)), + } +} + +fn required_bool(candidate: &C, field: FieldId) -> Result { + match candidate.field_value(field) { + Some(ConfigValue::Bool(value)) => Ok(value), + Some(_) | None => Err(ConfigViolation::field(field, ValidationRule::Inconsistent)), + } +} + +fn checked_rate(rate: u8, field: FieldId, service_hz: usize) -> Result { + let rate = usize::from(rate); + if rate == 0 { + return Err(ConfigViolation::field(field, ValidationRule::Zero)); + } + if rate > service_hz { + return Err(ConfigViolation::field(field, ValidationRule::AboveMaximum)); + } + Ok(rate) +} + +fn add_optional_rate( + rate: Option, + field: FieldId, + service_hz: usize, + scheduled_hz: usize, +) -> Result { + match rate { + Some(rate) => scheduled_hz + .checked_add(checked_rate(rate, field, service_hz)?) + .ok_or(ConfigViolation::global(ValidationRule::Inconsistent)), + None => Ok(scheduled_hz), + } +} + +const F722_LARGE_SECTOR_BASE: usize = 0x0802_0000; +const F722_LARGE_SECTOR_BYTES: usize = 128 * 1024; +const F722_FIRST_LARGE_SECTOR: usize = 5; +const FLASH_KEY1: u32 = 0x4567_0123; +const FLASH_KEY2: u32 = 0xcdef_89ab; +const FLASH_ERROR_FLAGS: u32 = 0x01f2; +const BUSY_POLL_LIMIT: u32 = 1_000_000_000; + +/// One schema-independent journal shared by every STM32F722 board image. +pub(crate) type F722ConfigStore = + DualSlotJournal, F722Nor, MAX_PAYLOAD_BYTES>; + +/// Concrete manager storage selected when the board macro monomorphizes the +/// common platform task graph for its aggregate and exact snapshot size. +pub(crate) type F722ConfigManager = + ConfigManager, F722ConfigStore>; + +/// Failure of the STM32F722 embedded-flash controller contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum F722FlashError { + Bounds, + BusyTimeout, + Unlock, + Controller, + Verification, +} + +/// Sole owner of the F722 flash controller used by the configuration journal. +pub(crate) struct F722Nor { + flash: stm32f722::FLASH, +} + +unsafe extern "C" { + static __kaptan_config_slot0_start: u8; + static __kaptan_config_slot1_start: u8; + static __kaptan_config_journal_end: u8; +} + +impl F722Nor { + pub(crate) const fn new(flash: stm32f722::FLASH) -> Self { + Self { flash } + } + + fn slot_bounds(slot: usize) -> Result<(usize, usize), F722FlashError> { + // The shared F722 linker script defines these absolute symbols and + // asserts both slots are adjacent 128 KiB sectors after FLASH. + let bounds = [ + ( + core::ptr::addr_of!(__kaptan_config_slot0_start) as usize, + core::ptr::addr_of!(__kaptan_config_slot1_start) as usize, + ), + ( + core::ptr::addr_of!(__kaptan_config_slot1_start) as usize, + core::ptr::addr_of!(__kaptan_config_journal_end) as usize, + ), + ]; + bounds.get(slot).copied().ok_or(F722FlashError::Bounds) + } + + fn address(slot: usize, offset: usize, length: usize) -> Result { + let (base, limit) = Self::slot_bounds(slot)?; + let slot_bytes = limit.checked_sub(base).ok_or(F722FlashError::Bounds)?; + let end = offset.checked_add(length).ok_or(F722FlashError::Bounds)?; + if end > slot_bytes { + return Err(F722FlashError::Bounds); + } + base.checked_add(offset).ok_or(F722FlashError::Bounds) + } + + fn sector(slot: usize) -> Result { + let (base, limit) = Self::slot_bounds(slot)?; + if limit.checked_sub(base) != Some(F722_LARGE_SECTOR_BYTES) { + return Err(F722FlashError::Bounds); + } + let offset = base + .checked_sub(F722_LARGE_SECTOR_BASE) + .ok_or(F722FlashError::Bounds)?; + if !offset.is_multiple_of(F722_LARGE_SECTOR_BYTES) { + return Err(F722FlashError::Bounds); + } + u8::try_from(F722_FIRST_LARGE_SECTOR + offset / F722_LARGE_SECTOR_BYTES) + .map_err(|_| F722FlashError::Bounds) + } + + fn wait_ready(&self) -> Result<(), F722FlashError> { + let mut remaining = BUSY_POLL_LIMIT; + while self.flash.sr().read().bsy().is_busy() { + if remaining == 0 { + return Err(F722FlashError::BusyTimeout); + } + remaining -= 1; + core::hint::spin_loop(); + } + Ok(()) + } + + fn unlock(&self) -> Result<(), F722FlashError> { + self.wait_ready()?; + if self.flash.cr().read().lock().is_locked() { + // SAFETY: these are the exact two full-width FPEC unlock keys from + // RM0431, written in the required order while the controller is idle. + self.flash + .keyr() + .write(|write| unsafe { write.key().bits(FLASH_KEY1) }); + // SAFETY: this is the second full-width key in the same documented + // idle-controller unlock sequence described above. + self.flash + .keyr() + .write(|write| unsafe { write.key().bits(FLASH_KEY2) }); + } + if self.flash.cr().read().lock().is_locked() { + return Err(F722FlashError::Unlock); + } + Ok(()) + } + + fn prepare(&self) -> Result<(), F722FlashError> { + self.unlock()?; + // SAFETY: all writable FLASH_SR bits in this mask are documented + // write-one-to-clear completion/error flags. Reserved bits remain zero. + self.flash + .sr() + .write(|write| unsafe { write.bits(FLASH_ERROR_FLAGS) }); + Ok(()) + } + + fn finish(&self) -> Result<(), F722FlashError> { + self.wait_ready()?; + let status = self.flash.sr().read(); + self.flash.cr().modify(|_, write| { + write.pg().clear_bit(); + write.ser().clear_bit(); + write.lock().locked() + }); + if status.bits() & FLASH_ERROR_FLAGS != 0 { + return Err(F722FlashError::Controller); + } + if status.eop().is_active() { + self.flash.sr().write(|write| write.eop().clear()); + } + Ok(()) + } +} + +impl DualSlotNor for F722Nor { + type Error = F722FlashError; + + fn slot_size(&self) -> usize { + Self::slot_bounds(0).map_or(0, |(base, limit)| limit - base) + } + + fn read(&mut self, slot: usize, offset: usize, output: &mut [u8]) -> Result<(), Self::Error> { + let address = Self::address(slot, offset, output.len())?; + for (index, byte) in output.iter_mut().enumerate() { + // SAFETY: `address..address + output.len()` was checked to lie in + // one of the two reserved flash slots. D-cache is disabled by F722 clock + // initialization before this store is constructed. + *byte = unsafe { core::ptr::read_volatile((address + index) as *const u8) }; + } + Ok(()) + } + + fn erase_slot(&mut self, slot: usize) -> Result<(), Self::Error> { + let sector = Self::sector(slot)?; + self.prepare()?; + // SAFETY: RM0431 assigns sectors 6 and 7 to the two reserved 128 KiB + // ranges above. No application section is linked into either sector. + self.flash.cr().modify(|_, write| unsafe { + write + .pg() + .clear_bit() + .ser() + .set_bit() + .snb() + .bits(sector) + .psize() + .psize32() + .strt() + .start() + }); + // RM0431 permits a DSB between asserting STRT and polling BSY so the + // control-register write cannot be observed out of order. + cortex_m::asm::dsb(); + self.finish()?; + let mut probe = [0_u8; 4]; + self.read(slot, 0, &mut probe)?; + if probe != [0xff; 4] { + return Err(F722FlashError::Verification); + } + Ok(()) + } + + fn program_word(&mut self, slot: usize, offset: usize, word: u32) -> Result<(), Self::Error> { + if !offset.is_multiple_of(4) { + return Err(F722FlashError::Bounds); + } + let address = Self::address(slot, offset, 4)?; + self.prepare()?; + self.flash + .cr() + .modify(|_, write| write.ser().clear_bit().psize().psize32().pg().set_bit()); + // SAFETY: the checked address is aligned and contained in a reserved + // erased journal sector, and PG with x32 parallelism is active. + unsafe { core::ptr::write_volatile(address as *mut u32, word) }; + // RM0431 requires the programming store to complete before BSY is + // polled or the PG/control state is changed. + cortex_m::asm::dsb(); + self.finish()?; + // SAFETY: the same checked flash word is readable after BSY clears. + if unsafe { core::ptr::read_volatile(address as *const u32) } != word { + return Err(F722FlashError::Verification); + } + Ok(()) + } +} diff --git a/firmware/platforms/stm32f7/src/f722/data_channel.rs b/firmware/platforms/stm32f7/src/f722/data_channel.rs index 8deb057..8dfbc29 100644 --- a/firmware/platforms/stm32f7/src/f722/data_channel.rs +++ b/firmware/platforms/stm32f7/src/f722/data_channel.rs @@ -1,17 +1,17 @@ //! F722 transport and vertical-route adapters for the portable data channel. use super::{VerticalRoute, peripherals}; -use kaptan_data_channel::{DmaCompletion, FrameAdmission, FrameSink, TxHealth, VerticalDataConfig}; +use kaptan_data_channel::{FrameSink, VerticalDataConfig}; use kaptan_dma::TxCompletion; use kaptan_mavlink::{ MavlinkFrame, encoder::DistanceSensorConfig, - transmitter::{FrameClass, MavlinkTransmitter, TransmitFaultKind}, + transmitter::{EnqueueResult, FrameClass, MavlinkTransmitter}, }; -// Board endpoint, baud, sensor topology, and mounting remain immutable -// build-time policy. They are not ground-station-editable in this telemetry -// image: the F722 has no authenticated config ingress or activation runtime. +// Baud, sensor topology, and mounting remain immutable build-time policy. The +// output-inhibited board image may recover endpoint identity and selected +// operational rates before this channel is constructed. pub(crate) const UART1_CLOCK_HZ: u32 = 108_000_000; pub(crate) const UART2_CLOCK_HZ: u32 = 54_000_000; pub(crate) const SERVICE_HZ: usize = 100; @@ -23,7 +23,7 @@ pub(crate) const SERVICE_TIMER_CONFIG: peripherals::Tim2PeriodicConfig = auto_reload: 99, }; -/// Maps a selected vertical electrical route to development data-channel metadata. +/// Maps a selected vertical electrical route to configured data-channel metadata. /// /// The values are telemetry descriptions only; they neither calibrate a flight /// estimator nor authorize an `ExternalControlOnly` profile. @@ -62,11 +62,9 @@ enum F722MavlinkTransport { Usart2(MavlinkTransmitter), } -/// Sole F722 MAVLink DMA owner plus unconfigurable transport-loss accounting. +/// Sole F722 MAVLink DMA owner. pub(crate) struct F722MavlinkTransmitter { inner: F722MavlinkTransport, - health: TxHealth, - pending_faults: u8, } impl F722MavlinkTransmitter { @@ -85,8 +83,6 @@ impl F722MavlinkTransmitter { memory, peripherals::Usart1TxDma::new(rcc, uart), )), - health: TxHealth::default(), - pending_faults: 0, }) } @@ -104,107 +100,35 @@ impl F722MavlinkTransmitter { memory, peripherals::Usart2TxDma::new(rcc, uart), )), - health: TxHealth::default(), - pending_faults: 0, }) } } impl FrameSink for F722MavlinkTransmitter { - fn send(&mut self, class: FrameClass, frame: MavlinkFrame) -> FrameAdmission { - let (admission, start_fault) = match &mut self.inner { + fn send(&mut self, class: FrameClass, frame: MavlinkFrame) -> EnqueueResult { + match &mut self.inner { F722MavlinkTransport::Usart1(transmitter) => { let outcome = transmitter.admit_and_start(class, frame); - ( - outcome.admission, - outcome.start.err().map(|fault| fault.kind()), - ) + let _ = outcome.start; + outcome.admission } F722MavlinkTransport::Usart2(transmitter) => { let outcome = transmitter.admit_and_start(class, frame); - ( - outcome.admission, - outcome.start.err().map(|fault| fault.kind()), - ) + let _ = outcome.start; + outcome.admission } - }; - if !matches!( - admission, - kaptan_mavlink::transmitter::EnqueueResult::Enqueued - ) { - self.health.admission_loss_count = self.health.admission_loss_count.saturating_add(1); - } - if let Some(fault) = start_fault { - self.record_fault(fault); - } - FrameAdmission::Retained(admission) - } - - fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion { - let start_fault = match &mut self.inner { - F722MavlinkTransport::Usart1(transmitter) => transmitter - .on_dma_complete(completion) - .err() - .map(|error| error.kind()), - F722MavlinkTransport::Usart2(transmitter) => transmitter - .on_dma_complete(completion) - .err() - .map(|error| error.kind()), - }; - let terminal = matches!( - completion, - TxCompletion::TransferFault | TxCompletion::Halted - ); - if let Some(fault) = start_fault { - self.record_fault(fault); - } - if terminal { - self.record_fault(TransmitFaultKind::Terminal); - } - if start_fault.is_some() || terminal { - DmaCompletion::Fault - } else { - DmaCompletion::Complete } } - fn health(&self) -> TxHealth { - self.health - } - - fn take_transport_fault(&mut self) -> Option { - let (bit, fault) = if self.pending_faults & 1 != 0 { - (1, TransmitFaultKind::Memory) - } else if self.pending_faults & 2 != 0 { - (2, TransmitFaultKind::Engine) - } else if self.pending_faults & 4 != 0 { - (4, TransmitFaultKind::Terminal) - } else { - return None; - }; - self.pending_faults &= !bit; - Some(fault) - } -} - -impl F722MavlinkTransmitter { - fn record_fault(&mut self, fault: TransmitFaultKind) { - self.health.dma_fault_count = self.health.dma_fault_count.saturating_add(1); - self.pending_faults |= match fault { - TransmitFaultKind::Memory => { - self.health.memory_fault_count = self.health.memory_fault_count.saturating_add(1); - 1 + fn on_dma_complete(&mut self, completion: TxCompletion) { + match &mut self.inner { + F722MavlinkTransport::Usart1(transmitter) => { + let _ = transmitter.on_dma_complete(completion); } - TransmitFaultKind::Engine => { - self.health.engine_fault_count = self.health.engine_fault_count.saturating_add(1); - 2 + F722MavlinkTransport::Usart2(transmitter) => { + let _ = transmitter.on_dma_complete(completion); } - TransmitFaultKind::Terminal => { - self.health.terminal_fault_count = - self.health.terminal_fault_count.saturating_add(1); - 4 - } - }; + } } } diff --git a/firmware/platforms/stm32f7/src/f722/firmware.rs b/firmware/platforms/stm32f7/src/f722/firmware.rs index e9d1210..f85b7fe 100644 --- a/firmware/platforms/stm32f7/src/f722/firmware.rs +++ b/firmware/platforms/stm32f7/src/f722/firmware.rs @@ -3,8 +3,10 @@ /// Defines the F722 application in the platform crate. All interrupt bindings /// are the finite platform-owned superset. Profile activation determines which /// acknowledged route has initialized hardware. -macro_rules! f722_flight_application { - ($routes:path, $config:path) => { +#[doc(hidden)] +#[macro_export] +macro_rules! __f722_flight_application { + ($board:ty) => { #[allow(unused_imports)] use $crate::__platform_internal::cmrt as _; #[allow(unused_imports)] @@ -36,12 +38,29 @@ macro_rules! f722_flight_application { #[allow(unused_imports)] use $crate::__platform_internal::rtic_app::Mutex; + type BoardUserConfig = <$board as $crate::F7Board>::UserConfig; + type BoardImmutableConfig = <$board as $crate::F7Board>::ImmutableConfig; + const CONFIG_PAYLOAD_BYTES: usize = $crate::config_snapshot_length::< + BoardUserConfig, + >(); + type BoardInitResources = F722InitResources< + BoardUserConfig, + BoardImmutableConfig, + CONFIG_PAYLOAD_BYTES, + >; + type BoardMavlinkState = F722MavlinkState< + BoardUserConfig, + BoardImmutableConfig, + CONFIG_PAYLOAD_BYTES, + >; + const IMU_EVENT_DRDY: u8 = 1 << 0; const IMU_EVENT_TRANSFER_COMPLETE: u8 = 1 << 1; const IMU_EVENT_TRANSFER_FAULT: u8 = 1 << 2; const IMU_EVENT_TIMER: u8 = 1 << 3; const IMU_EVENT_START: u8 = 1 << 4; const MAVLINK_EVENT_TIMER: u8 = 1 << 0; + const MAVLINK_EVENT_RECEIVED: u8 = 1 << 1; const MAVLINK_EVENT_DMA: u8 = 1 << 2; // Hardware IRQs and both software workers coalesce event bits. @@ -55,7 +74,7 @@ macro_rules! f722_flight_application { #[shared] struct Shared { - mavlink: F722MavlinkState, + mavlink: BoardMavlinkState, lidar: F722LidarState, } @@ -69,16 +88,20 @@ macro_rules! f722_flight_application { #[init] fn init(context: init::Context) -> (Shared, Local) { - let routes = $routes().unwrap_or_else(|_| safe_idle()); - let F722InitResources { + let BoardInitResources { imu, mtf01, mtf01_uart, timer, lidar, mavlink, - } = initialize(context.core, context.device, $config(), routes) - .unwrap_or_else(|_| safe_idle()); + } = initialize::( + context.core, + context.device, + <$board as $crate::F7Board>::FACTORY_USER_CONFIG, + <$board as $crate::F7Board>::IMMUTABLE_CONFIG, + ) + .unwrap_or_else(|_| safe_idle()); schedule_imu(IMU_EVENT_START); ( Shared { @@ -200,20 +223,28 @@ macro_rules! f722_flight_application { } } - #[task(binds = USART1, priority = 8)] - fn mavlink_rx_usart1(_: mavlink_rx_usart1::Context) { - // `ExternalControlOnly` is rejected and production signing is - // not implemented, so this image has no inbound authority - // path. Acknowledge every RX condition to keep the peripheral - // quiescent without taking any RTIC-shared flight state. - let _ = acknowledge_mavlink_rx_usart1(); + #[task(binds = USART1, priority = 8, shared = [mavlink])] + fn mavlink_rx_usart1(mut context: mavlink_rx_usart1::Context) { + let event = acknowledge_mavlink_rx_usart1(); + if context + .shared + .mavlink + .lock(|mavlink| mavlink.receive_config_interrupt(event)) + { + schedule_mavlink(MAVLINK_EVENT_RECEIVED); + } } - #[task(binds = USART2, priority = 8)] - fn mavlink_rx_usart2(_: mavlink_rx_usart2::Context) { - // See USART1: inbound bytes are drained but never decoded or - // dispatched by the telemetry-only image. - let _ = acknowledge_mavlink_rx_usart2(); + #[task(binds = USART2, priority = 8, shared = [mavlink])] + fn mavlink_rx_usart2(mut context: mavlink_rx_usart2::Context) { + let event = acknowledge_mavlink_rx_usart2(); + if context + .shared + .mavlink + .lock(|mavlink| mavlink.receive_config_interrupt(event)) + { + schedule_mavlink(MAVLINK_EVENT_RECEIVED); + } } #[task(binds = TIM2, priority = 4, local = [timer], shared = [lidar])] @@ -276,5 +307,3 @@ macro_rules! f722_flight_application { } }; } - -pub(crate) use f722_flight_application; diff --git a/firmware/platforms/stm32f7/src/f722/mod.rs b/firmware/platforms/stm32f7/src/f722/mod.rs index 8f38996..4bb6748 100644 --- a/firmware/platforms/stm32f7/src/f722/mod.rs +++ b/firmware/platforms/stm32f7/src/f722/mod.rs @@ -7,6 +7,7 @@ mod board; mod clock; +mod config; mod data_channel; mod firmware; mod interrupts; @@ -15,12 +16,13 @@ mod peripherals; mod routes; mod sensors; -use crate::{BoardError, F722BoardRoutes}; -use kaptan_board::BoardConfig; +use kaptan_config::Config; + +use crate::{BoardError, F7ImmutableConfig}; /// Completion classification crossing the RTIC interrupt template. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum F722ImuTransferCompletion { +pub enum F722ImuTransferCompletion { Complete, TransferFault, NotPending, @@ -43,59 +45,36 @@ pub(crate) enum VerticalRoute { LidarLiteV3, } -pub(crate) use board::{ +pub use board::{ F722InitResources, F722LidarState, F722MavlinkState, F722Mtf01, F722Mtf01Uart, F722ServiceTimer, on_uart4_interrupt, process_imu, record_mavlink_completion, - validate_board_routes, }; -pub(crate) use interrupts::{ +pub use interrupts::{ acknowledge_exti3, acknowledge_exti4, acknowledge_imu_rx_dma1, acknowledge_imu_rx_dma2, acknowledge_imu_spi1_error, acknowledge_imu_spi3_error, acknowledge_imu_tx_dma1, acknowledge_imu_tx_dma2, acknowledge_mavlink_dma1, acknowledge_mavlink_dma2, acknowledge_mavlink_rx_usart1, acknowledge_mavlink_rx_usart2, }; -pub(crate) use sensors::F722Imu; +pub use sensors::F722Imu; /// Materializes a board declaration after RTIC has exclusively taken the PAC -/// peripherals. The route/config check happens before any pin or peripheral -/// is initialized, so a malformed board declaration cannot drive hardware. -pub fn initialize( +/// peripherals. The output-inhibit write is the first hardware action; route +/// and configuration checks precede all non-safety hardware initialization. +pub fn initialize( core: cortex_m::Peripherals, device: stm32f7::stm32f722::Peripherals, - config: BoardConfig, - routes: F722BoardRoutes, -) -> Result { + factory: C, + immutable: I, +) -> Result, BoardError> +where + C: Clone + Config, + I: F7ImmutableConfig, +{ // This is deliberately the first hardware access, before configuration // validation, clocks, sensor routes, interrupts, or UART setup. It keeps // the former four motor pins low even for malformed configuration. - motor_inhibit::inhibit_motor_pins(&device.RCC, &device.GPIOB); - crate::validate_board_config(config, routes)?; - board::initialize(core, device, config, routes) + motor_inhibit::inhibit_pb0_pb1_pb4_pb5(&device.RCC, &device.GPIOB); + let hardware = immutable.f722().hardware; + board::validate_board_config(&factory, &immutable, hardware)?; + board::initialize::(core, device, factory, immutable, hardware) } - -unsafe extern "Rust" { - static KAPTAN_F722_BOARD_CONFIG: BoardConfig; - fn kaptan_f722_board_routes() -> Result; -} - -fn registered_config() -> BoardConfig { - // SAFETY: `flight_firmware!` emits this immutable symbol exactly once in - // the selected board binary. The platform reads it only during RTIC init, - // before any interrupt or worker can observe configuration state. - unsafe { KAPTAN_F722_BOARD_CONFIG } -} - -fn registered_routes() -> Result { - // SAFETY: `flight_firmware!` emits this function exactly once in the board - // binary. It only constructs a safe descriptor; it cannot touch PAC state - // because the board builder contains no peripherals. - unsafe { kaptan_f722_board_routes() } -} - -// This is one source-level task graph. Its compile-time bindings are the -// platform-owned finite interrupt superset, independent of the board crate. -#[cfg(target_os = "none")] -firmware::f722_flight_application!( - crate::f722::registered_routes, - crate::f722::registered_config -); diff --git a/firmware/platforms/stm32f7/src/f722/motor_inhibit.rs b/firmware/platforms/stm32f7/src/f722/motor_inhibit.rs index 33b8ff6..8f85099 100644 --- a/firmware/platforms/stm32f7/src/f722/motor_inhibit.rs +++ b/firmware/platforms/stm32f7/src/f722/motor_inhibit.rs @@ -7,8 +7,13 @@ use stm32f7::stm32f722; /// Drives PB0/PB1/PB4/PB5 low before selecting push-pull GPIO output mode. -pub(crate) fn inhibit_motor_pins(rcc: &stm32f722::RCC, port: &stm32f722::GPIOB) { +pub(crate) fn inhibit_pb0_pb1_pb4_pb5(rcc: &stm32f722::RCC, port: &stm32f722::GPIOB) { rcc.ahb1enr().modify(|_, write| write.gpioben().set_bit()); + // RM0431 section 5.2.12 requires two peripheral clock cycles before the + // first access. The volatile enable-register read supplies the standard + // synchronization delay and prevents the GPIO write from being reordered + // ahead of the clock enable. + let _ = rcc.ahb1enr().read().gpioben().bit_is_set(); port.bsrr().write(|write| { write .br0() diff --git a/firmware/platforms/stm32f7/src/f722/peripherals/usart.rs b/firmware/platforms/stm32f7/src/f722/peripherals/usart.rs index 5471a5a..b632e8b 100644 --- a/firmware/platforms/stm32f7/src/f722/peripherals/usart.rs +++ b/firmware/platforms/stm32f7/src/f722/peripherals/usart.rs @@ -82,6 +82,13 @@ impl Usart2Tx { self.peripheral.cr3().write(|write| write.dmat().set_bit()); } + pub(crate) fn disable_dma_transmit_from_interrupt() { + // SAFETY: the fixed USART2 DMA interrupt route is the sole TX-DMA + // owner. Receive interrupts never modify CR3. + let peripheral = unsafe { &*stm32f722::USART2::ptr() }; + peripheral.cr3().modify(|_, write| write.dmat().clear_bit()); + } + pub(crate) fn transmit_data_register_address(&self) -> u32 { self.peripheral.tdr().as_ptr() as u32 } @@ -138,6 +145,13 @@ impl Usart1Tx { self.peripheral.cr3().write(|write| write.dmat().set_bit()); } + pub(crate) fn disable_dma_transmit_from_interrupt() { + // SAFETY: the fixed USART1 DMA interrupt route is the sole TX-DMA + // owner. Receive interrupts never modify CR3. + let peripheral = unsafe { &*stm32f722::USART1::ptr() }; + peripheral.cr3().modify(|_, write| write.dmat().clear_bit()); + } + pub(crate) fn transmit_data_register_address(&self) -> u32 { self.peripheral.tdr().as_ptr() as u32 } diff --git a/firmware/platforms/stm32f7/src/f722/peripherals/usart1_tx_dma2_stream7.rs b/firmware/platforms/stm32f7/src/f722/peripherals/usart1_tx_dma2_stream7.rs index 5029a17..c3128ba 100644 --- a/firmware/platforms/stm32f7/src/f722/peripherals/usart1_tx_dma2_stream7.rs +++ b/firmware/platforms/stm32f7/src/f722/peripherals/usart1_tx_dma2_stream7.rs @@ -30,11 +30,9 @@ pub enum StartError { } impl Usart1TxDma { - /// Enables DMA2 and connects USART1 TX to its fixed request route. + /// Enables DMA2 for USART1's fixed transmit route. pub fn new(rcc: &stm32f722::RCC, usart: Usart1Tx) -> Self { rcc.ahb1enr().modify(|_, write| write.dma2en().set_bit()); - usart.enable_dma_transmit(); - Self { usart } } @@ -58,6 +56,9 @@ impl Usart1TxDma { self.clear_interrupt_flags(); self.configure_stream(bytes); self.stream().cr().modify(|_, write| write.en().enabled()); + // AN4031 section 4.3 requires the stream to be enabled before the + // peripheral request. Reversing this order can raise a spurious FEIF. + self.usart.enable_dma_transmit(); Ok(()) } @@ -71,7 +72,6 @@ impl Usart1TxDma { transfer_complete: status.tcif7().is_complete(), transfer_error: status.teif7().is_error(), direct_mode_error: status.dmeif7().is_error(), - fifo_error: status.feif7().is_error(), } .classify(); if completion == TxCompletion::NotPending { @@ -92,6 +92,7 @@ impl Usart1TxDma { break; } } + Usart1Tx::disable_dma_transmit_from_interrupt(); controller.hifcr().write(|write| { write .ctcif7() @@ -114,6 +115,9 @@ impl Usart1TxDma { } fn configure_stream(&mut self, buffer: &[u8]) { + self.stream() + .fcr() + .write(|write| write.dmdis().enabled().feie().disabled()); self.stream().cr().write(|write| { // SAFETY: Channel 4 and these transfer fields are the documented // USART1 TX mapping for DMA2 stream 7 on STM32F722. @@ -189,12 +193,15 @@ struct DmaTerminalStatus { transfer_complete: bool, transfer_error: bool, direct_mode_error: bool, - fifo_error: bool, } impl DmaTerminalStatus { const fn classify(self) -> TxCompletion { - if self.transfer_error || self.direct_mode_error || self.fifo_error { + // This route uses direct mode. FEIF can be raised when a peripheral + // request arrives before DMA can provide data; it does not terminate + // the stream and is cleared with the other status flags. TEIF and + // DMEIF remain genuine terminal faults. + if self.transfer_error || self.direct_mode_error { TxCompletion::TransferFault } else if self.transfer_complete { TxCompletion::Complete @@ -211,21 +218,3 @@ impl DmaTxEngine for Usart1TxDma { Self::start(self, buffer) } } - -#[cfg(test)] -mod tests { - use super::DmaTerminalStatus; - use kaptan_dma::TxCompletion; - - #[test] - fn terminal_error_has_precedence_over_completion() { - let status = DmaTerminalStatus { - transfer_complete: true, - transfer_error: false, - direct_mode_error: false, - fifo_error: true, - }; - - assert_eq!(status.classify(), TxCompletion::TransferFault); - } -} diff --git a/firmware/platforms/stm32f7/src/f722/peripherals/usart2_tx_dma1_stream6.rs b/firmware/platforms/stm32f7/src/f722/peripherals/usart2_tx_dma1_stream6.rs index 0821e9b..ee6b23c 100644 --- a/firmware/platforms/stm32f7/src/f722/peripherals/usart2_tx_dma1_stream6.rs +++ b/firmware/platforms/stm32f7/src/f722/peripherals/usart2_tx_dma1_stream6.rs @@ -10,6 +10,10 @@ use stm32f7::stm32f722; use super::Usart2Tx; +// Match the bounded terminal observation used by the USART1 route. A later +// service turn handles the documented short interval before EN reads low. +const DISABLE_POLL_LIMIT: u8 = 32; + /// Owns the fixed USART2 TX DMA1 Stream6 route. pub struct Usart2TxDma { usart: Usart2Tx, @@ -25,10 +29,9 @@ pub enum Usart2TxDmaStartError { } impl Usart2TxDma { - /// Enables DMA1 and connects USART2 TX to its fixed request route. + /// Enables DMA1 for USART2's fixed transmit route. pub fn new(rcc: &stm32f722::RCC, usart: Usart2Tx) -> Self { rcc.ahb1enr().modify(|_, write| write.dma1en().set_bit()); - usart.enable_dma_transmit(); Self { usart } } @@ -48,6 +51,9 @@ impl Usart2TxDma { self.clear_interrupt_flags(); self.configure_stream(bytes); self.stream().cr().modify(|_, write| write.en().enabled()); + // AN4031 section 4.3 requires the stream to be enabled before the + // peripheral request. Reversing this order can raise a spurious FEIF. + self.usart.enable_dma_transmit(); Ok(()) } @@ -60,7 +66,6 @@ impl Usart2TxDma { transfer_complete: status.tcif6().is_complete(), transfer_error: status.teif6().is_error(), direct_mode_error: status.dmeif6().is_error(), - fifo_error: status.feif6().is_error(), } .classify(); if completion == TxCompletion::NotPending { @@ -71,6 +76,12 @@ impl Usart2TxDma { .st(6) .cr() .modify(|_, write| write.en().disabled()); + for _ in 0..DISABLE_POLL_LIMIT { + if !controller.st(6).cr().read().en().is_enabled() { + break; + } + } + Usart2Tx::disable_dma_transmit_from_interrupt(); controller.hifcr().write(|write| { write .ctcif6() @@ -93,6 +104,9 @@ impl Usart2TxDma { } fn configure_stream(&mut self, buffer: &[u8]) { + self.stream() + .fcr() + .write(|write| write.dmdis().enabled().feie().disabled()); self.stream().cr().write(|write| { // SAFETY: Channel 4 and these transfer fields are the documented // USART2 TX mapping for DMA1 Stream6 on STM32F722. @@ -168,12 +182,15 @@ struct DmaTerminalStatus { transfer_complete: bool, transfer_error: bool, direct_mode_error: bool, - fifo_error: bool, } impl DmaTerminalStatus { const fn classify(self) -> TxCompletion { - if self.transfer_error || self.direct_mode_error || self.fifo_error { + // This route uses direct mode. FEIF can be raised when a peripheral + // request arrives before DMA can provide data; it does not terminate + // the stream and is cleared with the other status flags. TEIF and + // DMEIF remain genuine terminal faults. + if self.transfer_error || self.direct_mode_error { TxCompletion::TransferFault } else if self.transfer_complete { TxCompletion::Complete @@ -190,20 +207,3 @@ impl DmaTxEngine for Usart2TxDma { Self::start(self, buffer) } } - -#[cfg(test)] -mod tests { - use super::DmaTerminalStatus; - use kaptan_dma::TxCompletion; - - #[test] - fn terminal_error_has_precedence_over_completion() { - let status = DmaTerminalStatus { - transfer_complete: true, - transfer_error: false, - direct_mode_error: false, - fifo_error: true, - }; - assert_eq!(status.classify(), TxCompletion::TransferFault); - } -} diff --git a/firmware/platforms/stm32f7/src/f722/routes.rs b/firmware/platforms/stm32f7/src/f722/routes.rs index 10077f9..44040b9 100644 --- a/firmware/platforms/stm32f7/src/f722/routes.rs +++ b/firmware/platforms/stm32f7/src/f722/routes.rs @@ -7,7 +7,6 @@ use stm32f7::stm32f722; /// Owns the MPU6000 SPI1 transaction path and its PB2 chip select. pub(crate) struct Mpu6000Spi1Route { spi: Spi1, - chip_select: stm32f722::GPIOB, } impl Mpu6000Spi1Route { @@ -26,15 +25,29 @@ impl Mpu6000Spi1Route { .pupdr() .modify(|_, write| write.pupdr2().floating()); chip_select.bsrr().write(|write| write.bs2().set_bit()); - Self { spi, chip_select } + // Drop the full GPIOB owner after initialization. Runtime access is + // deliberately limited to PB2's atomic BSRR bits below, leaving no + // route-owned PAC token that could reconfigure the former motor pins. + Self { spi } + } + + fn chip_select_port() -> &'static stm32f722::gpiob::RegisterBlock { + // SAFETY: RTIC initialization configured PB2 once before interrupts + // were enabled. Runtime writes use only PB2's atomic BSRR bits. No + // runtime owner retains GPIOB or performs GPIOB read-modify-write. + unsafe { &*stm32f722::GPIOB::ptr() } } fn select(&self) { - self.chip_select.bsrr().write(|write| write.br2().set_bit()); + Self::chip_select_port() + .bsrr() + .write(|write| write.br2().set_bit()); } fn deselect(&self) { - self.chip_select.bsrr().write(|write| write.bs2().set_bit()); + Self::chip_select_port() + .bsrr() + .write(|write| write.bs2().set_bit()); } } diff --git a/firmware/platforms/stm32f7/src/f722/sensors.rs b/firmware/platforms/stm32f7/src/f722/sensors.rs index a971735..acb2776 100644 --- a/firmware/platforms/stm32f7/src/f722/sensors.rs +++ b/firmware/platforms/stm32f7/src/f722/sensors.rs @@ -1,29 +1,18 @@ //! Platform-owned F722 sensor-service assembly. use super::peripherals::{DwtMicrosecondClock, Spi1, Spi3}; -use kaptan_board::ImuMount; use kaptan_drivers::{ icm42688::{Icm42688, Icm42688Service, Icm42688ServiceEvent}, - mpu6000::{ - AccelerometerRange, DigitalLowPassFilter, GyroscopeRange, Mpu6000, Mpu6000Config, - Mpu6000Service, Mpu6000ServiceEvent, - }, + mpu6000::{Mpu6000, Mpu6000Config, Mpu6000Service, Mpu6000ServiceEvent}, }; use kaptan_log::{ErrorReport, StaticError}; use kaptan_sensor::imu::ImuSnapshot; use kaptan_spi::SpiCompletion; use stm32f7::stm32f722; -use super::routes::{Icm42688Spi3Route, Mpu6000Spi1Route}; +use crate::ImuMount; -// This is fixed driver setup for the selected board route. It is distinct from -// the immutable board mount and from a future safety-gated calibration flow. -const MPU6000_CONFIG: Mpu6000Config = Mpu6000Config { - accelerometer: AccelerometerRange::PlusMinus4G, - gyroscope: GyroscopeRange::PlusMinus500DegreesPerSecond, - digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz, - sample_rate_divider: 9, -}; +use super::routes::{Icm42688Spi3Route, Mpu6000Spi1Route}; /// A profile-neutral IMU worker result. Device protocol errors are consumed /// here because flight policy only needs the terminal healthy/fault outcome. @@ -58,13 +47,14 @@ impl F722Imu { chip_select: stm32f722::GPIOB, clock: DwtMicrosecondClock, mount: ImuMount, + config: Mpu6000Config, ) -> Self { Self { inner: F722ImuKind::Mpu6000(Mpu6000Service::new( clock, Mpu6000::new( Mpu6000Spi1Route::new(rcc, Spi1::new(rcc, spi, dma), chip_select), - MPU6000_CONFIG, + config, ), )), mount, @@ -171,7 +161,7 @@ fn align_mount(mut sample: ImuSnapshot, mount: ImuMount) -> ImuSnapshot { #[cfg(test)] mod tests { use super::align_mount; - use kaptan_board::ImuMount; + use crate::ImuMount; use kaptan_sensor::imu::ImuSnapshot; use kaptan_time::Timestamp; diff --git a/firmware/platforms/stm32f7/src/lib.rs b/firmware/platforms/stm32f7/src/lib.rs index ff4d69e..59f2d9b 100644 --- a/firmware/platforms/stm32f7/src/lib.rs +++ b/firmware/platforms/stm32f7/src/lib.rs @@ -1,375 +1,556 @@ //! STM32F7 execution platform for declarative Kaptan boards. //! //! A board crate selects the safe route descriptors below and invokes -//! [`flight_firmware!`]. The macro emits only a declarative link-time board -//! registration; the one RTIC application for every supported STM32F722 -//! profile is defined and compiled in this crate. Consequently RTIC, PAC -//! ownership, interrupt acknowledgement, DMA and atomics remain platform -//! implementation details. +//! [`flight_firmware!`]. The macro specializes one platform-defined RTIC task +//! graph for that board's configuration aggregate. RTIC, PAC ownership, +//! interrupt acknowledgement, DMA, and atomics remain platform implementation +//! details rather than board-package code. #![no_std] -use core::marker::PhantomData; +use kaptan_config::Config; use kaptan_log::KaptanError; -pub use kaptan_board::BoardConfig; -use kaptan_board::{BoardConfigError, ImuDevice, ImuMount, MavlinkPort}; +pub use kaptan_drivers::mpu6000::{ + AccelerometerRange, DigitalLowPassFilter, GyroscopeRange, Mpu6000Config, +}; +pub use kaptan_log::Severity; +/// Fixed sensor-to-body mounting transform. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ImuMount { + /// Sensor axes already match the vehicle body axes. + BodyAligned, + /// Sensor is rotated 180 degrees around the body Z axis. + Yaw180, +} + +#[doc(hidden)] #[cfg_attr( not(target_os = "none"), allow(dead_code, unused_imports, unused_macros) )] -mod f722; +pub mod f722; -/// Dependencies used only while the platform expands its private RTIC macro. -/// They stay crate-private: board binaries never name them. +/// Computes the exact bounded snapshot storage selected by a board aggregate. +#[doc(hidden)] +pub const fn config_snapshot_length() -> usize { + assert!( + C::FIELD_COUNT <= u16::MAX as usize, + "configuration schema exceeds wire field-count domain" + ); + kaptan_config::scalar_snapshot_length(C::FIELD_COUNT as u16) +} + +/// Dependencies named only by the platform's exported RTIC macro expansion. +/// Board source never depends on these implementation details directly. +#[doc(hidden)] #[cfg_attr(not(target_os = "none"), allow(unused_imports))] -pub(crate) mod __platform_internal { - pub(crate) use cortex_m as cm; - pub(crate) use cortex_m_rt as cmrt; - pub(crate) use kaptan_dma as dma; +pub mod __platform_internal { + pub use cortex_m as cm; + pub use cortex_m_rt as cmrt; + pub use kaptan_dma as dma; #[cfg(target_os = "none")] - pub(crate) use panic_halt as panic; - pub(crate) use rtic as rtic_app; - pub(crate) use stm32f7 as pac; + pub use panic_halt as panic; + pub use rtic as rtic_app; + pub use stm32f7 as pac; #[cfg(not(target_os = "none"))] - pub(crate) mod panic {} + pub mod panic {} } /// Construction or validation failure for a declarative F722 board. #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum BoardError { - /// Portable board metadata is self-inconsistent. - InvalidConfig(BoardConfigError), - /// Metadata and the selected vertical-sensor route disagree. + /// The immutable vertical route and its electrical parameters disagree. VerticalSensorCapabilityMismatch, - /// Metadata and the selected immutable IMU route disagree. - ImuRouteMismatch, - /// Metadata and the selected immutable MAVLink route disagree. - MavlinkRouteMismatch, - /// Metadata and the selected immutable IMU mounting transform disagree. - ImuMountMismatch, /// The declaration requests a service-timer frequency not implemented by /// the selected F722 platform profile. UnsupportedServiceFrequency, - /// The selected baud cannot carry the fixed telemetry/log schedule. + /// The selected baud cannot carry the deterministic telemetry schedule. TelemetryLinkOvercommitted, /// The platform could not establish a clock, peripheral, or DMA owner. Initialization, -} - -/// Validates immutable board metadata against a selected F722 route graph. -/// -/// This is pure validation: it does not claim a pin, clock, interrupt, DMA -/// stream, or peripheral. The runtime invokes it immediately after the motor -/// inhibit boundary and before every other hardware action. -pub fn validate_board_config( - config: BoardConfig, - routes: F722BoardRoutes, -) -> Result<(), BoardError> { - config.validate().map_err(BoardError::InvalidConfig)?; - f722::validate_board_routes(config, routes) + /// The target configuration could not be recovered from its flash journal. + Configuration, } /// MPU6000 component selector. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Mpu6000; - -/// The supported MPU6000 SPI1 electrical route before selecting DRDY. +/// MPU6000 SPI1 route awaiting a data-ready pin. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Mpu6000Spi1; - -/// The complete MPU6000 SPI1 / PA4 data-ready route. +/// MPU6000 SPI1/PA4 route awaiting its physical mount. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Mpu6000Spi1Exti4; +/// Mounted MPU6000 route awaiting its driver configuration. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MountedMpu6000 { + mount: ImuMount, +} +/// Complete MPU6000 declaration. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ConfiguredMpu6000 { + mount: ImuMount, + config: Mpu6000Config, +} impl Mpu6000 { - /// Selects the supported SPI1 MPU6000 connection. pub const fn on_spi1() -> Mpu6000Spi1 { Mpu6000Spi1 } } impl Mpu6000Spi1 { - /// Completes the supported PA4 data-ready connection. pub const fn with_drdy(self, _: Pa4) -> Mpu6000Spi1Exti4 { Mpu6000Spi1Exti4 } } +impl Mpu6000Spi1Exti4 { + pub const fn mounted(self, mount: ImuMount) -> MountedMpu6000 { + MountedMpu6000 { mount } + } +} + +impl MountedMpu6000 { + pub const fn configured(self, config: Mpu6000Config) -> ConfiguredMpu6000 { + ConfiguredMpu6000 { + mount: self.mount, + config, + } + } +} + /// ICM-42688-P component selector. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Icm42688; - -/// The supported ICM-42688-P SPI3 electrical route before selecting DRDY. +/// ICM-42688-P SPI3 route awaiting a data-ready pin. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Icm42688Spi3; - -/// The complete ICM-42688-P SPI3 / PC3 data-ready route. +/// ICM-42688-P SPI3/PC3 route awaiting its physical mount. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Icm42688Spi3Exti3; +/// Complete ICM-42688-P declaration. The driver currently exposes no tuning +/// object, so the manifest does not pretend one exists. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MountedIcm42688 { + mount: ImuMount, +} impl Icm42688 { - /// Selects the supported SPI3 ICM-42688-P connection. pub const fn on_spi3() -> Icm42688Spi3 { Icm42688Spi3 } } impl Icm42688Spi3 { - /// Completes the supported PC3 data-ready connection. pub const fn with_drdy(self, _: Pc3) -> Icm42688Spi3Exti3 { Icm42688Spi3Exti3 } } -/// MPU6000 data-ready pin selector. +impl Icm42688Spi3Exti3 { + pub const fn mounted(self, mount: ImuMount) -> MountedIcm42688 { + MountedIcm42688 { mount } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Pa4; -/// ICM-42688-P data-ready pin selector. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Pc3; /// MAVLink component selector. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Mavlink; -/// Validated MAVLink route using USART1. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct MavlinkUsart1; -/// Validated MAVLink route using USART2. +pub struct UnconfiguredMavlinkUsart1; #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct MavlinkUsart2; +pub struct UnconfiguredMavlinkUsart2; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MavlinkUsart1 { + baud: u32, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MavlinkUsart2 { + baud: u32, +} impl Mavlink { - /// Selects the supported USART1 MAVLink route. - pub const fn on_usart1() -> MavlinkUsart1 { - MavlinkUsart1 + pub const fn on_usart1() -> UnconfiguredMavlinkUsart1 { + UnconfiguredMavlinkUsart1 } - /// Selects the supported USART2 MAVLink route. - pub const fn on_usart2() -> MavlinkUsart2 { - MavlinkUsart2 + pub const fn on_usart2() -> UnconfiguredMavlinkUsart2 { + UnconfiguredMavlinkUsart2 + } +} + +impl UnconfiguredMavlinkUsart1 { + pub const fn baud(self, baud: u32) -> MavlinkUsart1 { + MavlinkUsart1 { baud } + } +} + +impl UnconfiguredMavlinkUsart2 { + pub const fn baud(self, baud: u32) -> MavlinkUsart2 { + MavlinkUsart2 { baud } } } /// Vertical-sensor component selector. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct VerticalSensor; -/// No vertical-sensor connection is selected. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct NoVerticalSensor; -/// Validated MTF-01 Microlink route. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct Mtf01Uart4; -/// Validated LIDAR-Lite v3 route. +pub struct UnconfiguredMtf01Uart4; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Mtf01Uart4 { + baud: u32, +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct LidarLiteV3I2c1; impl VerticalSensor { - /// Declares no vertical-sensor peripheral. pub const fn none() -> NoVerticalSensor { NoVerticalSensor } - /// Selects the supported MTF-01 Microlink connection. - pub const fn mtf01_on_uart4() -> Mtf01Uart4 { - Mtf01Uart4 + pub const fn mtf01_on_uart4() -> UnconfiguredMtf01Uart4 { + UnconfiguredMtf01Uart4 } - /// Selects the supported LIDAR-Lite v3 connection. pub const fn lidar_lite_v3_on_i2c1() -> LidarLiteV3I2c1 { LidarLiteV3I2c1 } } -/// Unselected typestate used only while a board declaration is incomplete. +impl UnconfiguredMtf01Uart4 { + pub const fn baud(self, baud: u32) -> Mtf01Uart4 { + Mtf01Uart4 { baud } + } +} + +/// Required boot-time output inhibition route. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Pb0Pb1Pb4Pb5Low; +/// Kakute status LED on PD2, active low. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Pd2ActiveLow; +/// GEPRC status LED on PA13, active high. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Pa13ActiveHigh; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Missing; -/// Safe typestate builder for a declarative F722 board. -/// -/// This type has no peripherals, PAC types, interrupt names, DMA identifiers, -/// atomics, or unsafe operations. The F722 platform has one fixed validated -/// clock tree, so only board-selectable electrical routes participate in -/// typestate. A final `build()` exists only for complete supported route -/// combinations, so omitted or cross-wired essential routes fail to compile at -/// the board declaration. -pub struct F7BoardBuilder { - _state: PhantomData<(Imu, MavlinkRoute, Vertical)>, + +/// Complete, non-executing hardware manifest stored in [`F722ImmutableConfig`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct F722Hardware { + profile: F722Profile, + vertical: f722::VerticalRoute, + imu_mount: ImuMount, + mpu6000_config: Option, + mavlink_baud: u32, + vertical_sensor_baud: Option, + status_led: F722StatusLedRoute, } -impl F7BoardBuilder { - /// Starts an empty board declaration. - pub const fn new() -> Self { - Self { - _state: PhantomData, +impl F722Hardware { + /// Starts an immutable hardware declaration. Only complete, compatible + /// declarations expose [`F722HardwareBuilder::telemetry_only`]. + #[allow(clippy::new_ret_no_self)] + pub const fn new() -> F722HardwareBuilder { + F722HardwareBuilder { + output: Missing, + imu: Missing, + mavlink: Missing, + vertical: Missing, + status_led: Missing, + } + } + + pub(crate) const fn profile(self) -> F722Profile { + self.profile + } + + pub(crate) const fn vertical(self) -> f722::VerticalRoute { + self.vertical + } + + pub(crate) const fn imu_mount(self) -> ImuMount { + self.imu_mount + } + + pub(crate) const fn mpu6000_config(self) -> Option { + self.mpu6000_config + } + + pub(crate) const fn mavlink_baud(self) -> u32 { + self.mavlink_baud + } + + pub(crate) const fn vertical_sensor_baud(self) -> Option { + self.vertical_sensor_baud + } + + pub(crate) const fn status_led(self) -> F722StatusLedRoute { + self.status_led + } + + #[doc(hidden)] + pub const fn has_range(self) -> bool { + matches!( + self.vertical, + f722::VerticalRoute::Mtf01 | f722::VerticalRoute::LidarLiteV3 + ) + } + + #[doc(hidden)] + pub const fn has_optical_flow(self) -> bool { + matches!(self.vertical, f722::VerticalRoute::Mtf01) + } +} + +/// Typestate used only while constructing [`F722Hardware`]. +pub struct F722HardwareBuilder< + Output = Missing, + Imu = Missing, + Link = Missing, + Vertical = Missing, + Led = Missing, +> { + output: Output, + imu: Imu, + mavlink: Link, + vertical: Vertical, + status_led: Led, +} + +impl F722HardwareBuilder { + pub const fn output_inhibit( + self, + output: Pb0Pb1Pb4Pb5Low, + ) -> F722HardwareBuilder { + F722HardwareBuilder { + output, + imu: self.imu, + mavlink: self.mavlink, + vertical: self.vertical, + status_led: self.status_led, } } } -impl Default for F7BoardBuilder { - fn default() -> Self { - Self::new() +impl F722HardwareBuilder { + pub const fn imu(self, imu: I) -> F722HardwareBuilder { + F722HardwareBuilder { + output: self.output, + imu, + mavlink: self.mavlink, + vertical: self.vertical, + status_led: self.status_led, + } } } -impl F7BoardBuilder { - /// Selects an IMU route descriptor. - /// - /// Only a complete, profile-compatible descriptor has a final - /// [`F7BoardBuilder::build`] implementation. Keeping this one selection - /// operation uniform avoids teaching a board author platform profile - /// mechanics merely because the selected IMU model differs. - pub fn imu(self, _: Selected) -> F7BoardBuilder { - F7BoardBuilder { - _state: PhantomData, +impl F722HardwareBuilder { + pub const fn mavlink(self, mavlink: M) -> F722HardwareBuilder { + F722HardwareBuilder { + output: self.output, + imu: self.imu, + mavlink, + vertical: self.vertical, + status_led: self.status_led, } } +} - /// Selects a validated MAVLink route. Only profile-compatible route types - /// have a final [`F7BoardBuilder::build`] implementation. - pub fn mavlink(self, _: Selected) -> F7BoardBuilder { - F7BoardBuilder { - _state: PhantomData, +impl F722HardwareBuilder { + pub const fn vertical_sensor(self, vertical: V) -> F722HardwareBuilder { + F722HardwareBuilder { + output: self.output, + imu: self.imu, + mavlink: self.mavlink, + vertical, + status_led: self.status_led, } } +} - /// Selects a validated vertical-sensor route. Its capability is checked - /// against [`F7Board::CONFIG`] before any peripheral is initialized. - pub fn vertical_sensor(self, _: Selected) -> F7BoardBuilder { - F7BoardBuilder { - _state: PhantomData, +impl F722HardwareBuilder { + pub const fn status_led(self, status_led: L) -> F722HardwareBuilder { + F722HardwareBuilder { + output: self.output, + imu: self.imu, + mavlink: self.mavlink, + vertical: self.vertical, + status_led, } } } -/// Finite STM32F722 execution profile selected from safe route descriptors. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum F722Profile { Mpu6000Spi1Usart1, Icm42688Spi3Usart2, } -/// Fully validated, non-executing F722 board declaration. -/// -/// It is deliberately only a compact route description. Peripheral tokens -/// are taken later by the platform's RTIC initializer and never reach a board -/// crate. -#[doc(hidden)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct F722BoardRoutes { - profile: F722Profile, - vertical: f722::VerticalRoute, +pub(crate) enum F722StatusLedRoute { + Pd2ActiveLow, + Pa13ActiveHigh, } -impl F722BoardRoutes { - #[cfg_attr(not(target_os = "none"), allow(dead_code))] - pub(crate) const fn profile(self) -> F722Profile { - self.profile - } - - pub(crate) const fn imu(self) -> ImuDevice { - match self.profile { - F722Profile::Mpu6000Spi1Usart1 => ImuDevice::Mpu6000, - F722Profile::Icm42688Spi3Usart2 => ImuDevice::Icm42688, - } - } - - pub(crate) const fn imu_mount(self) -> ImuMount { - match self.profile { - F722Profile::Mpu6000Spi1Usart1 => ImuMount::BodyAligned, - F722Profile::Icm42688Spi3Usart2 => ImuMount::Yaw180, - } - } - - pub(crate) const fn mavlink_port(self) -> MavlinkPort { - match self.profile { - F722Profile::Mpu6000Spi1Usart1 => MavlinkPort::Serial1, - F722Profile::Icm42688Spi3Usart2 => MavlinkPort::Serial2, - } - } -} - -macro_rules! impl_mpu6000_routes { - ($vertical:ty, $route:expr) => { - impl F7BoardBuilder { - /// Finishes a complete supported MPU6000 F722 declaration. - pub const fn build(self) -> Result { - Ok(F722BoardRoutes { +macro_rules! finish_mpu6000_hardware { + ($vertical:ty, $route:expr, no_serial_baud) => { + impl + F722HardwareBuilder< + Pb0Pb1Pb4Pb5Low, + ConfiguredMpu6000, + MavlinkUsart1, + $vertical, + Pd2ActiveLow, + > + { + pub const fn telemetry_only(self) -> F722Hardware { + F722Hardware { profile: F722Profile::Mpu6000Spi1Usart1, vertical: $route, - }) + imu_mount: self.imu.mount, + mpu6000_config: Some(self.imu.config), + mavlink_baud: self.mavlink.baud, + vertical_sensor_baud: None, + status_led: F722StatusLedRoute::Pd2ActiveLow, + } + } + } + }; + ($vertical:ty, $route:expr, serial_baud) => { + impl + F722HardwareBuilder< + Pb0Pb1Pb4Pb5Low, + ConfiguredMpu6000, + MavlinkUsart1, + $vertical, + Pd2ActiveLow, + > + { + pub const fn telemetry_only(self) -> F722Hardware { + F722Hardware { + profile: F722Profile::Mpu6000Spi1Usart1, + vertical: $route, + imu_mount: self.imu.mount, + mpu6000_config: Some(self.imu.config), + mavlink_baud: self.mavlink.baud, + vertical_sensor_baud: Some(self.vertical.baud), + status_led: F722StatusLedRoute::Pd2ActiveLow, + } } } }; } -impl_mpu6000_routes!(Mtf01Uart4, f722::VerticalRoute::Mtf01); -impl_mpu6000_routes!(LidarLiteV3I2c1, f722::VerticalRoute::LidarLiteV3); -impl_mpu6000_routes!(NoVerticalSensor, f722::VerticalRoute::None); +finish_mpu6000_hardware!(NoVerticalSensor, f722::VerticalRoute::None, no_serial_baud); +finish_mpu6000_hardware!(Mtf01Uart4, f722::VerticalRoute::Mtf01, serial_baud); +finish_mpu6000_hardware!( + LidarLiteV3I2c1, + f722::VerticalRoute::LidarLiteV3, + no_serial_baud +); -macro_rules! impl_icm42688_routes { - ($vertical:ty, $route:expr) => { - impl F7BoardBuilder { - /// Finishes a complete supported ICM-42688-P F722 declaration. - pub const fn build(self) -> Result { - Ok(F722BoardRoutes { +macro_rules! finish_icm42688_hardware { + ($vertical:ty, $route:expr, no_serial_baud) => { + impl + F722HardwareBuilder< + Pb0Pb1Pb4Pb5Low, + MountedIcm42688, + MavlinkUsart2, + $vertical, + Pa13ActiveHigh, + > + { + pub const fn telemetry_only(self) -> F722Hardware { + F722Hardware { profile: F722Profile::Icm42688Spi3Usart2, vertical: $route, - }) + imu_mount: self.imu.mount, + mpu6000_config: None, + mavlink_baud: self.mavlink.baud, + vertical_sensor_baud: None, + status_led: F722StatusLedRoute::Pa13ActiveHigh, + } + } + } + }; + ($vertical:ty, $route:expr, serial_baud) => { + impl + F722HardwareBuilder< + Pb0Pb1Pb4Pb5Low, + MountedIcm42688, + MavlinkUsart2, + $vertical, + Pa13ActiveHigh, + > + { + pub const fn telemetry_only(self) -> F722Hardware { + F722Hardware { + profile: F722Profile::Icm42688Spi3Usart2, + vertical: $route, + imu_mount: self.imu.mount, + mpu6000_config: None, + mavlink_baud: self.mavlink.baud, + vertical_sensor_baud: Some(self.vertical.baud), + status_led: F722StatusLedRoute::Pa13ActiveHigh, + } } } }; } -impl_icm42688_routes!(NoVerticalSensor, f722::VerticalRoute::None); -impl_icm42688_routes!(Mtf01Uart4, f722::VerticalRoute::Mtf01); -impl_icm42688_routes!(LidarLiteV3I2c1, f722::VerticalRoute::LidarLiteV3); +finish_icm42688_hardware!(NoVerticalSensor, f722::VerticalRoute::None, no_serial_baud); +finish_icm42688_hardware!(Mtf01Uart4, f722::VerticalRoute::Mtf01, serial_baud); +finish_icm42688_hardware!( + LidarLiteV3I2c1, + f722::VerticalRoute::LidarLiteV3, + no_serial_baud +); -/// Missing and cross-profile route selections are rejected before board I/O is -/// linked, because no matching `build` implementation exists. -/// -/// ```compile_fail -/// use platform_stm32f7::{ -/// F7BoardBuilder, Mavlink, Mpu6000, Pa4, -/// }; -/// -/// let _ = F7BoardBuilder::new() -/// .imu(Mpu6000::on_spi1().with_drdy(Pa4)) -/// .mavlink(Mavlink::on_usart1()) -/// .build(); // vertical route is missing -/// ``` -/// -/// ```compile_fail -/// use platform_stm32f7::{ -/// F7BoardBuilder, Icm42688, Mavlink, Pc3, -/// VerticalSensor, -/// }; -/// -/// let _ = F7BoardBuilder::new() -/// .imu(Icm42688::on_spi3().with_drdy(Pc3)) -/// .mavlink(Mavlink::on_usart1()) // ICM route requires USART2 -/// .vertical_sensor(VerticalSensor::none()) -/// .build(); -/// ``` -/// -/// Contract implemented by a declarative STM32F722 board crate. -pub trait F7Board { - /// Portable identity, capabilities and outbound data-channel policy. - const CONFIG: BoardConfig; - - /// Selects one complete supported F722 route graph. - fn build(platform: F7BoardBuilder) -> Result; +/// Literal immutable root embedded by each F722 board package. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct F722ImmutableConfig { + pub name: &'static str, + pub service_hz: usize, + pub hardware: F722Hardware, } -/// Checks portable metadata at compile time. -#[macro_export] -macro_rules! enforce_f7_board_contract { - ($board:ty) => { - const _: () = match <$board as $crate::F7Board>::CONFIG.validate() { - Ok(()) => (), - Err(_) => panic!("invalid STM32F7 board configuration"), - }; - }; +mod sealed { + pub trait ImmutableConfig {} + impl ImmutableConfig for super::F722ImmutableConfig {} +} + +/// Sealed associated-type bound preserving the visible two-root board +/// contract while requiring the concrete platform manifest above. +#[doc(hidden)] +pub trait F7ImmutableConfig: Copy + sealed::ImmutableConfig { + fn f722(&self) -> &F722ImmutableConfig; +} + +impl F7ImmutableConfig for F722ImmutableConfig { + fn f722(&self) -> &F722ImmutableConfig { + self + } +} + +/// Contract implemented by a declarative STM32F722 board crate. +pub trait F7Board { + type UserConfig: Clone + Config; + type ImmutableConfig: F7ImmutableConfig; + + const FACTORY_USER_CONFIG: Self::UserConfig; + const IMMUTABLE_CONFIG: Self::ImmutableConfig; } /// Installs the one platform-owned RTIC application for a declarative board. @@ -384,20 +565,7 @@ macro_rules! enforce_f7_board_contract { #[macro_export] macro_rules! flight_firmware { ($board:ty) => { - $crate::enforce_f7_board_contract!($board); - // The platform RTIC application calls these symbols during `init`. - // They carry only the declarative board contract; RTIC, PAC, DMA, - // atomics, and unsafe operations remain inside this platform crate. - #[unsafe(no_mangle)] - pub static KAPTAN_F722_BOARD_CONFIG: $crate::BoardConfig = - <$board as $crate::F7Board>::CONFIG; - #[unsafe(no_mangle)] - pub extern "Rust" fn kaptan_f722_board_routes() - -> Result<$crate::F722BoardRoutes, $crate::BoardError> { - <$board as $crate::F7Board>::build($crate::F7BoardBuilder::new()) - } - #[used] - static KAPTAN_F722_PLATFORM_FLIGHT: fn() = $crate::firmware_anchor; + $crate::__f722_flight_application!($board); }; } diff --git a/firmware/platforms/stm32f7/tests/config_active_state.rs b/firmware/platforms/stm32f7/tests/config_active_state.rs new file mode 100644 index 0000000..be7a570 --- /dev/null +++ b/firmware/platforms/stm32f7/tests/config_active_state.rs @@ -0,0 +1,250 @@ +#![allow(dead_code)] + +use kaptan_config::{Config, ConfigManager, ConfigModel, ConfigStore, ConfigValue, FieldId}; +use platform_stm32f7::{ + F7ImmutableConfig, F722Hardware, F722ImmutableConfig, ImuMount, Mavlink, Mpu6000, + Mpu6000Config, Pa4, Pb0Pb1Pb4Pb5Low, Pd2ActiveLow, Severity, VerticalSensor, +}; + +// Include the implementation as a child module so this platform-internal test +// can exercise the crate-private user-candidate adapter without flash hardware. +#[path = "../src/f722/config.rs"] +mod target_config; + +use target_config::UserConfigModel; + +#[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, +} + +#[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, +} + +#[derive(Clone, kaptan_config::Config)] +struct DriverConfig { + #[config( + id = 1000, + key = "test_driver.gain", + unit = "count", + range(min = 1, max = 100) + )] + driver_gain: u8, +} + +#[derive(Clone, kaptan_config::Config)] +struct TestUserConfig { + mavlink: MavlinkConfig, + telemetry: TelemetryConfig, + driver: DriverConfig, +} + +const IMMUTABLE: F722ImmutableConfig = F722ImmutableConfig { + name: "active-candidate-test", + service_hz: 100, + hardware: F722Hardware::new() + .output_inhibit(Pb0Pb1Pb4Pb5Low) + .imu( + Mpu6000::on_spi1() + .with_drdy(Pa4) + .mounted(ImuMount::BodyAligned) + .configured(Mpu6000Config { + accelerometer: platform_stm32f7::AccelerometerRange::PlusMinus4G, + gyroscope: platform_stm32f7::GyroscopeRange::PlusMinus500DegreesPerSecond, + digital_low_pass_filter: platform_stm32f7::DigitalLowPassFilter::Bandwidth42Hz, + sample_rate_divider: 9, + }), + ) + .mavlink(Mavlink::on_usart1().baud(115_200)) + .vertical_sensor(VerticalSensor::none()) + .status_led(Pd2ActiveLow) + .telemetry_only(), +}; + +struct OneRecord(Option); + +impl ConfigStore for OneRecord { + type Error = (); + + fn load(&mut self) -> Result, Self::Error> { + Ok(self.0.take()) + } + + fn persist(&mut self, candidate: &C) -> Result<(), Self::Error> { + self.0 = Some(candidate.clone()); + Ok(()) + } +} + +fn factory() -> TestUserConfig { + TestUserConfig { + mavlink: MavlinkConfig { + system_id: 1, + component_id: 1, + logging_enabled: true, + maximum_log_severity: Severity::Info, + }, + telemetry: TelemetryConfig { + raw_imu_hz: 20, + heartbeat_hz: 2, + }, + driver: DriverConfig { driver_gain: 7 }, + } +} + +struct MalformedProjection { + inner: TestUserConfig, + system_id: Option, +} + +impl Config for MalformedProjection { + const FIELD_COUNT: usize = TestUserConfig::FIELD_COUNT; + const SCHEMA_ID: [u8; 16] = TestUserConfig::SCHEMA_ID; + + fn descriptor(index: usize) -> Option<&'static kaptan_config::FieldDescriptor> { + TestUserConfig::descriptor(index) + } + + fn field_value(&self, id: FieldId) -> Option { + if id == FieldId::new(101) { + self.system_id + } else { + self.inner.field_value(id) + } + } + + fn set_field( + &mut self, + id: FieldId, + value: ConfigValue, + ) -> Result<(), kaptan_config::FieldWriteError> { + self.inner.set_field(id, value) + } + + fn validate(&self) -> Result<(), kaptan_config::ConfigViolation> { + self.inner.validate() + } +} + +#[test] +fn mavlink_logging_policy_is_projected_and_validated_independently() { + assert_eq!( + core::mem::size_of::(), + 10, + "runtime projection must retain narrow board-native scalar widths" + ); + let mut candidate = UserConfigModel::::new(factory()); + candidate + .set_field(FieldId::new(108), ConfigValue::Bool(false)) + .unwrap(); + candidate + .set_field(FieldId::new(105), ConfigValue::U32(0)) + .unwrap(); + + let active = candidate.validate(&IMMUTABLE).unwrap(); + let runtime = target_config::project_runtime(&active, &IMMUTABLE).unwrap(); + assert!(!runtime.mavlink_logging_enabled); + assert_eq!(runtime.mavlink_maximum_log_severity, Severity::Emergency); +} + +#[test] +fn runtime_projection_rejects_missing_wrong_kind_and_wide_semantic_values() { + for (system_id, rule) in [ + (None, kaptan_config::ValidationRule::Inconsistent), + ( + Some(ConfigValue::Bool(true)), + kaptan_config::ValidationRule::Inconsistent, + ), + ( + Some(ConfigValue::U32(256)), + kaptan_config::ValidationRule::AboveMaximum, + ), + ] { + let malformed = MalformedProjection { + inner: factory(), + system_id, + }; + let issue = target_config::project_runtime(&malformed, &IMMUTABLE).unwrap_err(); + assert_eq!(issue.primary(), Some(FieldId::new(101))); + assert_eq!(issue.rule(), rule); + } +} + +#[test] +fn runtime_construction_reads_the_persisted_board_owned_user_root() { + let factory = UserConfigModel::::new(factory()); + let mut persisted = factory.clone(); + persisted + .set_field(FieldId::new(1000), ConfigValue::U32(42)) + .expect("driver field must accept a valid persisted value"); + + let manager = ConfigManager::recover_compatible_or_factory( + factory, + IMMUTABLE, + OneRecord(Some(persisted)), + ) + .expect("synthetic persisted user configuration must recover"); + + assert_eq!( + manager.active().field_value(FieldId::new(1000)), + Some(ConfigValue::U32(42)), + "runtime construction must receive the validated persisted field" + ); +} + +#[test] +fn committing_a_user_edit_does_not_mutate_the_boot_active_view() { + let factory = UserConfigModel::::new(factory()); + let mut manager = ConfigManager::new(factory, IMMUTABLE, OneRecord(None)).unwrap(); + manager.begin().unwrap(); + manager + .set(FieldId::new(1000), ConfigValue::U32(42)) + .unwrap(); + manager.commit().unwrap(); + + assert_eq!( + manager.active().field_value(FieldId::new(1000)), + Some(ConfigValue::U32(7)) + ); + assert_eq!( + manager.committed_value(FieldId::new(1000)), + Some(ConfigValue::U32(42)) + ); +} diff --git a/firmware/platforms/stm32f7/tests/platform_safety_boundary.rs b/firmware/platforms/stm32f7/tests/platform_safety_boundary.rs index 111300a..ce9469d 100644 --- a/firmware/platforms/stm32f7/tests/platform_safety_boundary.rs +++ b/firmware/platforms/stm32f7/tests/platform_safety_boundary.rs @@ -1,14 +1,8 @@ use std::{fs, path::PathBuf}; -use kaptan_board::{ - BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort, - TelemetryProfile, VerticalSensingKind, -}; -use kaptan_log::{ErrorAdditionalInformation, ErrorPath, LogFlags, LogRecord, LogText, Severity}; -use kaptan_log_mavlink::{DiagnosticPayload, encode_diagnostic}; -use kaptan_mavlink::encoder::MavlinkEncoder; use platform_stm32f7::{ - BoardError, F7BoardBuilder, Mavlink, Mpu6000, Pa4, VerticalSensor, validate_board_config, + AccelerometerRange, DigitalLowPassFilter, F722Hardware, F722ImmutableConfig, GyroscopeRange, + ImuMount, Mavlink, Mpu6000, Mpu6000Config, Pa4, Pb0Pb1Pb4Pb5Low, Pd2ActiveLow, VerticalSensor, }; fn platform_source(relative: &str) -> String { @@ -20,7 +14,7 @@ fn platform_source(relative: &str) -> String { fn former_motor_pins_are_inhibited_before_all_other_initialization() { let module = platform_source("src/f722/mod.rs"); let interlock = module - .find("motor_inhibit::inhibit_motor_pins") + .find("motor_inhibit::inhibit_pb0_pb1_pb4_pb5") .expect("F722 initialization must claim the former motor pins first"); let validation = module .find("validate_board_config") @@ -36,11 +30,14 @@ fn former_motor_pins_are_inhibited_before_all_other_initialization() { let reset = interlock .find("port.bsrr()") .expect("interlock must reset the former motor-pin output latches"); + let clock_delay = interlock + .find("rcc.ahb1enr().read()") + .expect("GPIO access must follow the documented RCC synchronization delay"); let output_mode = interlock .find("port.moder()") .expect("interlock must configure the former motor pins as GPIO outputs"); assert!( - reset < output_mode, + clock_delay < reset && reset < output_mode, "output latches must be low before GPIO output mode is selected" ); for pin in ["br0", "br1", "br4", "br5"] { @@ -66,85 +63,32 @@ fn former_motor_pins_are_inhibited_before_all_other_initialization() { } } -fn mpu_config() -> 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 immutable_board_policy_must_match_the_executed_route_graph() { - let routes = F7BoardBuilder::new() - .imu(Mpu6000::on_spi1().with_drdy(Pa4)) - .mavlink(Mavlink::on_usart1()) - .vertical_sensor(VerticalSensor::mtf01_on_uart4()) - .build() - .unwrap(); - assert_eq!(validate_board_config(mpu_config(), routes), Ok(())); - - let mut wrong_imu = mpu_config(); - let BoardProfile::TelemetryOnly(profile) = &mut wrong_imu.profile else { - unreachable!(); +fn immutable_board_policy_is_one_complete_literal_manifest() { + const IMMUTABLE: F722ImmutableConfig = F722ImmutableConfig { + name: "test", + 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(), }; - profile.imu = ImuDevice::Icm42688; - assert_eq!( - validate_board_config(wrong_imu, routes), - Err(BoardError::ImuRouteMismatch) - ); - let mut wrong_mount = mpu_config(); - let BoardProfile::TelemetryOnly(profile) = &mut wrong_mount.profile else { - unreachable!(); - }; - profile.imu_mount = ImuMount::Yaw180; - assert_eq!( - validate_board_config(wrong_mount, routes), - Err(BoardError::ImuMountMismatch) - ); - - let mut wrong_vertical = mpu_config(); - let BoardProfile::TelemetryOnly(profile) = &mut wrong_vertical.profile else { - unreachable!(); - }; - profile.vertical_sensing = VerticalSensingKind::Range; - profile.data_channel.optical_flow_hz = None; - assert_eq!( - validate_board_config(wrong_vertical, routes), - Err(BoardError::VerticalSensorCapabilityMismatch) - ); - - let mut wrong_port = mpu_config(); - wrong_port.mavlink.port = MavlinkPort::Serial2; - assert_eq!( - validate_board_config(wrong_port, routes), - Err(BoardError::MavlinkRouteMismatch) - ); - - let mut overcommitted_link = mpu_config(); - overcommitted_link.mavlink.baud = 9_600; - assert_eq!( - validate_board_config(overcommitted_link, routes), - Err(BoardError::TelemetryLinkOvercommitted) - ); + assert_eq!(IMMUTABLE.name, "test"); + assert_eq!(IMMUTABLE.service_hz, 100); } #[test] @@ -184,20 +128,47 @@ fn telemetry_platform_has_no_physical_actuator_peripheral_path() { } #[test] -fn telemetry_has_no_inbound_command_authority() { +fn ordinary_f722_image_has_direct_configuration_after_output_inhibition() { let board = platform_source("src/f722/board.rs"); let firmware = platform_source("src/f722/firmware.rs"); + let routes = platform_source("src/f722/routes.rs"); + let manifest = platform_source("Cargo.toml"); assert!( - !board.contains("MavlinkReceiver"), - "telemetry must not retain an inbound MAVLink dispatch path" + !manifest.contains("development-config") + && !manifest.contains("development-authority") + && !manifest.contains("development-unsigned"), + "ordinary board images must not hide configuration behind a development feature" ); + for required in [ + "configuration: F722ConfigManager", + "config_protocol: ConfigProtocol", + "receiver: IntegrityMavlinkReceiver<2>", + "config_lease_ticks: u16", + "wire::decode_command", + "self.config_protocol.dispatch_from_composition", + "visit_response(response, &mut sender)", + ] { + assert!( + board.contains(required), + "ordinary image must retain configuration composition: {required}" + ); + } + assert!(!board.contains("DirectConfigAdmission")); + assert!( + routes.contains("pub(crate) struct Mpu6000Spi1Route {\n spi: Spi1,\n}") + && routes.contains("stm32f722::GPIOB::ptr()") + && routes.contains("write.br2().set_bit()") + && routes.contains("write.bs2().set_bit()"), + "the MPU runtime may drive only PB2 atomically, not retain GPIOB ownership" + ); + assert!(!board.contains("development-config")); assert!( !board.contains("ArmRequest"), "telemetry must not decode arm commands" ); assert!( - !board.contains("MAV_RESULT_ACCEPTED"), + !board.contains("MAV_CMD_COMPONENT_ARM_DISARM") && !board.contains("decode_arm_request"), "telemetry must not accept an arm command" ); assert!( @@ -205,49 +176,195 @@ fn telemetry_has_no_inbound_command_authority() { "the MAVLink worker must not share a motor resource" ); assert!( - !firmware.contains("MAVLINK_EVENT_RECEIVED"), - "telemetry must not schedule a worker from an inbound command" + firmware.contains("const MAVLINK_EVENT_RECEIVED") + && firmware.contains("mavlink.receive_config_interrupt(event)") + && !firmware.contains("development-config"), + "ordinary images must always dispatch received configuration frames" ); } #[test] -fn f722_link_budget_uses_both_maximum_generated_diagnostic_frames() { - let text = LogText::from_message(&"x".repeat(160)); - let log_record = LogRecord::new(Severity::Info, LogFlags::NONE, text); - let error_record = LogRecord::error( - LogFlags::NONE, - ErrorPath::from_message(&"p".repeat(96)), - ErrorAdditionalInformation::from_message(&"i".repeat(64)), - ); - let mut encoder = MavlinkEncoder::new(1, 1); - let DiagnosticPayload::Log(log_payload) = encode_diagnostic(&log_record) else { - panic!("info record must use KAPTAN_LOG"); - }; - let log_frame = encoder.custom(&log_payload); - let DiagnosticPayload::Error(error_payload) = encode_diagnostic(&error_record) else { - panic!("error record must use KAPTAN_ERROR"); - }; - let error_frame = encoder.custom(&error_payload); +fn usart_tx_dma_arms_each_stream_before_its_request() { + let usart = platform_source("src/f722/peripherals/usart.rs"); + assert!(usart.contains("disable_dma_transmit_from_interrupt")); - assert_eq!(log_frame.raw_bytes().len(), 175); - assert_eq!( - kaptan_mavlink::message::kaptan::KAPTAN_LOG_DATA::ENCODED_LEN, - 163 + for (name, path) in [ + ("USART1", "src/f722/peripherals/usart1_tx_dma2_stream7.rs"), + ("USART2", "src/f722/peripherals/usart2_tx_dma1_stream6.rs"), + ] { + let route = platform_source(path); + let constructor_start = route.find("pub fn new").expect("DMA constructor"); + let transfer_start = route.find("pub fn start").expect("DMA start operation"); + let acknowledge_start = route + .find("pub fn acknowledge_interrupt") + .expect("DMA interrupt acknowledgement"); + + assert!( + !route[constructor_start..transfer_start].contains("enable_dma_transmit"), + "{name} must not leave its DMA request armed between transfers" + ); + + assert!( + route.contains("write.dmdis().enabled().feie().disabled()"), + "{name} must explicitly select direct mode without FE interrupts" + ); + let start = &route[transfer_start..acknowledge_start]; + let stream_enable = start + .find("write.en().enabled()") + .expect("DMA stream must be enabled"); + let request_enable = start + .find("self.usart.enable_dma_transmit()") + .expect("USART DMA request must be enabled"); + assert!( + stream_enable < request_enable, + "{name} must enable its stream before the peripheral request" + ); + + let acknowledge = &route[acknowledge_start..]; + let stream_disable = acknowledge + .find("write.en().disabled()") + .expect("DMA stream must be disabled"); + let request_disable = acknowledge + .find("disable_dma_transmit_from_interrupt()") + .expect("USART DMA request must be disabled"); + assert!( + stream_disable < request_disable, + "{name} must disable its stream before the peripheral request" + ); + + let classifier = acknowledge + .find("impl DmaTerminalStatus") + .map(|index| &acknowledge[index..]) + .expect("DMA terminal classifier"); + assert!(classifier.contains("self.transfer_error || self.direct_mode_error")); + assert!(classifier.contains("TxCompletion::Complete")); + assert!( + !classifier.contains("fifo_error"), + "{name} direct-mode FEIF must not manufacture a terminal fault" + ); + } +} + +#[test] +fn configuration_schema_is_generated_without_board_forwarding_code() { + let platform = platform_source("src/lib.rs"); + let platform_config = platform_source("src/f722/config.rs"); + let platform_firmware = platform_source("src/f722/firmware.rs"); + let kakute = platform_source("../../boards/kakute-f7-mini-v3/src/board.rs"); + let geprc = platform_source("../../boards/geprc-f722-v2/src/board.rs"); + + assert!( + platform.contains("pub struct F722ImmutableConfig") + && platform.contains("pub hardware: F722Hardware") + && platform.contains("type UserConfig: Clone + Config") + && platform_config.contains("struct UserConfigModel") + && platform_config.contains("fn project_runtime") + && platform_config.contains("candidate.field_value(field)"), + "the platform must retain exactly one immutable manifest and one generic user adapter" ); - assert_eq!(error_frame.raw_bytes().len(), 175); - assert_eq!( - kaptan_mavlink::message::kaptan::KAPTAN_ERROR_DATA::ENCODED_LEN, - 163 + for semantic_id in [ + "MAVLINK_SYSTEM_ID", + "MAVLINK_COMPONENT_ID", + "RAW_IMU_HZ", + "HEARTBEAT_HZ", + "MAVLINK_MAXIMUM_LOG_SEVERITY", + "RANGE_HZ", + "OPTICAL_FLOW_HZ", + "MAVLINK_LOGGING_ENABLED", + ] { + assert!( + platform_config.contains(&format!("const {semantic_id}: FieldId")), + "missing stable platform-consumed semantic ID {semantic_id}" + ); + } + for (name, board) in [("Kakute", kakute), ("GEPRC", geprc)] { + assert!( + board.contains("type UserConfig = UserConfig;") + && board.contains("type ImmutableConfig = ImmutableConfig;") + && board.contains("const FACTORY_USER_CONFIG: UserConfig = UserConfig {") + && board.contains("const IMMUTABLE_CONFIG: ImmutableConfig = ImmutableConfig {") + && board.contains("hardware: F722Hardware::new()") + && board.contains(".telemetry_only()"), + "{name} must visibly declare the two literal roots" + ); + for redundant in [ + "impl F7ImmutableConfig for", + "impl F7UserConfig for", + "impl_f722_user_config!", + ] { + assert!(!board.contains(redundant), "{name} retains {redundant}"); + } + } + assert!( + platform_firmware.contains("<$board as $crate::F7Board>::UserConfig") + && platform_firmware.contains("<$board as $crate::F7Board>::ImmutableConfig") + && platform_firmware.contains("config_snapshot_length::<"), + "the common RTIC graph must specialize storage for the two board roots" ); - assert_eq!(error_payload.path_length, 96); - assert_eq!(error_payload.additional_information_length, 64); - assert_eq!(error_payload.flags.bits(), LogFlags::NONE.bits()); +} + +#[test] +fn f722_link_budget_reserves_only_deterministic_telemetry() { + let board = platform_source("src/f722/board.rs"); + assert!(board.contains("SCHEDULED_TELEMETRY_BYTES_PER_SECOND")); + assert!(board.contains("SERVICE_HZ * SCHEDULED_FRAME_MAXIMUM_BYTES")); assert_eq!( - 62 * 48 + 10 * error_frame.raw_bytes().len() + 5 * log_frame.raw_bytes().len(), - 5_601 + board + .matches("self.drain_one_mavlink_log_record();") + .count(), + 1, + "each service turn must make exactly one bounded log admission attempt" ); + assert!(board.contains("if runtime.mavlink_logging_enabled")); + assert!(board.contains("LogFilter::up_to(runtime.mavlink_maximum_log_severity)")); + assert!(board.contains("LogFilter::disabled()")); + for periodic_diagnostic_policy in [ + "ERROR_RECORDS_PER_SECOND", + "LOG_RECORDS_PER_SECOND", + "WORST_CASE_OBSERVABILITY_BYTES_PER_SECOND", + "KAPTAN_ERROR_FRAME_MAXIMUM_BYTES", + "KAPTAN_LOG_FRAME_MAXIMUM_BYTES", + "budget_ticks", + "error_budget", + "log_budget", + ] { + assert!( + !board.contains(periodic_diagnostic_policy), + "diagnostics must not have a periodic policy: {periodic_diagnostic_policy}" + ); + } +} + +#[test] +fn mavlink_transport_faults_do_not_recurse_or_retain_dead_accounting() { let board = platform_source("src/f722/board.rs"); - assert!(board.contains("KAPTAN_ERROR_FRAME_MAXIMUM_BYTES")); - assert!(board.contains("KAPTAN_LOG_FRAME_MAXIMUM_BYTES")); - assert!(board.contains("5,601 bytes/s")); + let transport = platform_source("src/f722/data_channel.rs"); + let portable_channel = platform_source("../../libs/kaptan-data-channel/src/lib.rs"); + + for recursive_path in [ + "drain_transport_faults", + "take_transport_fault", + "TransmitMemory", + "TransmitEngine", + "TransmitTerminal", + "pending_faults", + ] { + assert!( + !board.contains(recursive_path) + && !transport.contains(recursive_path) + && !portable_channel.contains(recursive_path), + "MAVLink transport must not feed {recursive_path} back into itself" + ); + } + for dead_counter in [ + "dma_fault_count", + "memory_fault_count", + "engine_fault_count", + "terminal_fault_count", + ] { + assert!( + !transport.contains(dead_counter), + "unused transport accounting must stay absent: {dead_counter}" + ); + } } diff --git a/firmware/scripts/report-test-coverage.sh b/firmware/scripts/report-test-coverage.sh index 90d5150..eabdec2 100755 --- a/firmware/scripts/report-test-coverage.sh +++ b/firmware/scripts/report-test-coverage.sh @@ -115,7 +115,6 @@ report_package() { echo "Host test coverage (reports: $CARGO_TARGET_DIR; strict core unions LCOV DA lines and JSON branches)" report_package kaptan-actuator /libs/kaptan-actuator/ -report_package kaptan-board /libs/kaptan-board/ report_package kaptan-control /libs/kaptan-control/ report_package kaptan-data-channel /libs/kaptan-data-channel/ report_package kaptan-dma /libs/kaptan-dma/ diff --git a/firmware/tools/sitl/Cargo.toml b/firmware/tools/sitl/Cargo.toml index 5dddf4d..38fc16a 100644 --- a/firmware/tools/sitl/Cargo.toml +++ b/firmware/tools/sitl/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] kaptan-control = { path = "../../libs/kaptan-control" } kaptan-config = { path = "../../libs/kaptan-config" } -kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink", features = ["host-authority"] } +kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink" } kaptan-flight-config = { path = "../../libs/kaptan-flight-config" } kaptan-flight-runtime = { path = "../../libs/kaptan-flight-runtime" } kaptan-log = { path = "../../libs/kaptan-log", features = ["facade"] } diff --git a/firmware/tools/sitl/kaptan-sim b/firmware/tools/sitl/kaptan-sim index 3e4c6d6..5b67de5 100755 --- a/firmware/tools/sitl/kaptan-sim +++ b/firmware/tools/sitl/kaptan-sim @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Small operator client for the local Kaptan host-SITL process.""" +"""Small user client for the local Kaptan host-SITL process.""" from __future__ import annotations @@ -14,11 +14,11 @@ import time CONTROL = ("127.0.0.1", 14662) TRUTH = ("127.0.0.1", 14663) -# The simulator lease identifies its one local operator by UDP source address. +# The simulator lease identifies its one local user by UDP source address. # Keep this source stable across separate CLI invocations so a completed move -# can be followed by the next command without a second, competing operator. -OPERATOR_CONTROL = ("127.0.0.1", 14665) -OPERATOR_CLOCK = ("127.0.0.1", 14664) +# can be followed by the next command without a second, competing user. +USER_CONTROL = ("127.0.0.1", 14665) +USER_CLOCK = ("127.0.0.1", 14664) VERSION = 1 STATUS_QUERY = struct.Struct("<4sB3x") STATUS = struct.Struct("<4sB3xQH2x") @@ -109,7 +109,7 @@ def recv(socket_: socket.socket, deadline: float) -> bytes | None: def latest_simulation_time( clock_socket: socket.socket, previous: int | None ) -> int | None: - """Drains Gazebo's test-operator clock without trusting host elapsed time.""" + """Drains Gazebo's test-user clock without trusting host elapsed time.""" latest = previous while True: try: @@ -514,13 +514,13 @@ def main() -> int: if tolerances: raise ValueError("truth tolerances and hold duration must be finite and positive") with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: - client.bind(OPERATOR_CONTROL) + client.bind(USER_CONTROL) if values.command == "ready": wait_ready(client, values.timeout) print("ready") elif values.command == "move": with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as clock: - clock.bind(OPERATOR_CLOCK) + clock.bind(USER_CLOCK) clock.setblocking(False) move( client, @@ -538,7 +538,7 @@ def main() -> int: ) else: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as clock: - clock.bind(OPERATOR_CLOCK) + clock.bind(USER_CLOCK) clock.setblocking(False) land( client, diff --git a/firmware/tools/sitl/src/config/logging.rs b/firmware/tools/sitl/src/config/logging.rs index 54b1833..d0a85bd 100644 --- a/firmware/tools/sitl/src/config/logging.rs +++ b/firmware/tools/sitl/src/config/logging.rs @@ -2,12 +2,10 @@ use std::sync::Once; -use kaptan_flight_runtime::RuntimeLogHub; -use kaptan_log::{FacadeIngress, GlobalLogger, LogFilter, Severity}; +use kaptan_log::{FacadeIngress, GlobalLogger, LogRecord}; const SITL_LOG_INGRESS_CAPACITY: usize = 16; -static SITL_LOG_INGRESS: FacadeIngress = - FacadeIngress::new(LogFilter::new(Severity::Info)); +static SITL_LOG_INGRESS: FacadeIngress = FacadeIngress::new(); static SITL_LOGGER: GlobalLogger = GlobalLogger::new(&SITL_LOG_INGRESS, sitl_forbidden_log_context); static INSTALL_SITL_LOGGER: Once = Once::new(); @@ -28,10 +26,7 @@ pub fn install_log_facade() { }); } -pub(super) fn set_filter(filter: LogFilter) { - SITL_LOG_INGRESS.set_filter(filter); -} - -pub(super) fn drain_one_into(hub: &mut RuntimeLogHub) { - let _ = SITL_LOG_INGRESS.drain_one_into(hub); +/// Removes one transport-neutral event for composition-level sink fanout. +pub(super) fn take() -> Option { + SITL_LOG_INGRESS.take() } diff --git a/firmware/tools/sitl/src/config/mod.rs b/firmware/tools/sitl/src/config/mod.rs index d461ae7..1100fff 100644 --- a/firmware/tools/sitl/src/config/mod.rs +++ b/firmware/tools/sitl/src/config/mod.rs @@ -12,7 +12,7 @@ mod store; mod tests; pub use logging::install_log_facade; -pub use runtime::{CAPABILITIES, SitlConfigRuntime, SitlSnapshotImporter, configuration_service}; +pub use runtime::{CAPABILITIES, SitlConfigRuntime, configuration_service}; pub use store::{ - FileProfileStore, FileStoreError, default_profile, load_profile, write_default_profile, + FileConfigStore, FileStoreError, default_profile, load_profile, write_default_profile, }; diff --git a/firmware/tools/sitl/src/config/runtime.rs b/firmware/tools/sitl/src/config/runtime.rs index b692cae..6815cee 100644 --- a/firmware/tools/sitl/src/config/runtime.rs +++ b/firmware/tools/sitl/src/config/runtime.rs @@ -1,69 +1,45 @@ //! Central SITL configuration and protocol composition. use kaptan_config::ConfigManager; -use kaptan_config_mavlink::{ - Authorized, ConfigCommand, ConfigProtocol, ProtocolError, ServiceResponse, SnapshotImporter, - Target, -}; -use kaptan_flight_config::{ - CandidateProfile, CborError, FORMAT_VERSION, VehicleCapabilities, VehicleProfile, - decode_candidate, -}; +use kaptan_config_mavlink::{ConfigCommand, ConfigProtocol, Principal, ServiceResponse, Target}; +use kaptan_flight_config::{CandidateProfile, VehicleCapabilities, VehicleProfile}; use kaptan_flight_runtime::FlightRuntime; use kaptan_log::TransportAdmission; use kaptan_log_mavlink::{DiagnosticPayload, encode_diagnostic}; -use super::{FileProfileStore, logging}; +use super::{FileConfigStore, logging}; /// SITL's immutable compiled capability contract. pub const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x(); -/// Host integration adapter from the schema's one bounded CBOR profile blob to -/// the generic custom-MAVLink transaction service. -#[derive(Clone, Copy, Debug, Default)] -pub struct SitlSnapshotImporter; - -impl SnapshotImporter for SitlSnapshotImporter { - const IMPORT_SUPPORTED: bool = true; - const PROFILE_FORMAT: u16 = FORMAT_VERSION; - - fn decode(&self, bytes: &[u8]) -> Result { - decode_candidate(bytes, &CAPABILITIES).map_err(|error| match error { - CborError::TooLarge => ProtocolError::CborTooLarge, - CborError::Malformed | CborError::TrailingData => ProtocolError::CborMalformed, - CborError::InvalidProfile(violation) => ProtocolError::Violation(violation), - }) - } -} - /// SITL composition root retaining the one profile manager and one protocol /// lease state machine. The MAVLink adapter never owns either resource. pub struct SitlConfigRuntime { - runtime: FlightRuntime, - protocol: ConfigProtocol, + runtime: FlightRuntime, + protocol: ConfigProtocol, } impl SitlConfigRuntime { - /// Dispatches one host-authorized transaction command. Real target ingress - /// remains disabled until signing/replay authority is implemented. - pub fn dispatch(&mut self, command: Authorized) -> ServiceResponse { - let (manager, _) = self.runtime.configuration_and_logs_mut(); - self.protocol.dispatch(manager, command) - } - - /// Enqueues the high-level error companion only after a composition owner - /// has retained the authoritative `KAPTAN_CONFIG_RESULT`. There is no - /// request correlation in `KAPTAN_ERROR`; its purpose is human-facing - /// observability, never replacement of the structured result. - pub fn after_config_result(&mut self, response: ServiceResponse) { - if let ServiceResponse::Result(reply) = response { - let (_, logs) = self.runtime.configuration_and_logs_mut(); - let _ = reply.publish_rejection(logs); - } + /// Dispatches one command for a caller-owned lease principal. + pub fn dispatch(&mut self, input: (Principal, ConfigCommand)) -> Vec { + let (principal, command) = input; + let mut responses = Vec::new(); + self.protocol + .dispatch_from_composition( + self.runtime.configuration_mut(), + principal, + command, + |response| { + responses.push(response); + Ok::<(), core::convert::Infallible>(()) + }, + ) + .unwrap(); + responses } /// Returns the central manager for SITL state inspection only. - pub const fn manager(&self) -> &ConfigManager { + pub const fn manager(&self) -> &ConfigManager { self.runtime.configuration() } @@ -72,32 +48,27 @@ impl SitlConfigRuntime { *self.runtime.configuration().active() } - /// Indicates custom CBOR import support to the local GCS bridge. - pub const fn capability_flags(&self) -> u8 { - self.protocol.capability_flags_for::() - } - /// Drains one standard `log` facade record and exposes the generated /// diagnostic payload for the host's sole MAVLink link owner. This /// host-only harness accepts the bounded handoff synchronously. pub fn pending_diagnostic_payload(&mut self) -> Option { - let (_, logs) = self.runtime.configuration_and_logs_mut(); - logging::drain_one_into(logs); + if let Some(record) = logging::take() { + self.runtime.publish_log_event(record); + } self.runtime - .submit_next(|record| (TransportAdmission::Accepted, encode_diagnostic(record))) + .submit_next_mavlink(|record| (TransportAdmission::Accepted, encode_diagnostic(record))) } } /// Builds the host-only central configuration composition used by forthcoming /// SITL/GCS integration. Flight targets must supply authenticated authority /// and a reviewed journal/activation coordinator before constructing one. -pub fn configuration_service(store: FileProfileStore) -> Result { +pub fn configuration_service(store: FileConfigStore) -> Result { logging::install_log_facade(); - let runtime = FlightRuntime::recover(CAPABILITIES, store) + let runtime = FlightRuntime::recover(CandidateProfile::factory(), CAPABILITIES, store) .map_err(|error| format!("configuration recovery failed: {error:?}"))?; - logging::set_filter(runtime.log_filter()); Ok(SitlConfigRuntime { runtime, - protocol: ConfigProtocol::with_importer(Target::new(1, 1), SitlSnapshotImporter), + protocol: ConfigProtocol::new(Target::new(1, 1)), }) } diff --git a/firmware/tools/sitl/src/config/store.rs b/firmware/tools/sitl/src/config/store.rs index b79cd94..1739ff1 100644 --- a/firmware/tools/sitl/src/config/store.rs +++ b/firmware/tools/sitl/src/config/store.rs @@ -6,7 +6,7 @@ use std::{ path::Path, }; -use kaptan_config::{Generation, ProfileStore}; +use kaptan_config::ConfigStore; use kaptan_flight_config::{ CandidateProfile, CborEncodeError, CborError, MAXIMUM_CANDIDATE_BYTES, VehicleProfile, decode_candidate, encode_candidate, @@ -24,44 +24,34 @@ pub enum FileStoreError { Cbor(CborError), /// The fixed snapshot buffer could not encode the complete candidate. Encode, - /// The host generation counter cannot advance. - GenerationExhausted, /// Factory-only startup deliberately has no durable configuration path. FactoryOnly, - /// The private host envelope was malformed or did not carry a valid - /// nonzero committed generation. - Envelope, } /// Same-directory atomic-replacement store for SITL only. -pub struct FileProfileStore { +pub struct FileConfigStore { path: Option, - generation: Option, } -impl FileProfileStore { +impl FileConfigStore { /// Creates a host-only store at a fixed profile path. pub fn new(path: impl Into) -> Self { Self { path: Some(path.into()), - generation: None, } } /// Creates a factory-only store for `kaptan-sitl serve` without /// `--config`. Commits are rejected rather than silently discarded. pub const fn factory_only() -> Self { - Self { - path: None, - generation: None, - } + Self { path: None } } } -impl ProfileStore for FileProfileStore { +impl ConfigStore for FileConfigStore { type Error = FileStoreError; - fn load(&mut self) -> Result, Self::Error> { + fn load(&mut self) -> Result, Self::Error> { let Some(path) = self.path.as_deref() else { return Ok(None); }; @@ -70,7 +60,7 @@ impl ProfileStore for FileProfileStore { Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(FileStoreError::Io(error)), }; - const MAXIMUM_FILE_BYTES: u64 = (MAXIMUM_CANDIDATE_BYTES + 64) as u64; + const MAXIMUM_FILE_BYTES: u64 = MAXIMUM_CANDIDATE_BYTES as u64; if file.metadata().map_err(FileStoreError::Io)?.len() > MAXIMUM_FILE_BYTES { return Err(FileStoreError::Cbor(CborError::TooLarge)); } @@ -81,35 +71,20 @@ impl ProfileStore for FileProfileStore { if bytes.len() as u64 > MAXIMUM_FILE_BYTES { return Err(FileStoreError::Cbor(CborError::TooLarge)); } - let (generation, candidate) = decode_envelope(&bytes)?; - self.generation = Some(generation); - Ok(Some((generation, candidate))) + decode_candidate(&bytes, &CAPABILITIES) + .map(Some) + .map_err(FileStoreError::Cbor) } - fn persist(&mut self, candidate: &CandidateProfile) -> Result { + fn persist(&mut self, candidate: &CandidateProfile) -> Result<(), Self::Error> { let path = self.path.clone().ok_or(FileStoreError::FactoryOnly)?; - if self.generation.is_none() { - let _ = self.load()?; - } - let generation = match self.generation { - Some(generation) => Generation::try_new( - generation - .get() - .checked_add(1) - .ok_or(FileStoreError::GenerationExhausted)?, - ) - .ok_or(FileStoreError::GenerationExhausted)?, - None => Generation::new(1), - }; - write_candidate(&path, *candidate, generation)?; - self.generation = Some(generation); - Ok(generation) + write_candidate(&path, *candidate) } } /// Returns the fully validated factory-active profile. pub fn default_profile() -> Result { - Ok(configuration_service(FileProfileStore::factory_only())?.active_profile()) + Ok(configuration_service(FileConfigStore::factory_only())?.active_profile()) } /// Loads one bounded, complete CBOR candidate and returns its active view. @@ -117,23 +92,18 @@ pub fn load_profile(path: impl AsRef) -> Result { if !path.as_ref().is_file() { return Err("configuration file does not exist".to_owned()); } - Ok(configuration_service(FileProfileStore::new(path.as_ref()))?.active_profile()) + Ok(configuration_service(FileConfigStore::new(path.as_ref()))?.active_profile()) } /// Writes one validated factory candidate using a same-directory replacement. pub fn write_default_profile(path: impl AsRef) -> Result<(), String> { - let mut store = FileProfileStore::new(path.as_ref()); + let mut store = FileConfigStore::new(path.as_ref()); store - .persist(&CandidateProfile::default()) - .map(|_| ()) + .persist(&CandidateProfile::factory()) .map_err(|error| error.to_string()) } -fn write_candidate( - path: &Path, - candidate: CandidateProfile, - generation: Generation, -) -> Result<(), FileStoreError> { +fn write_candidate(path: &Path, candidate: CandidateProfile) -> Result<(), FileStoreError> { let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]); encode_candidate(candidate, &CAPABILITIES, &mut writer).map_err(|error| match error { CborEncodeError::InvalidProfile(violation) => { @@ -143,35 +113,10 @@ fn write_candidate( })?; let length = writer.position(); let bytes = writer.into_inner(); - let mut envelope = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES + 64]); - let mut encoder = minicbor::Encoder::new(&mut envelope); - encoder - .array(2) - .and_then(|encoder| encoder.u64(generation.get())) - .and_then(|encoder| encoder.bytes(&bytes[..length])) - .map_err(|_| FileStoreError::Encode)?; - let envelope_length = encoder.writer_mut().position(); - let envelope = envelope.into_inner(); let temporary = path.with_extension("cbor.tmp"); let mut file = File::create(&temporary).map_err(FileStoreError::Io)?; - file.write_all(&envelope[..envelope_length]) + file.write_all(&bytes[..length]) .map_err(FileStoreError::Io)?; file.sync_all().map_err(FileStoreError::Io)?; fs::rename(&temporary, path).map_err(FileStoreError::Io) } - -fn decode_envelope(bytes: &[u8]) -> Result<(Generation, CandidateProfile), FileStoreError> { - let mut decoder = minicbor::Decoder::new(bytes); - if decoder.array().map_err(|_| FileStoreError::Envelope)? != Some(2) { - return Err(FileStoreError::Envelope); - } - let generation = Generation::try_new(decoder.u64().map_err(|_| FileStoreError::Envelope)?) - .ok_or(FileStoreError::Envelope)?; - let candidate_bytes = decoder.bytes().map_err(|_| FileStoreError::Envelope)?; - if decoder.position() != bytes.len() { - return Err(FileStoreError::Envelope); - } - let candidate = - decode_candidate(candidate_bytes, &CAPABILITIES).map_err(FileStoreError::Cbor)?; - Ok((generation, candidate)) -} diff --git a/firmware/tools/sitl/src/config/tests.rs b/firmware/tools/sitl/src/config/tests.rs index 591cd09..f1d183f 100644 --- a/firmware/tools/sitl/src/config/tests.rs +++ b/firmware/tools/sitl/src/config/tests.rs @@ -1,68 +1,14 @@ use std::fs; -use kaptan_config::{ConfigModel, Generation, ProfileStore}; -use kaptan_config_mavlink::{ - Authorized, ConfigCommand, ResultKind, ServiceResponse, SnapshotImporter, -}; -use kaptan_flight_config::{ - CandidateProfile, CborError, MAXIMUM_CANDIDATE_BYTES, SCHEMA_ID as PROFILE_SCHEMA_ID, - decode_candidate, encode_candidate, -}; +use kaptan_config::{ConfigModel, ConfigStore}; +use kaptan_flight_config::{CandidateProfile, decode_candidate}; use kaptan_log_mavlink::DiagnosticPayload; use kaptan_mavlink::{ encoder::MavlinkEncoder, message::{Message, kaptan::MavMessage}, }; -use super::{CAPABILITIES, FileProfileStore, SitlSnapshotImporter, configuration_service}; - -// Independently authored current-format CBOR map. It is intentionally not -// produced by `encode_candidate`: this is the GCS/upload compatibility vector -// for schema KAPTANPROFILE-01, with a non-default 1.7 kg mass. -const EXTERNAL_PROFILE_CBOR: &[u8] = &[ - 0xb8, 0x1a, // definite map, 26 entries - 0x00, 0x01, // format key/value - 0x19, 0xff, 0xff, 0x50, b'K', b'A', b'P', b'T', b'A', b'N', b'P', b'R', b'O', b'F', b'I', b'L', - b'E', b'-', b'0', b'1', // schema key/value - 0x01, 0x1a, 0x00, 0x01, 0x86, 0xa0, // prearm dwell: 100000 - 0x02, 0x19, 0xc3, 0x50, // IMU interval: 50000 - 0x03, 0x19, 0xc3, 0x50, // IMU freshness: 50000 - 0x04, 0x1a, 0x00, 0x02, 0x49, 0xf0, // local freshness: 150000 - 0x05, 0x1a, 0x00, 0x07, 0xa1, 0x20, // link freshness: 500000 - 0x06, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x07, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x08, 0xfa, 0x3f, 0x80, - 0x00, 0x00, 0x09, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x0a, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x0b, 0xfa, - 0x3f, 0x80, 0x00, 0x00, 0x0c, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x0d, 0xfa, 0x3f, 0xd9, 0x99, - 0x9a, // mass: 1.7 - 0x0e, 0xfa, 0x41, 0xa0, 0x00, 0x00, // collective thrust: 20 - 0x0f, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x10, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x11, 0xfa, 0x3f, 0x80, - 0x00, 0x00, 0x12, 0xfa, 0x00, 0x00, 0x00, 0x00, 0x13, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x14, 0xfa, - 0x3f, 0x80, 0x00, 0x00, 0x15, 0xfa, 0x00, 0x00, 0x00, 0x00, 0x16, 0xfa, 0x3f, 0x80, 0x00, 0x00, - 0x17, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x18, 0x18, 0x06, // maximum log level: INFO -]; - -fn result(response: ServiceResponse) -> kaptan_config_mavlink::ConfigReply { - match response { - ServiceResponse::Result(reply) => reply, - ServiceResponse::Info(_) | ServiceResponse::Descriptors(_) | ServiceResponse::Value(_) => { - panic!("expected a mutating configuration result") - } - } -} - -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 -} +use super::{CAPABILITIES, FileConfigStore, configuration_service}; #[test] fn host_store_round_trips_a_complete_validated_snapshot() { @@ -75,216 +21,24 @@ fn host_store_round_trips_a_complete_validated_snapshot() { let _ = fs::remove_file(&path); let _ = fs::remove_file(&temporary); - let mut store = FileProfileStore::new(&path); + let mut store = FileConfigStore::new(&path); assert!(store.load().unwrap().is_none()); - assert_eq!( - store.persist(&CandidateProfile::default()).unwrap(), - Generation::new(1) - ); - let envelope = fs::read(&path).unwrap(); - assert!(matches!( - decode_candidate(&envelope, &CAPABILITIES), - Err(CborError::Malformed) - )); - let (_, candidate) = store.load().unwrap().expect("persisted candidate"); + store.persist(&CandidateProfile::factory()).unwrap(); + let snapshot = fs::read(&path).unwrap(); + let decoded = decode_candidate(&snapshot, &CAPABILITIES).unwrap(); + assert!(decoded.validate(&CAPABILITIES).is_ok()); + let candidate = store.load().unwrap().expect("persisted candidate"); assert!(candidate.validate(&CAPABILITIES).is_ok()); - assert_eq!( - store.persist(&CandidateProfile::default()).unwrap(), - Generation::new(2) - ); - let mut reopened = FileProfileStore::new(&path); - assert_eq!( - reopened.load().unwrap().map(|(generation, _)| generation), - Some(Generation::new(2)) - ); - let mut fresh = FileProfileStore::new(&path); - assert_eq!( - fresh.persist(&CandidateProfile::default()).unwrap(), - Generation::new(3) - ); + store.persist(&CandidateProfile::factory()).unwrap(); + let mut reopened = FileConfigStore::new(&path); + assert!(reopened.load().unwrap().is_some()); + let mut fresh = FileConfigStore::new(&path); + fresh.persist(&CandidateProfile::factory()).unwrap(); fs::remove_file(&path).unwrap(); } -#[test] -fn sitl_snapshot_importer_uses_the_real_schema_and_capabilities() { - let candidate = CandidateProfile::default(); - let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]); - encode_candidate(candidate, &CAPABILITIES, &mut writer).unwrap(); - let length = writer.position(); - let bytes = writer.into_inner(); - let imported = SitlSnapshotImporter - .decode(&bytes[..length]) - .expect("valid current candidate import"); - assert!(imported.validate(&CAPABILITIES).is_ok()); -} - -#[test] -fn sitl_service_imports_a_real_cbor_candidate_only_into_its_transaction() { - let path = std::env::temp_dir().join(format!( - "kaptan-config-import-{}-{}.cbor", - std::process::id(), - std::thread::current().name().unwrap_or("test") - )); - let _ = fs::remove_file(&path); - let mut service = configuration_service(FileProfileStore::new(&path)).unwrap(); - let begin = result(service.dispatch(Authorized::host( - 9, - ConfigCommand::Begin { - request_id: 1, - expected_generation: 0, - target: kaptan_config_mavlink::Target::new(1, 1), - }, - ))); - let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]); - encode_candidate(CandidateProfile::default(), &CAPABILITIES, &mut writer).unwrap(); - let length = writer.position(); - assert!(length <= 192); - let bytes = writer.into_inner(); - let import_begin = result(service.dispatch(Authorized::host( - 9, - ConfigCommand::ImportBegin { - request_id: 2, - session_id: begin.session_id, - expected_revision: 0, - length: length as u16, - schema_id: PROFILE_SCHEMA_ID, - crc32c: crc32c(&bytes[..length]), - target: kaptan_config_mavlink::Target::new(1, 1), - }, - ))); - assert_eq!(import_begin.result, ResultKind::Accepted); - let mut chunk = [0; 192]; - chunk[..length].copy_from_slice(&bytes[..length]); - assert_eq!( - result(service.dispatch(Authorized::host( - 9, - ConfigCommand::ImportChunk { - request_id: 3, - session_id: begin.session_id, - transfer_id: import_begin.transfer_id, - offset: 0, - length: length as u8, - data: chunk, - target: kaptan_config_mavlink::Target::new(1, 1), - }, - ))) - .result, - ResultKind::Accepted - ); - assert_eq!( - result(service.dispatch(Authorized::host( - 9, - ConfigCommand::ImportFinish { - request_id: 4, - session_id: begin.session_id, - transfer_id: import_begin.transfer_id, - expected_revision: 0, - target: kaptan_config_mavlink::Target::new(1, 1), - }, - ))) - .result, - ResultKind::Accepted - ); - assert!( - service - .manager() - .transaction_value(kaptan_config::FieldId::new(1)) - .is_some() - ); - assert!( - service - .manager() - .active_value(kaptan_config::FieldId::new(1)) - .is_some() - ); - let _ = fs::remove_file(&path); -} - -#[test] -fn external_gcs_vector_imports_commits_and_reopens_as_the_boot_profile() { - let path = std::env::temp_dir().join(format!( - "kaptan-config-gcs-vector-{}-{}.cbor", - std::process::id(), - std::thread::current().name().unwrap_or("test") - )); - let _ = fs::remove_file(&path); - let mut service = configuration_service(FileProfileStore::new(&path)).unwrap(); - let target = kaptan_config_mavlink::Target::new(1, 1); - let begin = result(service.dispatch(Authorized::host( - 9, - ConfigCommand::Begin { - request_id: 1, - expected_generation: 0, - target, - }, - ))); - let import = result(service.dispatch(Authorized::host( - 9, - ConfigCommand::ImportBegin { - request_id: 2, - session_id: begin.session_id, - expected_revision: 0, - length: EXTERNAL_PROFILE_CBOR.len() as u16, - schema_id: PROFILE_SCHEMA_ID, - crc32c: crc32c(EXTERNAL_PROFILE_CBOR), - target, - }, - ))); - let mut data = [0_u8; 192]; - data[..EXTERNAL_PROFILE_CBOR.len()].copy_from_slice(EXTERNAL_PROFILE_CBOR); - assert_eq!( - result(service.dispatch(Authorized::host( - 9, - ConfigCommand::ImportChunk { - request_id: 3, - session_id: begin.session_id, - transfer_id: import.transfer_id, - offset: 0, - length: EXTERNAL_PROFILE_CBOR.len() as u8, - data, - target, - }, - ))) - .result, - ResultKind::Accepted - ); - assert_eq!( - result(service.dispatch(Authorized::host( - 9, - ConfigCommand::ImportFinish { - request_id: 4, - session_id: begin.session_id, - transfer_id: import.transfer_id, - expected_revision: 0, - target, - }, - ))) - .revision, - 1 - ); - assert_eq!( - result(service.dispatch(Authorized::host( - 9, - ConfigCommand::Commit { - request_id: 5, - session_id: begin.session_id, - expected_generation: 0, - expected_revision: 1, - target, - }, - ))) - .result, - ResultKind::CommittedRestartRequired - ); - drop(service); - - let reopened = configuration_service(FileProfileStore::new(&path)).unwrap(); - assert_eq!(reopened.active_profile().thrust().mass_kg(), 1.7); - let _ = fs::remove_file(&path); -} - #[test] fn standard_log_macro_reaches_the_generated_mavlink_log_payload() { let path = std::env::temp_dir().join(format!( @@ -293,13 +47,13 @@ fn standard_log_macro_reaches_the_generated_mavlink_log_payload() { std::thread::current().name().unwrap_or("test") )); let _ = fs::remove_file(&path); - let mut service = configuration_service(FileProfileStore::new(&path)).unwrap(); + let mut service = configuration_service(FileConfigStore::new(&path)).unwrap(); log::info!("SITL configuration service ready"); let Some(DiagnosticPayload::Log(payload)) = service.pending_diagnostic_payload() else { panic!("info facade record must reach KAPTAN_LOG"); }; - assert_eq!(payload.level, kaptan_log::Severity::Info as u8); + assert_eq!(payload.level, kaptan_log::Severity::Info.wire_value()); let message = std::str::from_utf8(&payload.message[..usize::from(payload.message_length)]) .expect("facade text remains UTF-8"); assert!(message.contains("config::tests")); @@ -320,37 +74,3 @@ fn standard_log_macro_reaches_the_generated_mavlink_log_payload() { )); let _ = fs::remove_file(&path); } - -#[test] -fn rejected_config_result_enqueues_a_separate_high_level_error_payload() { - let path = std::env::temp_dir().join(format!( - "kaptan-sitl-config-error-{}-{}.cbor", - std::process::id(), - std::thread::current().name().unwrap_or("test") - )); - let _ = fs::remove_file(&path); - let mut service = configuration_service(FileProfileStore::new(&path)).unwrap(); - let response = service.dispatch(Authorized::host( - 9, - ConfigCommand::Begin { - request_id: 1, - expected_generation: 1, - target: kaptan_config_mavlink::Target::new(1, 1), - }, - )); - let reply = result(response); - assert_eq!(reply.result, ResultKind::Rejected); - service.after_config_result(response); - - let Some(DiagnosticPayload::Error(payload)) = service.pending_diagnostic_payload() else { - panic!("rejected configuration result must enqueue KAPTAN_ERROR"); - }; - let error_path = std::str::from_utf8(&payload.path[..usize::from(payload.path_length)]) - .expect("derived error path remains UTF-8"); - assert_eq!( - error_path, - "kaptan_config_mavlink::protocol::ProtocolError::GenerationConflict" - ); - assert_eq!(payload.additional_information_length, 0); - let _ = fs::remove_file(&path); -} diff --git a/firmware/tools/sitl/src/kaptan_sitl_quad.cc b/firmware/tools/sitl/src/kaptan_sitl_quad.cc index f5ee2e6..2d90e79 100644 --- a/firmware/tools/sitl/src/kaptan_sitl_quad.cc +++ b/firmware/tools/sitl/src/kaptan_sitl_quad.cc @@ -273,7 +273,7 @@ class Quad final : public gz::sim::System, std::uint16_t sensor_port{}; std::uint16_t actuator_port{}; std::uint16_t truth_port{}; - std::uint16_t operator_clock_port{}; + std::uint16_t user_clock_port{}; std::chrono::microseconds actuator_timeout{}; double maximum_motor_thrust_newtons{}; double motor_time_constant_seconds{}; @@ -325,8 +325,8 @@ void Quad::Configure(const gz::sim::Entity &entity, const unsigned int actuator_port = sdf->Get("actuator_udp_port", 0U).first; const unsigned int truth_port = sdf->Get("truth_udp_port", 0U).first; - const unsigned int operator_clock_port = - sdf->Get("operator_clock_udp_port", 0U).first; + const unsigned int user_clock_port = + sdf->Get("user_clock_udp_port", 0U).first; const std::uint64_t timeout_usec = sdf->Get("actuator_timeout_usec", 0).first; this->data_->maximum_motor_thrust_newtons = @@ -342,11 +342,11 @@ void Quad::Configure(const gz::sim::Entity &entity, this->data_->reaction_torque_per_newton_metre = sdf->Get("reaction_torque_per_newton_metre", 0.0).first; if (imu_topic.empty() || sensor_port == 0 || actuator_port == 0 || truth_port == 0 || - operator_clock_port == 0 || + user_clock_port == 0 || sensor_port > std::numeric_limits::max() || actuator_port > std::numeric_limits::max() || truth_port > std::numeric_limits::max() || - operator_clock_port > std::numeric_limits::max() || + user_clock_port > std::numeric_limits::max() || timeout_usec == 0 || !std::isfinite(this->data_->maximum_motor_thrust_newtons) || this->data_->maximum_motor_thrust_newtons <= 0.0 || !std::isfinite(this->data_->motor_time_constant_seconds) || @@ -362,7 +362,7 @@ void Quad::Configure(const gz::sim::Entity &entity, this->data_->sensor_port = static_cast(sensor_port); this->data_->actuator_port = static_cast(actuator_port); this->data_->truth_port = static_cast(truth_port); - this->data_->operator_clock_port = static_cast(operator_clock_port); + this->data_->user_clock_port = static_cast(user_clock_port); this->data_->actuator_timeout = std::chrono::microseconds(timeout_usec); this->data_->local_position_noise = std::normal_distribution(0.0, local_position_noise_stddev); @@ -482,7 +482,7 @@ void Quad::PostUpdate(const gz::sim::UpdateInfo &info, (void)this->data_->socket.SendToLoopback( this->data_->sensor_port, clock_packet.data(), clock_packet.size()); (void)this->data_->socket.SendToLoopback( - this->data_->operator_clock_port, clock_packet.data(), clock_packet.size()); + this->data_->user_clock_port, clock_packet.data(), clock_packet.size()); }; gz::msgs::IMU imu; { diff --git a/firmware/tools/sitl/src/main.rs b/firmware/tools/sitl/src/main.rs index 468b068..2aff44c 100644 --- a/firmware/tools/sitl/src/main.rs +++ b/firmware/tools/sitl/src/main.rs @@ -38,7 +38,7 @@ use kaptan_time::Timestamp; #[cfg(test)] use crate::config::default_profile; use crate::config::{ - FileProfileStore, SitlConfigRuntime, configuration_service, load_profile, write_default_profile, + FileConfigStore, SitlConfigRuntime, configuration_service, load_profile, write_default_profile, }; use kaptan_flight_config::VehicleProfile; @@ -86,8 +86,8 @@ fn serve_configuration( arguments: &mut impl Iterator, ) -> Result { match (arguments.next().as_deref(), arguments.next()) { - (None, None) => configuration_service(FileProfileStore::factory_only()), - (Some("--config"), Some(path)) => configuration_service(FileProfileStore::new(path)), + (None, None) => configuration_service(FileConfigStore::factory_only()), + (Some("--config"), Some(path)) => configuration_service(FileConfigStore::new(path)), _ => Err("usage: kaptan-sitl serve [--config PROFILE.cbor]".to_owned()), } } @@ -122,10 +122,8 @@ fn serve(configuration: SitlConfigRuntime) -> Result<(), String> { .set_nonblocking(true) .map_err(|error| error.to_string())?; let mut runtime = SitlRuntime::from_configuration(configuration)?; - let config_generation = runtime.configuration.manager().active_generation(); eprintln!( - "kaptan-sitl: configuration generation {} selected at boot; listening for Gazebo sensors on 127.0.0.1:{SENSOR_PORT} and commands on 127.0.0.1:{CONTROL_PORT}", - config_generation.map_or(0, kaptan_config::Generation::get), + "kaptan-sitl: boot configuration selected; listening for Gazebo sensors on 127.0.0.1:{SENSOR_PORT} and commands on 127.0.0.1:{CONTROL_PORT}", ); loop { @@ -190,9 +188,6 @@ impl DatagramSink for UdpSocket { } struct SitlRuntime { - /// Retains the sole central manager that selected this boot-active profile. - /// No live apply path exists: a commit is restart-required. - configuration: SitlConfigRuntime, profile: VehicleProfile, initializer: StationaryInitializer<100>, estimator: Option, @@ -224,13 +219,12 @@ enum ClockWork { impl SitlRuntime { #[cfg(test)] fn new() -> Result { - Self::from_configuration(configuration_service(FileProfileStore::factory_only())?) + Self::from_configuration(configuration_service(FileConfigStore::factory_only())?) } fn from_configuration(configuration: SitlConfigRuntime) -> Result { let profile = configuration.active_profile(); Ok(Self { - configuration, profile, initializer: StationaryInitializer::new(), estimator: None, @@ -1071,7 +1065,7 @@ fn strictly_after( mod tests { use super::*; use core::cell::RefCell; - use kaptan_config::{ConfigModel, ConfigValue, FieldId, ProfileStore}; + use kaptan_config::{ConfigModel, ConfigStore, ConfigValue, FieldId}; #[derive(Default)] struct RecordingSink { @@ -1148,11 +1142,11 @@ mod tests { std::thread::current().name().unwrap_or("test") )); let _ = std::fs::remove_file(&path); - let mut candidate = kaptan_flight_config::CandidateProfile::default(); + let mut candidate = kaptan_flight_config::CandidateProfile::factory(); candidate .set_field(FieldId::new(13), ConfigValue::F32(1.7)) .unwrap(); - FileProfileStore::new(&path).persist(&candidate).unwrap(); + FileConfigStore::new(&path).persist(&candidate).unwrap(); let mut arguments = vec!["--config".to_owned(), path.to_string_lossy().into_owned()].into_iter(); diff --git a/firmware/tools/sitl/worlds/kaptan-sitl-quad.sdf.in b/firmware/tools/sitl/worlds/kaptan-sitl-quad.sdf.in index 10d076d..83434e5 100644 --- a/firmware/tools/sitl/worlds/kaptan-sitl-quad.sdf.in +++ b/firmware/tools/sitl/worlds/kaptan-sitl-quad.sdf.in @@ -190,7 +190,7 @@ 14660 14661 14663 - 14664 + 14664 25000 4.5 0.03