357 lines
12 KiB
Rust
357 lines
12 KiB
Rust
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_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
|
|
}
|
|
|
|
#[test]
|
|
fn host_store_round_trips_a_complete_validated_snapshot() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"kaptan-config-store-{}-{}.cbor",
|
|
std::process::id(),
|
|
std::thread::current().name().unwrap_or("test")
|
|
));
|
|
let temporary = path.with_extension("cbor.tmp");
|
|
let _ = fs::remove_file(&path);
|
|
let _ = fs::remove_file(&temporary);
|
|
|
|
let mut store = FileProfileStore::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");
|
|
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)
|
|
);
|
|
|
|
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!(
|
|
"kaptan-sitl-log-{}-{}.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();
|
|
|
|
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);
|
|
let message = std::str::from_utf8(&payload.message[..usize::from(payload.message_length)])
|
|
.expect("facade text remains UTF-8");
|
|
assert!(message.contains("config::tests"));
|
|
assert!(message.ends_with("SITL configuration service ready"));
|
|
let mut encoder = MavlinkEncoder::new(1, 1);
|
|
let frame = encoder.custom(&payload);
|
|
assert_eq!(frame.message_id(), 51_020);
|
|
assert!(matches!(
|
|
MavMessage::parse(
|
|
mavlink::MavlinkVersion::V2,
|
|
frame.message_id(),
|
|
frame.payload(),
|
|
),
|
|
Ok(MavMessage::KAPTAN_LOG(data))
|
|
if data.level == payload.level
|
|
&& data.message_length == payload.message_length
|
|
&& data.message == payload.message
|
|
));
|
|
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);
|
|
}
|