feat(config): add MAVLink transaction service

This commit is contained in:
Onur Özkan committed 2026-08-09 22:06:42 +03:00
1 parent 6c5e061359
commit 2b224f8eb9
14 files changed
+3116 -1

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ members = [
"libs/kaptan-control", "libs/kaptan-control",
"libs/kaptan-config", "libs/kaptan-config",
"libs/kaptan-config-derive", "libs/kaptan-config-derive",
"libs/kaptan-config-mavlink",
"libs/kaptan-data-channel", "libs/kaptan-data-channel",
"libs/kaptan-dma", "libs/kaptan-dma",
"libs/kaptan-drivers", "libs/kaptan-drivers",
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "kaptan-config-mavlink"
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
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" }
kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false, features = ["dialect-kaptan"] }
+271
View File
@@ -0,0 +1,271 @@
//! Authorized, transport-neutral configuration commands.
use kaptan_config::{ConfigValue, FieldId};
/// Exact vehicle endpoint selected by a configuration request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Target {
/// MAVLink system identity.
system: u8,
/// MAVLink component identity.
component: u8,
}
/// Selects the configuration snapshot read by a value request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ValueSource {
/// Immutable boot-active snapshot.
Active,
/// Last durably committed candidate.
Committed,
/// The caller's open RAM transaction.
Transaction,
}
impl Target {
/// Creates an exact system/component target.
pub const fn new(system: u8, component: u8) -> Self {
assert!(
system != 0 && component != 0,
"configuration target must be exact"
);
Self { system, component }
}
/// Parses an exact non-broadcast target from an untrusted transport.
pub const fn try_new(system: u8, component: u8) -> Option<Self> {
if system == 0 || component == 0 {
None
} else {
Some(Self { system, component })
}
}
}
/// 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.
InfoRequest {
/// Client correlation identity.
request_id: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Enumerates a bounded page of schema descriptors.
DescriptorRequest {
/// Client correlation identity.
request_id: u32,
/// First stable descriptor index.
first: u16,
/// Requested number of descriptors, clamped to the protocol bound.
count: u16,
/// Exact addressed endpoint.
target: Target,
},
/// Reads one typed scalar from an immutable or transaction snapshot.
ValueRequest {
/// Client correlation identity.
request_id: u32,
/// Lease-bound session for transaction reads, otherwise zero.
session_id: u32,
/// Stable field identity.
field: FieldId,
/// Selected snapshot.
source: ValueSource,
/// 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.
Begin {
/// Client correlation identity.
request_id: u32,
/// Optimistic concurrency generation.
expected_generation: u64,
/// Exact addressed endpoint.
target: Target,
},
/// Sets one typed scalar in the open transaction.
Set {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Optimistic transaction revision.
expected_revision: u32,
/// Stable schema-local field identity.
field: FieldId,
/// Typed value.
value: ConfigValue,
/// Exact addressed endpoint.
target: Target,
},
/// Validates the complete candidate without persistence.
Validate {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Optimistic transaction revision.
expected_revision: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Validates and atomically persists the complete candidate.
Commit {
/// Client correlation identity.
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.
target: Target,
},
/// Discards the RAM transaction.
Abort {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Exact addressed endpoint.
target: Target,
},
}
impl ConfigCommand {
/// Returns the client request identity used for idempotency.
pub const fn request_id(self) -> u32 {
match self {
Self::InfoRequest { request_id, .. }
| Self::DescriptorRequest { 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, .. }
| Self::Commit { request_id, .. }
| Self::Abort { request_id, .. } => request_id,
}
}
/// Returns the endpoint selected by this command.
pub const fn target(self) -> Target {
match self {
Self::InfoRequest { target, .. }
| Self::DescriptorRequest { target, .. }
| Self::ValueRequest { target, .. }
| Self::ImportBegin { target, .. }
| Self::ImportChunk { target, .. }
| Self::ImportFinish { target, .. }
| Self::Begin { target, .. }
| Self::Set { target, .. }
| Self::Validate { target, .. }
| Self::Commit { target, .. }
| Self::Abort { target, .. } => target,
}
}
}
/// 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.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Authorized<T> {
authority: VerifiedAuthority,
command: T,
}
impl<T> Authorized<T> {
#[cfg(any(test, feature = "host-authority"))]
/// Wraps a host/SITL command in an explicit test-only authority token.
pub const fn host(principal: u64, command: T) -> Self {
Self {
authority: VerifiedAuthority::host(principal),
command,
}
}
pub(crate) fn into_parts(self) -> (VerifiedAuthority, T) {
(self.authority, self.command)
}
}
+20
View File
@@ -0,0 +1,20 @@
//! Kaptan custom-MAVLink configuration microservice adapter.
//!
//! 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
//! consumers.
#![no_std]
mod command;
mod protocol;
pub mod service;
pub mod wire;
pub use command::{Authorized, ConfigCommand, Target, ValueSource, VerifiedAuthority};
pub use protocol::{ConfigReply, Operation, ProtocolError, ResultKind};
pub use service::{
CAPABILITY_CBOR_IMPORT, ConfigInfo, ConfigProtocol, DescriptorPage, NoSnapshotImporter,
ServiceResponse, SnapshotImporter, ValueResponse,
};
+194
View File
@@ -0,0 +1,194 @@
//! Stable typed outcomes for the Kaptan configuration microservice.
use kaptan_config::{ConfigState, ConfigViolation, FieldId};
use kaptan_log::{EventHub, KaptanError, LogFlags, PublishOutcome};
/// Operation reported in an authoritative result record.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Operation {
/// Query/read operation.
Query,
/// Begin transaction.
Begin,
/// Scalar write.
Set,
/// Whole-candidate validation.
Validate,
/// Durable commit.
Commit,
/// Transaction abort.
Abort,
/// CBOR snapshot import into the isolated transaction.
Import,
}
/// Stable result classification.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ResultKind {
/// The operation was accepted.
Accepted,
/// A full snapshot committed but requires restart before activation.
CommittedRestartRequired,
/// The operation was rejected without an active-state transition.
Rejected,
}
/// Compact protocol error code. Detail belongs in [`ConfigViolation`] when a
/// configuration invariant was responsible.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
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.
SessionBusy,
/// The session is unknown, expired, or belongs to a different principal.
InvalidSession,
/// Reusing one request ID with different content is forbidden.
RequestConflict,
/// The request was not based on the current transaction revision.
StaleRevision,
/// A command is invalid in the current lifecycle state.
BadState,
/// The field is not present in this schema.
UnknownField(FieldId),
/// 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,
}
/// Authoritative completion for one request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConfigReply {
/// Client request identity.
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.
pub result: ResultKind,
/// Exact typed failure, if rejected.
pub error: Option<ProtocolError>,
/// Current lifecycle state after handling the request.
pub state: ConfigState,
}
impl ConfigReply {
/// Stable primary field for the wire response, or the reserved sentinel.
pub const fn primary_field(self) -> u16 {
match self.error {
Some(ProtocolError::Violation(violation)) => match violation.primary() {
Some(field) => field.get(),
None => u16::MAX,
},
Some(ProtocolError::UnknownField(field)) | Some(ProtocolError::TypeMismatch(field)) => {
field.get()
}
_ => u16::MAX,
}
}
/// Stable related field for the wire response, or the reserved sentinel.
pub const fn related_field(self) -> u16 {
match self.error {
Some(ProtocolError::Violation(violation)) => match violation.related_field() {
Some(field) => field.get(),
None => u16::MAX,
},
_ => u16::MAX,
}
}
/// Stable validation-rule code, or zero when no validation rule applies.
pub const fn validation_rule(self) -> u16 {
match self.error {
Some(ProtocolError::Violation(violation)) => violation.rule().code(),
_ => 0,
}
}
/// Enqueues a critical diagnostic companion for a rejected structured
/// result. The `KAPTAN_CONFIG_RESULT` remains the correlated,
/// field/rule-precise authority; this sends only the derive-generated
/// high-level `ProtocolError` display identity through `KAPTAN_ERROR`.
///
/// The helper deliberately carries no request/session/generation fields
/// into the error channel. A composition owner may call it after it has
/// emitted or retained the authoritative result.
pub fn publish_rejection<const CRITICAL: usize, const BEST_EFFORT: usize>(
self,
hub: &mut EventHub<CRITICAL, BEST_EFFORT>,
) -> Option<PublishOutcome> {
if self.result != ResultKind::Rejected {
return None;
}
self.error
.as_ref()
.map(|error| hub.publish_error(LogFlags::NONE, error))
}
}
#[cfg(test)]
mod tests {
use kaptan_log::{EventHub, LogFilter, PublishOutcome, RecordChannel};
use super::{ConfigReply, Operation, ProtocolError, ResultKind};
use kaptan_config::ConfigState;
#[test]
fn rejected_result_enqueues_only_its_high_level_error_identity() {
let reply = ConfigReply {
request_id: 42,
session_id: 7,
transfer_id: 0,
revision: 3,
generation: 9,
operation: Operation::Set,
result: ResultKind::Rejected,
error: Some(ProtocolError::BadState),
state: ConfigState::Editing,
};
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert_eq!(
reply.publish_rejection(&mut hub),
Some(PublishOutcome::Queued)
);
let record = hub.peek().expect("rejection is retained");
assert_eq!(record.channel(), RecordChannel::Error);
assert_eq!(
record.message().as_str(),
"kaptan_config_mavlink::protocol::ProtocolError::BadState"
);
let accepted = ConfigReply {
result: ResultKind::Accepted,
error: None,
..reply
};
assert_eq!(accepted.publish_rejection(&mut hub), None);
}
}
+14
View File
@@ -0,0 +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
//! 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 session::ConfigProtocol;
@@ -0,0 +1,248 @@
//! Bounded CBOR snapshot transfer state.
use kaptan_config::{CommitError, ConfigManager, ConfigModel, ProfileStore};
use super::session::{ConfigProtocol, Session};
use crate::{ConfigCommand, ConfigReply, Operation, ProtocolError, ResultKind};
/// Maximum complete CBOR snapshot accepted by the protocol.
pub(super) const IMPORT_MAXIMUM_BYTES: usize = 4096;
/// Payload bytes carried by each aligned import chunk.
pub(super) const IMPORT_CHUNK_BYTES: usize = 192;
pub(super) const IMPORT_CHUNK_COUNT: usize = IMPORT_MAXIMUM_BYTES.div_ceil(IMPORT_CHUNK_BYTES);
/// `KAPTAN_CONFIG_INFO.capability_flags`: bounded CBOR transaction import.
pub const CAPABILITY_CBOR_IMPORT: u8 = 1;
/// Decodes a complete bounded CBOR snapshot into this service's candidate.
///
/// Vehicle-specific implementations live above this transport adapter so the
/// generic MAVLink layer never owns a profile schema or board capabilities.
pub trait SnapshotImporter<C> {
/// Whether this concrete deployment exposes the bounded import path.
const IMPORT_SUPPORTED: bool;
/// Current format carried inside the deployment's raw CBOR profile blob.
const PROFILE_FORMAT: u16;
/// Converts one complete CBOR payload to a candidate. The service performs
/// the subsequent complete-context validation before replacing the RAM
/// transaction.
fn decode(&self, bytes: &[u8]) -> Result<C, ProtocolError>;
}
/// Default importer used when a deployment deliberately does not expose CBOR
/// import yet. It keeps the service type usable for descriptor-only tests.
#[derive(Clone, Copy, Debug, Default)]
pub struct NoSnapshotImporter;
impl<C> SnapshotImporter<C> for NoSnapshotImporter {
const IMPORT_SUPPORTED: bool = false;
const PROFILE_FORMAT: u16 = 0;
fn decode(&self, _: &[u8]) -> Result<C, ProtocolError> {
Err(ProtocolError::CborMalformed)
}
}
#[derive(Clone, Copy)]
pub(super) struct ImportBuffer {
owner: u64,
session_id: u32,
transfer_id: u32,
revision: u32,
length: u16,
crc32c: u32,
received: [bool; IMPORT_CHUNK_COUNT],
data: [u8; IMPORT_MAXIMUM_BYTES],
}
impl<I> ConfigProtocol<I> {
pub(super) fn begin_import<C>(
&mut self,
owner: u64,
command: ConfigCommand,
session: Session,
) -> ConfigReply
where
C: ConfigModel,
I: SnapshotImporter<C>,
{
let ConfigCommand::ImportBegin {
request_id,
length,
schema_id,
crc32c,
..
} = command
else {
return self.rejected(command, ProtocolError::Internal);
};
if !I::IMPORT_SUPPORTED {
return self.rejected(command, ProtocolError::ImportUnavailable);
}
if schema_id != C::SCHEMA_ID {
return self.rejected(command, ProtocolError::SchemaMismatch);
}
if length == 0 || usize::from(length) > IMPORT_MAXIMUM_BYTES {
return self.rejected(command, ProtocolError::CborTooLarge);
}
let Some(transfer_id) = self.allocate_transfer_id() else {
return self.rejected(command, ProtocolError::Internal);
};
self.import = Some(ImportBuffer {
owner,
session_id: session.id,
transfer_id,
revision: session.revision,
length,
crc32c,
received: [false; IMPORT_CHUNK_COUNT],
data: [0; IMPORT_MAXIMUM_BYTES],
});
let mut reply = self.reply(
request_id,
session.id,
session.revision,
Operation::Import,
ResultKind::Accepted,
None,
);
reply.transfer_id = transfer_id;
reply
}
pub(super) fn import_chunk(&mut self, command: ConfigCommand, session: Session) -> ConfigReply {
let ConfigCommand::ImportChunk {
request_id,
transfer_id,
offset,
length,
data,
..
} = command
else {
return self.rejected(command, ProtocolError::Internal);
};
let offset = usize::from(offset);
let length = usize::from(length);
let Some(import) = self.import.as_mut() else {
return self.rejected(command, ProtocolError::BadState);
};
if import.owner != session.owner
|| import.session_id != session.id
|| import.transfer_id != transfer_id
{
return self.rejected(command, ProtocolError::InvalidSession);
}
if import.revision != session.revision {
return self.rejected(command, ProtocolError::StaleRevision);
}
let total = usize::from(import.length);
let Some(end) = offset.checked_add(length) else {
return self.rejected(command, ProtocolError::CborMalformed);
};
if length == 0
|| offset % IMPORT_CHUNK_BYTES != 0
|| end > total
|| (length != IMPORT_CHUNK_BYTES && end != total)
{
return self.rejected(command, ProtocolError::CborMalformed);
}
let chunk = offset / IMPORT_CHUNK_BYTES;
if import.received[chunk] {
if import.data[offset..end] != data[..length] {
return self.rejected(command, ProtocolError::RequestConflict);
}
} else {
import.data[offset..end].copy_from_slice(&data[..length]);
import.received[chunk] = true;
}
self.reply(
request_id,
session.id,
session.revision,
Operation::Import,
ResultKind::Accepted,
None,
)
}
pub(super) fn finish_import<C, S>(
&mut self,
manager: &mut ConfigManager<C, S>,
command: ConfigCommand,
session: Session,
request_id: u32,
transfer_id: u32,
) -> ConfigReply
where
C: ConfigModel,
S: ProfileStore<C>,
I: SnapshotImporter<C>,
{
let Some(import) = self.import else {
return self.rejected(command, ProtocolError::BadState);
};
if import.owner != session.owner
|| import.session_id != session.id
|| import.transfer_id != transfer_id
{
return self.rejected(command, ProtocolError::InvalidSession);
}
if import.revision != session.revision {
return self.rejected(command, ProtocolError::StaleRevision);
}
let total = usize::from(import.length);
let chunks = total.div_ceil(IMPORT_CHUNK_BYTES);
if import.received[..chunks].iter().any(|received| !received) {
return self.rejected(command, ProtocolError::CborMalformed);
}
if crc32c(&import.data[..total]) != import.crc32c {
return self.rejected(command, ProtocolError::BlobCrc);
}
let candidate = match self.importer.decode(&import.data[..total]) {
Ok(candidate) => candidate,
Err(error) => return self.rejected(command, error),
};
let Some(next_revision) = session.revision.checked_add(1) else {
return self.rejected(command, ProtocolError::Internal);
};
match manager.replace_transaction(candidate) {
Ok(()) => {
let Some(session) = self.session.as_mut() else {
return self.rejected(command, ProtocolError::Internal);
};
session.revision = next_revision;
let (session_id, revision) = (session.id, session.revision);
self.import = None;
self.reply(
request_id,
session_id,
revision,
Operation::Import,
ResultKind::Accepted,
None,
)
}
Err(CommitError::NoTransaction) => self.rejected(command, ProtocolError::BadState),
Err(CommitError::InvalidCandidate(violation)) => {
self.rejected(command, ProtocolError::Violation(violation))
}
Err(CommitError::Store(_)) => self.rejected(command, ProtocolError::Internal),
}
}
}
pub(super) fn crc32c(bytes: &[u8]) -> u32 {
let mut crc = !0_u32;
for byte in bytes {
crc ^= u32::from(*byte);
for _ in 0..8 {
crc = if crc & 1 == 0 {
crc >> 1
} else {
(crc >> 1) ^ 0x82f6_3b78
};
}
}
!crc
}
@@ -0,0 +1,208 @@
//! Read-only protocol discovery and value-query responses.
use kaptan_config::{
ConfigManager, ConfigModel, ConfigState, ConfigValue, FieldDescriptor, FieldId, Generation,
ProfileStore,
};
use super::{import::SnapshotImporter, session::ConfigProtocol};
use crate::{ProtocolError, ValueSource};
/// Bounded descriptor page returned by a schema discovery request.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DescriptorPage {
/// 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],
}
/// One typed value read from an explicitly selected configuration snapshot.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ValueResponse {
/// Request being answered.
pub request_id: u32,
/// Transaction session for transaction values, otherwise zero.
pub session_id: u32,
/// Current transaction revision, otherwise zero.
pub revision: u32,
/// Requested stable field identity.
pub field: FieldId,
/// Declared scalar value.
pub value: ConfigValue,
}
/// Correlated protocol/schema discovery response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConfigInfo {
/// Request being answered.
pub request_id: u32,
/// Boot-active generation, or `None` for the factory profile.
pub active_generation: Option<Generation>,
/// Last committed generation, or `None` for the factory profile.
pub committed_generation: Option<Generation>,
/// Exact schema identifier expected by profile import.
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.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ServiceResponse {
/// Correlated protocol/schema discovery response.
Info(ConfigInfo),
/// Completion for a mutating operation or rejected query.
Result(crate::ConfigReply),
/// Bounded self-describing schema page.
Descriptors(DescriptorPage),
/// One typed scalar value.
Value(ValueResponse),
}
impl<I> ConfigProtocol<I> {
/// Returns static descriptor metadata by stable schema index.
pub fn descriptor<C, S>(
&self,
manager: &ConfigManager<C, S>,
index: u16,
) -> Option<&'static FieldDescriptor>
where
C: ConfigModel,
S: ProfileStore<C>,
{
manager.descriptor(index)
}
/// Reads one typed value without opening or changing a transaction.
pub fn value<C, S>(
&self,
manager: &ConfigManager<C, S>,
source: ValueSource,
field: FieldId,
) -> Option<ConfigValue>
where
C: ConfigModel,
S: ProfileStore<C>,
{
match source {
ValueSource::Active => manager.active_value(field),
ValueSource::Committed => manager.committed_value(field),
ValueSource::Transaction => manager.transaction_value(field),
}
}
pub(super) fn descriptors<C, S>(
&self,
manager: &ConfigManager<C, S>,
request_id: u32,
first: u16,
requested: u16,
) -> DescriptorPage
where
C: ConfigModel,
S: ProfileStore<C>,
{
let mut descriptors = [None; 4];
let count = requested.min(descriptors.len() as u16);
let mut len = 0;
for offset in 0..count {
let Some(index) = first.checked_add(offset) else {
break;
};
let Some(descriptor) = manager.descriptor(index) else {
break;
};
descriptors[usize::from(len)] = Some(descriptor);
len += 1;
}
DescriptorPage {
request_id,
first,
len,
descriptors,
}
}
pub(super) fn info<C, S>(&self, manager: &ConfigManager<C, S>, request_id: u32) -> ConfigInfo
where
C: ConfigModel,
S: ProfileStore<C>,
I: SnapshotImporter<C>,
{
ConfigInfo {
request_id,
active_generation: manager.active_generation(),
committed_generation: manager.committed_generation(),
schema_id: C::SCHEMA_ID,
profile_format: I::PROFILE_FORMAT,
field_count: manager.field_count(),
state: self.state,
capability_flags: self.capability_flags_for::<C>(),
}
}
pub(super) fn value_response<C, S>(
&self,
manager: &ConfigManager<C, S>,
owner: u64,
request_id: u32,
session_id: u32,
field: FieldId,
source: ValueSource,
) -> ServiceResponse
where
C: ConfigModel,
S: ProfileStore<C>,
{
let (value, reply_session, revision) = match source {
ValueSource::Active => (manager.active_value(field), 0, 0),
ValueSource::Committed => (manager.committed_value(field), 0, 0),
ValueSource::Transaction => {
let Some(session) = self.session else {
return ServiceResponse::Result(self.rejected_for(
request_id,
session_id,
ProtocolError::InvalidSession,
));
};
if session.id != session_id || session.owner != owner {
return ServiceResponse::Result(self.rejected_for(
request_id,
session_id,
ProtocolError::InvalidSession,
));
}
(
manager.transaction_value(field),
session.id,
session.revision,
)
}
};
let Some(value) = value else {
return ServiceResponse::Result(self.rejected_for(
request_id,
session_id,
ProtocolError::UnknownField(field),
));
};
ServiceResponse::Value(ValueResponse {
request_id,
session_id: reply_session,
revision,
field,
value,
})
}
}
@@ -0,0 +1,272 @@
//! Bounded request-replay cache for idempotent client retries.
use kaptan_config::ConfigValue;
use super::session::ConfigProtocol;
use crate::{ConfigCommand, ConfigReply, ProtocolError};
pub(super) const REQUEST_CACHE_CAPACITY: usize = 8;
#[derive(Clone, Copy)]
pub(super) struct RememberedResult {
pub(super) owner: u64,
pub(super) command: ConfigCommand,
pub(super) reply: ConfigReply,
}
impl<I> ConfigProtocol<I> {
pub(super) fn cached(&self, owner: u64, command: ConfigCommand) -> Option<ConfigReply> {
for cached in self.remembered.iter().flatten() {
if cached.owner != owner || cached.command.request_id() != command.request_id() {
continue;
}
if same_command(cached.command, command) {
return Some(cached.reply);
}
return Some(self.rejected(command, ProtocolError::RequestConflict));
}
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;
}
}
fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
match (left, right) {
(
ConfigCommand::InfoRequest {
request_id: left_request,
target: left_target,
},
ConfigCommand::InfoRequest {
request_id: right_request,
target: right_target,
},
) => left_request == right_request && left_target == right_target,
(
ConfigCommand::DescriptorRequest {
request_id: left_request,
first: left_first,
count: left_count,
target: left_target,
},
ConfigCommand::DescriptorRequest {
request_id: right_request,
first: right_first,
count: right_count,
target: right_target,
},
) => {
left_request == right_request
&& left_first == right_first
&& left_count == right_count
&& left_target == right_target
}
(
ConfigCommand::ValueRequest {
request_id: left_request,
session_id: left_session,
field: left_field,
source: left_source,
target: left_target,
},
ConfigCommand::ValueRequest {
request_id: right_request,
session_id: right_session,
field: right_field,
source: right_source,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_field == right_field
&& left_source == right_source
&& left_target == right_target
}
(
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
}
(
ConfigCommand::Set {
request_id: left_request,
session_id: left_session,
expected_revision: left_revision,
field: left_field,
value: left_value,
target: left_target,
},
ConfigCommand::Set {
request_id: right_request,
session_id: right_session,
expected_revision: right_revision,
field: right_field,
value: right_value,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_revision == right_revision
&& left_field == right_field
&& same_value(left_value, right_value)
&& left_target == right_target
}
(
ConfigCommand::Validate {
request_id: left_request,
session_id: left_session,
expected_revision: left_revision,
target: left_target,
},
ConfigCommand::Validate {
request_id: right_request,
session_id: right_session,
expected_revision: right_revision,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_revision == right_revision
&& left_target == right_target
}
(
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
}
(
ConfigCommand::Abort {
request_id: left_request,
session_id: left_session,
target: left_target,
},
ConfigCommand::Abort {
request_id: right_request,
session_id: right_session,
target: right_target,
},
) => {
left_request == right_request
&& 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,
}
}
fn same_value(left: ConfigValue, right: ConfigValue) -> bool {
left.kind() == right.kind() && left.raw_bits() == right.raw_bits()
}
File diff suppressed because it is too large. Load diff
+501
View File
@@ -0,0 +1,501 @@
//! Generated Kaptan-dialect message translation.
use kaptan_config::{
ApplyPolicy, ConfigState, ConfigValue, FieldDescriptor, FieldId, Generation, 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,
KaptanConfigResult, KaptanConfigState, KaptanConfigValueKind, KaptanConfigValueSource,
MavMessage,
};
use crate::{
ConfigCommand, ConfigReply, Operation, ProtocolError, ResultKind, Target, ValueSource,
service::{ConfigInfo, ValueResponse},
};
/// Rejects a generated message that is not a Kaptan configuration command.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum DecodeError {
/// The message belongs to a response, telemetry, or another service.
UnsupportedMessage,
/// A wire field identity used a reserved sentinel.
InvalidFieldId,
/// The message used an unknown value-source discriminator.
InvalidValueSource,
/// A boolean setter used a representation other than zero or one.
InvalidBoolean,
/// A configuration request used a broadcast/zero target endpoint.
InvalidTarget,
}
/// 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.
pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError> {
if matches!(message.target_system_id(), Some(0))
|| matches!(message.target_component_id(), Some(0))
{
return Err(DecodeError::InvalidTarget);
}
match message {
MavMessage::KAPTAN_CONFIG_INFO_REQUEST(data) => Ok(ConfigCommand::InfoRequest {
request_id: data.request_id,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_DESCRIPTOR_REQUEST(data) => {
Ok(ConfigCommand::DescriptorRequest {
request_id: data.request_id,
first: data.first,
count: data.count,
target: Target::new(data.target_system, data.target_component),
})
}
MavMessage::KAPTAN_CONFIG_VALUE_REQUEST(data) => Ok(ConfigCommand::ValueRequest {
request_id: data.request_id,
session_id: data.session_id,
field: field_id(data.field_id)?,
source: value_source(data.source)?,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_IMPORT_BEGIN(data) => Ok(ConfigCommand::ImportBegin {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
length: data.length,
schema_id: data.schema_id,
crc32c: data.crc32c,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_IMPORT_CHUNK(data) => Ok(ConfigCommand::ImportChunk {
request_id: data.request_id,
session_id: data.session_id,
transfer_id: data.transfer_id,
offset: data.offset,
length: data.length,
data: data.data,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_IMPORT_FINISH(data) => Ok(ConfigCommand::ImportFinish {
request_id: data.request_id,
session_id: data.session_id,
transfer_id: data.transfer_id,
expected_revision: data.expected_revision,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_BEGIN(data) => Ok(ConfigCommand::Begin {
request_id: data.request_id,
expected_generation: data.expected_generation,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_F32(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::F32(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_BOOL(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::Bool(boolean(data.value)?),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_I32(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::I32(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_U32(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::U32(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_U64(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::U64(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_F64(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::F64(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_VALIDATE(data) => Ok(ConfigCommand::Validate {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_COMMIT(data) => Ok(ConfigCommand::Commit {
request_id: data.request_id,
session_id: data.session_id,
expected_generation: data.expected_generation,
expected_revision: data.expected_revision,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_ABORT(data) => Ok(ConfigCommand::Abort {
request_id: data.request_id,
session_id: data.session_id,
target: Target::new(data.target_system, data.target_component),
}),
_ => Err(DecodeError::UnsupportedMessage),
}
}
const fn field_id(value: u16) -> Result<FieldId, DecodeError> {
match FieldId::try_new(value) {
Some(value) => Ok(value),
None => Err(DecodeError::InvalidFieldId),
}
}
const fn value_source(value: KaptanConfigValueSource) -> Result<ValueSource, DecodeError> {
match value {
KaptanConfigValueSource::KAPTAN_CONFIG_VALUE_SOURCE_ACTIVE => Ok(ValueSource::Active),
KaptanConfigValueSource::KAPTAN_CONFIG_VALUE_SOURCE_COMMITTED => Ok(ValueSource::Committed),
KaptanConfigValueSource::KAPTAN_CONFIG_VALUE_SOURCE_TRANSACTION => {
Ok(ValueSource::Transaction)
}
}
}
const fn boolean(value: u8) -> Result<bool, DecodeError> {
match value {
0 => Ok(false),
1 => Ok(true),
_ => Err(DecodeError::InvalidBoolean),
}
}
/// 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(),
validation_rule: reply.validation_rule(),
operation: operation(reply.operation),
result: result(reply.result),
error: error(reply.error),
config_state: state(reply.state),
}
}
/// 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,
config_state: state(info.state),
capability_flags: info.capability_flags,
}
}
/// Encodes self-describing metadata for one schema field.
pub fn encode_descriptor(
request_id: u32,
descriptor_index: u16,
descriptor: FieldDescriptor,
) -> 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(),
request_id,
descriptor_index,
field_id: descriptor.id.get(),
display_key: descriptor.display_key.into(),
unit: descriptor.unit.into(),
value_kind: value_kind(descriptor.kind),
apply_policy: policy(descriptor.apply_policy),
flags: 0,
}
}
/// Encodes a typed scalar read response for the Kaptan control station.
pub fn encode_value(value: ValueResponse) -> KAPTAN_CONFIG_VALUE_DATA {
KAPTAN_CONFIG_VALUE_DATA {
value_bits: value.value.raw_bits(),
request_id: value.request_id,
session_id: value.session_id,
revision: value.revision,
field_id: value.field.get(),
value_kind: value_kind(value.value.kind()),
}
}
const fn value_kind(kind: ValueKind) -> KaptanConfigValueKind {
match kind {
ValueKind::Bool => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_BOOL,
ValueKind::I32 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_I32,
ValueKind::U32 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32,
ValueKind::U64 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U64,
ValueKind::F32 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_F32,
ValueKind::F64 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_F64,
}
}
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,
Operation::Begin => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN,
Operation::Set => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_SET,
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,
}
}
const fn result(result: ResultKind) -> KaptanConfigResult {
match result {
ResultKind::Accepted => KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED,
ResultKind::CommittedRestartRequired => {
KaptanConfigResult::KAPTAN_CONFIG_RESULT_COMMITTED_RESTART_REQUIRED
}
ResultKind::Rejected => KaptanConfigResult::KAPTAN_CONFIG_RESULT_REJECTED,
}
}
const fn state(state: ConfigState) -> KaptanConfigState {
match state {
ConfigState::Active => KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE,
ConfigState::Editing => KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING,
ConfigState::RestartRequired => KaptanConfigState::KAPTAN_CONFIG_STATE_RESTART_REQUIRED,
}
}
const fn error(error: Option<ProtocolError>) -> KaptanConfigError {
match error {
None => KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE,
Some(ProtocolError::BadTarget) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BAD_TARGET,
Some(ProtocolError::AuthorizationDenied) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_AUTHORIZATION_DENIED
}
Some(ProtocolError::GenerationConflict) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_GENERATION_CONFLICT
}
Some(ProtocolError::SessionBusy) => KaptanConfigError::KAPTAN_CONFIG_ERROR_SESSION_BUSY,
Some(ProtocolError::InvalidSession) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_SESSION
}
Some(ProtocolError::RequestConflict) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_REQUEST_CONFLICT
}
Some(ProtocolError::StaleRevision) => KaptanConfigError::KAPTAN_CONFIG_ERROR_STALE_REVISION,
Some(ProtocolError::BadState) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BAD_STATE,
Some(ProtocolError::UnknownField(_)) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNKNOWN_FIELD
}
Some(ProtocolError::TypeMismatch(_)) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_TYPE_MISMATCH
}
Some(ProtocolError::Violation(violation)) => match violation.rule() {
ValidationRule::NonFinite => KaptanConfigError::KAPTAN_CONFIG_ERROR_NON_FINITE,
ValidationRule::BelowMinimum | ValidationRule::Zero => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_BELOW_MINIMUM
}
ValidationRule::AboveMaximum => KaptanConfigError::KAPTAN_CONFIG_ERROR_ABOVE_MAXIMUM,
ValidationRule::Inconsistent => KaptanConfigError::KAPTAN_CONFIG_ERROR_CROSS_FIELD,
ValidationRule::UnsupportedCapability => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_CAPABILITY
}
ValidationRule::UnsupportedFormat => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_FORMAT
}
},
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_mavlink::message::kaptan::{
KAPTAN_CONFIG_INFO_REQUEST_DATA, KAPTAN_CONFIG_SET_F32_DATA, MavMessage,
};
use super::*;
use crate::CAPABILITY_CBOR_IMPORT;
#[test]
fn generated_set_message_preserves_typed_value_and_identifiers() {
let message = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
request_id: 7,
session_id: 8,
expected_revision: 9,
field_id: 10,
value: 1.5,
target_system: 1,
target_component: 2,
});
assert_eq!(
decode_command(&message),
Ok(ConfigCommand::Set {
request_id: 7,
session_id: 8,
expected_revision: 9,
field: FieldId::new(10),
value: ConfigValue::F32(1.5),
target: Target::new(1, 2),
})
);
}
#[test]
fn generated_info_request_is_correlated_and_exactly_targeted() {
let message = MavMessage::KAPTAN_CONFIG_INFO_REQUEST(KAPTAN_CONFIG_INFO_REQUEST_DATA {
request_id: 31,
target_system: 1,
target_component: 2,
});
assert_eq!(
decode_command(&message),
Ok(ConfigCommand::InfoRequest {
request_id: 31,
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 {
request_id: 7,
session_id: 8,
expected_revision: 9,
field_id: 10,
value: 1.5,
target_system: 0,
target_component: 2,
});
let zero_component = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
request_id: 7,
session_id: 8,
expected_revision: 9,
field_id: 10,
value: 1.5,
target_system: 1,
target_component: 0,
});
assert_eq!(
decode_command(&zero_system),
Err(DecodeError::InvalidTarget)
);
assert_eq!(
decode_command(&zero_component),
Err(DecodeError::InvalidTarget)
);
}
#[test]
fn result_encodes_exact_validation_identity() {
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(
FieldId::new(11),
ValidationRule::NonFinite,
))),
state: ConfigState::Editing,
};
let encoded = encode_result(reply);
assert_eq!(encoded.primary_field, 11);
assert_eq!(encoded.validation_rule, ValidationRule::NonFinite.code());
assert_eq!(
encoded.error,
KaptanConfigError::KAPTAN_CONFIG_ERROR_NON_FINITE
);
}
#[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),
state: ConfigState::Active,
};
assert_eq!(
encode_result(reply).error,
KaptanConfigError::KAPTAN_CONFIG_ERROR_IMPORT_UNAVAILABLE
);
}
#[test]
fn info_preserves_service_capabilities() {
let info = encode_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);
}
}
+2 -1
View File
@@ -3,8 +3,9 @@ use std::path::PathBuf;
fn main() { fn main() {
let manifest_directory = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); let manifest_directory = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let dialect = manifest_directory.join("definitions/kaptan.xml"); let dialect = manifest_directory.join("definitions/kaptan.xml");
let config = manifest_directory.join("definitions/kaptan/config.xml");
let log = manifest_directory.join("definitions/kaptan/log.xml"); let log = manifest_directory.join("definitions/kaptan/log.xml");
for definition in [&dialect, &log] { for definition in [&dialect, &config, &log] {
println!("cargo::rerun-if-changed={}", definition.display()); println!("cargo::rerun-if-changed={}", definition.display());
} }
println!("cargo::rustc-check-cfg=cfg(kaptan_has_messages)"); println!("cargo::rustc-check-cfg=cfg(kaptan_has_messages)");
@@ -2,5 +2,6 @@
<mavlink> <mavlink>
<!-- The root is the only bindgen input. Feature domains remain independent <!-- The root is the only bindgen input. Feature domains remain independent
fragments so their message catalogs can evolve without a monolith. --> fragments so their message catalogs can evolve without a monolith. -->
<include>kaptan/config.xml</include>
<include>kaptan/log.xml</include> <include>kaptan/log.xml</include>
</mavlink> </mavlink>
@@ -0,0 +1,270 @@
<?xml version="1.0"?>
<mavlink>
<version>3</version>
<dialect>0</dialect>
<!-- Kaptan configuration dialect. Register the development IDs before release. 42000
belongs to Icarous and is deliberately not used here. -->
<enums>
<enum name="KAPTAN_CONFIG_VALUE_KIND">
<entry value="1" name="KAPTAN_CONFIG_VALUE_KIND_BOOL"/>
<entry value="2" name="KAPTAN_CONFIG_VALUE_KIND_I32"/>
<entry value="3" name="KAPTAN_CONFIG_VALUE_KIND_U32"/>
<entry value="4" name="KAPTAN_CONFIG_VALUE_KIND_U64"/>
<entry value="5" name="KAPTAN_CONFIG_VALUE_KIND_F32"/>
<entry value="6" name="KAPTAN_CONFIG_VALUE_KIND_F64"/>
</enum>
<enum name="KAPTAN_CONFIG_POLICY">
<entry value="1" name="KAPTAN_CONFIG_POLICY_LIVE"/>
<entry value="2" name="KAPTAN_CONFIG_POLICY_DISARMED_RESET"/>
<entry value="3" name="KAPTAN_CONFIG_POLICY_RESTART_REQUIRED"/>
</enum>
<enum name="KAPTAN_CONFIG_STATE">
<entry value="0" name="KAPTAN_CONFIG_STATE_ACTIVE"/>
<entry value="1" name="KAPTAN_CONFIG_STATE_EDITING"/>
<entry value="2" name="KAPTAN_CONFIG_STATE_RESTART_REQUIRED"/>
</enum>
<enum name="KAPTAN_CONFIG_VALUE_SOURCE">
<entry value="0" name="KAPTAN_CONFIG_VALUE_SOURCE_ACTIVE"/>
<entry value="1" name="KAPTAN_CONFIG_VALUE_SOURCE_COMMITTED"/>
<entry value="2" name="KAPTAN_CONFIG_VALUE_SOURCE_TRANSACTION"/>
</enum>
<enum name="KAPTAN_CONFIG_OPERATION">
<entry value="1" name="KAPTAN_CONFIG_OPERATION_QUERY"/>
<entry value="2" name="KAPTAN_CONFIG_OPERATION_BEGIN"/>
<entry value="3" name="KAPTAN_CONFIG_OPERATION_SET"/>
<entry value="4" name="KAPTAN_CONFIG_OPERATION_VALIDATE"/>
<entry value="5" name="KAPTAN_CONFIG_OPERATION_COMMIT"/>
<entry value="6" name="KAPTAN_CONFIG_OPERATION_ABORT"/>
<entry value="7" name="KAPTAN_CONFIG_OPERATION_IMPORT"/>
</enum>
<enum name="KAPTAN_CONFIG_RESULT">
<entry value="0" name="KAPTAN_CONFIG_RESULT_ACCEPTED"/>
<entry value="1" name="KAPTAN_CONFIG_RESULT_COMMITTED_RESTART_REQUIRED"/>
<entry value="2" name="KAPTAN_CONFIG_RESULT_REJECTED"/>
</enum>
<enum name="KAPTAN_CONFIG_ERROR">
<entry value="0" name="KAPTAN_CONFIG_ERROR_NONE"/>
<entry value="1" name="KAPTAN_CONFIG_ERROR_BAD_TARGET"/>
<entry value="2" name="KAPTAN_CONFIG_ERROR_AUTHORIZATION_DENIED"/>
<entry value="3" name="KAPTAN_CONFIG_ERROR_GENERATION_CONFLICT"/>
<entry value="4" name="KAPTAN_CONFIG_ERROR_SESSION_BUSY"/>
<entry value="5" name="KAPTAN_CONFIG_ERROR_INVALID_SESSION"/>
<entry value="6" name="KAPTAN_CONFIG_ERROR_STALE_REVISION"/>
<entry value="7" name="KAPTAN_CONFIG_ERROR_BAD_STATE"/>
<entry value="8" name="KAPTAN_CONFIG_ERROR_UNKNOWN_FIELD"/>
<entry value="9" name="KAPTAN_CONFIG_ERROR_TYPE_MISMATCH"/>
<entry value="10" name="KAPTAN_CONFIG_ERROR_NON_FINITE"/>
<entry value="11" name="KAPTAN_CONFIG_ERROR_BELOW_MINIMUM"/>
<entry value="12" name="KAPTAN_CONFIG_ERROR_ABOVE_MAXIMUM"/>
<entry value="13" name="KAPTAN_CONFIG_ERROR_CROSS_FIELD"/>
<entry value="14" name="KAPTAN_CONFIG_ERROR_UNSUPPORTED_CAPABILITY"/>
<entry value="15" name="KAPTAN_CONFIG_ERROR_UNSUPPORTED_FORMAT"/>
<entry value="16" name="KAPTAN_CONFIG_ERROR_STORAGE_FAILURE"/>
<entry value="17" name="KAPTAN_CONFIG_ERROR_INTERNAL"/>
<entry value="18" name="KAPTAN_CONFIG_ERROR_REQUEST_CONFLICT"/>
<entry value="19" name="KAPTAN_CONFIG_ERROR_CBOR_MALFORMED"/>
<entry value="20" name="KAPTAN_CONFIG_ERROR_CBOR_TOO_LARGE"/>
<entry value="21" name="KAPTAN_CONFIG_ERROR_BLOB_CRC"/>
<entry value="22" name="KAPTAN_CONFIG_ERROR_SCHEMA_MISMATCH"/>
<entry value="23" name="KAPTAN_CONFIG_ERROR_IMPORT_UNAVAILABLE"/>
</enum>
</enums>
<messages>
<message id="51000" name="KAPTAN_CONFIG_INFO">
<description>Correlated configuration protocol and active snapshot metadata.</description>
<field type="uint64_t" name="active_generation"/>
<field type="uint64_t" name="committed_generation"/>
<field type="uint32_t" name="request_id"/>
<field type="uint8_t[16]" name="schema_id"/>
<field type="uint16_t" name="profile_format"/>
<field type="uint16_t" name="field_count"/>
<field type="uint8_t" name="protocol_major"/>
<field type="uint8_t" name="protocol_minor"/>
<field type="uint8_t" name="config_state" enum="KAPTAN_CONFIG_STATE"/>
<field type="uint8_t" name="capability_flags"/>
</message>
<message id="51014" name="KAPTAN_CONFIG_INFO_REQUEST">
<description>Requests correlated configuration protocol and schema metadata.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51001" name="KAPTAN_CONFIG_DESCRIPTOR_REQUEST">
<description>Requests a bounded page of field descriptors.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint16_t" name="first"/>
<field type="uint16_t" name="count"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51002" name="KAPTAN_CONFIG_DESCRIPTOR">
<description>One self-describing editable scalar field.</description>
<field type="uint64_t" name="minimum_bits"/>
<field type="uint64_t" name="maximum_bits"/>
<field type="uint64_t" name="default_bits"/>
<field type="uint32_t" name="request_id"/>
<field type="uint16_t" name="descriptor_index"/>
<field type="uint16_t" name="field_id"/>
<field type="char[48]" name="display_key"/>
<field type="char[16]" name="unit"/>
<field type="uint8_t" name="value_kind" enum="KAPTAN_CONFIG_VALUE_KIND"/>
<field type="uint8_t" name="apply_policy" enum="KAPTAN_CONFIG_POLICY"/>
<field type="uint8_t" name="flags"/>
</message>
<message id="51003" name="KAPTAN_CONFIG_VALUE_REQUEST">
<description>Reads one active, committed, or transaction value.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="source" enum="KAPTAN_CONFIG_VALUE_SOURCE"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51004" name="KAPTAN_CONFIG_VALUE">
<description>One typed configuration scalar as canonical raw bits.</description>
<field type="uint64_t" name="value_bits"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="value_kind" enum="KAPTAN_CONFIG_VALUE_KIND"/>
</message>
<message id="51005" name="KAPTAN_CONFIG_BEGIN">
<description>Opens the one lease-bound edit transaction.</description>
<field type="uint64_t" name="expected_generation"/>
<field type="uint32_t" name="request_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51006" name="KAPTAN_CONFIG_SET_F32">
<description>Writes one f32 field in the open transaction.</description>
<field type="float" name="value"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51007" name="KAPTAN_CONFIG_SET_I32">
<description>Writes one i32 field in the open transaction.</description>
<field type="int32_t" name="value"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51008" name="KAPTAN_CONFIG_SET_U32">
<description>Writes one u32 field in the open transaction.</description>
<field type="uint32_t" name="value"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51009" name="KAPTAN_CONFIG_SET_U64">
<description>Writes one u64 field in the open transaction.</description>
<field type="uint64_t" name="value"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51010" name="KAPTAN_CONFIG_VALIDATE">
<description>Validates the complete open transaction.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51011" name="KAPTAN_CONFIG_COMMIT">
<description>Validates then atomically persists the open transaction.</description>
<field type="uint64_t" name="expected_generation"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51012" name="KAPTAN_CONFIG_ABORT">
<description>Discards the open RAM transaction.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51013" name="KAPTAN_CONFIG_RESULT">
<description>Authoritative typed completion for a configuration request.</description>
<field type="uint64_t" name="generation"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="transfer_id"/>
<field type="uint32_t" name="revision"/>
<field type="uint16_t" name="primary_field"/>
<field type="uint16_t" name="related_field"/>
<field type="uint16_t" name="validation_rule"/>
<field type="uint8_t" name="operation" enum="KAPTAN_CONFIG_OPERATION"/>
<field type="uint8_t" name="result" enum="KAPTAN_CONFIG_RESULT"/>
<field type="uint8_t" name="error" enum="KAPTAN_CONFIG_ERROR"/>
<field type="uint8_t" name="config_state" enum="KAPTAN_CONFIG_STATE"/>
</message>
<message id="51015" name="KAPTAN_CONFIG_SET_BOOL">
<description>Writes one boolean field in the open transaction.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="value"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51016" name="KAPTAN_CONFIG_SET_F64">
<description>Writes one f64 field in the open transaction.</description>
<field type="double" name="value"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51017" name="KAPTAN_CONFIG_IMPORT_BEGIN">
<description>Starts a fixed-bounded CBOR import inside an open transaction.</description>
<field type="uint32_t" name="crc32c"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="length"/>
<field type="uint8_t[16]" name="schema_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51018" name="KAPTAN_CONFIG_IMPORT_CHUNK">
<description>One aligned, idempotent 192-byte-or-final CBOR import chunk. Chunks may arrive out of order.</description>
<field type="uint8_t[192]" name="data"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="transfer_id"/>
<field type="uint16_t" name="offset"/>
<field type="uint8_t" name="length"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51019" name="KAPTAN_CONFIG_IMPORT_FINISH">
<description>Validates a complete received CBOR import into the transaction.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="transfer_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
</messages>
</mavlink>