refactor: move firmware workspace under firmware
This commit is contained in:
1 parent
153b0bf262
commit
f03fc119a4
222 files changed
+56
-13
No files matched your search
@@ -0,0 +1,37 @@
|
||||
//! Host-only standard-log facade composition.
|
||||
|
||||
use std::sync::Once;
|
||||
|
||||
use kaptan_flight_runtime::RuntimeLogHub;
|
||||
use kaptan_log::{FacadeIngress, GlobalLogger, LogFilter, Severity};
|
||||
|
||||
const SITL_LOG_INGRESS_CAPACITY: usize = 16;
|
||||
static SITL_LOG_INGRESS: FacadeIngress<SITL_LOG_INGRESS_CAPACITY> =
|
||||
FacadeIngress::new(LogFilter::new(Severity::Info));
|
||||
static SITL_LOGGER: GlobalLogger<SITL_LOG_INGRESS_CAPACITY> =
|
||||
GlobalLogger::new(&SITL_LOG_INGRESS, sitl_forbidden_log_context);
|
||||
static INSTALL_SITL_LOGGER: Once = Once::new();
|
||||
|
||||
const fn sitl_forbidden_log_context() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Installs the host-only standard logging facade once for the SITL process.
|
||||
///
|
||||
/// SITL has no ISR, panic, or hard-fault context. Flight targets deliberately
|
||||
/// do not enable this host-only feature.
|
||||
pub fn install_log_facade() {
|
||||
INSTALL_SITL_LOGGER.call_once(|| {
|
||||
SITL_LOGGER
|
||||
.install()
|
||||
.expect("SITL owns the process-global 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);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Host-only configuration composition for SITL.
|
||||
//!
|
||||
//! This module is deliberately not a firmware storage backend. Flight targets
|
||||
//! need a reviewed A/B journal and authority path; SITL uses a same-directory
|
||||
//! file replacement to make startup and configuration tests reproducible.
|
||||
|
||||
mod logging;
|
||||
mod runtime;
|
||||
mod store;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use logging::install_log_facade;
|
||||
pub use runtime::{CAPABILITIES, SitlConfigRuntime, SitlSnapshotImporter, configuration_service};
|
||||
pub use store::{
|
||||
FileProfileStore, FileStoreError, default_profile, load_profile, write_default_profile,
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 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_flight_runtime::FlightRuntime;
|
||||
use kaptan_log::TransportAdmission;
|
||||
use kaptan_log_mavlink::{DiagnosticPayload, encode_diagnostic};
|
||||
|
||||
use super::{FileProfileStore, 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<CandidateProfile> for SitlSnapshotImporter {
|
||||
const IMPORT_SUPPORTED: bool = true;
|
||||
const PROFILE_FORMAT: u16 = FORMAT_VERSION;
|
||||
|
||||
fn decode(&self, bytes: &[u8]) -> Result<CandidateProfile, ProtocolError> {
|
||||
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<FileProfileStore>,
|
||||
protocol: ConfigProtocol<SitlSnapshotImporter>,
|
||||
}
|
||||
|
||||
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<ConfigCommand>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the central manager for SITL state inspection only.
|
||||
pub const fn manager(&self) -> &ConfigManager<CandidateProfile, FileProfileStore> {
|
||||
self.runtime.configuration()
|
||||
}
|
||||
|
||||
/// Returns the immutable profile selected by the central manager at boot.
|
||||
pub const fn active_profile(&self) -> VehicleProfile {
|
||||
*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::<CandidateProfile>()
|
||||
}
|
||||
|
||||
/// 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<DiagnosticPayload> {
|
||||
let (_, logs) = self.runtime.configuration_and_logs_mut();
|
||||
logging::drain_one_into(logs);
|
||||
self.runtime
|
||||
.submit_next(|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<SitlConfigRuntime, String> {
|
||||
logging::install_log_facade();
|
||||
let runtime = FlightRuntime::recover(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),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Host-only persistence for a validated SITL profile.
|
||||
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::{Read, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use kaptan_config::{Generation, ProfileStore};
|
||||
use kaptan_flight_config::{
|
||||
CandidateProfile, CborEncodeError, CborError, MAXIMUM_CANDIDATE_BYTES, VehicleProfile,
|
||||
decode_candidate, encode_candidate,
|
||||
};
|
||||
use kaptan_log::KaptanError;
|
||||
|
||||
use super::{CAPABILITIES, configuration_service};
|
||||
|
||||
/// Host file-store error. It intentionally makes no flash power-loss claim.
|
||||
#[derive(Debug, KaptanError)]
|
||||
pub enum FileStoreError {
|
||||
/// Host filesystem operation failed.
|
||||
Io(std::io::Error),
|
||||
/// The bounded file record did not decode and validate.
|
||||
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 {
|
||||
path: Option<std::path::PathBuf>,
|
||||
generation: Option<Generation>,
|
||||
}
|
||||
|
||||
impl FileProfileStore {
|
||||
/// Creates a host-only store at a fixed profile path.
|
||||
pub fn new(path: impl Into<std::path::PathBuf>) -> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProfileStore<CandidateProfile> for FileProfileStore {
|
||||
type Error = FileStoreError;
|
||||
|
||||
fn load(&mut self) -> Result<Option<(Generation, CandidateProfile)>, Self::Error> {
|
||||
let Some(path) = self.path.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let file = match File::open(path) {
|
||||
Ok(file) => file,
|
||||
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;
|
||||
if file.metadata().map_err(FileStoreError::Io)?.len() > MAXIMUM_FILE_BYTES {
|
||||
return Err(FileStoreError::Cbor(CborError::TooLarge));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAXIMUM_FILE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(FileStoreError::Io)?;
|
||||
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)))
|
||||
}
|
||||
|
||||
fn persist(&mut self, candidate: &CandidateProfile) -> Result<Generation, 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the fully validated factory-active profile.
|
||||
pub fn default_profile() -> Result<VehicleProfile, String> {
|
||||
Ok(configuration_service(FileProfileStore::factory_only())?.active_profile())
|
||||
}
|
||||
|
||||
/// Loads one bounded, complete CBOR candidate and returns its active view.
|
||||
pub fn load_profile(path: impl AsRef<Path>) -> Result<VehicleProfile, String> {
|
||||
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())
|
||||
}
|
||||
|
||||
/// Writes one validated factory candidate using a same-directory replacement.
|
||||
pub fn write_default_profile(path: impl AsRef<Path>) -> Result<(), String> {
|
||||
let mut store = FileProfileStore::new(path.as_ref());
|
||||
store
|
||||
.persist(&CandidateProfile::default())
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn write_candidate(
|
||||
path: &Path,
|
||||
candidate: CandidateProfile,
|
||||
generation: Generation,
|
||||
) -> 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) => {
|
||||
FileStoreError::Cbor(CborError::InvalidProfile(violation))
|
||||
}
|
||||
CborEncodeError::Encode(_) => FileStoreError::Encode,
|
||||
})?;
|
||||
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])
|
||||
.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))
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user