refactor(firmware): centralize configuration and diagnostics

This commit is contained in:
Onur Özkan committed 2026-08-22 18:37:18 +03:00
1 parent 7048ed89d0
commit 387fdaad23
140 files changed
+6891 -4541

No files matched your search

+5 -10
View File
@@ -2,12 +2,10 @@
use std::sync::Once;
use kaptan_flight_runtime::RuntimeLogHub;
use kaptan_log::{FacadeIngress, GlobalLogger, LogFilter, Severity};
use kaptan_log::{FacadeIngress, GlobalLogger, LogRecord};
const SITL_LOG_INGRESS_CAPACITY: usize = 16;
static SITL_LOG_INGRESS: FacadeIngress<SITL_LOG_INGRESS_CAPACITY> =
FacadeIngress::new(LogFilter::new(Severity::Info));
static SITL_LOG_INGRESS: FacadeIngress<SITL_LOG_INGRESS_CAPACITY> = FacadeIngress::new();
static SITL_LOGGER: GlobalLogger<SITL_LOG_INGRESS_CAPACITY> =
GlobalLogger::new(&SITL_LOG_INGRESS, sitl_forbidden_log_context);
static INSTALL_SITL_LOGGER: Once = Once::new();
@@ -28,10 +26,7 @@ pub fn install_log_facade() {
});
}
pub(super) fn set_filter(filter: LogFilter) {
SITL_LOG_INGRESS.set_filter(filter);
}
pub(super) fn drain_one_into(hub: &mut RuntimeLogHub) {
let _ = SITL_LOG_INGRESS.drain_one_into(hub);
/// Removes one transport-neutral event for composition-level sink fanout.
pub(super) fn take() -> Option<LogRecord> {
SITL_LOG_INGRESS.take()
}
+2 -2
View File
@@ -12,7 +12,7 @@ mod store;
mod tests;
pub use logging::install_log_facade;
pub use runtime::{CAPABILITIES, SitlConfigRuntime, SitlSnapshotImporter, configuration_service};
pub use runtime::{CAPABILITIES, SitlConfigRuntime, configuration_service};
pub use store::{
FileProfileStore, FileStoreError, default_profile, load_profile, write_default_profile,
FileConfigStore, FileStoreError, default_profile, load_profile, write_default_profile,
};
+29 -58
View File
@@ -1,69 +1,45 @@
//! Central SITL configuration and protocol composition.
use kaptan_config::ConfigManager;
use kaptan_config_mavlink::{
Authorized, ConfigCommand, ConfigProtocol, ProtocolError, ServiceResponse, SnapshotImporter,
Target,
};
use kaptan_flight_config::{
CandidateProfile, CborError, FORMAT_VERSION, VehicleCapabilities, VehicleProfile,
decode_candidate,
};
use kaptan_config_mavlink::{ConfigCommand, ConfigProtocol, Principal, ServiceResponse, Target};
use kaptan_flight_config::{CandidateProfile, VehicleCapabilities, VehicleProfile};
use kaptan_flight_runtime::FlightRuntime;
use kaptan_log::TransportAdmission;
use kaptan_log_mavlink::{DiagnosticPayload, encode_diagnostic};
use super::{FileProfileStore, logging};
use super::{FileConfigStore, logging};
/// SITL's immutable compiled capability contract.
pub const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
/// Host integration adapter from the schema's one bounded CBOR profile blob to
/// the generic custom-MAVLink transaction service.
#[derive(Clone, Copy, Debug, Default)]
pub struct SitlSnapshotImporter;
impl SnapshotImporter<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>,
runtime: FlightRuntime<FileConfigStore>,
protocol: ConfigProtocol,
}
impl SitlConfigRuntime {
/// Dispatches one host-authorized transaction command. Real target ingress
/// remains disabled until signing/replay authority is implemented.
pub fn dispatch(&mut self, command: Authorized<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);
}
/// Dispatches one command for a caller-owned lease principal.
pub fn dispatch(&mut self, input: (Principal, ConfigCommand)) -> Vec<ServiceResponse> {
let (principal, command) = input;
let mut responses = Vec::new();
self.protocol
.dispatch_from_composition(
self.runtime.configuration_mut(),
principal,
command,
|response| {
responses.push(response);
Ok::<(), core::convert::Infallible>(())
},
)
.unwrap();
responses
}
/// Returns the central manager for SITL state inspection only.
pub const fn manager(&self) -> &ConfigManager<CandidateProfile, FileProfileStore> {
pub const fn manager(&self) -> &ConfigManager<CandidateProfile, FileConfigStore> {
self.runtime.configuration()
}
@@ -72,32 +48,27 @@ impl SitlConfigRuntime {
*self.runtime.configuration().active()
}
/// Indicates custom CBOR import support to the local GCS bridge.
pub const fn capability_flags(&self) -> u8 {
self.protocol.capability_flags_for::<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);
if let Some(record) = logging::take() {
self.runtime.publish_log_event(record);
}
self.runtime
.submit_next(|record| (TransportAdmission::Accepted, encode_diagnostic(record)))
.submit_next_mavlink(|record| (TransportAdmission::Accepted, encode_diagnostic(record)))
}
}
/// Builds the host-only central configuration composition used by forthcoming
/// SITL/GCS integration. Flight targets must supply authenticated authority
/// and a reviewed journal/activation coordinator before constructing one.
pub fn configuration_service(store: FileProfileStore) -> Result<SitlConfigRuntime, String> {
pub fn configuration_service(store: FileConfigStore) -> Result<SitlConfigRuntime, String> {
logging::install_log_facade();
let runtime = FlightRuntime::recover(CAPABILITIES, store)
let runtime = FlightRuntime::recover(CandidateProfile::factory(), CAPABILITIES, store)
.map_err(|error| format!("configuration recovery failed: {error:?}"))?;
logging::set_filter(runtime.log_filter());
Ok(SitlConfigRuntime {
runtime,
protocol: ConfigProtocol::with_importer(Target::new(1, 1), SitlSnapshotImporter),
protocol: ConfigProtocol::new(Target::new(1, 1)),
})
}
+18 -73
View File
@@ -6,7 +6,7 @@ use std::{
path::Path,
};
use kaptan_config::{Generation, ProfileStore};
use kaptan_config::ConfigStore;
use kaptan_flight_config::{
CandidateProfile, CborEncodeError, CborError, MAXIMUM_CANDIDATE_BYTES, VehicleProfile,
decode_candidate, encode_candidate,
@@ -24,44 +24,34 @@ pub enum FileStoreError {
Cbor(CborError),
/// The fixed snapshot buffer could not encode the complete candidate.
Encode,
/// The host generation counter cannot advance.
GenerationExhausted,
/// Factory-only startup deliberately has no durable configuration path.
FactoryOnly,
/// The private host envelope was malformed or did not carry a valid
/// nonzero committed generation.
Envelope,
}
/// Same-directory atomic-replacement store for SITL only.
pub struct FileProfileStore {
pub struct FileConfigStore {
path: Option<std::path::PathBuf>,
generation: Option<Generation>,
}
impl FileProfileStore {
impl FileConfigStore {
/// 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,
}
Self { path: None }
}
}
impl ProfileStore<CandidateProfile> for FileProfileStore {
impl ConfigStore<CandidateProfile> for FileConfigStore {
type Error = FileStoreError;
fn load(&mut self) -> Result<Option<(Generation, CandidateProfile)>, Self::Error> {
fn load(&mut self) -> Result<Option<CandidateProfile>, Self::Error> {
let Some(path) = self.path.as_deref() else {
return Ok(None);
};
@@ -70,7 +60,7 @@ impl ProfileStore<CandidateProfile> for FileProfileStore {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(FileStoreError::Io(error)),
};
const MAXIMUM_FILE_BYTES: u64 = (MAXIMUM_CANDIDATE_BYTES + 64) as u64;
const MAXIMUM_FILE_BYTES: u64 = MAXIMUM_CANDIDATE_BYTES as u64;
if file.metadata().map_err(FileStoreError::Io)?.len() > MAXIMUM_FILE_BYTES {
return Err(FileStoreError::Cbor(CborError::TooLarge));
}
@@ -81,35 +71,20 @@ impl ProfileStore<CandidateProfile> for FileProfileStore {
if bytes.len() as u64 > MAXIMUM_FILE_BYTES {
return Err(FileStoreError::Cbor(CborError::TooLarge));
}
let (generation, candidate) = decode_envelope(&bytes)?;
self.generation = Some(generation);
Ok(Some((generation, candidate)))
decode_candidate(&bytes, &CAPABILITIES)
.map(Some)
.map_err(FileStoreError::Cbor)
}
fn persist(&mut self, candidate: &CandidateProfile) -> Result<Generation, Self::Error> {
fn persist(&mut self, candidate: &CandidateProfile) -> Result<(), Self::Error> {
let path = self.path.clone().ok_or(FileStoreError::FactoryOnly)?;
if self.generation.is_none() {
let _ = self.load()?;
}
let generation = match self.generation {
Some(generation) => Generation::try_new(
generation
.get()
.checked_add(1)
.ok_or(FileStoreError::GenerationExhausted)?,
)
.ok_or(FileStoreError::GenerationExhausted)?,
None => Generation::new(1),
};
write_candidate(&path, *candidate, generation)?;
self.generation = Some(generation);
Ok(generation)
write_candidate(&path, *candidate)
}
}
/// Returns the fully validated factory-active profile.
pub fn default_profile() -> Result<VehicleProfile, String> {
Ok(configuration_service(FileProfileStore::factory_only())?.active_profile())
Ok(configuration_service(FileConfigStore::factory_only())?.active_profile())
}
/// Loads one bounded, complete CBOR candidate and returns its active view.
@@ -117,23 +92,18 @@ pub fn load_profile(path: impl AsRef<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())
Ok(configuration_service(FileConfigStore::new(path.as_ref()))?.active_profile())
}
/// Writes one validated factory candidate using a same-directory replacement.
pub fn write_default_profile(path: impl AsRef<Path>) -> Result<(), String> {
let mut store = FileProfileStore::new(path.as_ref());
let mut store = FileConfigStore::new(path.as_ref());
store
.persist(&CandidateProfile::default())
.map(|_| ())
.persist(&CandidateProfile::factory())
.map_err(|error| error.to_string())
}
fn write_candidate(
path: &Path,
candidate: CandidateProfile,
generation: Generation,
) -> Result<(), FileStoreError> {
fn write_candidate(path: &Path, candidate: CandidateProfile) -> Result<(), FileStoreError> {
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
encode_candidate(candidate, &CAPABILITIES, &mut writer).map_err(|error| match error {
CborEncodeError::InvalidProfile(violation) => {
@@ -143,35 +113,10 @@ fn write_candidate(
})?;
let length = writer.position();
let bytes = writer.into_inner();
let mut envelope = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES + 64]);
let mut encoder = minicbor::Encoder::new(&mut envelope);
encoder
.array(2)
.and_then(|encoder| encoder.u64(generation.get()))
.and_then(|encoder| encoder.bytes(&bytes[..length]))
.map_err(|_| FileStoreError::Encode)?;
let envelope_length = encoder.writer_mut().position();
let envelope = envelope.into_inner();
let temporary = path.with_extension("cbor.tmp");
let mut file = File::create(&temporary).map_err(FileStoreError::Io)?;
file.write_all(&envelope[..envelope_length])
file.write_all(&bytes[..length])
.map_err(FileStoreError::Io)?;
file.sync_all().map_err(FileStoreError::Io)?;
fs::rename(&temporary, path).map_err(FileStoreError::Io)
}
fn decode_envelope(bytes: &[u8]) -> Result<(Generation, CandidateProfile), FileStoreError> {
let mut decoder = minicbor::Decoder::new(bytes);
if decoder.array().map_err(|_| FileStoreError::Envelope)? != Some(2) {
return Err(FileStoreError::Envelope);
}
let generation = Generation::try_new(decoder.u64().map_err(|_| FileStoreError::Envelope)?)
.ok_or(FileStoreError::Envelope)?;
let candidate_bytes = decoder.bytes().map_err(|_| FileStoreError::Envelope)?;
if decoder.position() != bytes.len() {
return Err(FileStoreError::Envelope);
}
let candidate =
decode_candidate(candidate_bytes, &CAPABILITIES).map_err(FileStoreError::Cbor)?;
Ok((generation, candidate))
}
+16 -296
View File
@@ -1,68 +1,14 @@
use std::fs;
use kaptan_config::{ConfigModel, Generation, ProfileStore};
use kaptan_config_mavlink::{
Authorized, ConfigCommand, ResultKind, ServiceResponse, SnapshotImporter,
};
use kaptan_flight_config::{
CandidateProfile, CborError, MAXIMUM_CANDIDATE_BYTES, SCHEMA_ID as PROFILE_SCHEMA_ID,
decode_candidate, encode_candidate,
};
use kaptan_config::{ConfigModel, ConfigStore};
use kaptan_flight_config::{CandidateProfile, decode_candidate};
use kaptan_log_mavlink::DiagnosticPayload;
use kaptan_mavlink::{
encoder::MavlinkEncoder,
message::{Message, kaptan::MavMessage},
};
use super::{CAPABILITIES, FileProfileStore, SitlSnapshotImporter, configuration_service};
// Independently authored current-format CBOR map. It is intentionally not
// produced by `encode_candidate`: this is the GCS/upload compatibility vector
// for schema KAPTANPROFILE-01, with a non-default 1.7 kg mass.
const EXTERNAL_PROFILE_CBOR: &[u8] = &[
0xb8, 0x1a, // definite map, 26 entries
0x00, 0x01, // format key/value
0x19, 0xff, 0xff, 0x50, b'K', b'A', b'P', b'T', b'A', b'N', b'P', b'R', b'O', b'F', b'I', b'L',
b'E', b'-', b'0', b'1', // schema key/value
0x01, 0x1a, 0x00, 0x01, 0x86, 0xa0, // prearm dwell: 100000
0x02, 0x19, 0xc3, 0x50, // IMU interval: 50000
0x03, 0x19, 0xc3, 0x50, // IMU freshness: 50000
0x04, 0x1a, 0x00, 0x02, 0x49, 0xf0, // local freshness: 150000
0x05, 0x1a, 0x00, 0x07, 0xa1, 0x20, // link freshness: 500000
0x06, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x07, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x08, 0xfa, 0x3f, 0x80,
0x00, 0x00, 0x09, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x0a, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x0b, 0xfa,
0x3f, 0x80, 0x00, 0x00, 0x0c, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x0d, 0xfa, 0x3f, 0xd9, 0x99,
0x9a, // mass: 1.7
0x0e, 0xfa, 0x41, 0xa0, 0x00, 0x00, // collective thrust: 20
0x0f, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x10, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x11, 0xfa, 0x3f, 0x80,
0x00, 0x00, 0x12, 0xfa, 0x00, 0x00, 0x00, 0x00, 0x13, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x14, 0xfa,
0x3f, 0x80, 0x00, 0x00, 0x15, 0xfa, 0x00, 0x00, 0x00, 0x00, 0x16, 0xfa, 0x3f, 0x80, 0x00, 0x00,
0x17, 0xfa, 0x3f, 0x80, 0x00, 0x00, 0x18, 0x18, 0x06, // maximum log level: INFO
];
fn result(response: ServiceResponse) -> kaptan_config_mavlink::ConfigReply {
match response {
ServiceResponse::Result(reply) => reply,
ServiceResponse::Info(_) | ServiceResponse::Descriptors(_) | ServiceResponse::Value(_) => {
panic!("expected a mutating configuration result")
}
}
}
fn crc32c(bytes: &[u8]) -> u32 {
let mut crc = !0_u32;
for byte in bytes {
crc ^= u32::from(*byte);
for _ in 0..8 {
crc = if crc & 1 == 0 {
crc >> 1
} else {
(crc >> 1) ^ 0x82f6_3b78
};
}
}
!crc
}
use super::{CAPABILITIES, FileConfigStore, configuration_service};
#[test]
fn host_store_round_trips_a_complete_validated_snapshot() {
@@ -75,216 +21,24 @@ fn host_store_round_trips_a_complete_validated_snapshot() {
let _ = fs::remove_file(&path);
let _ = fs::remove_file(&temporary);
let mut store = FileProfileStore::new(&path);
let mut store = FileConfigStore::new(&path);
assert!(store.load().unwrap().is_none());
assert_eq!(
store.persist(&CandidateProfile::default()).unwrap(),
Generation::new(1)
);
let envelope = fs::read(&path).unwrap();
assert!(matches!(
decode_candidate(&envelope, &CAPABILITIES),
Err(CborError::Malformed)
));
let (_, candidate) = store.load().unwrap().expect("persisted candidate");
store.persist(&CandidateProfile::factory()).unwrap();
let snapshot = fs::read(&path).unwrap();
let decoded = decode_candidate(&snapshot, &CAPABILITIES).unwrap();
assert!(decoded.validate(&CAPABILITIES).is_ok());
let candidate = store.load().unwrap().expect("persisted candidate");
assert!(candidate.validate(&CAPABILITIES).is_ok());
assert_eq!(
store.persist(&CandidateProfile::default()).unwrap(),
Generation::new(2)
);
let mut reopened = FileProfileStore::new(&path);
assert_eq!(
reopened.load().unwrap().map(|(generation, _)| generation),
Some(Generation::new(2))
);
let mut fresh = FileProfileStore::new(&path);
assert_eq!(
fresh.persist(&CandidateProfile::default()).unwrap(),
Generation::new(3)
);
store.persist(&CandidateProfile::factory()).unwrap();
let mut reopened = FileConfigStore::new(&path);
assert!(reopened.load().unwrap().is_some());
let mut fresh = FileConfigStore::new(&path);
fresh.persist(&CandidateProfile::factory()).unwrap();
fs::remove_file(&path).unwrap();
}
#[test]
fn sitl_snapshot_importer_uses_the_real_schema_and_capabilities() {
let candidate = CandidateProfile::default();
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
encode_candidate(candidate, &CAPABILITIES, &mut writer).unwrap();
let length = writer.position();
let bytes = writer.into_inner();
let imported = SitlSnapshotImporter
.decode(&bytes[..length])
.expect("valid current candidate import");
assert!(imported.validate(&CAPABILITIES).is_ok());
}
#[test]
fn sitl_service_imports_a_real_cbor_candidate_only_into_its_transaction() {
let path = std::env::temp_dir().join(format!(
"kaptan-config-import-{}-{}.cbor",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
let _ = fs::remove_file(&path);
let mut service = configuration_service(FileProfileStore::new(&path)).unwrap();
let begin = result(service.dispatch(Authorized::host(
9,
ConfigCommand::Begin {
request_id: 1,
expected_generation: 0,
target: kaptan_config_mavlink::Target::new(1, 1),
},
)));
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
encode_candidate(CandidateProfile::default(), &CAPABILITIES, &mut writer).unwrap();
let length = writer.position();
assert!(length <= 192);
let bytes = writer.into_inner();
let import_begin = result(service.dispatch(Authorized::host(
9,
ConfigCommand::ImportBegin {
request_id: 2,
session_id: begin.session_id,
expected_revision: 0,
length: length as u16,
schema_id: PROFILE_SCHEMA_ID,
crc32c: crc32c(&bytes[..length]),
target: kaptan_config_mavlink::Target::new(1, 1),
},
)));
assert_eq!(import_begin.result, ResultKind::Accepted);
let mut chunk = [0; 192];
chunk[..length].copy_from_slice(&bytes[..length]);
assert_eq!(
result(service.dispatch(Authorized::host(
9,
ConfigCommand::ImportChunk {
request_id: 3,
session_id: begin.session_id,
transfer_id: import_begin.transfer_id,
offset: 0,
length: length as u8,
data: chunk,
target: kaptan_config_mavlink::Target::new(1, 1),
},
)))
.result,
ResultKind::Accepted
);
assert_eq!(
result(service.dispatch(Authorized::host(
9,
ConfigCommand::ImportFinish {
request_id: 4,
session_id: begin.session_id,
transfer_id: import_begin.transfer_id,
expected_revision: 0,
target: kaptan_config_mavlink::Target::new(1, 1),
},
)))
.result,
ResultKind::Accepted
);
assert!(
service
.manager()
.transaction_value(kaptan_config::FieldId::new(1))
.is_some()
);
assert!(
service
.manager()
.active_value(kaptan_config::FieldId::new(1))
.is_some()
);
let _ = fs::remove_file(&path);
}
#[test]
fn external_gcs_vector_imports_commits_and_reopens_as_the_boot_profile() {
let path = std::env::temp_dir().join(format!(
"kaptan-config-gcs-vector-{}-{}.cbor",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
let _ = fs::remove_file(&path);
let mut service = configuration_service(FileProfileStore::new(&path)).unwrap();
let target = kaptan_config_mavlink::Target::new(1, 1);
let begin = result(service.dispatch(Authorized::host(
9,
ConfigCommand::Begin {
request_id: 1,
expected_generation: 0,
target,
},
)));
let import = result(service.dispatch(Authorized::host(
9,
ConfigCommand::ImportBegin {
request_id: 2,
session_id: begin.session_id,
expected_revision: 0,
length: EXTERNAL_PROFILE_CBOR.len() as u16,
schema_id: PROFILE_SCHEMA_ID,
crc32c: crc32c(EXTERNAL_PROFILE_CBOR),
target,
},
)));
let mut data = [0_u8; 192];
data[..EXTERNAL_PROFILE_CBOR.len()].copy_from_slice(EXTERNAL_PROFILE_CBOR);
assert_eq!(
result(service.dispatch(Authorized::host(
9,
ConfigCommand::ImportChunk {
request_id: 3,
session_id: begin.session_id,
transfer_id: import.transfer_id,
offset: 0,
length: EXTERNAL_PROFILE_CBOR.len() as u8,
data,
target,
},
)))
.result,
ResultKind::Accepted
);
assert_eq!(
result(service.dispatch(Authorized::host(
9,
ConfigCommand::ImportFinish {
request_id: 4,
session_id: begin.session_id,
transfer_id: import.transfer_id,
expected_revision: 0,
target,
},
)))
.revision,
1
);
assert_eq!(
result(service.dispatch(Authorized::host(
9,
ConfigCommand::Commit {
request_id: 5,
session_id: begin.session_id,
expected_generation: 0,
expected_revision: 1,
target,
},
)))
.result,
ResultKind::CommittedRestartRequired
);
drop(service);
let reopened = configuration_service(FileProfileStore::new(&path)).unwrap();
assert_eq!(reopened.active_profile().thrust().mass_kg(), 1.7);
let _ = fs::remove_file(&path);
}
#[test]
fn standard_log_macro_reaches_the_generated_mavlink_log_payload() {
let path = std::env::temp_dir().join(format!(
@@ -293,13 +47,13 @@ fn standard_log_macro_reaches_the_generated_mavlink_log_payload() {
std::thread::current().name().unwrap_or("test")
));
let _ = fs::remove_file(&path);
let mut service = configuration_service(FileProfileStore::new(&path)).unwrap();
let mut service = configuration_service(FileConfigStore::new(&path)).unwrap();
log::info!("SITL configuration service ready");
let Some(DiagnosticPayload::Log(payload)) = service.pending_diagnostic_payload() else {
panic!("info facade record must reach KAPTAN_LOG");
};
assert_eq!(payload.level, kaptan_log::Severity::Info as u8);
assert_eq!(payload.level, kaptan_log::Severity::Info.wire_value());
let message = std::str::from_utf8(&payload.message[..usize::from(payload.message_length)])
.expect("facade text remains UTF-8");
assert!(message.contains("config::tests"));
@@ -320,37 +74,3 @@ fn standard_log_macro_reaches_the_generated_mavlink_log_payload() {
));
let _ = fs::remove_file(&path);
}
#[test]
fn rejected_config_result_enqueues_a_separate_high_level_error_payload() {
let path = std::env::temp_dir().join(format!(
"kaptan-sitl-config-error-{}-{}.cbor",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
let _ = fs::remove_file(&path);
let mut service = configuration_service(FileProfileStore::new(&path)).unwrap();
let response = service.dispatch(Authorized::host(
9,
ConfigCommand::Begin {
request_id: 1,
expected_generation: 1,
target: kaptan_config_mavlink::Target::new(1, 1),
},
));
let reply = result(response);
assert_eq!(reply.result, ResultKind::Rejected);
service.after_config_result(response);
let Some(DiagnosticPayload::Error(payload)) = service.pending_diagnostic_payload() else {
panic!("rejected configuration result must enqueue KAPTAN_ERROR");
};
let error_path = std::str::from_utf8(&payload.path[..usize::from(payload.path_length)])
.expect("derived error path remains UTF-8");
assert_eq!(
error_path,
"kaptan_config_mavlink::protocol::ProtocolError::GenerationConflict"
);
assert_eq!(payload.additional_information_length, 0);
let _ = fs::remove_file(&path);
}
+7 -7
View File
@@ -273,7 +273,7 @@ class Quad final : public gz::sim::System,
std::uint16_t sensor_port{};
std::uint16_t actuator_port{};
std::uint16_t truth_port{};
std::uint16_t operator_clock_port{};
std::uint16_t user_clock_port{};
std::chrono::microseconds actuator_timeout{};
double maximum_motor_thrust_newtons{};
double motor_time_constant_seconds{};
@@ -325,8 +325,8 @@ void Quad::Configure(const gz::sim::Entity &entity,
const unsigned int actuator_port =
sdf->Get<unsigned int>("actuator_udp_port", 0U).first;
const unsigned int truth_port = sdf->Get<unsigned int>("truth_udp_port", 0U).first;
const unsigned int operator_clock_port =
sdf->Get<unsigned int>("operator_clock_udp_port", 0U).first;
const unsigned int user_clock_port =
sdf->Get<unsigned int>("user_clock_udp_port", 0U).first;
const std::uint64_t timeout_usec =
sdf->Get<std::uint64_t>("actuator_timeout_usec", 0).first;
this->data_->maximum_motor_thrust_newtons =
@@ -342,11 +342,11 @@ void Quad::Configure(const gz::sim::Entity &entity,
this->data_->reaction_torque_per_newton_metre =
sdf->Get<double>("reaction_torque_per_newton_metre", 0.0).first;
if (imu_topic.empty() || sensor_port == 0 || actuator_port == 0 || truth_port == 0 ||
operator_clock_port == 0 ||
user_clock_port == 0 ||
sensor_port > std::numeric_limits<std::uint16_t>::max() ||
actuator_port > std::numeric_limits<std::uint16_t>::max() ||
truth_port > std::numeric_limits<std::uint16_t>::max() ||
operator_clock_port > std::numeric_limits<std::uint16_t>::max() ||
user_clock_port > std::numeric_limits<std::uint16_t>::max() ||
timeout_usec == 0 || !std::isfinite(this->data_->maximum_motor_thrust_newtons) ||
this->data_->maximum_motor_thrust_newtons <= 0.0 ||
!std::isfinite(this->data_->motor_time_constant_seconds) ||
@@ -362,7 +362,7 @@ void Quad::Configure(const gz::sim::Entity &entity,
this->data_->sensor_port = static_cast<std::uint16_t>(sensor_port);
this->data_->actuator_port = static_cast<std::uint16_t>(actuator_port);
this->data_->truth_port = static_cast<std::uint16_t>(truth_port);
this->data_->operator_clock_port = static_cast<std::uint16_t>(operator_clock_port);
this->data_->user_clock_port = static_cast<std::uint16_t>(user_clock_port);
this->data_->actuator_timeout = std::chrono::microseconds(timeout_usec);
this->data_->local_position_noise =
std::normal_distribution<double>(0.0, local_position_noise_stddev);
@@ -482,7 +482,7 @@ void Quad::PostUpdate(const gz::sim::UpdateInfo &info,
(void)this->data_->socket.SendToLoopback(
this->data_->sensor_port, clock_packet.data(), clock_packet.size());
(void)this->data_->socket.SendToLoopback(
this->data_->operator_clock_port, clock_packet.data(), clock_packet.size());
this->data_->user_clock_port, clock_packet.data(), clock_packet.size());
};
gz::msgs::IMU imu;
{
+8 -14
View File
@@ -38,7 +38,7 @@ use kaptan_time::Timestamp;
#[cfg(test)]
use crate::config::default_profile;
use crate::config::{
FileProfileStore, SitlConfigRuntime, configuration_service, load_profile, write_default_profile,
FileConfigStore, SitlConfigRuntime, configuration_service, load_profile, write_default_profile,
};
use kaptan_flight_config::VehicleProfile;
@@ -86,8 +86,8 @@ fn serve_configuration(
arguments: &mut impl Iterator<Item = String>,
) -> Result<SitlConfigRuntime, String> {
match (arguments.next().as_deref(), arguments.next()) {
(None, None) => configuration_service(FileProfileStore::factory_only()),
(Some("--config"), Some(path)) => configuration_service(FileProfileStore::new(path)),
(None, None) => configuration_service(FileConfigStore::factory_only()),
(Some("--config"), Some(path)) => configuration_service(FileConfigStore::new(path)),
_ => Err("usage: kaptan-sitl serve [--config PROFILE.cbor]".to_owned()),
}
}
@@ -122,10 +122,8 @@ fn serve(configuration: SitlConfigRuntime) -> Result<(), String> {
.set_nonblocking(true)
.map_err(|error| error.to_string())?;
let mut runtime = SitlRuntime::from_configuration(configuration)?;
let config_generation = runtime.configuration.manager().active_generation();
eprintln!(
"kaptan-sitl: configuration generation {} selected at boot; listening for Gazebo sensors on 127.0.0.1:{SENSOR_PORT} and commands on 127.0.0.1:{CONTROL_PORT}",
config_generation.map_or(0, kaptan_config::Generation::get),
"kaptan-sitl: boot configuration selected; listening for Gazebo sensors on 127.0.0.1:{SENSOR_PORT} and commands on 127.0.0.1:{CONTROL_PORT}",
);
loop {
@@ -190,9 +188,6 @@ impl DatagramSink for UdpSocket {
}
struct SitlRuntime {
/// Retains the sole central manager that selected this boot-active profile.
/// No live apply path exists: a commit is restart-required.
configuration: SitlConfigRuntime,
profile: VehicleProfile,
initializer: StationaryInitializer<100>,
estimator: Option<InertialEskf>,
@@ -224,13 +219,12 @@ enum ClockWork {
impl SitlRuntime {
#[cfg(test)]
fn new() -> Result<Self, String> {
Self::from_configuration(configuration_service(FileProfileStore::factory_only())?)
Self::from_configuration(configuration_service(FileConfigStore::factory_only())?)
}
fn from_configuration(configuration: SitlConfigRuntime) -> Result<Self, String> {
let profile = configuration.active_profile();
Ok(Self {
configuration,
profile,
initializer: StationaryInitializer::new(),
estimator: None,
@@ -1071,7 +1065,7 @@ fn strictly_after(
mod tests {
use super::*;
use core::cell::RefCell;
use kaptan_config::{ConfigModel, ConfigValue, FieldId, ProfileStore};
use kaptan_config::{ConfigModel, ConfigStore, ConfigValue, FieldId};
#[derive(Default)]
struct RecordingSink {
@@ -1148,11 +1142,11 @@ mod tests {
std::thread::current().name().unwrap_or("test")
));
let _ = std::fs::remove_file(&path);
let mut candidate = kaptan_flight_config::CandidateProfile::default();
let mut candidate = kaptan_flight_config::CandidateProfile::factory();
candidate
.set_field(FieldId::new(13), ConfigValue::F32(1.7))
.unwrap();
FileProfileStore::new(&path).persist(&candidate).unwrap();
FileConfigStore::new(&path).persist(&candidate).unwrap();
let mut arguments =
vec!["--config".to_owned(), path.to_string_lossy().into_owned()].into_iter();