feat(mavlink): adopt the kaptan-native dialect

This commit is contained in:
Onur Özkan committed 2026-08-24 00:56:06 +03:00
1 parent 1c90870634
commit 5f91465e66
87 files changed
+7733 -6850

No files matched your search

+3 -3
View File
@@ -10,10 +10,10 @@ workspace = true
[dependencies]
kaptan-config = { path = "../../libs/kaptan-config" }
kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink" }
kaptan-file-mavlink = { path = "../../libs/kaptan-file-mavlink" }
kaptan-log = { path = "../../libs/kaptan-log" }
kaptan-mavlink = { path = "../../libs/kaptan-mavlink", default-features = false, features = ["dialect-kaptan"] }
mavlink-core = { version = "0.18", default-features = false }
kaptan-mavlink = { path = "../../libs/kaptan-mavlink", default-features = false }
libc = "0.2"
mavlink-core = { version = "0.18", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+20 -1
View File
@@ -196,7 +196,7 @@ mod tests {
assert!(!APP.contains("HEARTBEAT"));
assert!(APP.contains("field.id"));
assert!(
APP.contains("field.minimum, field.maximum, field.default, field.help, field.options")
APP.contains("field.minimum, field.maximum, field.factory, field.help, field.options")
);
assert!(APP.contains("document.createElement('select')"));
assert!(APP.contains("snapshot.packets"));
@@ -213,4 +213,23 @@ mod tests {
assert!(APP.contains("truncated.textContent = 'TRUNCATED'"));
assert!(INDEX.contains("id=\"log-entries\""));
}
#[test]
fn browser_clears_recovered_errors_and_retries_failed_configuration_fetches() {
assert!(APP.contains("showError(state.error ?? '')"));
assert!(APP.contains("q('#configuration').hidden = !state.has_config"));
let fetch = APP
.find("const config = await api('/api/config')")
.expect("configuration refresh fetches the latest document");
let render = APP[fetch..]
.find("renderConfig(config)")
.map(|offset| fetch + offset)
.expect("a fetched configuration is rendered");
let revision = APP[render..]
.find("configRevision = state.config_revision")
.map(|offset| render + offset)
.expect("a successful render advances the observed revision");
assert!(fetch < render && render < revision);
}
}
File diff suppressed because it is too large. Load diff
+10 -6
View File
@@ -1,8 +1,12 @@
//! Local, board-agnostic configurator server and transport modules.
pub mod api;
pub mod config_client;
pub mod log_monitor;
pub mod packet_monitor;
pub mod serial_worker;
pub mod state;
mod api;
mod config_client;
mod log_monitor;
mod packet_monitor;
mod serial_worker;
mod state;
pub use api::serve_connection;
pub use serial_worker::{SerialBaud, SerialController};
pub use state::SharedState;
+24 -38
View File
@@ -3,6 +3,7 @@
use std::{collections::VecDeque, time::SystemTime};
use kaptan_log::Level;
use kaptan_mavlink::generated::kaptan::LogLevel;
use serde::{Serialize, Serializer};
const MAX_LOG_ENTRIES: usize = 512;
@@ -14,30 +15,23 @@ where
serializer.serialize_str(level.as_str())
}
/// Decodes the exact Kaptan log-level subset from its MAVLink representation.
///
/// Kaptan emits Error, Warn, Info, and Debug on the conventional MAVLink
/// numbers 3, 4, 6, and 7. Trace is Debug plus the dedicated trace flag. The
/// other MAVLink severity numbers are not Kaptan log levels.
pub const fn level_from_wire(level: u8, trace: bool) -> Option<Level> {
if trace && level != 7 {
return None;
}
/// Decodes a wire event level, rejecting the filter-only Off value.
pub const fn event_level_from_wire(level: LogLevel) -> Option<Level> {
match level {
3 => Some(Level::Error),
4 => Some(Level::Warn),
6 => Some(Level::Info),
7 if trace => Some(Level::Trace),
7 => Some(Level::Debug),
_ => None,
LogLevel::LOG_LEVEL_OFF => None,
LogLevel::LOG_LEVEL_TRACE => Some(Level::Trace),
LogLevel::LOG_LEVEL_DEBUG => Some(Level::Debug),
LogLevel::LOG_LEVEL_INFO => Some(Level::Info),
LogLevel::LOG_LEVEL_WARN => Some(Level::Warn),
LogLevel::LOG_LEVEL_ERROR => Some(Level::Error),
}
}
/// Human-readable packet-view representation, including malformed values.
pub fn wire_level_label(level: u8, trace: bool) -> String {
match level_from_wire(level, trace) {
Some(level) => level.as_str().into(),
None => format!("UNKNOWN ({level})"),
/// Human-readable packet-view representation.
pub const fn wire_level_label(level: LogLevel) -> &'static str {
match event_level_from_wire(level) {
Some(level) => level.as_str(),
None => "OFF",
}
}
@@ -194,27 +188,19 @@ mod tests {
}
#[test]
fn wire_level_accepts_only_the_exact_kaptan_contract() {
fn wire_level_accepts_only_event_values() {
assert_eq!(event_level_from_wire(LogLevel::LOG_LEVEL_OFF), None);
for (wire, expected) in [
(0, None),
(1, None),
(2, None),
(3, Some(Level::Error)),
(4, Some(Level::Warn)),
(5, None),
(6, Some(Level::Info)),
(7, Some(Level::Debug)),
(8, None),
(u8::MAX, None),
(LogLevel::LOG_LEVEL_TRACE, Level::Trace),
(LogLevel::LOG_LEVEL_DEBUG, Level::Debug),
(LogLevel::LOG_LEVEL_INFO, Level::Info),
(LogLevel::LOG_LEVEL_WARN, Level::Warn),
(LogLevel::LOG_LEVEL_ERROR, Level::Error),
] {
assert_eq!(level_from_wire(wire, false), expected, "wire {wire}");
assert_eq!(event_level_from_wire(wire), Some(expected));
}
assert_eq!(level_from_wire(7, true), Some(Level::Trace));
for invalid_trace_level in [0, 1, 2, 3, 4, 5, 6, 8, u8::MAX] {
assert_eq!(level_from_wire(invalid_trace_level, true), None);
}
assert_eq!(wire_level_label(2, false), "UNKNOWN (2)");
assert_eq!(wire_level_label(9, false), "UNKNOWN (9)");
assert_eq!(wire_level_label(LogLevel::LOG_LEVEL_TRACE), "TRACE");
assert_eq!(wire_level_label(LogLevel::LOG_LEVEL_OFF), "OFF");
}
#[test]
+66 -19
View File
@@ -2,19 +2,21 @@
use std::{net::TcpListener, sync::Arc};
use kaptan_configurator::{
api::serve_connection,
serial_worker::{SerialBaud, SerialController},
state::SharedState,
};
use kaptan_configurator::{SerialBaud, SerialController, SharedState, serve_connection};
use kaptan_file_mavlink::Principal;
const LISTEN_ADDRESS: &str = "127.0.0.1:4861";
const GCS_COMPONENT_ID: u8 = 190;
fn main() -> std::io::Result<()> {
let arguments = arguments()?;
let state = Arc::new(SharedState::default());
let serial =
SerialController::new(Arc::clone(&state), arguments.baud).map_err(std::io::Error::other)?;
let serial = SerialController::new(
Arc::clone(&state),
arguments.baud,
Principal::new(arguments.source_system, GCS_COMPONENT_ID),
)
.map_err(std::io::Error::other)?;
if let Some(path) = arguments.serial_path {
serial.connect(path).map_err(std::io::Error::other)?;
}
@@ -39,6 +41,7 @@ fn main() -> std::io::Result<()> {
struct Arguments {
serial_path: Option<String>,
baud: SerialBaud,
source_system: u8,
}
fn arguments() -> std::io::Result<Arguments> {
@@ -49,6 +52,7 @@ fn arguments_from(mut values: impl Iterator<Item = String>) -> std::io::Result<A
let mut serial_path = None;
let mut baud = SerialBaud::DEFAULT;
let mut baud_seen = false;
let mut source_system = None;
while let Some(flag) = values.next() {
match flag.as_str() {
"--serial" if serial_path.is_none() => {
@@ -67,16 +71,30 @@ fn arguments_from(mut values: impl Iterator<Item = String>) -> std::io::Result<A
.ok_or_else(|| invalid_arguments(&flag))?;
baud_seen = true;
}
"--source-system" if source_system.is_none() => {
let value = values.next().ok_or_else(|| invalid_arguments(&flag))?;
source_system = value.parse::<u8>().ok().filter(|value| *value != 0);
if source_system.is_none() {
return Err(invalid_arguments(&flag));
}
}
_ => return Err(invalid_arguments(&flag)),
}
}
Ok(Arguments { serial_path, baud })
let source_system = source_system.ok_or_else(|| invalid_arguments("--source-system"))?;
Ok(Arguments {
serial_path,
baud,
source_system,
})
}
fn invalid_arguments(flag: &str) -> std::io::Error {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid {flag}; usage: kaptan-configurator [--serial PATH] [--baud RATE]"),
format!(
"invalid {flag}; usage: kaptan-configurator --source-system ID [--serial PATH] [--baud RATE]"
),
)
}
@@ -90,31 +108,60 @@ mod tests {
}
#[test]
fn command_line_defaults_to_115200_and_accepts_an_optional_serial_path() {
let accepted = arguments_from(["--serial", "/dev/ttyACM0"].into_iter().map(String::from))
.expect("recovery endpoint is accepted");
fn command_line_defaults_to_115200_and_requires_an_exact_source_system() {
let accepted = arguments_from(
["--source-system", "255", "--serial", "/dev/ttyACM0"]
.into_iter()
.map(String::from),
)
.expect("recovery endpoint is accepted");
assert_eq!(accepted.serial_path.as_deref(), Some("/dev/ttyACM0"));
assert_eq!(accepted.baud.bits_per_second(), 115_200);
assert_eq!(accepted.baud, SerialBaud::try_new(115_200).unwrap());
assert_eq!(accepted.source_system, 255);
}
#[test]
fn command_line_accepts_a_supported_baud_in_either_order() {
for values in [
["--baud", "57600", "--serial", "/dev/ttyUSB0"],
["--serial", "/dev/ttyUSB0", "--baud", "57600"],
[
"--source-system",
"254",
"--baud",
"57600",
"--serial",
"/dev/ttyUSB0",
],
[
"--serial",
"/dev/ttyUSB0",
"--baud",
"57600",
"--source-system",
"254",
],
] {
let accepted = arguments_from(values.into_iter().map(String::from)).unwrap();
assert_eq!(accepted.serial_path.as_deref(), Some("/dev/ttyUSB0"));
assert_eq!(accepted.baud.bits_per_second(), 57_600);
assert_eq!(accepted.baud, SerialBaud::try_new(57_600).unwrap());
}
}
#[test]
fn command_line_rejects_unsupported_or_duplicate_baud_arguments() {
for values in [
vec!["--baud", "12345"],
vec!["--baud", "115200", "--baud", "57600"],
vec!["--baud"],
vec!["--source-system", "255", "--baud", "12345"],
vec![
"--source-system",
"255",
"--baud",
"115200",
"--baud",
"57600",
],
vec!["--source-system", "255", "--baud"],
vec!["--source-system", "0"],
vec!["--source-system", "255", "--source-system", "254"],
vec![],
] {
let rejected = arguments_from(values.into_iter().map(String::from))
.expect_err("invalid baud arguments must be rejected");
@@ -166,12 +166,15 @@ mod tests {
fn aggregates_counts_and_rates_without_packet_history() {
let start = Instant::now();
let mut monitor = PacketMonitor::default();
monitor.observe([packet(0, "HEARTBEAT"), packet(0, "HEARTBEAT")], start);
monitor.observe(
[packet(51_027, "HEARTBEAT"), packet(51_027, "HEARTBEAT")],
start,
);
monitor.observe(
[
packet(0, "HEARTBEAT"),
packet(0, "HEARTBEAT"),
packet(0, "HEARTBEAT"),
packet(51_027, "HEARTBEAT"),
packet(51_027, "HEARTBEAT"),
packet(51_027, "HEARTBEAT"),
],
start + Duration::from_millis(500),
);
@@ -180,14 +183,14 @@ mod tests {
assert_eq!(snapshot.packets.len(), 1);
assert_eq!(snapshot.packets[0].count, 5);
assert_eq!(snapshot.packets[0].frequency_hz, 5.0);
assert_eq!(snapshot.packets[0].values["sequence"], 0);
assert_eq!(snapshot.packets[0].values["sequence"], 51_027);
}
#[test]
fn reports_zero_after_a_window_with_no_packets() {
let start = Instant::now();
let mut monitor = PacketMonitor::default();
monitor.observe([packet(0, "HEARTBEAT")], start);
monitor.observe([packet(51_027, "HEARTBEAT")], start);
assert_eq!(
monitor.snapshot(start + RATE_WINDOW).packets[0].frequency_hz,
1.0
@@ -201,9 +204,9 @@ mod tests {
#[test]
fn retains_only_the_latest_decoded_values_for_each_identity() {
let start = Instant::now();
let mut first = packet(51021, "KAPTAN_ERROR");
let mut first = packet(51021, "ERROR");
first.values = serde_json::json!({"path": "first"});
let mut second = packet(51021, "KAPTAN_ERROR");
let mut second = packet(51021, "ERROR");
second.values = serde_json::json!({"path": "second"});
let mut monitor = PacketMonitor::default();
@@ -240,7 +243,7 @@ mod tests {
fn clear_starts_a_new_link_session() {
let start = Instant::now();
let mut monitor = PacketMonitor::default();
monitor.observe([packet(0, "HEARTBEAT")], start);
monitor.observe([packet(51_027, "HEARTBEAT")], start);
monitor.clear();
assert!(monitor.snapshot(start + RATE_WINDOW).packets.is_empty());
}
+276 -179
View File
@@ -14,18 +14,19 @@ use std::{
};
use crate::{
config_client::{ClientEvent, ConfigClient},
log_monitor::{LogObservation, level_from_wire, wire_level_label},
config_client::{ConfigClient, Edit, Outcome},
log_monitor::{LogObservation, event_level_from_wire, wire_level_label},
packet_monitor::PacketObservation,
state::{SaveRequest, SharedState},
state::SharedState,
};
use kaptan_file_mavlink::{Principal, Target};
use kaptan_log::Level;
use kaptan_mavlink::{
MavlinkFrame,
message::{self, Message},
generated::kaptan::{HeartbeatState, LogFlags, MavMessage},
receiver::IntegrityMavlinkReceiver,
};
use mavlink_core::MavlinkVersion;
use mavlink_core::Message;
const COMMAND_CAPACITY: usize = 8;
const IDLE_POLL: Duration = Duration::from_millis(20);
@@ -48,11 +49,6 @@ impl SerialBaud {
}
}
/// Returns the user-selected numeric line rate.
pub const fn bits_per_second(self) -> u32 {
self.0
}
fn termios_speed(self) -> libc::speed_t {
match self.0 {
9_600 => libc::B9600,
@@ -68,10 +64,10 @@ impl SerialBaud {
}
}
pub enum Command {
pub(crate) enum Command {
Connect { path: String, baud: SerialBaud },
Disconnect,
Save(SaveRequest),
Save(Vec<Edit>),
Shutdown,
}
@@ -82,11 +78,15 @@ pub struct SerialController {
}
impl SerialController {
pub fn new(state: Arc<SharedState>, baud: SerialBaud) -> Result<Self, String> {
pub fn new(
state: Arc<SharedState>,
baud: SerialBaud,
local: Principal,
) -> Result<Self, String> {
let (commands, receiver) = sync_channel(COMMAND_CAPACITY);
let worker = thread::Builder::new()
.name("kaptan-config-serial".into())
.spawn(move || worker(receiver, state))
.spawn(move || worker(receiver, state, local))
.map_err(|error| error.to_string())?;
Ok(Self {
commands,
@@ -103,7 +103,7 @@ impl SerialController {
})
}
pub fn send(&self, command: Command) -> Result<(), String> {
pub(crate) fn send(&self, command: Command) -> Result<(), String> {
self.commands
.try_send(command)
.map_err(|error| match error {
@@ -112,7 +112,7 @@ impl SerialController {
})
}
pub fn shutdown(&self) {
fn shutdown(&self) {
let _ = self.commands.send(Command::Shutdown);
if let Some(worker) = lock(&self.worker).take() {
let _ = worker.join();
@@ -126,27 +126,30 @@ impl Drop for SerialController {
}
}
#[derive(Default)]
struct Session {
link: Option<File>,
receiver: IntegrityMavlinkReceiver<1>,
client: ConfigClient,
target: Option<(u8, u8)>,
sequence: u8,
}
impl Session {
fn new(local: Principal) -> Self {
Self {
link: None,
receiver: IntegrityMavlinkReceiver::default(),
client: ConfigClient::new(local),
}
}
fn close(&mut self) {
self.link = None;
self.receiver.reset();
self.client.reset();
self.target = None;
self.sequence = 0;
}
}
fn worker(commands: Receiver<Command>, state: Arc<SharedState>) {
let mut session = Session::default();
fn worker(commands: Receiver<Command>, state: Arc<SharedState>, local: Principal) {
let mut session = Session::new(local);
loop {
loop {
match commands.try_recv() {
@@ -190,8 +193,8 @@ fn handle_command(command: Command, session: &mut Session, state: &SharedState)
session.close();
state.disconnected(None);
}
Command::Save(save) => {
let request = session.client.save(save.edits, &mut session.sequence);
Command::Save(edits) => {
let request = session.client.save(edits);
match request {
Ok(request) => {
if let Err(error) = write_raw(session, request.raw_bytes()) {
@@ -233,40 +236,30 @@ fn service_link(session: &mut Session, state: &SharedState) -> Result<(), String
frames.push(frame);
}
}
let mut packets = Vec::with_capacity(frames.len());
let mut logs = Vec::new();
for frame in &frames {
let (packet, log) = frame_observations(frame);
packets.push(packet);
logs.extend(log);
}
state.observe_packets(packets);
state.observe_logs(logs);
for frame in frames {
if session.target.is_none()
&& let Some(target) = heartbeat_target(&frame)
let Some(message) = frame.decode() else {
continue;
};
let raw = frame.raw();
let (packet, log) = decoded_frame_observations(raw, &message);
state.observe_packets([packet]);
state.observe_logs(log);
if session.client.target().is_err()
&& let Some(target) = decoded_heartbeat_target(raw, &message)
{
session.target = Some(target);
state.discovering();
let request = session.client.discover(target, &mut session.sequence)?;
let request = session.client.discover(target)?;
write_raw(session, request.raw_bytes())?;
}
let (event, next) = session.client.observe(&frame, &mut session.sequence);
if let Some(event) = event {
handle_client_event(session, state, event);
}
if let Some(next) = next {
write_raw(session, next.raw_bytes())?;
}
let Some(source) = Target::try_new(raw.system_id(), raw.component_id()) else {
continue;
};
let outcome = session.client.observe(source, &message);
handle_client_outcome(session, state, outcome)?;
}
let (event, retry) = session.client.retry_due(&mut session.sequence);
if let Some(event) = event {
handle_client_event(session, state, event);
}
if let Some(request) = retry {
write_raw(session, request.raw_bytes())?;
}
let outcome = session.client.retry_due();
handle_client_outcome(session, state, outcome)?;
Ok(())
}
@@ -275,66 +268,46 @@ fn packet_observation(frame: &MavlinkFrame) -> PacketObservation {
frame_observations(frame).0
}
#[cfg(test)]
fn parse_test_message(frame: &MavlinkFrame) -> Option<MavMessage> {
let mut receiver = IntegrityMavlinkReceiver::<1>::new();
for byte in frame.raw_bytes() {
receiver.receive_byte(*byte);
}
receiver.take_received()?.decode()
}
#[cfg(test)]
fn frame_observations(frame: &MavlinkFrame) -> (PacketObservation, Option<LogObservation>) {
let message_id = frame.message_id();
let (name, values, log) = decoded_observations(frame);
let message = parse_test_message(frame).expect("test frame must be CRC-valid Kaptan MAVLink");
decoded_frame_observations(frame, &message)
}
fn decoded_frame_observations(
frame: &MavlinkFrame,
message: &MavMessage,
) -> (PacketObservation, Option<LogObservation>) {
let packet = PacketObservation {
system_id: frame.system_id(),
component_id: frame.component_id(),
message_id,
name,
values,
message_id: frame.message_id(),
name: message.message_name(),
values: kaptan_message_values(message),
};
(packet, log)
(packet, log_observation(frame, message))
}
fn decoded_observations(
frame: &MavlinkFrame,
) -> (&'static str, serde_json::Value, Option<LogObservation>) {
if frame.has_valid_crc::<message::kaptan::MavMessage>()
&& let Ok(message) = message::kaptan::MavMessage::parse(
MavlinkVersion::V2,
frame.message_id(),
frame.payload(),
)
{
return (
message.message_name(),
kaptan_message_values(&message),
log_observation(frame, &message),
);
}
if frame.has_valid_crc::<message::MavMessage>()
&& let Ok(message) =
message::MavMessage::parse(MavlinkVersion::V2, frame.message_id(), frame.payload())
{
let name = message.message_name();
return (name, decoded_fields(&message), None);
}
(
"UNKNOWN",
serde_json::json!({"payload_hex": hex_bytes(frame.payload())}),
None,
)
}
fn log_observation(
frame: &MavlinkFrame,
message: &message::kaptan::MavMessage,
) -> Option<LogObservation> {
fn log_observation(frame: &MavlinkFrame, message: &MavMessage) -> Option<LogObservation> {
let (level, truncated, text) = match message {
message::kaptan::MavMessage::KAPTAN_LOG(log) => {
let trace = log
.flags
.contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE);
(
level_from_wire(log.level, trace)?,
log.flags
.contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRUNCATED),
bounded_text(&log.message, log.message_length)?,
)
}
message::kaptan::MavMessage::KAPTAN_ERROR(error) => {
MavMessage::LOG(log) => (
event_level_from_wire(log.level)?,
log.flags.contains(LogFlags::LOG_FLAG_TRUNCATED),
bounded_text(&log.message, log.message_length)?,
),
MavMessage::ERROR(error) => {
if error.error_id == 0 {
return None;
}
let raised_at = zero_padded_text(&error.raised_at)?;
let text = if raised_at.is_empty() {
format!("Kaptan error {}", error.error_id)
@@ -354,29 +327,59 @@ fn log_observation(
})
}
fn kaptan_message_values(message: &message::kaptan::MavMessage) -> serde_json::Value {
fn kaptan_message_values(message: &MavMessage) -> serde_json::Value {
match message {
message::kaptan::MavMessage::KAPTAN_ERROR(error) => serde_json::json!({
MavMessage::ERROR(error) => serde_json::json!({
"error_id": error.error_id,
"raised_at": packet_zero_padded_text(&error.raised_at),
}),
message::kaptan::MavMessage::KAPTAN_LOG(log) => serde_json::json!({
"level": wire_level_label(
log.level,
log.flags.contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE),
),
MavMessage::LOG(log) => serde_json::json!({
"level": wire_level_label(log.level),
"flags": format!("{:?}", log.flags),
"flags_bits": log.flags.bits(),
"message": packet_text(&log.message, log.message_length),
}),
_ => decoded_fields(message),
MavMessage::HEARTBEAT(heartbeat) => serde_json::json!({
"state": format!("{:?}", heartbeat.state),
}),
MavMessage::IMU(imu) => serde_json::json!({
"time_us": imu.time_us,
"acceleration_milligravity": imu.acceleration_milligravity,
"angular_rate_milliradians_per_second": imu.angular_rate_milliradians_per_second,
"temperature_centidegrees_celsius": imu.temperature_centidegrees_celsius,
}),
MavMessage::RANGE(range) => serde_json::json!({
"time_us": range.time_us,
"distance_millimetres": range.distance_millimetres,
}),
MavMessage::OPTICAL_FLOW(flow) => serde_json::json!({
"time_us": flow.time_us,
"velocity_millimetres_per_second": flow.velocity_millimetres_per_second,
"ground_distance_millimetres": flow.ground_distance_millimetres,
"quality": flow.quality,
}),
MavMessage::FILE_CONTROL(control) => serde_json::json!({
"target_system": control.target_system,
"target_component": control.target_component,
"operation": format!("{:?}", control.operation),
"status": format!("{:?}", control.status),
"transfer_id": control.transfer_id,
"position": control.position,
"file_size": control.file_size,
"checksum_crc32c": control.checksum_crc32c,
"content_hex": hex_bytes(&control.content[..usize::from(control.content_length)
.min(control.content.len())]),
}),
MavMessage::FILE_DATA(data) => serde_json::json!({
"target_system": data.target_system,
"target_component": data.target_component,
"status": format!("{:?}", data.status),
"transfer_id": data.transfer_id,
"position": data.position,
"data_hex": hex_bytes(&data.data),
}),
}
}
fn decoded_fields(message: &impl core::fmt::Debug) -> serde_json::Value {
format!("{message:#?}").into()
}
fn bounded_text<const N: usize>(bytes: &[u8; N], length: u8) -> Option<String> {
let text = core::str::from_utf8(bytes.get(..usize::from(length))?).ok()?;
Some(text.into())
@@ -405,11 +408,19 @@ fn hex_bytes(bytes: &[u8]) -> String {
encoded
}
fn handle_client_event(session: &mut Session, state: &SharedState, event: ClientEvent) {
if matches!(event, ClientEvent::RediscoveryRequired(_)) {
session.target = None;
fn handle_client_outcome(
session: &mut Session,
state: &SharedState,
outcome: Outcome,
) -> Result<(), String> {
match outcome {
Outcome::Idle => Ok(()),
Outcome::Event(event) => {
state.configuration_event(event);
Ok(())
}
Outcome::Send(request) => write_raw(session, request.raw_bytes()),
}
state.configuration_event(event);
}
fn write_raw(session: &mut Session, bytes: &[u8]) -> Result<(), String> {
@@ -421,17 +432,20 @@ fn write_raw(session: &mut Session, bytes: &[u8]) -> Result<(), String> {
.map_err(|error| error.to_string())
}
fn heartbeat_target(frame: &MavlinkFrame) -> Option<(u8, u8)> {
if !frame.has_valid_crc::<message::MavMessage>() {
fn decoded_heartbeat_target(frame: &MavlinkFrame, message: &MavMessage) -> Option<Target> {
let MavMessage::HEARTBEAT(heartbeat) = message else {
return None;
};
if heartbeat.state != HeartbeatState::HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED {
return None;
}
let message =
message::MavMessage::parse(MavlinkVersion::V2, frame.message_id(), frame.payload()).ok()?;
if !matches!(message, message::MavMessage::HEARTBEAT(_)) {
return None;
}
let target = (frame.system_id(), frame.component_id());
(target.0 != 0 && target.1 != 0).then_some(target)
Target::try_new(frame.system_id(), frame.component_id())
}
#[cfg(test)]
fn heartbeat_target(frame: &MavlinkFrame) -> Option<Target> {
let message = parse_test_message(frame)?;
decoded_heartbeat_target(frame, &message)
}
fn open_serial(path: &str, baud: SerialBaud) -> Result<File, String> {
@@ -481,11 +495,18 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
#[cfg(test)]
mod tests {
use super::*;
use kaptan_mavlink::message::{
HEARTBEAT_DATA, MavAutopilot, MavHeader, MavModeFlag, MavState, MavType,
use kaptan_file_mavlink::DATA_BYTES;
use kaptan_mavlink::generated::kaptan::{
ERROR_DATA, FILE_DATA_DATA, FileStatus as WireFileStatus, HEARTBEAT_DATA, IMU_DATA,
LOG_DATA, LogLevel,
};
use mavlink_core::{MavHeader, MessageData};
fn heartbeat(system_id: u8, component_id: u8, autopilot: MavAutopilot) -> MavlinkFrame {
fn frame<M: MessageData<Message = MavMessage>>(
system_id: u8,
component_id: u8,
data: &M,
) -> MavlinkFrame {
let mut raw = MavlinkFrame::new();
raw.serialize_message_data(
MavHeader {
@@ -493,31 +514,31 @@ mod tests {
system_id,
component_id,
},
&HEARTBEAT_DATA {
custom_mode: 0,
mavtype: MavType::MAV_TYPE_ONBOARD_CONTROLLER,
autopilot,
base_mode: MavModeFlag::empty(),
system_status: MavState::MAV_STATE_STANDBY,
mavlink_version: 3,
},
data,
);
raw
}
fn heartbeat(system_id: u8, component_id: u8) -> MavlinkFrame {
frame(
system_id,
component_id,
&HEARTBEAT_DATA {
state: HeartbeatState::HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED,
},
)
}
#[test]
fn point_to_point_discovery_accepts_kaptans_invalid_autopilot_heartbeat() {
let frame = heartbeat(42, 7, MavAutopilot::MAV_AUTOPILOT_INVALID);
assert_eq!(heartbeat_target(&frame), Some((42, 7)));
fn point_to_point_discovery_accepts_the_exact_kaptan_heartbeat() {
let frame = heartbeat(42, 7);
assert_eq!(heartbeat_target(&frame), Some(Target::new(42, 7)));
}
#[test]
fn heartbeat_discovery_rejects_broadcast_sources_and_unverified_frames() {
assert_eq!(
heartbeat_target(&heartbeat(0, 7, MavAutopilot::MAV_AUTOPILOT_GENERIC)),
None
);
let frame = heartbeat(42, 7, MavAutopilot::MAV_AUTOPILOT_GENERIC);
assert_eq!(heartbeat_target(&heartbeat(0, 7)), None);
let frame = heartbeat(42, 7);
let mut bytes = frame.into_inner();
bytes[10] ^= 1;
let unverified = MavlinkFrame::from_bytes_unparsed(bytes);
@@ -526,16 +547,34 @@ mod tests {
#[test]
fn packet_monitor_uses_the_decoded_dialect_name() {
let observation =
packet_observation(&heartbeat(42, 7, MavAutopilot::MAV_AUTOPILOT_GENERIC));
let observation = packet_observation(&heartbeat(42, 7));
assert_eq!(observation.system_id, 42);
assert_eq!(observation.component_id, 7);
assert_eq!(observation.message_id, 0);
assert_eq!(observation.message_id, 51_027);
assert_eq!(observation.name, "HEARTBEAT");
let decoded = observation.values.as_str().unwrap();
assert!(decoded.contains("custom_mode: 0"));
assert!(decoded.contains("MAV_STATE_STANDBY"));
assert!(decoded.contains('\n'));
assert_eq!(
observation.values["state"],
"HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED"
);
}
#[test]
fn file_data_packet_exposes_its_direction() {
let observation = packet_observation(&frame(
42,
7,
&FILE_DATA_DATA {
position: 246,
transfer_id: 9,
target_system: 255,
target_component: 190,
status: WireFileStatus::FILE_STATUS_OK,
data: [0; DATA_BYTES],
},
));
assert_eq!(observation.name, "FILE_DATA");
assert_eq!(observation.values["status"], "FILE_STATUS_OK");
}
#[test]
@@ -550,14 +589,14 @@ mod tests {
system_id: 42,
component_id: 7,
},
&message::kaptan::KAPTAN_ERROR_DATA {
&ERROR_DATA {
error_id: 1234,
raised_at,
},
);
let (observation, log) = frame_observations(&frame);
assert_eq!(observation.name, "KAPTAN_ERROR");
assert_eq!(observation.name, "ERROR");
assert!(observation.values.get("level").is_none());
assert_eq!(observation.values["error_id"], 1234);
assert_eq!(observation.values["raised_at"], raised_at_text);
@@ -589,16 +628,16 @@ mod tests {
system_id: 42,
component_id: 7,
},
&message::kaptan::KAPTAN_LOG_DATA {
level: 6,
flags: message::kaptan::KaptanLogFlags::empty(),
&LOG_DATA {
level: LogLevel::LOG_LEVEL_INFO,
flags: LogFlags::empty(),
message_length: message_text.len() as u8,
message: message_bytes,
},
);
let (packet, log) = frame_observations(&frame);
assert_eq!(packet.name, "KAPTAN_LOG");
assert_eq!(packet.name, "LOG");
assert_eq!(packet.values["message"], "estimator initialized");
assert_eq!(
log,
@@ -612,6 +651,28 @@ mod tests {
);
}
#[test]
fn off_level_is_visible_as_a_packet_but_not_a_log_event() {
let mut frame = MavlinkFrame::new();
frame.serialize_message_data(
MavHeader {
sequence: 1,
system_id: 42,
component_id: 7,
},
&LOG_DATA {
level: LogLevel::LOG_LEVEL_OFF,
flags: LogFlags::empty(),
message_length: 0,
message: [0; 160],
},
);
let (packet, log) = frame_observations(&frame);
assert_eq!(packet.values["level"], "OFF");
assert_eq!(log, None);
}
#[test]
fn error_level_runtime_log_remains_kaptan_log() {
let message_bytes = [b'x'; 160];
@@ -623,16 +684,16 @@ mod tests {
system_id: 42,
component_id: 7,
},
&message::kaptan::KAPTAN_LOG_DATA {
level: 3,
flags: message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRUNCATED,
&LOG_DATA {
level: LogLevel::LOG_LEVEL_ERROR,
flags: LogFlags::LOG_FLAG_TRUNCATED,
message_length: message_bytes.len() as u8,
message: message_bytes,
},
);
let (packet, log) = frame_observations(&frame);
assert_eq!(packet.name, "KAPTAN_LOG");
assert_eq!(packet.name, "LOG");
assert_eq!(
log,
Some(LogObservation {
@@ -646,23 +707,17 @@ mod tests {
}
#[test]
fn malformed_log_text_or_level_is_not_added_to_history() {
fn malformed_log_text_is_not_added_to_history() {
let mut invalid_utf8 = [0_u8; 160];
invalid_utf8[0] = 0xff;
for (level, flags, length, message) in [
(
6,
message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE,
0,
[0_u8; 160],
),
(
6,
message::kaptan::KaptanLogFlags::empty(),
LogLevel::LOG_LEVEL_INFO,
LogFlags::empty(),
161,
[0_u8; 160],
),
(6, message::kaptan::KaptanLogFlags::empty(), 1, invalid_utf8),
(LogLevel::LOG_LEVEL_INFO, LogFlags::empty(), 1, invalid_utf8),
] {
let mut frame = MavlinkFrame::new();
frame.serialize_message_data(
@@ -671,7 +726,7 @@ mod tests {
system_id: 42,
component_id: 7,
},
&message::kaptan::KAPTAN_LOG_DATA {
&LOG_DATA {
level,
flags,
message_length: length,
@@ -683,6 +738,28 @@ mod tests {
}
}
#[test]
fn native_sensor_packet_values_are_structured() {
let observation = packet_observation(&frame(
42,
7,
&IMU_DATA {
time_us: 123,
acceleration_milligravity: [1, 2, 3],
angular_rate_milliradians_per_second: [-4, 5, -6],
temperature_centidegrees_celsius: 2_500,
},
));
assert_eq!(observation.name, "IMU");
assert_eq!(observation.values["time_us"], 123);
assert_eq!(
observation.values["angular_rate_milliradians_per_second"],
serde_json::json!([-4, 5, -6])
);
assert!(observation.values.as_str().is_none());
}
#[test]
fn malformed_reportable_error_location_is_not_added_to_history() {
let mut raised_at = [0_u8; 96];
@@ -694,7 +771,7 @@ mod tests {
system_id: 42,
component_id: 7,
},
&message::kaptan::KAPTAN_ERROR_DATA {
&ERROR_DATA {
error_id: 1,
raised_at,
},
@@ -703,13 +780,33 @@ mod tests {
assert_eq!(log, None);
}
#[test]
fn reserved_zero_error_id_is_visible_as_a_packet_but_not_an_event() {
let mut frame = MavlinkFrame::new();
frame.serialize_message_data(
MavHeader {
sequence: 1,
system_id: 42,
component_id: 7,
},
&ERROR_DATA {
error_id: 0,
raised_at: [0; 96],
},
);
let (packet, log) = frame_observations(&frame);
assert_eq!(packet.values["error_id"], 0);
assert_eq!(log, None);
}
#[test]
fn serial_baud_accepts_only_rates_with_an_exact_termios_mapping() {
for rate in [
9_600, 19_200, 38_400, 57_600, 115_200, 230_400, 460_800, 921_600,
] {
let baud = SerialBaud::try_new(rate).expect("supported rate");
assert_eq!(baud.bits_per_second(), rate);
assert_eq!(baud, SerialBaud::try_new(rate).unwrap());
assert_ne!(baud.termios_speed(), 0);
}
assert_eq!(SerialBaud::try_new(0), None);
+104 -58
View File
@@ -6,13 +6,11 @@ use std::{
time::{Instant, SystemTime},
};
use kaptan_config::{ConfigValue, FieldId, ValueKind};
use serde::Serialize;
use crate::{
config_client::{
ClientEvent, Edit, RemoteConfig, RemoteField, Scalar, ValueKind, scalar_json,
scalar_less_than,
},
config_client::{ClientEvent, Edit, RemoteConfig, RemoteField, scalar_json, scalar_less_than},
log_monitor::{LogMonitor, LogObservation, LogSnapshot},
packet_monitor::{PacketMonitor, PacketObservation, PacketSnapshot},
};
@@ -23,6 +21,7 @@ pub struct StateSnapshot {
pub status: String,
pub error: Option<String>,
pub config_revision: u64,
pub has_config: bool,
pub can_edit: bool,
pub can_save: bool,
pub can_disconnect: bool,
@@ -46,7 +45,7 @@ pub struct FieldView {
pub kind: &'static str,
pub minimum: Option<serde_json::Value>,
pub maximum: Option<serde_json::Value>,
pub default: serde_json::Value,
pub factory: serde_json::Value,
pub value: serde_json::Value,
pub help: String,
pub options: Vec<EnumOptionView>,
@@ -58,11 +57,6 @@ pub struct EnumOptionView {
pub label: String,
}
#[derive(Clone)]
pub struct SaveRequest {
pub edits: Vec<Edit>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Operation {
Disconnected,
@@ -70,6 +64,7 @@ enum Operation {
Discovering,
Ready,
Saving,
DurableStateUnknown,
}
struct State {
@@ -78,7 +73,7 @@ struct State {
status: String,
error: Option<String>,
config: Option<RemoteConfig>,
draft: BTreeMap<u16, Scalar>,
draft: BTreeMap<FieldId, ConfigValue>,
config_revision: u64,
packets: PacketMonitor,
logs: LogMonitor,
@@ -111,6 +106,7 @@ impl SharedState {
status: state.status.clone(),
error: state.error.clone(),
config_revision: state.config_revision,
has_config: state.config.is_some(),
can_edit: state.operation == Operation::Ready && state.config.is_some(),
can_save: state.operation == Operation::Ready && !state.draft.is_empty(),
can_disconnect: !matches!(state.operation, Operation::Disconnected),
@@ -128,8 +124,13 @@ impl SharedState {
};
};
ConfigSnapshot {
schema_id: Some(hex(&config.info.schema_id)),
state: config.info.state.clone(),
schema_id: Some(hex(&config.schema_id)),
state: if config.restart_required {
"restart required"
} else {
"active"
}
.into(),
fields: config
.fields
.iter()
@@ -195,16 +196,16 @@ impl SharedState {
pub fn discovering(&self) {
let mut state = lock(&self.0);
state.operation = Operation::Discovering;
state.status = "Reading configuration schema and values…".into();
state.status = "Exporting configuration file…".into();
state.error = None;
}
pub fn configuration_event(&self, event: ClientEvent) {
pub(crate) fn configuration_event(&self, event: ClientEvent) {
let mut state = lock(&self.0);
match event {
ClientEvent::Configuration(config) => {
state.operation = Operation::Ready;
state.status = if config.info.state == "restart required" {
state.status = if config.restart_required {
"Saved. Restart the controller to activate the new configuration.".into()
} else {
"Configuration ready.".into()
@@ -224,8 +225,17 @@ impl SharedState {
}
ClientEvent::RediscoveryRequired(detail) => {
state.operation = Operation::Discovering;
state.status = "Waiting to rediscover configuration…".into();
state.error = Some(detail);
state.config = None;
state.draft.clear();
state.config_revision = state.config_revision.wrapping_add(1);
}
ClientEvent::DurableStateUnknown(detail) => {
state.operation = Operation::DurableStateUnknown;
state.status =
"Controller state changed; waiting to rediscover configuration…".into();
"Configuration state is unknown. Power-cycle the controller, then disconnect and reconnect."
.into();
state.error = Some(detail);
state.config = None;
state.draft.clear();
@@ -235,6 +245,8 @@ impl SharedState {
}
pub fn edit(&self, field_id: u16, value: &serde_json::Value) -> Result<ConfigSnapshot, String> {
let field_id =
FieldId::try_new(field_id).ok_or_else(|| format!("unknown field id {field_id}"))?;
let mut state = lock(&self.0);
if state.operation != Operation::Ready {
return Err("configuration is not ready for edits".into());
@@ -243,7 +255,7 @@ impl SharedState {
.config
.as_ref()
.and_then(|config| config.fields.iter().find(|field| field.id == field_id))
.ok_or_else(|| format!("unknown field id {field_id}"))?;
.ok_or_else(|| format!("unknown field id {}", field_id.get()))?;
let next = parse_scalar(field.kind, value)?;
validate_bounds(field, next)?;
if next == field.value {
@@ -257,7 +269,7 @@ impl SharedState {
Ok(self.configuration())
}
pub fn begin_save(&self) -> Result<SaveRequest, String> {
pub(crate) fn begin_save(&self) -> Result<Vec<Edit>, String> {
let mut state = lock(&self.0);
if state.operation != Operation::Ready {
return Err("configuration is busy".into());
@@ -269,20 +281,18 @@ impl SharedState {
if state.draft.is_empty() {
return Err("configuration has no changed fields".into());
}
let request = SaveRequest {
edits: state
.draft
.iter()
.map(|(field_id, value)| Edit {
field_id: *field_id,
value: *value,
})
.collect(),
};
let edits = state
.draft
.iter()
.map(|(field_id, value)| Edit {
field_id: *field_id,
value: *value,
})
.collect();
state.operation = Operation::Saving;
state.status = "Validating and saving configuration…".into();
state.error = None;
Ok(request)
Ok(edits)
}
pub fn worker_failed(&self, detail: String) {
@@ -298,13 +308,13 @@ impl SharedState {
}
}
fn field_view(field: &RemoteField, draft: Option<Scalar>) -> FieldView {
fn field_view(field: &RemoteField, draft: Option<ConfigValue>) -> FieldView {
let (group, tail) = field
.key
.rsplit_once('.')
.unwrap_or(("configuration", field.key.as_str()));
FieldView {
id: field.id,
id: field.id.get(),
key: field.key.clone(),
label: humanize(tail),
group: humanize(group),
@@ -312,25 +322,27 @@ fn field_view(field: &RemoteField, draft: Option<Scalar>) -> FieldView {
kind: match field.kind {
ValueKind::Bool => "bool",
ValueKind::F32 | ValueKind::F64 => "decimal",
ValueKind::I32 | ValueKind::U32 | ValueKind::U64 => "integer",
ValueKind::U8 | ValueKind::U16 | ValueKind::I32 | ValueKind::U32 | ValueKind::U64 => {
"integer"
}
},
minimum: field.minimum.map(scalar_json),
maximum: field.maximum.map(scalar_json),
default: scalar_json(field.default),
factory: scalar_json(field.factory),
value: scalar_json(draft.unwrap_or(field.value)),
help: format!("Stable field ID {}.", field.id),
help: format!("Stable field ID {}.", field.id.get()),
options: field
.options
.iter()
.map(|option| EnumOptionView {
value: scalar_json(option.value),
value: option.value.into(),
label: option.label.clone(),
})
.collect(),
}
}
fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<Scalar, String> {
fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<ConfigValue, String> {
let text = value
.as_str()
.map(str::to_owned)
@@ -338,19 +350,27 @@ fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<Scalar, St
match kind {
ValueKind::Bool => value
.as_bool()
.map(Scalar::Bool)
.map(ConfigValue::Bool)
.ok_or_else(|| "expected a boolean".into()),
ValueKind::U8 => text
.parse()
.map(ConfigValue::U8)
.map_err(|_| "expected an unsigned 8-bit integer".into()),
ValueKind::U16 => text
.parse()
.map(ConfigValue::U16)
.map_err(|_| "expected an unsigned 16-bit integer".into()),
ValueKind::I32 => text
.parse()
.map(Scalar::I32)
.map(ConfigValue::I32)
.map_err(|_| "expected a signed 32-bit integer".into()),
ValueKind::U32 => text
.parse()
.map(Scalar::U32)
.map(ConfigValue::U32)
.map_err(|_| "expected an unsigned 32-bit integer".into()),
ValueKind::U64 => text
.parse()
.map(Scalar::U64)
.map(ConfigValue::U64)
.map_err(|_| "expected an unsigned 64-bit integer".into()),
ValueKind::F32 => text
.parse::<f32>()
@@ -358,7 +378,7 @@ fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<Scalar, St
.and_then(|value| {
value
.is_finite()
.then_some(Scalar::F32(value))
.then_some(ConfigValue::F32(value))
.ok_or_else(|| "decimal must be finite".into())
}),
ValueKind::F64 => text
@@ -367,14 +387,20 @@ fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<Scalar, St
.and_then(|value| {
value
.is_finite()
.then_some(Scalar::F64(value))
.then_some(ConfigValue::F64(value))
.ok_or_else(|| "decimal must be finite".into())
}),
}
}
fn validate_bounds(field: &RemoteField, value: Scalar) -> Result<(), String> {
if !field.options.is_empty() && !field.options.iter().any(|option| option.value == value) {
fn validate_bounds(field: &RemoteField, value: ConfigValue) -> Result<(), String> {
if !field.options.is_empty()
&& !matches!(
value,
ConfigValue::U8(value)
if field.options.iter().any(|option| option.value == value)
)
{
return Err(format!("{} requires a named choice", field.key));
}
if field
@@ -425,23 +451,21 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
#[cfg(test)]
mod tests {
use super::*;
use crate::config_client::{RemoteEnumOption, RemoteInfo, ValueKind};
use crate::config_client::RemoteEnumOption;
fn unknown_board() -> RemoteConfig {
RemoteConfig {
info: RemoteInfo {
schema_id: *b"UNKNOWN-SCHEMA01",
state: "active".into(),
},
schema_id: *b"UNKNOWN-SCHEMA01",
restart_required: false,
fields: vec![RemoteField {
id: 401,
id: FieldId::new(401),
key: "future.sensor.rate_hz".into(),
unit: "Hz".into(),
kind: ValueKind::U32,
minimum: Some(Scalar::U32(1)),
maximum: Some(Scalar::U32(100)),
default: Scalar::U32(20),
value: Scalar::U32(25),
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(100)),
factory: ConfigValue::U32(20),
value: ConfigValue::U32(25),
options: Vec::new(),
}],
}
@@ -455,7 +479,24 @@ mod tests {
let edited = state.edit(401, &serde_json::json!(30)).unwrap();
assert_eq!(edited.fields[0].value, serde_json::json!(30));
let save = state.begin_save().unwrap();
assert_eq!(save.edits[0].field_id, 401);
assert_eq!(save[0].field_id, FieldId::new(401));
}
#[test]
fn snapshot_exposes_configuration_availability_across_link_states() {
let state = SharedState::default();
assert!(!state.snapshot().has_config);
state.connected("test".into());
state.configuration_event(ClientEvent::Configuration(unknown_board()));
assert!(state.snapshot().has_config);
state.edit(401, &serde_json::json!(30)).unwrap();
state.begin_save().unwrap();
assert!(state.snapshot().has_config);
state.disconnected(None);
assert!(!state.snapshot().has_config);
}
#[test]
@@ -469,13 +510,18 @@ mod tests {
#[test]
fn enum_choices_are_projected_and_validated_from_remote_metadata() {
let mut config = unknown_board();
config.fields[0].kind = ValueKind::U8;
config.fields[0].minimum = Some(ConfigValue::U8(1));
config.fields[0].maximum = Some(ConfigValue::U8(100));
config.fields[0].factory = ConfigValue::U8(20);
config.fields[0].value = ConfigValue::U8(25);
config.fields[0].options = vec![
RemoteEnumOption {
value: Scalar::U32(20),
value: 20,
label: "Standard".into(),
},
RemoteEnumOption {
value: Scalar::U32(25),
value: 25,
label: "Fast".into(),
},
];
+6 -5
View File
@@ -81,7 +81,7 @@ q('#save').addEventListener('click', () => {
function fieldStructure(field) {
return [
field.id, field.key, field.label, field.group, field.unit, field.kind,
field.minimum, field.maximum, field.default, field.help, field.options,
field.minimum, field.maximum, field.factory, field.help, field.options,
];
}
@@ -152,7 +152,7 @@ function buildEditor(fields) {
const bounds = field.minimum !== null || field.maximum !== null
? `Inclusive bounds: ${field.minimum ?? 'unbounded'} to ${field.maximum ?? 'unbounded'}.`
: '';
const helpText = [field.help, `Default: ${field.default}.`, bounds].filter(Boolean).join(' ');
const helpText = [field.help, `Factory: ${field.factory}.`, bounds].filter(Boolean).join(' ');
if (helpText) {
const help = document.createElement('small');
help.id = `help-${field.id}`;
@@ -402,8 +402,8 @@ function renderLogs(snapshot) {
function renderState(state) {
latestState = state;
setText(q('#connection-status'), state.path ? `${state.status} (${state.path})` : state.status);
if (state.error) showError(state.error);
q('#configuration').hidden = latestConfig?.schema_id === null || latestConfig === null;
showError(state.error ?? '');
q('#configuration').hidden = !state.has_config;
q('#packet-monitor').hidden = !state.can_disconnect;
q('#log-monitor').hidden = !state.can_disconnect;
renderPermissions();
@@ -420,8 +420,9 @@ async function refresh() {
renderPackets(packets);
renderLogs(logs);
if (state.config_revision !== configRevision) {
const config = await api('/api/config');
renderConfig(config);
configRevision = state.config_revision;
renderConfig(await api('/api/config'));
q('#configuration').hidden = latestConfig.schema_id === null;
}
} catch (error) {
+2 -4
View File
@@ -12,12 +12,9 @@ workspace = true
kaptan-err-reportable = { path = "../../libs/kaptan-err-reportable" }
kaptan-control = { path = "../../libs/kaptan-control" }
kaptan-config = { path = "../../libs/kaptan-config" }
kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink" }
kaptan-flight-config = { path = "../../libs/kaptan-flight-config" }
kaptan-flight-runtime = { path = "../../libs/kaptan-flight-runtime" }
kaptan-log = { path = "../../libs/kaptan-log" }
kaptan-diagnostics-mavlink = { path = "../../libs/kaptan-diagnostics-mavlink" }
kaptan-mavlink = { path = "../../libs/kaptan-mavlink" }
kaptan-math = { path = "../../libs/kaptan-math" }
kaptan-navigation = { path = "../../libs/kaptan-navigation", features = ["sitl"] }
kaptan-safety = { path = "../../libs/kaptan-safety" }
@@ -25,4 +22,5 @@ kaptan-time = { path = "../../libs/kaptan-time" }
minicbor = "0.25"
[dev-dependencies]
mavlink = { version = "0.18", default-features = false, features = ["embedded"] }
kaptan-diagnostics-mavlink = { path = "../../libs/kaptan-diagnostics-mavlink" }
kaptan-mavlink = { path = "../../libs/kaptan-mavlink", default-features = false }
+2 -1
View File
@@ -4,6 +4,7 @@
//! need a reviewed A/B journal and authority path; SITL uses a same-directory
//! file replacement to make startup and configuration tests reproducible.
#[cfg(test)]
mod logging;
mod runtime;
mod store;
@@ -11,7 +12,7 @@ mod store;
#[cfg(test)]
mod tests;
pub use runtime::{CAPABILITIES, SitlConfigRuntime, configuration_service};
pub(super) use runtime::{SitlConfigRuntime, configuration_service};
pub use store::{
FileConfigStore, FileStoreError, default_profile, load_profile, write_default_profile,
};
+14 -40
View File
@@ -1,49 +1,27 @@
//! Central SITL configuration and protocol composition.
//! Central SITL configuration composition.
use kaptan_config::ConfigManager;
use kaptan_config_mavlink::{ConfigCommand, ConfigProtocol, Principal, ServiceResponse, Target};
#[cfg(test)]
use kaptan_diagnostics_mavlink::encode_log;
use kaptan_flight_config::{CandidateProfile, VehicleCapabilities, VehicleProfile};
use kaptan_flight_runtime::FlightRuntime;
#[cfg(test)]
use kaptan_log::TransportAdmission;
use kaptan_mavlink::message::kaptan::KAPTAN_LOG_DATA;
#[cfg(test)]
use kaptan_mavlink::generated::kaptan::LOG_DATA;
use super::{FileConfigStore, logging};
use super::FileConfigStore;
#[cfg(test)]
use super::logging;
/// SITL's immutable compiled capability contract.
pub const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
pub(super) const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
/// SITL composition root retaining the one profile manager and one protocol
/// lease state machine. The MAVLink adapter never owns either resource.
/// SITL composition root retaining the one profile manager.
pub struct SitlConfigRuntime {
runtime: FlightRuntime<FileConfigStore>,
protocol: ConfigProtocol,
}
impl SitlConfigRuntime {
/// 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, FileConfigStore> {
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()
@@ -52,7 +30,8 @@ impl SitlConfigRuntime {
/// 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_log_payload(&mut self) -> Option<KAPTAN_LOG_DATA> {
#[cfg(test)]
pub(super) fn pending_log_payload(&mut self) -> Option<LOG_DATA> {
let runtime = &mut self.runtime;
let _ = logging::drain_one(|record| runtime.publish_log_event(*record));
self.runtime
@@ -60,14 +39,9 @@ impl SitlConfigRuntime {
}
}
/// 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.
/// Builds the host-only central configuration composition.
pub fn configuration_service(store: FileConfigStore) -> Result<SitlConfigRuntime, String> {
let runtime = FlightRuntime::recover(CandidateProfile::factory(), CAPABILITIES, store)
.map_err(|error| format!("configuration recovery failed: {error:?}"))?;
Ok(SitlConfigRuntime {
runtime,
protocol: ConfigProtocol::new(Target::new(1, 1)),
})
Ok(SitlConfigRuntime { runtime })
}
+2 -2
View File
@@ -13,7 +13,7 @@ use kaptan_flight_config::{
decode_candidate, encode_candidate,
};
use super::{CAPABILITIES, configuration_service};
use super::{configuration_service, runtime::CAPABILITIES};
/// Host file-store error. It intentionally makes no flash power-loss claim.
#[derive(Debug, KaptanError)]
@@ -55,7 +55,7 @@ impl FileConfigStore {
impl ConfigStore<CandidateProfile> for FileConfigStore {
type Error = FileStoreError;
fn load(&mut self) -> Result<Option<CandidateProfile>, Self::Error> {
fn load(&mut self, _: &CandidateProfile) -> Result<Option<CandidateProfile>, Self::Error> {
let Some(path) = self.path.as_deref() else {
return Ok(None);
};
+24 -17
View File
@@ -3,11 +3,21 @@ use std::fs;
use kaptan_config::{ConfigModel, ConfigStore};
use kaptan_flight_config::{CandidateProfile, decode_candidate};
use kaptan_mavlink::{
MavlinkFrame,
encoder::MavlinkEncoder,
message::{Message, kaptan::MavMessage},
generated::kaptan::{LogLevel, MavMessage},
receiver::IntegrityMavlinkReceiver,
};
use super::{CAPABILITIES, FileConfigStore, configuration_service};
use super::{FileConfigStore, configuration_service, runtime::CAPABILITIES};
fn parse_test_message(frame: &MavlinkFrame) -> Option<MavMessage> {
let mut receiver = IntegrityMavlinkReceiver::<1>::new();
for byte in frame.raw_bytes() {
receiver.receive_byte(*byte);
}
receiver.take_received()?.decode()
}
#[test]
fn host_store_round_trips_a_complete_validated_snapshot() {
@@ -20,20 +30,21 @@ fn host_store_round_trips_a_complete_validated_snapshot() {
let _ = fs::remove_file(&path);
let _ = fs::remove_file(&temporary);
let factory = CandidateProfile::factory();
let mut store = FileConfigStore::new(&path);
assert!(store.load().unwrap().is_none());
store.persist(&CandidateProfile::factory()).unwrap();
assert!(store.load(&factory).unwrap().is_none());
store.persist(&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");
let candidate = store.load(&factory).unwrap().expect("persisted candidate");
assert!(candidate.validate(&CAPABILITIES).is_ok());
store.persist(&CandidateProfile::factory()).unwrap();
store.persist(&factory).unwrap();
let mut reopened = FileConfigStore::new(&path);
assert!(reopened.load().unwrap().is_some());
assert!(reopened.load(&factory).unwrap().is_some());
let mut fresh = FileConfigStore::new(&path);
fresh.persist(&CandidateProfile::factory()).unwrap();
fresh.persist(&factory).unwrap();
fs::remove_file(&path).unwrap();
}
@@ -51,21 +62,17 @@ fn standard_log_macro_reaches_the_generated_mavlink_log_payload() {
kaptan_log::info!("SITL configuration service ready");
let payload = service
.pending_log_payload()
.expect("info facade record must reach KAPTAN_LOG");
assert_eq!(payload.level, 6);
.expect("info facade record must reach LOG");
assert_eq!(payload.level, LogLevel::LOG_LEVEL_INFO);
let message = std::str::from_utf8(&payload.message[..usize::from(payload.message_length)])
.expect("facade text remains UTF-8");
assert_eq!(message, "SITL configuration service ready");
let mut encoder = MavlinkEncoder::new(1, 1);
let frame = encoder.custom(&payload);
let frame = encoder.encode(&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))
parse_test_message(&frame),
Some(MavMessage::LOG(data))
if data.level == payload.level
&& data.message_length == payload.message_length
&& data.message == payload.message