From b7631c0bc73fad76d323e3741d571e3744a08bd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onur=20=C3=96zkan?= Date: Sat, 22 Aug 2026 18:40:43 +0300 Subject: [PATCH] feat(configurator): add web configuration console --- firmware/Cargo.lock | 20 + firmware/Cargo.toml | 1 + firmware/tools/configurator/Cargo.toml | 18 + firmware/tools/configurator/src/api.rs | 214 ++ .../tools/configurator/src/config_client.rs | 1770 +++++++++++++++++ firmware/tools/configurator/src/lib.rs | 8 + .../tools/configurator/src/log_monitor.rs | 241 +++ firmware/tools/configurator/src/main.rs | 124 ++ .../tools/configurator/src/packet_monitor.rs | 247 +++ .../tools/configurator/src/serial_worker.rs | 622 ++++++ firmware/tools/configurator/src/state.rs | 493 +++++ firmware/tools/configurator/web/app.js | 427 ++++ firmware/tools/configurator/web/index.html | 74 + firmware/tools/configurator/web/styles.css | 254 +++ 14 files changed, 4513 insertions(+) create mode 100644 firmware/tools/configurator/Cargo.toml create mode 100644 firmware/tools/configurator/src/api.rs create mode 100644 firmware/tools/configurator/src/config_client.rs create mode 100644 firmware/tools/configurator/src/lib.rs create mode 100644 firmware/tools/configurator/src/log_monitor.rs create mode 100644 firmware/tools/configurator/src/main.rs create mode 100644 firmware/tools/configurator/src/packet_monitor.rs create mode 100644 firmware/tools/configurator/src/serial_worker.rs create mode 100644 firmware/tools/configurator/src/state.rs create mode 100644 firmware/tools/configurator/web/app.js create mode 100644 firmware/tools/configurator/web/index.html create mode 100644 firmware/tools/configurator/web/styles.css diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock index a8c1d6e..4a92a46 100644 --- a/firmware/Cargo.lock +++ b/firmware/Cargo.lock @@ -179,6 +179,19 @@ dependencies = [ "kaptan-mavlink", ] +[[package]] +name = "kaptan-configurator" +version = "0.1.0" +dependencies = [ + "kaptan-config", + "kaptan-config-mavlink", + "kaptan-mavlink", + "libc", + "mavlink-core", + "serde", + "serde_json", +] + [[package]] name = "kaptan-control" version = "0.1.0" @@ -363,6 +376,12 @@ version = "0.1.0" name = "kaptan-tx" version = "0.1.0" +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "log" version = "0.4.33" @@ -622,6 +641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", + "serde_derive", ] [[package]] diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index 6108f48..29db59a 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -26,6 +26,7 @@ members = [ "libs/kaptan-tx", "platforms/stm32f7", "tools/sitl", + "tools/configurator", ] resolver = "2" diff --git a/firmware/tools/configurator/Cargo.toml b/firmware/tools/configurator/Cargo.toml new file mode 100644 index 0000000..945cca6 --- /dev/null +++ b/firmware/tools/configurator/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "kaptan-configurator" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +kaptan-config = { path = "../../libs/kaptan-config" } +kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink" } +kaptan-mavlink = { path = "../../libs/kaptan-mavlink", default-features = false, features = ["dialect-kaptan"] } +mavlink-core = { version = "0.18", default-features = false } +libc = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/firmware/tools/configurator/src/api.rs b/firmware/tools/configurator/src/api.rs new file mode 100644 index 0000000..7722612 --- /dev/null +++ b/firmware/tools/configurator/src/api.rs @@ -0,0 +1,214 @@ +//! Loopback-only HTTP adapter for the local configurator. + +use std::{ + io::{Read, Write}, + net::TcpStream, + sync::Arc, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + serial_worker::{Command, SerialController}, + state::SharedState, +}; + +const INDEX: &str = include_str!("../web/index.html"); +const APP: &str = include_str!("../web/app.js"); +const CSS: &str = include_str!("../web/styles.css"); +const CSP: &str = "default-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self'; script-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; + +#[derive(Deserialize)] +struct Connect { + path: String, +} + +#[derive(Deserialize)] +struct EditDraft { + field_id: u16, + value: serde_json::Value, +} + +pub fn serve_connection( + mut stream: TcpStream, + state: &Arc, + serial: &SerialController, + origin: &str, +) -> std::io::Result<()> { + let mut request = Vec::with_capacity(4096); + let header_end = loop { + let mut chunk = [0_u8; 4096]; + let length = stream.read(&mut chunk)?; + if length == 0 || request.len() + length > 64 * 1024 { + return reply(&mut stream, 400, "text/plain", "bad request"); + } + request.extend_from_slice(&chunk[..length]); + if let Some(separator) = request.windows(4).position(|window| window == b"\r\n\r\n") { + let end = separator + 4; + let head = String::from_utf8_lossy(&request[..separator]); + let content_length = header(&head, "Content-Length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + if content_length > 60 * 1024 { + return reply(&mut stream, 400, "text/plain", "bad request"); + } + if request.len() >= end + content_length { + break end; + } + } + }; + let head = String::from_utf8_lossy(&request[..header_end - 4]); + let body = &request[header_end..]; + let mut parts = head.lines().next().unwrap_or_default().split_whitespace(); + let method = parts.next().unwrap_or_default(); + let path = parts.next().unwrap_or_default(); + if method == "POST" && !same_origin(&head, origin) { + return reply(&mut stream, 403, "text/plain", "forbidden"); + } + match (method, path) { + ("GET", "/") => reply(&mut stream, 200, "text/html; charset=utf-8", INDEX), + ("GET", "/app.js") => reply( + &mut stream, + 200, + "application/javascript; charset=utf-8", + APP, + ), + ("GET", "/styles.css") => reply(&mut stream, 200, "text/css; charset=utf-8", CSS), + ("GET", "/api/state") => json(&mut stream, 200, &state.snapshot()), + ("GET", "/api/config") => json(&mut stream, 200, &state.configuration()), + ("GET", "/api/packets") => json(&mut stream, 200, &state.packets()), + ("GET", "/api/logs") => json(&mut stream, 200, &state.logs()), + ("POST", "/api/connect") => match serde_json::from_slice::(body) { + Ok(value) if !value.path.trim().is_empty() => match state.begin_connect() { + Ok(()) => { + let result = serial.connect(value.path.trim().into()); + if let Err(error) = &result { + state.worker_failed(error.clone()); + } + endpoint(&mut stream, result) + } + Err(error) => json(&mut stream, 409, &serde_json::json!({"error":error})), + }, + _ => json( + &mut stream, + 400, + &serde_json::json!({"error":"A serial device path is required."}), + ), + }, + ("POST", "/api/disconnect") => endpoint(&mut stream, serial.send(Command::Disconnect)), + ("POST", "/api/config/edit") => match serde_json::from_slice::(body) { + Ok(edit) => match state.edit(edit.field_id, &edit.value) { + Ok(config) => json(&mut stream, 200, &config), + Err(error) => json(&mut stream, 409, &serde_json::json!({"error":error})), + }, + Err(_) => json( + &mut stream, + 400, + &serde_json::json!({"error":"Expected a stable field_id and typed value."}), + ), + }, + ("POST", "/api/config/save") => match state.begin_save() { + Ok(save) => { + let result = serial.send(Command::Save(save)); + if let Err(error) = &result { + state.worker_failed(error.clone()); + } + endpoint(&mut stream, result) + } + Err(error) => json(&mut stream, 409, &serde_json::json!({"error":error})), + }, + _ => reply(&mut stream, 404, "text/plain", "not found"), + } +} + +fn same_origin(head: &str, origin: &str) -> bool { + let Some(expected_host) = origin.strip_prefix("http://") else { + return false; + }; + header(head, "Host") == Some(expected_host) && header(head, "Origin") == Some(origin) +} + +fn header<'a>(head: &'a str, name: &str) -> Option<&'a str> { + head.lines().skip(1).find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case(name).then_some(value.trim()) + }) +} + +fn endpoint(stream: &mut TcpStream, result: Result<(), String>) -> std::io::Result<()> { + match result { + Ok(()) => json(stream, 202, &serde_json::json!({"ok":true})), + Err(error) => json(stream, 409, &serde_json::json!({"error":error})), + } +} + +fn json(stream: &mut TcpStream, status: u16, value: &T) -> std::io::Result<()> { + match serde_json::to_string(value) { + Ok(body) => reply(stream, status, "application/json; charset=utf-8", &body), + Err(_) => reply(stream, 500, "text/plain", "serialization error"), + } +} + +fn reply( + stream: &mut TcpStream, + status: u16, + content_type: &str, + body: &str, +) -> std::io::Result<()> { + let reason = match status { + 200 => "OK", + 202 => "Accepted", + 400 => "Bad Request", + 403 => "Forbidden", + 404 => "Not Found", + 409 => "Conflict", + _ => "Internal Server Error", + }; + write!( + stream, + "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Security-Policy: {CSP}\r\nCache-Control: no-store\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mutations_require_the_exact_loopback_origin() { + let origin = "http://127.0.0.1:4861"; + assert!(same_origin( + "POST /api/connect HTTP/1.1\r\nHost: 127.0.0.1:4861\r\nOrigin: http://127.0.0.1:4861", + origin + )); + assert!(!same_origin( + "POST /api/connect HTTP/1.1\r\nHost: 127.0.0.1:4861", + origin + )); + } + + #[test] + fn browser_contains_no_board_or_configuration_registry() { + assert!(!APP.contains("telemetry.heartbeat_hz")); + assert!(!APP.to_ascii_lowercase().contains("kakute")); + assert!(!APP.to_ascii_lowercase().contains("geprc")); + assert!(!APP.contains("HEARTBEAT")); + assert!(APP.contains("field.id")); + assert!( + APP.contains("field.minimum, field.maximum, field.default, field.help, field.options") + ); + assert!(APP.contains("document.createElement('select')")); + assert!(APP.contains("snapshot.packets")); + assert!(APP.contains("packet.values")); + assert!(APP.contains("aria-expanded")); + assert!(INDEX.contains("id=\"draft-fields\"")); + assert!(APP.contains("const packetRows = new Map()")); + assert!(APP.contains("typeof values === 'string'")); + assert!(APP.contains("setText(entry.values, packetValues(packet.values))")); + assert!(INDEX.contains("id=\"packet-rows\"")); + assert!(APP.contains("api('/api/logs')")); + assert!(APP.contains("newEntries.map(createLogEntry)")); + assert!(INDEX.contains("id=\"log-entries\"")); + } +} diff --git a/firmware/tools/configurator/src/config_client.rs b/firmware/tools/configurator/src/config_client.rs new file mode 100644 index 0000000..c58d5df --- /dev/null +++ b/firmware/tools/configurator/src/config_client.rs @@ -0,0 +1,1770 @@ +//! Board-agnostic stop-and-wait client for the self-describing configuration service. + +use std::time::{Duration, Instant}; + +pub use kaptan_config::{ConfigValue as Scalar, ValueKind}; +use kaptan_config_mavlink::{ + DESCRIPTOR_HAS_MAXIMUM, DESCRIPTOR_HAS_MINIMUM, PROTOCOL_MAJOR, PROTOCOL_MINOR, +}; +use kaptan_mavlink::message::kaptan::{ + KAPTAN_CONFIG_ABORT_DATA, KAPTAN_CONFIG_BEGIN_DATA, KAPTAN_CONFIG_COMMIT_DATA, + KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_DESCRIPTOR_REQUEST_DATA, + KAPTAN_CONFIG_ENUM_OPTION_DATA, KAPTAN_CONFIG_ENUM_OPTION_REQUEST_DATA, + KAPTAN_CONFIG_INFO_DATA, KAPTAN_CONFIG_INFO_REQUEST_DATA, KAPTAN_CONFIG_SET_BOOL_DATA, + KAPTAN_CONFIG_SET_F32_DATA, KAPTAN_CONFIG_SET_F64_DATA, KAPTAN_CONFIG_SET_I32_DATA, + KAPTAN_CONFIG_SET_U32_DATA, KAPTAN_CONFIG_SET_U64_DATA, KAPTAN_CONFIG_VALIDATE_DATA, + KAPTAN_CONFIG_VALUE_DATA, KAPTAN_CONFIG_VALUE_REQUEST_DATA, KaptanConfigError, + KaptanConfigOperation, KaptanConfigResult, KaptanConfigState, KaptanConfigValueKind, + KaptanConfigValueSource, MavMessage, +}; +use kaptan_mavlink::message::{MAVLinkV2MessageRaw, MavHeader, Message}; +use mavlink_core::MavlinkVersion; + +const REQUEST_TIMEOUT: Duration = Duration::from_millis(500); +// A commit erases one 128-KiB STM32F7 sector before programming the next +// journal record. The target cannot service serial traffic while flash is +// busy, so the ordinary interactive timeout is deliberately too short here. +const COMMIT_TIMEOUT: Duration = Duration::from_secs(5); +const REQUEST_ATTEMPTS: u8 = 3; +const CLIENT_SYSTEM_ID: u8 = 255; +const CLIENT_COMPONENT_ID: u8 = 190; + +pub(crate) fn scalar_json(value: Scalar) -> serde_json::Value { + match value { + Scalar::Bool(value) => value.into(), + Scalar::I32(value) => value.into(), + Scalar::U32(value) => value.into(), + // JSON numbers cannot preserve the full u64 domain in a browser. + Scalar::U64(value) => value.to_string().into(), + Scalar::F32(value) => serde_json::Value::from(value), + Scalar::F64(value) => serde_json::Value::from(value), + } +} + +fn scalar_from_bits(kind: ValueKind, bits: u64) -> Result { + let value = Scalar::try_from_raw_bits(kind, bits) + .ok_or_else(|| "invalid scalar representation".to_owned())?; + match value { + Scalar::F32(value) if !value.is_finite() => Err("f32 value must be finite".into()), + Scalar::F64(value) if !value.is_finite() => Err("f64 value must be finite".into()), + _ => Ok(value), + } +} + +pub(crate) const fn scalar_less_than(left: Scalar, right: Scalar) -> bool { + match (left, right) { + (Scalar::I32(left), Scalar::I32(right)) => left < right, + (Scalar::U32(left), Scalar::U32(right)) => left < right, + (Scalar::U64(left), Scalar::U64(right)) => left < right, + (Scalar::F32(left), Scalar::F32(right)) => left < right, + (Scalar::F64(left), Scalar::F64(right)) => left < right, + (Scalar::Bool(left), Scalar::Bool(right)) => !left && right, + _ => false, + } +} + +#[derive(Clone, Debug)] +pub struct RemoteEnumOption { + pub value: Scalar, + pub label: String, +} + +#[derive(Clone, Debug)] +pub struct RemoteField { + pub id: u16, + pub key: String, + pub unit: String, + pub kind: ValueKind, + pub minimum: Option, + pub maximum: Option, + pub default: Scalar, + pub value: Scalar, + pub options: Vec, +} + +#[derive(Clone, Debug)] +pub struct RemoteInfo { + pub schema_id: [u8; 16], + pub state: String, +} + +#[derive(Clone, Debug)] +pub struct RemoteConfig { + pub info: RemoteInfo, + pub fields: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Edit { + pub field_id: u16, + pub value: Scalar, +} + +pub enum ClientEvent { + Configuration(RemoteConfig), + Failure(String), + RediscoveryRequired(String), +} + +type Outcome = (Option, Option); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Operation { + Info, + Descriptor(u16), + EnumOption { + descriptor_index: u16, + option_index: u8, + option_count: u8, + }, + Value(usize), + Begin, + Set(usize), + Validate, + Commit, + Abort, +} + +struct Pending { + id: u32, + operation: Operation, + raw: MAVLinkV2MessageRaw, + sent_at: Instant, + attempts_left: u8, +} + +struct Save { + edits: Vec, + session_id: u32, + revision: u32, +} + +/// The client contains no board IDs, field names, schema IDs, or snapshot codec. +pub struct ConfigClient { + next_request_id: u32, + target: Option<(u8, u8)>, + info: Option, + field_count: u16, + fields: Vec, + pending: Option, + save: Option, + failure_after_abort: Option, + rediscover_after_abort: bool, +} + +impl Default for ConfigClient { + fn default() -> Self { + Self::new() + } +} + +impl ConfigClient { + pub const fn new() -> Self { + Self { + next_request_id: 1, + target: None, + info: None, + field_count: 0, + fields: Vec::new(), + pending: None, + save: None, + failure_after_abort: None, + rediscover_after_abort: false, + } + } + + pub fn reset(&mut self) { + let next_request_id = self.next_request_id; + *self = Self { + next_request_id, + ..Self::new() + }; + } + + pub fn discover( + &mut self, + target: (u8, u8), + sequence: &mut u8, + ) -> Result { + if target.0 == 0 || target.1 == 0 { + return Err("configuration target must be exact".into()); + } + self.target = Some(target); + self.info = None; + self.field_count = 0; + self.fields.clear(); + self.pending = None; + self.save = None; + self.failure_after_abort = None; + self.rediscover_after_abort = false; + self.request_info(sequence) + } + + pub fn save( + &mut self, + edits: Vec, + sequence: &mut u8, + ) -> Result { + if edits.is_empty() { + return Err("configuration has no changed fields".into()); + } + if self.pending.is_some() || self.save.is_some() { + return Err("configuration protocol is busy".into()); + } + for edit in &edits { + let Some(field) = self.fields.iter().find(|field| field.id == edit.field_id) else { + return Err(format!("unknown field id {}", edit.field_id)); + }; + if edit.value.kind() != field.kind { + return Err(format!("field {} has the wrong value type", edit.field_id)); + } + if !field.options.is_empty() + && !field + .options + .iter() + .any(|option| option.value == edit.value) + { + return Err(format!("field {} has an unnamed enum value", edit.field_id)); + } + } + self.save = Some(Save { + edits, + session_id: 0, + revision: 0, + }); + self.request_begin(sequence) + } + + pub fn observe(&mut self, frame: &MAVLinkV2MessageRaw, sequence: &mut u8) -> Outcome { + let Some(target) = self.target else { + return (None, None); + }; + if frame.incompatibility_flags() != 0 || (frame.system_id(), frame.component_id()) != target + { + return (None, None); + } + if !frame.has_valid_crc::() { + return (None, None); + } + let Ok(message) = + MavMessage::parse(MavlinkVersion::V2, frame.message_id(), frame.payload()) + else { + return (None, None); + }; + let Some(response_id) = response_id(&message) else { + return (None, None); + }; + let Some(pending) = self.pending.take() else { + return (None, None); + }; + if pending.id != response_id { + self.pending = Some(pending); + return (None, None); + } + + match (pending.operation, message) { + (Operation::Info, MavMessage::KAPTAN_CONFIG_INFO(data)) => { + self.accept_info(data, sequence) + } + (Operation::Descriptor(index), MavMessage::KAPTAN_CONFIG_DESCRIPTOR(data)) => { + self.accept_descriptor(index, data, sequence) + } + ( + Operation::EnumOption { + descriptor_index, + option_index, + option_count, + }, + MavMessage::KAPTAN_CONFIG_ENUM_OPTION(data), + ) => self.accept_enum_option( + descriptor_index, + option_index, + option_count, + data, + sequence, + ), + (Operation::Value(index), MavMessage::KAPTAN_CONFIG_VALUE(data)) => { + self.accept_value(index, data, sequence) + } + (operation, MavMessage::KAPTAN_CONFIG_RESULT(data)) => { + self.accept_result(operation, data, sequence) + } + _ => self.fail("controller returned an unexpected configuration response"), + } + } + + pub fn retry_due(&mut self, sequence: &mut u8) -> Outcome { + let Some(pending) = self.pending.as_mut() else { + return (None, None); + }; + if pending.sent_at.elapsed() < request_timeout(pending.operation) { + return (None, None); + } + if pending.attempts_left != 0 { + pending.attempts_left -= 1; + pending.sent_at = Instant::now(); + return (None, Some(pending.raw)); + } + let operation = pending.operation; + self.pending = None; + let detail = format!( + "configuration {} request timed out after bounded retries", + operation_name(operation) + ); + match operation { + Operation::Set(_) | Operation::Validate => self.abort_after(detail, sequence), + Operation::Commit => self.rediscovery_required(format!( + "{detail}; the commit outcome is indeterminate and the controller must be rediscovered before another Save" + )), + Operation::Abort => { + let original = self.failure_after_abort.take().unwrap_or(detail); + self.rediscovery_required(format!( + "{original}; transaction cleanup timed out and the controller must be rediscovered before another Save" + )) + } + Operation::Info + | Operation::Descriptor(_) + | Operation::EnumOption { .. } + | Operation::Value(_) + | Operation::Begin => self.fail(detail), + } + } + + fn accept_info(&mut self, data: KAPTAN_CONFIG_INFO_DATA, sequence: &mut u8) -> Outcome { + if data.protocol_major != PROTOCOL_MAJOR || data.protocol_minor != PROTOCOL_MINOR { + return self.fail(format!( + "unsupported configuration protocol {}.{}", + data.protocol_major, data.protocol_minor + )); + } + self.info = Some(RemoteInfo { + schema_id: data.schema_id, + state: config_state(data.config_state), + }); + self.field_count = data.field_count; + self.fields.clear(); + if data.field_count == 0 { + return self.ready(); + } + follow_up(self.request_descriptor(0, sequence)) + } + + fn accept_descriptor( + &mut self, + expected: u16, + data: KAPTAN_CONFIG_DESCRIPTOR_DATA, + sequence: &mut u8, + ) -> Outcome { + if data.descriptor_index != expected || data.field_id == 0 || data.field_id == u16::MAX { + return self.fail("controller returned an invalid descriptor identity"); + } + if data.flags & !(DESCRIPTOR_HAS_MINIMUM | DESCRIPTOR_HAS_MAXIMUM) != 0 { + return self.fail("controller returned unsupported descriptor flags"); + } + let key = match wire_text(&data.display_key) { + Ok(key) if !key.is_empty() => key, + Ok(_) => return self.fail("controller returned an empty configuration key"), + Err(error) => return self.fail(error), + }; + let unit = match wire_text(&data.unit) { + Ok(unit) => unit, + Err(error) => return self.fail(error), + }; + if self + .fields + .iter() + .any(|field| field.id == data.field_id || field.key == key) + { + return self.fail("controller returned duplicate configuration metadata"); + } + let kind = value_kind(data.value_kind); + if data.enum_option_count != 0 && kind != ValueKind::U32 { + return self.fail("controller returned enum options for a non-enum scalar kind"); + } + let default = match scalar_from_bits(kind, data.default_bits) { + Ok(value) => value, + Err(error) => return self.fail(error), + }; + let minimum = if data.flags & DESCRIPTOR_HAS_MINIMUM != 0 { + match scalar_from_bits(kind, data.minimum_bits) { + Ok(value) => Some(value), + Err(error) => return self.fail(error), + } + } else { + None + }; + let maximum = if data.flags & DESCRIPTOR_HAS_MAXIMUM != 0 { + match scalar_from_bits(kind, data.maximum_bits) { + Ok(value) => Some(value), + Err(error) => return self.fail(error), + } + } else { + None + }; + if minimum.is_some_and(|minimum| { + maximum.is_some_and(|maximum| scalar_less_than(maximum, minimum)) + }) || minimum.is_some_and(|minimum| scalar_less_than(default, minimum)) + || maximum.is_some_and(|maximum| scalar_less_than(maximum, default)) + { + return self.fail("controller returned inconsistent descriptor bounds"); + } + self.fields.push(RemoteField { + id: data.field_id, + key, + unit, + kind, + minimum, + maximum, + default, + value: default, + options: Vec::with_capacity(usize::from(data.enum_option_count)), + }); + if data.enum_option_count == 0 { + self.request_after_descriptor(expected, sequence) + } else { + follow_up(self.request_enum_option(expected, 0, data.enum_option_count, sequence)) + } + } + + fn accept_enum_option( + &mut self, + descriptor_index: u16, + option_index: u8, + option_count: u8, + data: KAPTAN_CONFIG_ENUM_OPTION_DATA, + sequence: &mut u8, + ) -> Outcome { + let label = match wire_text(&data.label) { + Ok(label) if !label.is_empty() => label, + Ok(_) => return self.fail("controller returned an empty enum option label"), + Err(error) => return self.fail(error), + }; + let Some(field) = self.fields.get(usize::from(descriptor_index)) else { + return self.fail("controller returned an enum option without its descriptor"); + }; + if data.descriptor_index != descriptor_index + || data.field_id != field.id + || data.option_index != option_index + || field.options.len() != usize::from(option_index) + { + return self.fail("controller returned an enum option with the wrong identity"); + } + let value = Scalar::U32(u32::from(data.value)); + if field + .minimum + .is_some_and(|minimum| scalar_less_than(value, minimum)) + || field + .maximum + .is_some_and(|maximum| scalar_less_than(maximum, value)) + || field + .options + .iter() + .any(|option| option.value == value || option.label == label) + { + return self.fail("controller returned an inconsistent enum option"); + } + self.fields[usize::from(descriptor_index)] + .options + .push(RemoteEnumOption { value, label }); + + let next = option_index + 1; + if next < option_count { + follow_up(self.request_enum_option(descriptor_index, next, option_count, sequence)) + } else { + let field = &self.fields[usize::from(descriptor_index)]; + if !field + .options + .iter() + .any(|option| option.value == field.default) + { + return self.fail("controller returned enum options without its default value"); + } + self.request_after_descriptor(descriptor_index, sequence) + } + } + + fn request_after_descriptor(&mut self, current: u16, sequence: &mut u8) -> Outcome { + let next = current + 1; + if next < self.field_count { + follow_up(self.request_descriptor(next, sequence)) + } else { + follow_up(self.request_value(0, sequence)) + } + } + + fn accept_value( + &mut self, + index: usize, + data: KAPTAN_CONFIG_VALUE_DATA, + sequence: &mut u8, + ) -> Outcome { + let Some(field) = self.fields.get_mut(index) else { + return self.fail("controller returned a value outside its descriptor set"); + }; + let kind = value_kind(data.value_kind); + if data.field_id != field.id + || kind != field.kind + || data.session_id != 0 + || data.revision != 0 + { + return self.fail("controller returned a value that disagrees with its descriptor"); + } + field.value = match scalar_from_bits(kind, data.value_bits) { + Ok(value) => value, + Err(error) => return self.fail(error), + }; + if !field.options.is_empty() + && !field + .options + .iter() + .any(|option| option.value == field.value) + { + return self.fail("controller returned an unnamed enum value"); + } + let next = index + 1; + if next < self.fields.len() { + follow_up(self.request_value(next, sequence)) + } else { + self.ready() + } + } + + fn accept_result( + &mut self, + operation: Operation, + data: kaptan_mavlink::message::kaptan::KAPTAN_CONFIG_RESULT_DATA, + sequence: &mut u8, + ) -> Outcome { + if operation == Operation::Abort { + return self.accept_abort(data); + } + if data.result == KaptanConfigResult::KAPTAN_CONFIG_RESULT_REJECTED { + let detail = format!( + "configuration {:?} rejected: {:?} (field {}, related {}, rule {})", + data.operation, + data.error, + data.primary_field, + data.related_field, + data.validation_rule + ); + if operation == Operation::Begin + && data.operation == KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN + && data.error == KaptanConfigError::KAPTAN_CONFIG_ERROR_SESSION_BUSY + && data.session_id != 0 + { + let Some(save) = self.save.as_mut() else { + return self.fail(detail); + }; + save.session_id = data.session_id; + save.revision = data.revision; + return self.abort_after(detail, sequence); + } + if operation == Operation::Commit + && data.error == KaptanConfigError::KAPTAN_CONFIG_ERROR_STORAGE_FAILURE + && self.save.as_ref().is_some_and(|save| save.session_id != 0) + { + return self.abort_then_rediscover( + format!( + "{detail}; persistence may have completed despite the storage error, so power-cycle and rediscover before another Save" + ), + sequence, + ); + } + if matches!( + operation, + Operation::Set(_) | Operation::Validate | Operation::Commit + ) && self.save.as_ref().is_some_and(|save| save.session_id != 0) + { + return self.abort_after(detail, sequence); + } + return self.fail(detail); + } + if data.error != KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE { + return self.fail_operation( + operation, + "controller returned an error with a successful result", + sequence, + ); + } + match operation { + Operation::Begin + if data.operation == KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN + && data.result == KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED => + { + let Some(save) = self.save.as_mut() else { + return self.fail("missing save transaction"); + }; + if data.session_id == 0 || data.revision != 0 { + if data.session_id != 0 { + save.session_id = data.session_id; + save.revision = data.revision; + return self.abort_after( + "controller returned an invalid transaction lease".into(), + sequence, + ); + } + return self.fail("controller returned an invalid transaction lease"); + } + save.session_id = data.session_id; + if save.edits.is_empty() { + follow_up(self.request_validate(sequence)) + } else { + follow_up(self.request_set(0, sequence)) + } + } + Operation::Set(index) + if data.operation == KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_SET + && data.result == KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED => + { + let (next_edit, edit_count) = { + let Some(save) = self.save.as_mut() else { + return self.fail("missing save transaction"); + }; + if data.session_id != save.session_id || data.revision != save.revision + 1 { + return self.abort_after( + "controller returned an invalid transaction revision".into(), + sequence, + ); + } + save.revision = data.revision; + (index + 1, save.edits.len()) + }; + if next_edit < edit_count { + follow_up(self.request_set(next_edit, sequence)) + } else { + follow_up(self.request_validate(sequence)) + } + } + Operation::Validate + if data.operation == KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE + && data.result == KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED => + { + let Some(save) = self.save.as_ref() else { + return self.fail("missing save transaction"); + }; + if data.session_id != save.session_id || data.revision != save.revision { + return self.abort_after( + "controller returned an invalid validated transaction".into(), + sequence, + ); + } + follow_up(self.request_commit(sequence)) + } + Operation::Commit + if data.operation == KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT + && data.result + == KaptanConfigResult::KAPTAN_CONFIG_RESULT_COMMITTED_RESTART_REQUIRED => + { + let Some(save) = self.save.as_ref() else { + return self.fail("missing committed transaction"); + }; + if data.session_id != 0 + || data.revision != save.revision + || data.config_state != KaptanConfigState::KAPTAN_CONFIG_STATE_RESTART_REQUIRED + { + return self.rediscovery_required( + "controller returned an invalid committed transaction; the commit outcome is indeterminate and the controller must be rediscovered before another Save", + ); + } + let Some(save) = self.save.take() else { + return self.fail("missing committed transaction"); + }; + for edit in save.edits { + if let Some(field) = self + .fields + .iter_mut() + .find(|field| field.id == edit.field_id) + { + field.value = edit.value; + } + } + if let Some(info) = self.info.as_mut() { + info.state = "restart required".into(); + } + self.ready() + } + Operation::Commit => self.rediscovery_required( + "controller returned an invalid commit response; the commit outcome is indeterminate and the controller must be rediscovered before another Save", + ), + _ => self.fail_operation( + operation, + "controller returned a result for the wrong operation", + sequence, + ), + } + } + + fn accept_abort( + &mut self, + data: kaptan_mavlink::message::kaptan::KAPTAN_CONFIG_RESULT_DATA, + ) -> Outcome { + let original = self + .failure_after_abort + .take() + .unwrap_or_else(|| "configuration transaction cleanup was not requested".into()); + if data.operation == KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT + && data.result == KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED + && data.error == KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE + && data.session_id == 0 + { + if self.rediscover_after_abort { + return self.rediscovery_required(original); + } + return self.fail(original); + } + self.rediscovery_required(format!( + "{original}; transaction cleanup was not confirmed and the controller must be rediscovered before another Save" + )) + } + + fn fail_operation( + &mut self, + operation: Operation, + detail: impl Into, + sequence: &mut u8, + ) -> Outcome { + let detail = detail.into(); + match operation { + Operation::Set(_) | Operation::Validate => self.abort_after(detail, sequence), + Operation::Commit => self.rediscovery_required(format!( + "{detail}; the commit outcome is indeterminate and the controller must be rediscovered before another Save" + )), + _ => self.fail(detail), + } + } + + fn abort_after(&mut self, detail: String, sequence: &mut u8) -> Outcome { + if self.save.as_ref().is_none_or(|save| save.session_id == 0) { + return self.fail(detail); + } + self.failure_after_abort = Some(detail); + self.rediscover_after_abort = false; + match self.request_abort(sequence) { + Ok(request) => (None, Some(request)), + Err(error) => { + let original = self.failure_after_abort.take().unwrap_or_default(); + self.rediscovery_required(format!( + "{original}; cannot request transaction cleanup: {error}; the controller must be rediscovered before another Save" + )) + } + } + } + + fn abort_then_rediscover(&mut self, detail: String, sequence: &mut u8) -> Outcome { + if self.save.as_ref().is_none_or(|save| save.session_id == 0) { + return self.rediscovery_required(detail); + } + self.failure_after_abort = Some(detail); + self.rediscover_after_abort = true; + match self.request_abort(sequence) { + Ok(request) => (None, Some(request)), + Err(error) => { + let original = self.failure_after_abort.take().unwrap_or_default(); + self.rediscovery_required(format!( + "{original}; cannot request transaction cleanup: {error}" + )) + } + } + } + + fn ready(&self) -> Outcome { + let Some(info) = self.info.clone() else { + return ( + Some(ClientEvent::Failure("configuration INFO is missing".into())), + None, + ); + }; + ( + Some(ClientEvent::Configuration(RemoteConfig { + info, + fields: self.fields.clone(), + })), + None, + ) + } + + fn fail(&mut self, detail: impl Into) -> Outcome { + self.pending = None; + self.save = None; + self.failure_after_abort = None; + self.rediscover_after_abort = false; + (Some(ClientEvent::Failure(detail.into())), None) + } + + fn rediscovery_required(&mut self, detail: impl Into) -> Outcome { + let detail = detail.into(); + self.reset(); + (Some(ClientEvent::RediscoveryRequired(detail)), None) + } + + fn request_info(&mut self, sequence: &mut u8) -> Result { + let target = self.target()?; + let id = self.request_id(); + Ok(self.send( + Operation::Info, + id, + MavMessage::KAPTAN_CONFIG_INFO_REQUEST(KAPTAN_CONFIG_INFO_REQUEST_DATA { + request_id: id, + target_system: target.0, + target_component: target.1, + }), + sequence, + )) + } + + fn request_descriptor( + &mut self, + index: u16, + sequence: &mut u8, + ) -> Result { + let target = self.target()?; + let id = self.request_id(); + Ok(self.send( + Operation::Descriptor(index), + id, + MavMessage::KAPTAN_CONFIG_DESCRIPTOR_REQUEST(KAPTAN_CONFIG_DESCRIPTOR_REQUEST_DATA { + request_id: id, + descriptor_index: index, + target_system: target.0, + target_component: target.1, + }), + sequence, + )) + } + + fn request_value( + &mut self, + index: usize, + sequence: &mut u8, + ) -> Result { + let target = self.target()?; + let field_id = self + .fields + .get(index) + .ok_or("missing descriptor for value request")? + .id; + let id = self.request_id(); + Ok(self.send( + Operation::Value(index), + id, + MavMessage::KAPTAN_CONFIG_VALUE_REQUEST(KAPTAN_CONFIG_VALUE_REQUEST_DATA { + request_id: id, + session_id: 0, + field_id, + source: KaptanConfigValueSource::KAPTAN_CONFIG_VALUE_SOURCE_COMMITTED, + target_system: target.0, + target_component: target.1, + }), + sequence, + )) + } + + fn request_enum_option( + &mut self, + descriptor_index: u16, + option_index: u8, + option_count: u8, + sequence: &mut u8, + ) -> Result { + let target = self.target()?; + let id = self.request_id(); + Ok(self.send( + Operation::EnumOption { + descriptor_index, + option_index, + option_count, + }, + id, + MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(KAPTAN_CONFIG_ENUM_OPTION_REQUEST_DATA { + request_id: id, + descriptor_index, + option_index, + target_system: target.0, + target_component: target.1, + }), + sequence, + )) + } + + fn request_begin(&mut self, sequence: &mut u8) -> Result { + let target = self.target()?; + self.save.as_ref().ok_or("missing save transaction")?; + let id = self.request_id(); + Ok(self.send( + Operation::Begin, + id, + MavMessage::KAPTAN_CONFIG_BEGIN(KAPTAN_CONFIG_BEGIN_DATA { + request_id: id, + target_system: target.0, + target_component: target.1, + }), + sequence, + )) + } + + fn request_set( + &mut self, + index: usize, + sequence: &mut u8, + ) -> Result { + let target = self.target()?; + let (edit, session_id, revision) = { + let save = self.save.as_ref().ok_or("missing save transaction")?; + ( + *save.edits.get(index).ok_or("missing configuration edit")?, + save.session_id, + save.revision, + ) + }; + let id = self.request_id(); + let message = match edit.value { + Scalar::Bool(value) => { + MavMessage::KAPTAN_CONFIG_SET_BOOL(KAPTAN_CONFIG_SET_BOOL_DATA { + request_id: id, + session_id, + expected_revision: revision, + field_id: edit.field_id, + value: u8::from(value), + target_system: target.0, + target_component: target.1, + }) + } + Scalar::I32(value) => MavMessage::KAPTAN_CONFIG_SET_I32(KAPTAN_CONFIG_SET_I32_DATA { + value, + request_id: id, + session_id, + expected_revision: revision, + field_id: edit.field_id, + target_system: target.0, + target_component: target.1, + }), + Scalar::U32(value) => MavMessage::KAPTAN_CONFIG_SET_U32(KAPTAN_CONFIG_SET_U32_DATA { + value, + request_id: id, + session_id, + expected_revision: revision, + field_id: edit.field_id, + target_system: target.0, + target_component: target.1, + }), + Scalar::U64(value) => MavMessage::KAPTAN_CONFIG_SET_U64(KAPTAN_CONFIG_SET_U64_DATA { + value, + request_id: id, + session_id, + expected_revision: revision, + field_id: edit.field_id, + target_system: target.0, + target_component: target.1, + }), + Scalar::F32(value) => MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA { + value, + request_id: id, + session_id, + expected_revision: revision, + field_id: edit.field_id, + target_system: target.0, + target_component: target.1, + }), + Scalar::F64(value) => MavMessage::KAPTAN_CONFIG_SET_F64(KAPTAN_CONFIG_SET_F64_DATA { + value, + request_id: id, + session_id, + expected_revision: revision, + field_id: edit.field_id, + target_system: target.0, + target_component: target.1, + }), + }; + Ok(self.send(Operation::Set(index), id, message, sequence)) + } + + fn request_validate(&mut self, sequence: &mut u8) -> Result { + let target = self.target()?; + let save = self.save.as_ref().ok_or("missing save transaction")?; + let (session_id, revision) = (save.session_id, save.revision); + let id = self.request_id(); + Ok(self.send( + Operation::Validate, + id, + MavMessage::KAPTAN_CONFIG_VALIDATE(KAPTAN_CONFIG_VALIDATE_DATA { + request_id: id, + session_id, + expected_revision: revision, + target_system: target.0, + target_component: target.1, + }), + sequence, + )) + } + + fn request_commit(&mut self, sequence: &mut u8) -> Result { + let target = self.target()?; + let save = self.save.as_ref().ok_or("missing save transaction")?; + let (session_id, revision) = (save.session_id, save.revision); + let id = self.request_id(); + Ok(self.send( + Operation::Commit, + id, + MavMessage::KAPTAN_CONFIG_COMMIT(KAPTAN_CONFIG_COMMIT_DATA { + request_id: id, + session_id, + expected_revision: revision, + target_system: target.0, + target_component: target.1, + }), + sequence, + )) + } + + fn request_abort(&mut self, sequence: &mut u8) -> Result { + let target = self.target()?; + let session_id = self + .save + .as_ref() + .filter(|save| save.session_id != 0) + .ok_or("missing configuration transaction lease")? + .session_id; + let id = self.request_id(); + Ok(self.send( + Operation::Abort, + id, + MavMessage::KAPTAN_CONFIG_ABORT(KAPTAN_CONFIG_ABORT_DATA { + request_id: id, + session_id, + target_system: target.0, + target_component: target.1, + }), + sequence, + )) + } + + fn target(&self) -> Result<(u8, u8), String> { + self.target + .ok_or_else(|| "configuration target is unknown".into()) + } + + fn request_id(&mut self) -> u32 { + let id = self.next_request_id; + self.next_request_id = self.next_request_id.wrapping_add(1).max(1); + id + } + + fn send( + &mut self, + operation: Operation, + id: u32, + message: M, + sequence: &mut u8, + ) -> MAVLinkV2MessageRaw { + let mut raw = MAVLinkV2MessageRaw::new(); + raw.serialize_message( + MavHeader { + sequence: *sequence, + system_id: CLIENT_SYSTEM_ID, + component_id: CLIENT_COMPONENT_ID, + }, + &message, + ); + *sequence = sequence.wrapping_add(1); + self.pending = Some(Pending { + id, + operation, + raw, + sent_at: Instant::now(), + attempts_left: REQUEST_ATTEMPTS - 1, + }); + raw + } +} + +const fn request_timeout(operation: Operation) -> Duration { + match operation { + Operation::Commit => COMMIT_TIMEOUT, + Operation::Info + | Operation::Descriptor(_) + | Operation::EnumOption { .. } + | Operation::Value(_) + | Operation::Begin + | Operation::Set(_) + | Operation::Validate + | Operation::Abort => REQUEST_TIMEOUT, + } +} + +const fn operation_name(operation: Operation) -> &'static str { + match operation { + Operation::Info => "INFO", + Operation::Descriptor(_) => "DESCRIPTOR", + Operation::EnumOption { .. } => "ENUM_OPTION", + Operation::Value(_) => "VALUE", + Operation::Begin => "BEGIN", + Operation::Set(_) => "SET", + Operation::Validate => "VALIDATE", + Operation::Commit => "COMMIT", + Operation::Abort => "ABORT", + } +} + +fn follow_up(request: Result) -> Outcome { + match request { + Ok(request) => (None, Some(request)), + Err(error) => (Some(ClientEvent::Failure(error)), None), + } +} + +fn response_id(message: &MavMessage) -> Option { + match message { + MavMessage::KAPTAN_CONFIG_INFO(data) => Some(data.request_id), + MavMessage::KAPTAN_CONFIG_DESCRIPTOR(data) => Some(data.request_id), + MavMessage::KAPTAN_CONFIG_ENUM_OPTION(data) => Some(data.request_id), + MavMessage::KAPTAN_CONFIG_VALUE(data) => Some(data.request_id), + MavMessage::KAPTAN_CONFIG_RESULT(data) => Some(data.request_id), + _ => None, + } +} + +fn value_kind(kind: KaptanConfigValueKind) -> ValueKind { + match kind { + KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_BOOL => ValueKind::Bool, + KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_I32 => ValueKind::I32, + KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32 => ValueKind::U32, + KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U64 => ValueKind::U64, + KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_F32 => ValueKind::F32, + KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_F64 => ValueKind::F64, + } +} + +fn config_state(state: KaptanConfigState) -> String { + match state { + KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE => "active", + KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING => "editing", + KaptanConfigState::KAPTAN_CONFIG_STATE_RESTART_REQUIRED => "restart required", + } + .into() +} + +fn wire_text(bytes: &[u8; N]) -> Result { + let length = bytes.iter().position(|byte| *byte == 0).unwrap_or(N); + std::str::from_utf8(&bytes[..length]) + .map(str::to_owned) + .map_err(|_| "controller returned non-UTF-8 descriptor text".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TARGET: (u8, u8) = (42, 9); + + fn response(message: MavMessage) -> MAVLinkV2MessageRaw { + let mut raw = MAVLinkV2MessageRaw::new(); + raw.serialize_message( + MavHeader { + sequence: 0, + system_id: TARGET.0, + component_id: TARGET.1, + }, + &message, + ); + raw + } + + fn request_id(raw: &MAVLinkV2MessageRaw) -> u32 { + let message = + MavMessage::parse(MavlinkVersion::V2, raw.message_id(), raw.payload()).unwrap(); + match message { + MavMessage::KAPTAN_CONFIG_ABORT(data) => data.request_id, + MavMessage::KAPTAN_CONFIG_BEGIN(data) => data.request_id, + MavMessage::KAPTAN_CONFIG_SET_U32(data) => data.request_id, + MavMessage::KAPTAN_CONFIG_VALIDATE(data) => data.request_id, + MavMessage::KAPTAN_CONFIG_COMMIT(data) => data.request_id, + _ => panic!("unexpected request in save transcript"), + } + } + + fn rejected_result( + request_id: u32, + operation: KaptanConfigOperation, + session_id: u32, + revision: u32, + error: KaptanConfigError, + ) -> MAVLinkV2MessageRaw { + response(MavMessage::KAPTAN_CONFIG_RESULT( + kaptan_mavlink::message::kaptan::KAPTAN_CONFIG_RESULT_DATA { + request_id, + session_id, + revision, + primary_field: 401, + related_field: 0, + validation_rule: 0, + operation, + result: KaptanConfigResult::KAPTAN_CONFIG_RESULT_REJECTED, + error, + config_state: KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, + }, + )) + } + + fn configured_client() -> (ConfigClient, u8) { + let mut client = ConfigClient::new(); + client.target = Some(TARGET); + client.info = Some(RemoteInfo { + schema_id: *b"UNKNOWN-SCHEMA01", + state: "active".into(), + }); + client.field_count = 1; + client.fields.push(RemoteField { + id: 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), + options: Vec::new(), + }); + (client, 0) + } + + fn open_save(client: &mut ConfigClient, sequence: &mut u8) -> MAVLinkV2MessageRaw { + let begin = client + .save( + vec![Edit { + field_id: 401, + value: Scalar::U32(30), + }], + sequence, + ) + .unwrap(); + let accepted = result( + request_id(&begin), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN, + KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, + KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, + 50, + 0, + ); + let (event, set) = client.observe(&accepted, sequence); + assert!(event.is_none()); + set.expect("accepted BEGIN must advance to SET") + } + + fn accept_set( + client: &mut ConfigClient, + sequence: &mut u8, + set: &MAVLinkV2MessageRaw, + ) -> MAVLinkV2MessageRaw { + let accepted = result( + request_id(set), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_SET, + KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, + KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, + 50, + 1, + ); + let (event, validate) = client.observe(&accepted, sequence); + assert!(event.is_none()); + validate.expect("accepted SET must advance to VALIDATE") + } + + fn assert_abort(request: &MAVLinkV2MessageRaw, session_id: u32) { + let MavMessage::KAPTAN_CONFIG_ABORT(data) = + MavMessage::parse(MavlinkVersion::V2, request.message_id(), request.payload()).unwrap() + else { + panic!("cleanup request was not ABORT"); + }; + assert_eq!(data.session_id, session_id); + assert_eq!((data.target_system, data.target_component), TARGET); + } + + fn accept_abort( + client: &mut ConfigClient, + sequence: &mut u8, + abort: &MAVLinkV2MessageRaw, + revision: u32, + ) -> String { + let accepted = result( + request_id(abort), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT, + KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, + KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE, + 0, + revision, + ); + let (event, next) = client.observe(&accepted, sequence); + assert!(next.is_none()); + let Some(ClientEvent::Failure(detail)) = event else { + panic!("confirmed cleanup must report the original failure"); + }; + detail + } + + fn result( + request_id: u32, + operation: KaptanConfigOperation, + result: KaptanConfigResult, + state: KaptanConfigState, + session_id: u32, + revision: u32, + ) -> MAVLinkV2MessageRaw { + response(MavMessage::KAPTAN_CONFIG_RESULT( + kaptan_mavlink::message::kaptan::KAPTAN_CONFIG_RESULT_DATA { + request_id, + session_id, + revision, + primary_field: 0, + related_field: 0, + validation_rule: 0, + operation, + result, + error: KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE, + config_state: state, + }, + )) + } + + #[test] + fn scalar_bits_preserve_every_wire_type() { + assert_eq!(scalar_from_bits(ValueKind::Bool, 1), Ok(Scalar::Bool(true))); + assert_eq!( + scalar_from_bits(ValueKind::I32, (-17_i32) as u32 as u64), + Ok(Scalar::I32(-17)) + ); + assert_eq!(scalar_from_bits(ValueKind::U32, 17), Ok(Scalar::U32(17))); + assert_eq!( + scalar_from_bits(ValueKind::U64, u64::MAX), + Ok(Scalar::U64(u64::MAX)) + ); + assert_eq!( + scalar_from_bits(ValueKind::F32, 1.25_f32.to_bits() as u64), + Ok(Scalar::F32(1.25)) + ); + assert_eq!( + scalar_from_bits(ValueKind::F64, 1.25_f64.to_bits()), + Ok(Scalar::F64(1.25)) + ); + assert_eq!( + scalar_json(Scalar::U64(u64::MAX)), + serde_json::Value::String(u64::MAX.to_string()) + ); + assert!(scalar_from_bits(ValueKind::F32, f32::NAN.to_bits().into()).is_err()); + } + + #[test] + fn client_has_no_compiled_board_or_schema_identity() { + let client = ConfigClient::new(); + assert!(client.target.is_none()); + assert!(client.info.is_none()); + assert!(client.fields.is_empty()); + } + + #[test] + fn commit_timeout_covers_the_targets_synchronous_flash_erase() { + assert_eq!(request_timeout(Operation::Commit), Duration::from_secs(5)); + assert_eq!(request_timeout(Operation::Validate), REQUEST_TIMEOUT); + } + + #[test] + fn rejected_set_is_aborted_before_the_original_failure_is_reported() { + let (mut client, mut sequence) = configured_client(); + let set = open_save(&mut client, &mut sequence); + let rejected = rejected_result( + request_id(&set), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_SET, + 50, + 0, + KaptanConfigError::KAPTAN_CONFIG_ERROR_ABOVE_MAXIMUM, + ); + let (event, abort) = client.observe(&rejected, &mut sequence); + assert!(event.is_none()); + let abort = abort.expect("rejected SET must be followed by ABORT"); + assert_abort(&abort, 50); + // ABORT has no expected-revision precondition. The target's current + // revision is informative and may be newer than the last accepted SET. + let detail = accept_abort(&mut client, &mut sequence, &abort, 9); + assert!(detail.contains("KAPTAN_CONFIG_ERROR_ABOVE_MAXIMUM")); + assert!(open_save(&mut client, &mut sequence).message_id() != 0); + } + + #[test] + fn rejected_validate_is_aborted_at_the_last_known_revision() { + let (mut client, mut sequence) = configured_client(); + let set = open_save(&mut client, &mut sequence); + let validate = accept_set(&mut client, &mut sequence, &set); + let rejected = rejected_result( + request_id(&validate), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE, + 50, + 1, + KaptanConfigError::KAPTAN_CONFIG_ERROR_CROSS_FIELD, + ); + let (event, abort) = client.observe(&rejected, &mut sequence); + assert!(event.is_none()); + let abort = abort.expect("rejected VALIDATE must be followed by ABORT"); + assert_abort(&abort, 50); + let detail = accept_abort(&mut client, &mut sequence, &abort, 1); + assert!(detail.contains("KAPTAN_CONFIG_ERROR_CROSS_FIELD")); + } + + #[test] + fn same_principal_busy_begin_uses_the_returned_lease_for_cleanup() { + let (mut client, mut sequence) = configured_client(); + let begin = client + .save( + vec![Edit { + field_id: 401, + value: Scalar::U32(30), + }], + &mut sequence, + ) + .unwrap(); + let rejected = rejected_result( + request_id(&begin), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN, + 77, + 4, + KaptanConfigError::KAPTAN_CONFIG_ERROR_SESSION_BUSY, + ); + let (event, abort) = client.observe(&rejected, &mut sequence); + assert!(event.is_none()); + let abort = abort.expect("owned busy lease must be aborted"); + assert_abort(&abort, 77); + let detail = accept_abort(&mut client, &mut sequence, &abort, 4); + assert!(detail.contains("KAPTAN_CONFIG_ERROR_SESSION_BUSY")); + } + + #[test] + fn exhausted_set_retries_abort_but_commit_retries_require_rediscovery() { + let (mut client, mut sequence) = configured_client(); + let _set = open_save(&mut client, &mut sequence); + let pending = client.pending.as_mut().unwrap(); + pending.attempts_left = 0; + pending.sent_at = Instant::now() - REQUEST_TIMEOUT; + let (event, abort) = client.retry_due(&mut sequence); + assert!(event.is_none()); + let abort = abort.expect("known timed-out SET session must be aborted"); + assert_abort(&abort, 50); + let detail = accept_abort(&mut client, &mut sequence, &abort, 0); + assert!(detail.contains("SET request timed out")); + + let (mut client, mut sequence) = configured_client(); + let set = open_save(&mut client, &mut sequence); + let validate = accept_set(&mut client, &mut sequence, &set); + let accepted = result( + request_id(&validate), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE, + KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, + KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, + 50, + 1, + ); + let (event, commit) = client.observe(&accepted, &mut sequence); + assert!(event.is_none()); + assert!(commit.is_some()); + let pending = client.pending.as_mut().unwrap(); + pending.attempts_left = 0; + pending.sent_at = Instant::now() - COMMIT_TIMEOUT; + let (event, next) = client.retry_due(&mut sequence); + assert!(next.is_none(), "an uncertain COMMIT must never be aborted"); + let Some(ClientEvent::RediscoveryRequired(detail)) = event else { + panic!("an uncertain COMMIT must invalidate discovery"); + }; + assert!(detail.contains("commit outcome is indeterminate")); + assert!(client.target.is_none()); + assert!(client.fields.is_empty()); + assert!(client.save.is_none()); + } + + #[test] + fn storage_failed_commit_cleans_up_but_remains_indeterminate() { + let (mut client, mut sequence) = configured_client(); + let set = open_save(&mut client, &mut sequence); + let validate = accept_set(&mut client, &mut sequence, &set); + let accepted = result( + request_id(&validate), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE, + KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, + KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, + 50, + 1, + ); + let (event, commit) = client.observe(&accepted, &mut sequence); + assert!(event.is_none()); + let commit = commit.unwrap(); + let rejected = rejected_result( + request_id(&commit), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT, + 50, + 1, + KaptanConfigError::KAPTAN_CONFIG_ERROR_STORAGE_FAILURE, + ); + let (event, abort) = client.observe(&rejected, &mut sequence); + assert!(event.is_none()); + let abort = abort.expect("storage-failed COMMIT must clean up its RAM lease"); + assert_abort(&abort, 50); + + let accepted = result( + request_id(&abort), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT, + KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, + KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE, + 0, + 1, + ); + let (event, next) = client.observe(&accepted, &mut sequence); + assert!(next.is_none()); + let Some(ClientEvent::RediscoveryRequired(detail)) = event else { + panic!("storage failure must not be presented as a definitive commit rejection"); + }; + assert!(detail.contains("power-cycle and rediscover")); + assert!(client.target.is_none()); + } + + #[test] + fn enum_options_are_discovered_one_at_a_time_without_a_host_catalog() { + let mut client = ConfigClient::new(); + let mut sequence = 0; + client.discover(TARGET, &mut sequence).unwrap(); + + let info = response(MavMessage::KAPTAN_CONFIG_INFO(KAPTAN_CONFIG_INFO_DATA { + request_id: 1, + schema_id: *b"UNKNOWN-SCHEMA01", + field_count: 1, + protocol_major: PROTOCOL_MAJOR, + protocol_minor: PROTOCOL_MINOR, + config_state: KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE, + })); + let (_, descriptor_request) = client.observe(&info, &mut sequence); + assert!(descriptor_request.is_some()); + + let descriptor = response(MavMessage::KAPTAN_CONFIG_DESCRIPTOR( + KAPTAN_CONFIG_DESCRIPTOR_DATA { + minimum_bits: 5, + maximum_bits: 6, + default_bits: 6, + request_id: 2, + descriptor_index: 0, + field_id: 108, + enum_option_count: 2, + display_key: "future.logging.severity".into(), + unit: "MAV_SEVERITY".into(), + value_kind: KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32, + flags: DESCRIPTOR_HAS_MINIMUM | DESCRIPTOR_HAS_MAXIMUM, + }, + )); + let (event, first_request) = client.observe(&descriptor, &mut sequence); + assert!(event.is_none()); + let first_request = first_request.expect("first enum option is requested"); + let MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(first) = MavMessage::parse( + MavlinkVersion::V2, + first_request.message_id(), + first_request.payload(), + ) + .unwrap() else { + panic!("descriptor with choices must request an enum option"); + }; + assert_eq!((first.descriptor_index, first.option_index), (0, 0)); + + let first_option = response(MavMessage::KAPTAN_CONFIG_ENUM_OPTION( + KAPTAN_CONFIG_ENUM_OPTION_DATA { + value: 5, + request_id: first.request_id, + descriptor_index: 0, + field_id: 108, + option_index: 0, + label: "Notice".into(), + }, + )); + let (event, second_request) = client.observe(&first_option, &mut sequence); + assert!(event.is_none()); + let second_request = second_request.expect("second enum option is requested"); + let MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(second) = MavMessage::parse( + MavlinkVersion::V2, + second_request.message_id(), + second_request.payload(), + ) + .unwrap() else { + panic!("enum discovery must remain stop-and-wait"); + }; + assert_eq!((second.descriptor_index, second.option_index), (0, 1)); + + let second_option = response(MavMessage::KAPTAN_CONFIG_ENUM_OPTION( + KAPTAN_CONFIG_ENUM_OPTION_DATA { + value: 6, + request_id: second.request_id, + descriptor_index: 0, + field_id: 108, + option_index: 1, + label: "Info".into(), + }, + )); + let (event, value_request) = client.observe(&second_option, &mut sequence); + assert!(event.is_none()); + let value_request = value_request.expect("enum discovery advances to its value"); + let value_request_id = match MavMessage::parse( + MavlinkVersion::V2, + value_request.message_id(), + value_request.payload(), + ) + .unwrap() + { + MavMessage::KAPTAN_CONFIG_VALUE_REQUEST(data) => data.request_id, + _ => panic!("enum discovery must advance to VALUE_REQUEST"), + }; + let value = response(MavMessage::KAPTAN_CONFIG_VALUE(KAPTAN_CONFIG_VALUE_DATA { + value_bits: 6, + request_id: value_request_id, + session_id: 0, + revision: 0, + field_id: 108, + value_kind: KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32, + })); + let (event, next) = client.observe(&value, &mut sequence); + assert!(next.is_none()); + let Some(ClientEvent::Configuration(configuration)) = event else { + panic!("complete enum discovery must publish the configuration"); + }; + assert_eq!(configuration.fields[0].options.len(), 2); + assert_eq!(configuration.fields[0].options[0].label, "Notice"); + assert_eq!(configuration.fields[0].options[0].value, Scalar::U32(5)); + assert_eq!(configuration.fields[0].options[1].label, "Info"); + } + + #[test] + fn discovery_rejects_an_older_incompatible_protocol_version() { + let mut client = ConfigClient::new(); + let mut sequence = 0; + client.discover(TARGET, &mut sequence).unwrap(); + let info = response(MavMessage::KAPTAN_CONFIG_INFO(KAPTAN_CONFIG_INFO_DATA { + request_id: 1, + schema_id: *b"UNKNOWN-SCHEMA01", + field_count: 0, + protocol_major: 1, + protocol_minor: 1, + config_state: KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE, + })); + + let (event, next) = client.observe(&info, &mut sequence); + assert!(next.is_none()); + let Some(ClientEvent::Failure(detail)) = event else { + panic!("incompatible minor version must fail discovery"); + }; + assert!(detail.contains("unsupported configuration protocol 1.1")); + } + + #[test] + fn unknown_schema_is_discovered_and_saved_by_descriptor_identity() { + let mut client = ConfigClient::new(); + let mut sequence = 0; + client.discover(TARGET, &mut sequence).unwrap(); + + let info = response(MavMessage::KAPTAN_CONFIG_INFO(KAPTAN_CONFIG_INFO_DATA { + request_id: 1, + schema_id: *b"UNKNOWN-SCHEMA01", + field_count: 1, + protocol_major: PROTOCOL_MAJOR, + protocol_minor: PROTOCOL_MINOR, + config_state: KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE, + })); + let (event, descriptor_request) = client.observe(&info, &mut sequence); + assert!(event.is_none()); + assert!(descriptor_request.is_some()); + + let descriptor = response(MavMessage::KAPTAN_CONFIG_DESCRIPTOR( + KAPTAN_CONFIG_DESCRIPTOR_DATA { + minimum_bits: 1, + maximum_bits: 100, + default_bits: 20, + request_id: 2, + descriptor_index: 0, + field_id: 401, + enum_option_count: 0, + display_key: "future.sensor.rate_hz".into(), + unit: "Hz".into(), + value_kind: KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32, + flags: DESCRIPTOR_HAS_MINIMUM | DESCRIPTOR_HAS_MAXIMUM, + }, + )); + let (event, value_request) = client.observe(&descriptor, &mut sequence); + assert!(event.is_none()); + assert!(value_request.is_some()); + + let value = response(MavMessage::KAPTAN_CONFIG_VALUE(KAPTAN_CONFIG_VALUE_DATA { + value_bits: 25, + request_id: 3, + session_id: 0, + revision: 0, + field_id: 401, + value_kind: KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32, + })); + let (event, next) = client.observe(&value, &mut sequence); + assert!(next.is_none()); + let ClientEvent::Configuration(configuration) = event.unwrap() else { + panic!("discovery did not produce a configuration"); + }; + assert_eq!(configuration.fields[0].key, "future.sensor.rate_hz"); + + let begin = client + .save( + vec![Edit { + field_id: 401, + value: Scalar::U32(30), + }], + &mut sequence, + ) + .unwrap(); + let begin_result = result( + request_id(&begin), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN, + KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, + KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, + 50, + 0, + ); + let (_, set) = client.observe(&begin_result, &mut sequence); + let set = set.unwrap(); + let MavMessage::KAPTAN_CONFIG_SET_U32(set_data) = + MavMessage::parse(MavlinkVersion::V2, set.message_id(), set.payload()).unwrap() + else { + panic!("typed setter was not selected from the descriptor kind"); + }; + assert_eq!((set_data.field_id, set_data.value), (401, 30)); + + let set_result = result( + request_id(&set), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_SET, + KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, + KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, + 50, + 1, + ); + let (_, validate) = client.observe(&set_result, &mut sequence); + let validate = validate.unwrap(); + let validate_result = result( + request_id(&validate), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE, + KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, + KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, + 50, + 1, + ); + let (_, commit) = client.observe(&validate_result, &mut sequence); + let commit = commit.unwrap(); + let commit_result = result( + request_id(&commit), + KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT, + KaptanConfigResult::KAPTAN_CONFIG_RESULT_COMMITTED_RESTART_REQUIRED, + KaptanConfigState::KAPTAN_CONFIG_STATE_RESTART_REQUIRED, + 0, + 1, + ); + let (event, next) = client.observe(&commit_result, &mut sequence); + assert!(next.is_none()); + let ClientEvent::Configuration(configuration) = event.unwrap() else { + panic!("commit did not refresh the configuration"); + }; + assert_eq!(configuration.info.state, "restart required"); + assert_eq!(configuration.fields[0].value, Scalar::U32(30)); + } +} diff --git a/firmware/tools/configurator/src/lib.rs b/firmware/tools/configurator/src/lib.rs new file mode 100644 index 0000000..b44aa41 --- /dev/null +++ b/firmware/tools/configurator/src/lib.rs @@ -0,0 +1,8 @@ +//! 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; diff --git a/firmware/tools/configurator/src/log_monitor.rs b/firmware/tools/configurator/src/log_monitor.rs new file mode 100644 index 0000000..01a04cb --- /dev/null +++ b/firmware/tools/configurator/src/log_monitor.rs @@ -0,0 +1,241 @@ +//! Bounded diagnostic-event history for the browser's live log view. + +use std::{collections::VecDeque, time::SystemTime}; + +use serde::{Serialize, Serializer}; + +const MAX_LOG_ENTRIES: usize = 512; + +/// Severity attached to one decoded diagnostic event. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LogSeverity { + Emergency, + Alert, + Critical, + Error, + Warning, + Notice, + Info, + Debug, + Trace, + Unknown(u8), +} + +impl LogSeverity { + /// Decodes the stable MAV_SEVERITY numeric representation without + /// accepting values that the protocol has not defined. + pub const fn from_wire(level: u8, trace: bool) -> Self { + match level { + 0 => Self::Emergency, + 1 => Self::Alert, + 2 => Self::Critical, + 3 => Self::Error, + 4 => Self::Warning, + 5 => Self::Notice, + 6 => Self::Info, + 7 if trace => Self::Trace, + 7 => Self::Debug, + value => Self::Unknown(value), + } + } + + const fn known_label(self) -> Option<&'static str> { + match self { + Self::Emergency => Some("EMERGENCY"), + Self::Alert => Some("ALERT"), + Self::Critical => Some("CRITICAL"), + Self::Error => Some("ERROR"), + Self::Warning => Some("WARNING"), + Self::Notice => Some("NOTICE"), + Self::Info => Some("INFO"), + Self::Debug => Some("DEBUG"), + Self::Trace => Some("TRACE"), + Self::Unknown(_) => None, + } + } +} + +impl Serialize for LogSeverity { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + if let Some(label) = self.known_label() { + serializer.serialize_str(label) + } else if let Self::Unknown(value) = self { + serializer.serialize_str(&format!("UNKNOWN ({value})")) + } else { + unreachable!("every known severity has a static label") + } + } +} + +/// One diagnostic event decoded from a CRC-valid Kaptan MAVLink frame. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LogObservation { + pub system_id: u8, + pub component_id: u8, + pub severity: LogSeverity, + pub text: String, +} + +/// Read-only browser projection of the current link session's log history. +#[derive(Clone, Debug, Serialize)] +pub struct LogSnapshot { + pub session: u32, + pub discarded: u64, + pub entries: Vec, +} + +/// One retained diagnostic event, ordered by `id` within a link session. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct LogView { + pub id: u32, + pub received_at_ms: u64, + pub system_id: u8, + pub component_id: u8, + pub severity: LogSeverity, + pub text: String, +} + +#[derive(Default)] +pub struct LogMonitor { + entries: VecDeque, + session: u32, + next_id: u32, + discarded: u64, +} + +impl LogMonitor { + /// Retains every event in receive order up to the fixed host-side bound. + pub fn observe( + &mut self, + observations: impl IntoIterator, + now: SystemTime, + ) { + let received_at_ms = now + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u128::from(u64::MAX)) as u64; + for observation in observations { + if self.next_id == u32::MAX { + self.clear(); + } + if self.entries.len() == MAX_LOG_ENTRIES { + self.entries.pop_front(); + self.discarded = self.discarded.saturating_add(1); + } + self.entries.push_back(LogView { + id: self.next_id, + received_at_ms, + system_id: observation.system_id, + component_id: observation.component_id, + severity: observation.severity, + text: observation.text, + }); + self.next_id += 1; + } + } + + pub fn snapshot(&self) -> LogSnapshot { + LogSnapshot { + session: self.session, + discarded: self.discarded, + entries: self.entries.iter().cloned().collect(), + } + } + + /// Starts a fresh browser history for a new serial-link session. + pub fn clear(&mut self) { + self.entries.clear(); + self.session = self.session.wrapping_add(1); + self.next_id = 0; + self.discarded = 0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn observation(index: usize) -> LogObservation { + LogObservation { + system_id: 1, + component_id: 2, + severity: LogSeverity::Info, + text: format!("event {index}"), + } + } + + #[test] + fn retains_distinct_events_in_receive_order() { + let mut monitor = LogMonitor::default(); + let now = SystemTime::UNIX_EPOCH + Duration::from_millis(1_234); + monitor.observe([observation(0), observation(1)], now); + + let snapshot = monitor.snapshot(); + assert_eq!(snapshot.entries.len(), 2); + assert_eq!(snapshot.entries[0].id, 0); + assert_eq!(snapshot.entries[0].received_at_ms, 1_234); + assert_eq!(snapshot.entries[0].text, "event 0"); + assert_eq!(snapshot.entries[1].id, 1); + assert_eq!(snapshot.entries[1].text, "event 1"); + assert_eq!(snapshot.discarded, 0); + } + + #[test] + fn drops_only_the_oldest_entries_at_the_fixed_bound() { + let mut monitor = LogMonitor::default(); + monitor.observe( + (0..=MAX_LOG_ENTRIES).map(observation), + SystemTime::UNIX_EPOCH, + ); + + let snapshot = monitor.snapshot(); + assert_eq!(snapshot.entries.len(), MAX_LOG_ENTRIES); + assert_eq!(snapshot.entries[0].text, "event 1"); + assert_eq!(snapshot.entries[MAX_LOG_ENTRIES - 1].text, "event 512"); + assert_eq!(snapshot.discarded, 1); + } + + #[test] + fn clear_starts_a_new_link_session() { + let mut monitor = LogMonitor::default(); + monitor.observe([observation(0)], SystemTime::UNIX_EPOCH); + monitor.clear(); + + let snapshot = monitor.snapshot(); + assert_eq!(snapshot.session, 1); + assert!(snapshot.entries.is_empty()); + assert_eq!(snapshot.discarded, 0); + } + + #[test] + fn wire_severity_is_checked_and_unknown_values_remain_visible() { + assert_eq!(LogSeverity::from_wire(3, false), LogSeverity::Error); + assert_eq!(LogSeverity::from_wire(7, false), LogSeverity::Debug); + assert_eq!(LogSeverity::from_wire(7, true), LogSeverity::Trace); + assert_eq!(LogSeverity::from_wire(9, false), LogSeverity::Unknown(9)); + assert_eq!( + serde_json::to_value(LogSeverity::Unknown(9)).unwrap(), + "UNKNOWN (9)" + ); + } + + #[test] + fn identifier_exhaustion_starts_a_fresh_rendering_session() { + let mut monitor = LogMonitor { + next_id: u32::MAX, + ..LogMonitor::default() + }; + monitor.observe([observation(0)], SystemTime::UNIX_EPOCH); + + let snapshot = monitor.snapshot(); + assert_eq!(snapshot.session, 1); + assert_eq!(snapshot.entries.len(), 1); + assert_eq!(snapshot.entries[0].id, 0); + assert_eq!(snapshot.entries[0].text, "event 0"); + } +} diff --git a/firmware/tools/configurator/src/main.rs b/firmware/tools/configurator/src/main.rs new file mode 100644 index 0000000..9df6dac --- /dev/null +++ b/firmware/tools/configurator/src/main.rs @@ -0,0 +1,124 @@ +//! Local browser bridge for the target-advertised configuration service. + +use std::{net::TcpListener, sync::Arc}; + +use kaptan_configurator::{ + api::serve_connection, + serial_worker::{SerialBaud, SerialController}, + state::SharedState, +}; + +const LISTEN_ADDRESS: &str = "127.0.0.1:4861"; + +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)?; + if let Some(path) = arguments.serial_path { + serial.connect(path).map_err(std::io::Error::other)?; + } + let listener = TcpListener::bind(LISTEN_ADDRESS)?; + let address = listener.local_addr()?; + let origin = format!("http://{address}"); + eprintln!("Kaptan configurator listening at {origin}"); + for stream in listener.incoming() { + match stream { + Ok(stream) => { + if let Err(error) = serve_connection(stream, &state, &serial, &origin) { + eprintln!("HTTP request failed: {error}"); + } + } + Err(error) => eprintln!("HTTP accept failed: {error}"), + } + } + Ok(()) +} + +#[derive(Debug)] +struct Arguments { + serial_path: Option, + baud: SerialBaud, +} + +fn arguments() -> std::io::Result { + arguments_from(std::env::args().skip(1)) +} + +fn arguments_from(mut values: impl Iterator) -> std::io::Result { + let mut serial_path = None; + let mut baud = SerialBaud::DEFAULT; + let mut baud_seen = false; + while let Some(flag) = values.next() { + match flag.as_str() { + "--serial" if serial_path.is_none() => { + let path = values.next().ok_or_else(|| invalid_arguments(&flag))?; + if path.is_empty() { + return Err(invalid_arguments(&flag)); + } + serial_path = Some(path); + } + "--baud" if !baud_seen => { + let value = values.next().ok_or_else(|| invalid_arguments(&flag))?; + baud = value + .parse::() + .ok() + .and_then(SerialBaud::try_new) + .ok_or_else(|| invalid_arguments(&flag))?; + baud_seen = true; + } + _ => return Err(invalid_arguments(&flag)), + } + } + Ok(Arguments { serial_path, baud }) +} + +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]"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn web_server_uses_the_documented_fixed_loopback_port() { + assert_eq!(LISTEN_ADDRESS, "127.0.0.1:4861"); + } + + #[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"); + assert_eq!(accepted.serial_path.as_deref(), Some("/dev/ttyACM0")); + assert_eq!(accepted.baud.bits_per_second(), 115_200); + } + + #[test] + fn command_line_accepts_a_supported_baud_in_either_order() { + for values in [ + ["--baud", "57600", "--serial", "/dev/ttyUSB0"], + ["--serial", "/dev/ttyUSB0", "--baud", "57600"], + ] { + 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); + } + } + + #[test] + fn command_line_rejects_unsupported_or_duplicate_baud_arguments() { + for values in [ + vec!["--baud", "12345"], + vec!["--baud", "115200", "--baud", "57600"], + vec!["--baud"], + ] { + let rejected = arguments_from(values.into_iter().map(String::from)) + .expect_err("invalid baud arguments must be rejected"); + assert_eq!(rejected.kind(), std::io::ErrorKind::InvalidInput); + } + } +} diff --git a/firmware/tools/configurator/src/packet_monitor.rs b/firmware/tools/configurator/src/packet_monitor.rs new file mode 100644 index 0000000..0e5980b --- /dev/null +++ b/firmware/tools/configurator/src/packet_monitor.rs @@ -0,0 +1,247 @@ +//! Bounded receive-rate aggregation for the browser's MAVLink monitor. + +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; + +use serde::Serialize; + +const RATE_WINDOW: Duration = Duration::from_secs(1); +const MAX_PACKET_KINDS: usize = 128; + +/// Identity and decoded value of one CRC-valid frame received from the flight controller. +#[derive(Clone, Debug)] +pub struct PacketObservation { + pub system_id: u8, + pub component_id: u8, + pub message_id: u32, + pub name: &'static str, + pub values: serde_json::Value, +} + +/// Read-only browser projection of the bounded packet aggregate. +#[derive(Clone, Debug, Serialize)] +pub struct PacketSnapshot { + pub packets: Vec, +} + +/// One source/message tuple observed on the receive link. +#[derive(Clone, Debug, Serialize)] +pub struct PacketView { + pub system_id: u8, + pub component_id: u8, + pub message_id: u32, + pub name: &'static str, + pub count: u64, + pub frequency_hz: f64, + /// Decoded fields from the most recently received frame of this identity. + pub values: serde_json::Value, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct PacketKey { + system_id: u8, + component_id: u8, + message_id: u32, +} + +struct PacketAggregate { + name: &'static str, + count: u64, + window_count: u64, + frequency_hz: f64, + last_seen: Instant, + values: serde_json::Value, +} + +#[derive(Default)] +pub struct PacketMonitor { + entries: BTreeMap, + window_started: Option, +} + +impl PacketMonitor { + /// Records a serial receive batch without retaining individual frames. + pub fn observe( + &mut self, + observations: impl IntoIterator, + now: Instant, + ) { + self.roll_window(now); + for observation in observations { + let key = PacketKey { + system_id: observation.system_id, + component_id: observation.component_id, + message_id: observation.message_id, + }; + if !self.entries.contains_key(&key) && self.entries.len() == MAX_PACKET_KINDS { + self.evict_oldest(); + } + let entry = self.entries.entry(key).or_insert(PacketAggregate { + name: observation.name, + count: 0, + window_count: 0, + frequency_hz: 0.0, + last_seen: now, + values: serde_json::Value::Null, + }); + entry.name = observation.name; + entry.count = entry.count.saturating_add(1); + entry.window_count = entry.window_count.saturating_add(1); + entry.last_seen = now; + entry.values = observation.values; + } + } + + /// Returns the current aggregate and closes any elapsed measurement window. + pub fn snapshot(&mut self, now: Instant) -> PacketSnapshot { + self.roll_window(now); + PacketSnapshot { + packets: self + .entries + .iter() + .map(|(key, entry)| PacketView { + system_id: key.system_id, + component_id: key.component_id, + message_id: key.message_id, + name: entry.name, + count: entry.count, + frequency_hz: entry.frequency_hz, + values: entry.values.clone(), + }) + .collect(), + } + } + + pub fn clear(&mut self) { + self.entries.clear(); + self.window_started = None; + } + + fn roll_window(&mut self, now: Instant) { + let Some(started) = self.window_started else { + self.window_started = Some(now); + return; + }; + let elapsed = now.saturating_duration_since(started); + if elapsed < RATE_WINDOW { + return; + } + let seconds = elapsed.as_secs_f64(); + for entry in self.entries.values_mut() { + entry.frequency_hz = entry.window_count as f64 / seconds; + entry.window_count = 0; + } + self.window_started = Some(now); + } + + fn evict_oldest(&mut self) { + let oldest = self + .entries + .iter() + .min_by_key(|(_, entry)| entry.last_seen) + .map(|(key, _)| *key); + if let Some(key) = oldest { + self.entries.remove(&key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn packet(message_id: u32, name: &'static str) -> PacketObservation { + PacketObservation { + system_id: 42, + component_id: 7, + message_id, + name, + values: serde_json::json!({"sequence": message_id}), + } + } + + #[test] + 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(0, "HEARTBEAT"), + packet(0, "HEARTBEAT"), + packet(0, "HEARTBEAT"), + ], + start + Duration::from_millis(500), + ); + + let snapshot = monitor.snapshot(start + RATE_WINDOW); + 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); + } + + #[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); + assert_eq!( + monitor.snapshot(start + RATE_WINDOW).packets[0].frequency_hz, + 1.0 + ); + assert_eq!( + monitor.snapshot(start + RATE_WINDOW + RATE_WINDOW).packets[0].frequency_hz, + 0.0 + ); + } + + #[test] + fn retains_only_the_latest_decoded_values_for_each_identity() { + let start = Instant::now(); + let mut first = packet(51021, "KAPTAN_ERROR"); + first.values = serde_json::json!({"path": "first"}); + let mut second = packet(51021, "KAPTAN_ERROR"); + second.values = serde_json::json!({"path": "second"}); + let mut monitor = PacketMonitor::default(); + + monitor.observe([first], start); + monitor.observe([second], start + Duration::from_millis(1)); + + let snapshot = monitor.snapshot(start + Duration::from_millis(1)); + assert_eq!(snapshot.packets.len(), 1); + assert_eq!(snapshot.packets[0].count, 2); + assert_eq!(snapshot.packets[0].values["path"], "second"); + } + + #[test] + fn evicts_the_oldest_identity_at_the_fixed_capacity() { + let start = Instant::now(); + let mut monitor = PacketMonitor::default(); + for message_id in 0..u32::try_from(MAX_PACKET_KINDS).unwrap() { + monitor.observe([packet(message_id, "KNOWN")], start); + } + monitor.observe([packet(99_999, "NEW")], start + Duration::from_millis(1)); + + let snapshot = monitor.snapshot(start + Duration::from_millis(1)); + assert_eq!(snapshot.packets.len(), MAX_PACKET_KINDS); + assert!(!snapshot.packets.iter().any(|packet| packet.message_id == 0)); + assert!( + snapshot + .packets + .iter() + .any(|packet| packet.message_id == 99_999) + ); + } + + #[test] + fn clear_starts_a_new_link_session() { + let start = Instant::now(); + let mut monitor = PacketMonitor::default(); + monitor.observe([packet(0, "HEARTBEAT")], start); + monitor.clear(); + assert!(monitor.snapshot(start + RATE_WINDOW).packets.is_empty()); + } +} diff --git a/firmware/tools/configurator/src/serial_worker.rs b/firmware/tools/configurator/src/serial_worker.rs new file mode 100644 index 0000000..9eb23e4 --- /dev/null +++ b/firmware/tools/configurator/src/serial_worker.rs @@ -0,0 +1,622 @@ +//! Serial bridge between one controller and the board-agnostic protocol client. + +use std::{ + fs::{File, OpenOptions}, + io::{Read, Write}, + os::fd::AsRawFd, + os::unix::fs::OpenOptionsExt, + sync::{ + Arc, Mutex, MutexGuard, + mpsc::{Receiver, SyncSender, TryRecvError, TrySendError, sync_channel}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use crate::{ + config_client::{ClientEvent, ConfigClient}, + log_monitor::{LogObservation, LogSeverity}, + packet_monitor::PacketObservation, + state::{SaveRequest, SharedState}, +}; +use kaptan_mavlink::{ + MavlinkFrame, + message::{self, Message}, + receiver::IntegrityMavlinkReceiver, +}; +use mavlink_core::MavlinkVersion; + +const COMMAND_CAPACITY: usize = 8; +const IDLE_POLL: Duration = Duration::from_millis(20); + +/// A line rate that the host termios adapter can represent exactly. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SerialBaud(u32); + +impl SerialBaud { + /// Default recovery-link rate when the user does not select one. + pub const DEFAULT: Self = Self(115_200); + + /// Validates a numeric line rate against the host termios API. + pub const fn try_new(bits_per_second: u32) -> Option { + match bits_per_second { + 9_600 | 19_200 | 38_400 | 57_600 | 115_200 | 230_400 | 460_800 | 921_600 => { + Some(Self(bits_per_second)) + } + _ => None, + } + } + + /// 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, + 19_200 => libc::B19200, + 38_400 => libc::B38400, + 57_600 => libc::B57600, + 115_200 => libc::B115200, + 230_400 => libc::B230400, + 460_800 => libc::B460800, + 921_600 => libc::B921600, + _ => unreachable!("SerialBaud cannot contain an unsupported rate"), + } + } +} + +pub enum Command { + Connect { path: String, baud: SerialBaud }, + Disconnect, + Save(SaveRequest), + Shutdown, +} + +pub struct SerialController { + commands: SyncSender, + worker: Mutex>>, + baud: SerialBaud, +} + +impl SerialController { + pub fn new(state: Arc, baud: SerialBaud) -> Result { + let (commands, receiver) = sync_channel(COMMAND_CAPACITY); + let worker = thread::Builder::new() + .name("kaptan-config-serial".into()) + .spawn(move || worker(receiver, state)) + .map_err(|error| error.to_string())?; + Ok(Self { + commands, + worker: Mutex::new(Some(worker)), + baud, + }) + } + + /// Opens one explicit point-to-point serial device at the selected CLI rate. + pub fn connect(&self, path: String) -> Result<(), String> { + self.send(Command::Connect { + path, + baud: self.baud, + }) + } + + pub fn send(&self, command: Command) -> Result<(), String> { + self.commands + .try_send(command) + .map_err(|error| match error { + TrySendError::Full(_) => "configuration transport is busy".into(), + TrySendError::Disconnected(_) => "configuration transport is unavailable".into(), + }) + } + + pub fn shutdown(&self) { + let _ = self.commands.send(Command::Shutdown); + if let Some(worker) = lock(&self.worker).take() { + let _ = worker.join(); + } + } +} + +impl Drop for SerialController { + fn drop(&mut self) { + self.shutdown(); + } +} + +#[derive(Default)] +struct Session { + link: Option, + receiver: IntegrityMavlinkReceiver<1>, + client: ConfigClient, + target: Option<(u8, u8)>, + sequence: u8, +} + +impl Session { + fn close(&mut self) { + self.link = None; + self.receiver.reset(); + self.client.reset(); + self.target = None; + self.sequence = 0; + } +} + +fn worker(commands: Receiver, state: Arc) { + let mut session = Session::default(); + loop { + loop { + match commands.try_recv() { + Ok(Command::Shutdown) => { + session.close(); + state.disconnected(None); + return; + } + Ok(command) => handle_command(command, &mut session, &state), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return, + } + } + + if session.link.is_none() { + thread::sleep(IDLE_POLL); + continue; + } + if let Err(error) = service_link(&mut session, &state) { + session.close(); + state.disconnected(Some(format!("serial link failed: {error}"))); + } + } +} + +fn handle_command(command: Command, session: &mut Session, state: &SharedState) { + match command { + Command::Connect { path, baud } => { + session.close(); + match open_serial(&path, baud) { + Ok(link) => { + session.link = Some(link); + state.connected(path); + } + Err(error) => { + state.disconnected(Some(format!("cannot open serial device: {error}"))) + } + } + } + Command::Disconnect => { + session.close(); + state.disconnected(None); + } + Command::Save(save) => { + let request = session.client.save(save.edits, &mut session.sequence); + match request { + Ok(request) => { + if let Err(error) = write_raw(session, request.raw_bytes()) { + session.close(); + state.disconnected(Some(format!("serial link failed: {error}"))); + } + } + Err(error) => state.worker_failed(error), + } + } + Command::Shutdown => unreachable!("shutdown handled by worker"), + } +} + +fn service_link(session: &mut Session, state: &SharedState) -> Result<(), String> { + let mut bytes = [0_u8; 512]; + let read = match session + .link + .as_mut() + .ok_or("serial link is closed")? + .read(&mut bytes) + { + Ok(length) => length, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) => + { + 0 + } + Err(error) => return Err(error.to_string()), + }; + + let mut frames = Vec::new(); + for byte in &bytes[..read] { + session.receiver.receive_byte(*byte); + if let Some(frame) = session.receiver.take_received() { + 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) + { + session.target = Some(target); + state.discovering(); + let request = session.client.discover(target, &mut session.sequence)?; + 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 (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())?; + } + Ok(()) +} + +#[cfg(test)] +fn packet_observation(frame: &MavlinkFrame) -> PacketObservation { + frame_observations(frame).0 +} + +fn frame_observations(frame: &MavlinkFrame) -> (PacketObservation, Option) { + let message_id = frame.message_id(); + let (name, values, log) = decoded_observations(frame); + let packet = PacketObservation { + system_id: frame.system_id(), + component_id: frame.component_id(), + message_id, + name, + values, + }; + (packet, log) +} + +fn decoded_observations( + frame: &MavlinkFrame, +) -> (&'static str, serde_json::Value, Option) { + if frame.has_valid_crc::() + && 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::() + && 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 { + let (severity, text) = match message { + message::kaptan::MavMessage::KAPTAN_LOG(log) => { + let trace = log + .flags + .contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE); + ( + LogSeverity::from_wire(log.level, trace), + bounded_text(&log.message, log.message_length), + ) + } + message::kaptan::MavMessage::KAPTAN_ERROR(error) => { + let path = bounded_text(&error.path, error.path_length); + let information = bounded_text( + &error.additional_information, + error.additional_information_length, + ); + let text = match (path.is_empty(), information.is_empty()) { + (false, false) => format!("{path}: {information}"), + (false, true) => path, + (true, false) => information, + (true, true) => "Unspecified Kaptan error".into(), + }; + (LogSeverity::from_wire(error.level, false), text) + } + _ => return None, + }; + Some(LogObservation { + system_id: frame.system_id(), + component_id: frame.component_id(), + severity, + text, + }) +} + +fn kaptan_message_values(message: &message::kaptan::MavMessage) -> serde_json::Value { + match message { + message::kaptan::MavMessage::KAPTAN_ERROR(error) => serde_json::json!({ + "level": LogSeverity::from_wire(error.level, false), + "flags": format!("{:?}", error.flags), + "flags_bits": error.flags.bits(), + "path": bounded_text(&error.path, error.path_length), + "additional_information": bounded_text( + &error.additional_information, + error.additional_information_length, + ), + }), + message::kaptan::MavMessage::KAPTAN_LOG(log) => serde_json::json!({ + "level": LogSeverity::from_wire( + log.level, + log.flags.contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE), + ), + "flags": format!("{:?}", log.flags), + "flags_bits": log.flags.bits(), + "message": bounded_text(&log.message, log.message_length), + }), + _ => decoded_fields(message), + } +} + +fn decoded_fields(message: &impl core::fmt::Debug) -> serde_json::Value { + format!("{message:#?}").into() +} + +fn bounded_text(bytes: &[u8; N], length: u8) -> String { + let end = usize::from(length).min(N); + String::from_utf8_lossy(&bytes[..end]).into_owned() +} + +fn hex_bytes(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(encoded, "{byte:02x}"); + } + encoded +} + +fn handle_client_event(session: &mut Session, state: &SharedState, event: ClientEvent) { + if matches!(event, ClientEvent::RediscoveryRequired(_)) { + session.target = None; + } + state.configuration_event(event); +} + +fn write_raw(session: &mut Session, bytes: &[u8]) -> Result<(), String> { + session + .link + .as_mut() + .ok_or("serial link is closed")? + .write_all(bytes) + .map_err(|error| error.to_string()) +} + +fn heartbeat_target(frame: &MavlinkFrame) -> Option<(u8, u8)> { + if !frame.has_valid_crc::() { + 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) +} + +fn open_serial(path: &str, baud: SerialBaud) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NOCTTY) + .open(path) + .map_err(|error| error.to_string())?; + let descriptor = file.as_raw_fd(); + // SAFETY: `options` is an out-parameter for a valid open file descriptor. + let mut options = unsafe { + let mut options = std::mem::zeroed(); + if libc::tcgetattr(descriptor, &mut options) != 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + options + }; + // SAFETY: `options` was initialized by `tcgetattr` and remains exclusively + // borrowed while libc applies the standard raw-mode bit transformation. + unsafe { libc::cfmakeraw(&mut options) }; + options.c_cflag |= libc::CLOCAL | libc::CREAD; + options.c_cflag &= !(libc::PARENB | libc::CSTOPB | libc::CSIZE | libc::CRTSCTS); + options.c_cflag |= libc::CS8; + options.c_cc[libc::VMIN] = 0; + options.c_cc[libc::VTIME] = 1; + // SAFETY: both pointers reference the initialized local termios object; + // `descriptor` remains open for the duration of all three calls. + let configured = unsafe { + libc::cfsetispeed(&mut options, baud.termios_speed()) == 0 + && libc::cfsetospeed(&mut options, baud.termios_speed()) == 0 + && libc::tcsetattr(descriptor, libc::TCSANOW, &options) == 0 + }; + if !configured { + return Err(std::io::Error::last_os_error().to_string()); + } + Ok(file) +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + match mutex.lock() { + Ok(value) => value, + Err(poisoned) => poisoned.into_inner(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kaptan_mavlink::message::{ + HEARTBEAT_DATA, MavAutopilot, MavHeader, MavModeFlag, MavState, MavType, + }; + + fn heartbeat(system_id: u8, component_id: u8, autopilot: MavAutopilot) -> MavlinkFrame { + let mut raw = MavlinkFrame::new(); + raw.serialize_message_data( + MavHeader { + sequence: 1, + 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, + }, + ); + raw + } + + #[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))); + } + + #[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); + let mut bytes = frame.into_inner(); + bytes[10] ^= 1; + let unverified = MavlinkFrame::from_bytes_unparsed(bytes); + assert_eq!(heartbeat_target(&unverified), None); + } + + #[test] + fn packet_monitor_uses_the_decoded_dialect_name() { + let observation = + packet_observation(&heartbeat(42, 7, MavAutopilot::MAV_AUTOPILOT_GENERIC)); + assert_eq!(observation.system_id, 42); + assert_eq!(observation.component_id, 7); + assert_eq!(observation.message_id, 0); + 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')); + } + + #[test] + fn packet_monitor_decodes_kaptan_error_text() { + let path_text = b"driver::Icm42688::Transfer"; + let information_text = b"SPI timeout"; + let mut path = [0_u8; 96]; + let mut additional_information = [0_u8; 64]; + path[..path_text.len()].copy_from_slice(path_text); + additional_information[..information_text.len()].copy_from_slice(information_text); + let mut frame = MavlinkFrame::new(); + frame.serialize_message_data( + MavHeader { + sequence: 1, + system_id: 42, + component_id: 7, + }, + &message::kaptan::KAPTAN_ERROR_DATA { + level: 2, + flags: message::kaptan::KaptanErrorFlags::empty(), + path_length: path_text.len() as u8, + additional_information_length: information_text.len() as u8, + path, + additional_information, + }, + ); + + let (observation, log) = frame_observations(&frame); + assert_eq!(observation.name, "KAPTAN_ERROR"); + assert_eq!(observation.values["level"], "CRITICAL"); + assert_eq!(observation.values["path"], "driver::Icm42688::Transfer"); + assert_eq!(observation.values["additional_information"], "SPI timeout"); + assert_eq!( + log, + Some(LogObservation { + system_id: 42, + component_id: 7, + severity: LogSeverity::Critical, + text: "driver::Icm42688::Transfer: SPI timeout".into(), + }) + ); + } + + #[test] + fn runtime_log_is_a_distinct_typed_event() { + let message_text = b"estimator initialized"; + let mut message_bytes = [0_u8; 160]; + message_bytes[..message_text.len()].copy_from_slice(message_text); + let mut frame = MavlinkFrame::new(); + frame.serialize_message_data( + MavHeader { + sequence: 1, + system_id: 42, + component_id: 7, + }, + &message::kaptan::KAPTAN_LOG_DATA { + level: 6, + flags: message::kaptan::KaptanLogFlags::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.values["message"], "estimator initialized"); + assert_eq!( + log, + Some(LogObservation { + system_id: 42, + component_id: 7, + severity: LogSeverity::Info, + text: "estimator initialized".into(), + }) + ); + } + + #[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_ne!(baud.termios_speed(), 0); + } + assert_eq!(SerialBaud::try_new(0), None); + assert_eq!(SerialBaud::try_new(123_456), None); + } +} diff --git a/firmware/tools/configurator/src/state.rs b/firmware/tools/configurator/src/state.rs new file mode 100644 index 0000000..a7747a3 --- /dev/null +++ b/firmware/tools/configurator/src/state.rs @@ -0,0 +1,493 @@ +//! Small shared projection from remote descriptors to the browser API. + +use std::{ + collections::BTreeMap, + sync::{Mutex, MutexGuard}, + time::{Instant, SystemTime}, +}; + +use serde::Serialize; + +use crate::{ + config_client::{ + ClientEvent, Edit, RemoteConfig, RemoteField, Scalar, ValueKind, scalar_json, + scalar_less_than, + }, + log_monitor::{LogMonitor, LogObservation, LogSnapshot}, + packet_monitor::{PacketMonitor, PacketObservation, PacketSnapshot}, +}; + +#[derive(Clone, Serialize)] +pub struct StateSnapshot { + pub path: Option, + pub status: String, + pub error: Option, + pub config_revision: u64, + pub can_edit: bool, + pub can_save: bool, + pub can_disconnect: bool, +} + +#[derive(Clone, Serialize)] +pub struct ConfigSnapshot { + pub schema_id: Option, + pub state: String, + pub fields: Vec, + pub validation: Vec, +} + +#[derive(Clone, Serialize)] +pub struct FieldView { + pub id: u16, + pub key: String, + pub label: String, + pub group: String, + pub unit: String, + pub kind: &'static str, + pub minimum: Option, + pub maximum: Option, + pub default: serde_json::Value, + pub value: serde_json::Value, + pub help: String, + pub options: Vec, +} + +#[derive(Clone, Serialize)] +pub struct EnumOptionView { + pub value: serde_json::Value, + pub label: String, +} + +#[derive(Clone)] +pub struct SaveRequest { + pub edits: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Operation { + Disconnected, + Connecting, + Discovering, + Ready, + Saving, +} + +struct State { + operation: Operation, + path: Option, + status: String, + error: Option, + config: Option, + draft: BTreeMap, + config_revision: u64, + packets: PacketMonitor, + logs: LogMonitor, +} + +impl Default for State { + fn default() -> Self { + Self { + operation: Operation::Disconnected, + path: None, + status: "Disconnected.".into(), + error: None, + config: None, + draft: BTreeMap::new(), + config_revision: 0, + packets: PacketMonitor::default(), + logs: LogMonitor::default(), + } + } +} + +#[derive(Default)] +pub struct SharedState(Mutex); + +impl SharedState { + pub fn snapshot(&self) -> StateSnapshot { + let state = lock(&self.0); + StateSnapshot { + path: state.path.clone(), + status: state.status.clone(), + error: state.error.clone(), + config_revision: state.config_revision, + 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), + } + } + + pub fn configuration(&self) -> ConfigSnapshot { + let state = lock(&self.0); + let Some(config) = &state.config else { + return ConfigSnapshot { + schema_id: None, + state: "unavailable".into(), + fields: Vec::new(), + validation: state.error.clone().into_iter().collect(), + }; + }; + ConfigSnapshot { + schema_id: Some(hex(&config.info.schema_id)), + state: config.info.state.clone(), + fields: config + .fields + .iter() + .map(|field| field_view(field, state.draft.get(&field.id).copied())) + .collect(), + validation: state.error.clone().into_iter().collect(), + } + } + + pub fn observe_packets(&self, packets: impl IntoIterator) { + lock(&self.0).packets.observe(packets, Instant::now()); + } + + pub fn packets(&self) -> PacketSnapshot { + lock(&self.0).packets.snapshot(Instant::now()) + } + + pub fn observe_logs(&self, logs: impl IntoIterator) { + lock(&self.0).logs.observe(logs, SystemTime::now()); + } + + pub fn logs(&self) -> LogSnapshot { + lock(&self.0).logs.snapshot() + } + + pub fn begin_connect(&self) -> Result<(), String> { + let mut state = lock(&self.0); + if state.operation != Operation::Disconnected { + return Err("disconnect the current serial link first".into()); + } + state.operation = Operation::Connecting; + state.status = "Connecting…".into(); + state.error = None; + Ok(()) + } + + pub fn connected(&self, path: String) { + let mut state = lock(&self.0); + state.operation = Operation::Discovering; + state.path = Some(path); + state.status = "Connected; waiting for controller heartbeat…".into(); + state.error = None; + state.config = None; + state.draft.clear(); + state.packets.clear(); + state.logs.clear(); + state.config_revision = state.config_revision.wrapping_add(1); + } + + pub fn disconnected(&self, error: Option) { + let mut state = lock(&self.0); + state.operation = Operation::Disconnected; + state.path = None; + state.status = "Disconnected.".into(); + state.error = error; + state.config = None; + state.draft.clear(); + state.packets.clear(); + state.logs.clear(); + state.config_revision = state.config_revision.wrapping_add(1); + } + + pub fn discovering(&self) { + let mut state = lock(&self.0); + state.operation = Operation::Discovering; + state.status = "Reading configuration schema and values…".into(); + state.error = None; + } + + pub 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" { + "Saved. Restart the controller to activate the new configuration.".into() + } else { + "Configuration ready.".into() + }; + state.error = None; + state.config = Some(config); + state.draft.clear(); + state.config_revision = state.config_revision.wrapping_add(1); + } + ClientEvent::Failure(detail) => { + state.error = Some(detail); + state.operation = if state.config.is_some() { + Operation::Ready + } else { + Operation::Discovering + }; + } + ClientEvent::RediscoveryRequired(detail) => { + state.operation = Operation::Discovering; + state.status = + "Controller state changed; waiting to rediscover configuration…".into(); + state.error = Some(detail); + state.config = None; + state.draft.clear(); + state.config_revision = state.config_revision.wrapping_add(1); + } + } + } + + pub fn edit(&self, field_id: u16, value: &serde_json::Value) -> Result { + let mut state = lock(&self.0); + if state.operation != Operation::Ready { + return Err("configuration is not ready for edits".into()); + } + let field = state + .config + .as_ref() + .and_then(|config| config.fields.iter().find(|field| field.id == field_id)) + .ok_or_else(|| format!("unknown field id {field_id}"))?; + let next = parse_scalar(field.kind, value)?; + validate_bounds(field, next)?; + if next == field.value { + state.draft.remove(&field_id); + } else { + state.draft.insert(field_id, next); + } + state.error = None; + state.config_revision = state.config_revision.wrapping_add(1); + drop(state); + Ok(self.configuration()) + } + + pub fn begin_save(&self) -> Result { + let mut state = lock(&self.0); + if state.operation != Operation::Ready { + return Err("configuration is busy".into()); + } + state + .config + .as_ref() + .ok_or("configuration has not been discovered")?; + 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(), + }; + state.operation = Operation::Saving; + state.status = "Validating and saving configuration…".into(); + state.error = None; + Ok(request) + } + + pub fn worker_failed(&self, detail: String) { + let mut state = lock(&self.0); + state.error = Some(detail); + state.operation = if state.config.is_some() { + Operation::Ready + } else if state.path.is_some() { + Operation::Discovering + } else { + Operation::Disconnected + }; + } +} + +fn field_view(field: &RemoteField, draft: Option) -> FieldView { + let (group, tail) = field + .key + .rsplit_once('.') + .unwrap_or(("configuration", field.key.as_str())); + FieldView { + id: field.id, + key: field.key.clone(), + label: humanize(tail), + group: humanize(group), + unit: field.unit.clone(), + kind: match field.kind { + ValueKind::Bool => "bool", + ValueKind::F32 | ValueKind::F64 => "decimal", + ValueKind::I32 | ValueKind::U32 | ValueKind::U64 => "integer", + }, + minimum: field.minimum.map(scalar_json), + maximum: field.maximum.map(scalar_json), + default: scalar_json(field.default), + value: scalar_json(draft.unwrap_or(field.value)), + help: format!("Stable field ID {}.", field.id), + options: field + .options + .iter() + .map(|option| EnumOptionView { + value: scalar_json(option.value), + label: option.label.clone(), + }) + .collect(), + } +} + +fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result { + let text = value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()); + match kind { + ValueKind::Bool => value + .as_bool() + .map(Scalar::Bool) + .ok_or_else(|| "expected a boolean".into()), + ValueKind::I32 => text + .parse() + .map(Scalar::I32) + .map_err(|_| "expected a signed 32-bit integer".into()), + ValueKind::U32 => text + .parse() + .map(Scalar::U32) + .map_err(|_| "expected an unsigned 32-bit integer".into()), + ValueKind::U64 => text + .parse() + .map(Scalar::U64) + .map_err(|_| "expected an unsigned 64-bit integer".into()), + ValueKind::F32 => text + .parse::() + .map_err(|_| "expected a 32-bit decimal".to_owned()) + .and_then(|value| { + value + .is_finite() + .then_some(Scalar::F32(value)) + .ok_or_else(|| "decimal must be finite".into()) + }), + ValueKind::F64 => text + .parse::() + .map_err(|_| "expected a 64-bit decimal".to_owned()) + .and_then(|value| { + value + .is_finite() + .then_some(Scalar::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) { + return Err(format!("{} requires a named choice", field.key)); + } + if field + .minimum + .is_some_and(|minimum| scalar_less_than(value, minimum)) + { + return Err(format!("{} is below its inclusive minimum", field.key)); + } + if field + .maximum + .is_some_and(|maximum| scalar_less_than(maximum, value)) + { + return Err(format!("{} is above its inclusive maximum", field.key)); + } + Ok(()) +} + +fn humanize(value: &str) -> String { + let mut output = String::with_capacity(value.len()); + for (index, character) in value.chars().enumerate() { + if character == '_' || character == '.' { + output.push(' '); + } else if index == 0 { + output.extend(character.to_uppercase()); + } else { + output.push(character); + } + } + output +} + +fn hex(bytes: &[u8]) -> String { + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + let _ = write!(output, "{byte:02x}"); + } + output +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + match mutex.lock() { + Ok(value) => value, + Err(poisoned) => poisoned.into_inner(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config_client::{RemoteEnumOption, RemoteInfo, ValueKind}; + + fn unknown_board() -> RemoteConfig { + RemoteConfig { + info: RemoteInfo { + schema_id: *b"UNKNOWN-SCHEMA01", + state: "active".into(), + }, + fields: vec![RemoteField { + id: 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), + options: Vec::new(), + }], + } + } + + #[test] + fn unknown_schema_edits_by_stable_field_id_without_a_catalog() { + let state = SharedState::default(); + state.connected("test".into()); + state.configuration_event(ClientEvent::Configuration(unknown_board())); + 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); + } + + #[test] + fn bounds_are_applied_from_remote_metadata() { + let state = SharedState::default(); + state.connected("test".into()); + state.configuration_event(ClientEvent::Configuration(unknown_board())); + assert!(state.edit(401, &serde_json::json!(101)).is_err()); + } + + #[test] + fn enum_choices_are_projected_and_validated_from_remote_metadata() { + let mut config = unknown_board(); + config.fields[0].options = vec![ + RemoteEnumOption { + value: Scalar::U32(20), + label: "Standard".into(), + }, + RemoteEnumOption { + value: Scalar::U32(25), + label: "Fast".into(), + }, + ]; + let state = SharedState::default(); + state.connected("test".into()); + state.configuration_event(ClientEvent::Configuration(config)); + + let view = state.configuration(); + assert_eq!(view.fields[0].options.len(), 2); + assert_eq!(view.fields[0].options[1].label, "Fast"); + assert_eq!(view.fields[0].options[1].value, serde_json::json!(25)); + assert!(state.edit(401, &serde_json::json!(20)).is_ok()); + assert!(state.edit(401, &serde_json::json!(30)).is_err()); + } +} diff --git a/firmware/tools/configurator/web/app.js b/firmware/tools/configurator/web/app.js new file mode 100644 index 0000000..7b94e66 --- /dev/null +++ b/firmware/tools/configurator/web/app.js @@ -0,0 +1,427 @@ +const q = selector => document.querySelector(selector); + +const pendingActions = new Set(); +let latestState = null; +let latestConfig = null; +let configRevision = -1; +let editorStructure = ''; +const expandedPackets = new Set(); +const packetRows = new Map(); +let logSession = null; +let latestLogId = -1; + +function setText(element, value) { + const text = value ?? ''; + if (element.textContent !== String(text)) element.textContent = text; +} + +async function responseValue(response) { + const type = response.headers.get('content-type') || ''; + if (!type.includes('application/json')) return response.text(); + try { + return await response.json(); + } catch (_) { + throw Error(`HTTP ${response.status}: invalid JSON response`); + } +} + +async function api(path, options = {}) { + const response = await fetch(path, options); + const value = await responseValue(response); + if (!response.ok) { + const detail = typeof value === 'object' && value !== null ? value.error : value; + throw Error(detail || `Request failed with HTTP ${response.status}`); + } + return value; +} + +function post(path, body) { + return api(path, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +function showError(message) { + setText(q('#connect-error'), message); +} + +async function runAction(id, action) { + pendingActions.add(id); + renderPermissions(); + try { + const value = await action(); + showError(''); + return value; + } catch (error) { + showError(error.message); + return null; + } finally { + pendingActions.delete(id); + renderPermissions(); + } +} + +q('#connect-form').addEventListener('submit', event => { + event.preventDefault(); + runAction('connect', () => post('/api/connect', {path: q('#path').value})); +}); + +q('#disconnect').addEventListener('click', () => { + runAction('disconnect', () => post('/api/disconnect')); +}); + +q('#draft-fields').addEventListener('submit', event => event.preventDefault()); + +q('#save').addEventListener('click', () => { + runAction('save', () => post('/api/config/save')); +}); + +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, + ]; +} + +function fieldControl(field) { + let control; + if (field.options.length !== 0) { + control = document.createElement('select'); + for (const choice of field.options) { + const option = document.createElement('option'); + option.value = String(choice.value); + option.textContent = choice.label; + control.append(option); + } + } else { + control = document.createElement('input'); + } + control.name = String(field.id); + control.dataset.fieldId = String(field.id); + if (field.options.length === 0) { + if (field.kind === 'bool') { + control.type = 'checkbox'; + } else { + control.type = 'number'; + control.step = field.kind === 'decimal' ? 'any' : '1'; + control.inputMode = field.kind === 'decimal' ? 'decimal' : 'numeric'; + if (field.minimum !== null) control.min = String(field.minimum); + if (field.maximum !== null) control.max = String(field.maximum); + } + } + control.addEventListener('change', async () => { + let value = control.value; + if (field.kind === 'bool') { + value = field.options.length === 0 ? control.checked : control.value === 'true'; + } + const action = `edit:${field.id}`; + const config = await runAction(action, () => post('/api/config/edit', { + field_id: field.id, + value, + })); + if (config !== null) renderConfig(config); + }); + return control; +} + +function buildEditor(fields) { + const groups = new Map(); + for (const field of fields) { + if (!groups.has(field.group)) groups.set(field.group, []); + groups.get(field.group).push(field); + } + + const sections = []; + for (const [group, groupFields] of groups) { + const section = document.createElement('fieldset'); + const legend = document.createElement('legend'); + legend.textContent = group; + section.append(legend); + for (const field of groupFields) { + const label = document.createElement('label'); + const title = document.createElement('span'); + title.textContent = `${field.label}${field.unit ? ` (${field.unit})` : ''}`; + const key = document.createElement('code'); + key.className = 'field-key'; + key.textContent = `${field.key} [${field.id}]`; + const control = fieldControl(field); + label.append(title, key, control); + + 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(' '); + if (helpText) { + const help = document.createElement('small'); + help.id = `help-${field.id}`; + help.textContent = helpText; + control.setAttribute('aria-describedby', help.id); + label.append(help); + } + section.append(label); + } + sections.push(section); + } + q('#draft-fields').replaceChildren(...sections); +} + +function syncEditorValues(fields) { + for (const field of fields) { + const control = q(`[data-field-id="${field.id}"]`); + if (!control) continue; + if (field.kind === 'bool' && field.options.length === 0) { + if (document.activeElement !== control) control.checked = field.value; + } else if (document.activeElement !== control && control.value !== String(field.value)) { + control.value = String(field.value); + } + } +} + +function renderValidation(messages) { + q('#validation').replaceChildren(...messages.map(message => + Object.assign(document.createElement('li'), {textContent: message}))); +} + +function renderConfig(config) { + latestConfig = config; + setText(q('#schema-id'), config.schema_id || '—'); + setText(q('#config-state'), config.state); + q('#restart-guidance').hidden = config.state !== 'restart required'; + renderValidation(config.validation); + + const structure = JSON.stringify(config.fields.map(fieldStructure)); + if (structure !== editorStructure) { + editorStructure = structure; + buildEditor(config.fields); + } + syncEditorValues(config.fields); + renderPermissions(); +} + +function renderPermissions() { + if (latestState === null) return; + const busy = pendingActions.size !== 0; + q('#path').disabled = latestState.can_disconnect || busy; + q('#connect-serial').disabled = latestState.can_disconnect || busy; + q('#disconnect').hidden = !latestState.can_disconnect; + q('#disconnect').disabled = busy; + q('#save').disabled = !latestState.can_save || busy; + for (const control of q('#draft-fields').elements) { + control.disabled = !latestState.can_edit || busy; + } +} + +function frequency(value) { + if (!Number.isFinite(value)) return '—'; + const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(digits)} Hz`; +} + +function packetValues(values) { + return typeof values === 'string' ? values : JSON.stringify(values, null, 2); +} + +function createPacketRows(key) { + const row = document.createElement('tr'); + const messageCell = document.createElement('td'); + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'packet-toggle'; + messageCell.append(toggle); + row.append(messageCell); + + const cells = []; + for (let index = 0; index < 4; index += 1) { + const cell = document.createElement('td'); + cells.push(cell); + row.append(cell); + } + + const detail = document.createElement('tr'); + detail.className = 'packet-detail'; + detail.hidden = true; + const detailCell = document.createElement('td'); + detailCell.colSpan = 5; + const values = document.createElement('pre'); + detailCell.append(values); + detail.append(detailCell); + + const entry = {row, toggle, cells, detail, values, latestValues: null}; + toggle.addEventListener('click', () => { + if (expandedPackets.has(key)) { + expandedPackets.delete(key); + } else { + expandedPackets.add(key); + setText(values, packetValues(entry.latestValues)); + } + const expanded = expandedPackets.has(key); + toggle.setAttribute('aria-expanded', String(expanded)); + detail.hidden = !expanded; + }); + return entry; +} + +function renderPackets(snapshot) { + const body = q('#packet-rows'); + const packets = [...snapshot.packets].sort((left, right) => + left.name.localeCompare(right.name) + || left.system_id - right.system_id + || left.component_id - right.component_id + || left.message_id - right.message_id); + const liveKeys = new Set(packets.map(packet => + `${packet.system_id}:${packet.component_id}:${packet.message_id}`)); + for (const key of expandedPackets) { + if (!liveKeys.has(key)) expandedPackets.delete(key); + } + for (const [key, entry] of packetRows) { + if (liveKeys.has(key)) continue; + entry.row.remove(); + entry.detail.remove(); + packetRows.delete(key); + } + if (packets.length === 0) { + if (!body.querySelector('.empty-row')) { + const row = document.createElement('tr'); + row.className = 'empty-row'; + const cell = document.createElement('td'); + cell.colSpan = 5; + cell.textContent = 'No MAVLink packets received on this link.'; + row.append(cell); + body.replaceChildren(row); + } + return; + } + + body.querySelector('.empty-row')?.remove(); + const orderedRows = []; + for (const packet of packets) { + const key = `${packet.system_id}:${packet.component_id}:${packet.message_id}`; + let entry = packetRows.get(key); + if (!entry) { + entry = createPacketRows(key); + packetRows.set(key, entry); + } + entry.latestValues = packet.values; + setText(entry.toggle, packet.name); + entry.toggle.setAttribute('aria-expanded', String(expandedPackets.has(key))); + for (const [index, value] of [ + `${packet.system_id}:${packet.component_id}`, + packet.message_id, + frequency(packet.frequency_hz), + packet.count, + ].entries()) { + setText(entry.cells[index], value); + } + entry.detail.hidden = !expandedPackets.has(key); + setText(entry.values, packetValues(packet.values)); + orderedRows.push(entry.row, entry.detail); + } + + let cursor = body.firstChild; + for (const row of orderedRows) { + if (row !== cursor) body.insertBefore(row, cursor); + cursor = row.nextSibling; + } +} + +function logTime(receivedAtMs) { + const received = new Date(receivedAtMs); + if (Number.isNaN(received.getTime())) return '—'; + return received.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + hour12: false, + }); +} + +function createLogEntry(entry) { + const item = document.createElement('li'); + item.dataset.logId = String(entry.id); + const severityClass = entry.severity.split(/[^A-Z]+/, 1)[0].toLowerCase(); + item.className = `log-entry log-${severityClass}`; + + const time = document.createElement('time'); + time.dateTime = new Date(entry.received_at_ms).toISOString(); + time.textContent = logTime(entry.received_at_ms); + + const severity = document.createElement('span'); + severity.className = 'log-severity'; + severity.textContent = entry.severity; + + const text = document.createElement('span'); + text.className = 'log-text'; + text.textContent = entry.text; + + item.title = `MAVLink source ${entry.system_id}:${entry.component_id}`; + item.append(time, severity, text); + return item; +} + +function renderLogs(snapshot) { + const entries = q('#log-entries'); + if (snapshot.session !== logSession) { + logSession = snapshot.session; + latestLogId = -1; + entries.replaceChildren(); + } + + const followsLatest = entries.scrollHeight - entries.scrollTop - entries.clientHeight < 32; + const retainedIds = new Set(snapshot.entries.map(entry => String(entry.id))); + for (const rendered of entries.querySelectorAll('[data-log-id]')) { + if (!retainedIds.has(rendered.dataset.logId)) rendered.remove(); + } + const newEntries = snapshot.entries.filter(entry => entry.id > latestLogId); + if (newEntries.length !== 0) { + entries.querySelector('.empty-log')?.remove(); + entries.append(...newEntries.map(createLogEntry)); + latestLogId = newEntries[newEntries.length - 1].id; + if (followsLatest) entries.scrollTop = entries.scrollHeight; + } else if (entries.children.length === 0) { + const empty = document.createElement('li'); + empty.className = 'empty-log'; + empty.textContent = 'No Kaptan logs received on this link.'; + entries.append(empty); + } + + setText(q('#log-retention'), snapshot.discarded === 0 + ? '' + : `${snapshot.discarded} older host-side log entries discarded after reaching the display limit.`); +} + +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; + q('#packet-monitor').hidden = !state.can_disconnect; + q('#log-monitor').hidden = !state.can_disconnect; + renderPermissions(); +} + +async function refresh() { + try { + const [state, packets, logs] = await Promise.all([ + api('/api/state'), + api('/api/packets'), + api('/api/logs'), + ]); + renderState(state); + renderPackets(packets); + renderLogs(logs); + if (state.config_revision !== configRevision) { + configRevision = state.config_revision; + renderConfig(await api('/api/config')); + q('#configuration').hidden = latestConfig.schema_id === null; + } + } catch (error) { + showError(error.message); + } finally { + window.setTimeout(refresh, 500); + } +} + +refresh(); diff --git a/firmware/tools/configurator/web/index.html b/firmware/tools/configurator/web/index.html new file mode 100644 index 0000000..da21737 --- /dev/null +++ b/firmware/tools/configurator/web/index.html @@ -0,0 +1,74 @@ + + + + + + Kaptan configurator + + + + +
+
+

Connection

+
+ +
+ + +
+
+

Disconnected.

+ + +
+ + + + + + +
+ + + diff --git a/firmware/tools/configurator/web/styles.css b/firmware/tools/configurator/web/styles.css new file mode 100644 index 0000000..839fdf4 --- /dev/null +++ b/firmware/tools/configurator/web/styles.css @@ -0,0 +1,254 @@ +:root { + color-scheme: light dark; + --background: #fff; + --foreground: #161616; + --muted: #595959; + --rule: #8a8a8a; + --subtle: #f2f2f2; + --link: #0000c0; + --visited: #6b287c; + --error: #9f1111; + --warning: #6b4b00; + --focus: #005fcc; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #111; + --foreground: #eee; + --muted: #bbb; + --rule: #777; + --subtle: #202020; + --link: #8db8ff; + --visited: #d4a1e4; + --error: #ff9d9d; + --warning: #f0c76a; + --focus: #82b7ff; + } +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } + +html { + background: var(--background); + color: var(--foreground); + font: 15px/1.45 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; +} + +body { + width: min(100% - 2rem, 76rem); + margin: 0 auto 4rem; +} + +header, +section { + border-bottom: 1px solid var(--rule); + padding: 1.25rem 0; +} + +h1, h2, h3, p { margin-top: 0; } +h1 { margin-bottom: .35rem; font-size: 1.35rem; } +h2 { font-size: 1.15rem; } +h3 { font-size: 1rem; } + +a { + color: var(--link); + text-decoration: underline; + text-underline-offset: .15em; +} + +a:visited { color: var(--visited); } + +.skip-link { + position: absolute; + left: .5rem; + top: -5rem; + padding: .5rem; + background: var(--background); + border: 2px solid var(--focus); + z-index: 2; +} + +.skip-link:focus { top: .5rem; } + +button, +input, +select { + max-width: 100%; + padding: .35rem .5rem; + border: 1px solid var(--rule); + border-radius: 0; + background: var(--background); + color: var(--foreground); + font: inherit; +} + +button { background: var(--subtle); cursor: pointer; } + +button:disabled, +input:disabled, +select:disabled { cursor: not-allowed; opacity: .58; } + +:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } + +.control-line, +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem; + margin-bottom: .75rem; +} + +.control-line input:first-of-type { flex: 1 1 18rem; } +.hint, small { color: var(--muted); } + +.error { + min-height: 1.45em; + color: var(--error); + font-weight: 700; +} + +.status, +.important { + padding: .7rem; + border-left: .3rem solid var(--warning); + background: var(--subtle); +} + +.important { margin: 1rem 0; } +.important h3 { margin-bottom: .35rem; } +.important p { margin-bottom: 0; } + +details { margin: .75rem 0; } +summary { cursor: pointer; font-weight: 700; } + +dl { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: .25rem 1rem; +} + +dt { color: var(--muted); } +dd { margin: 0; overflow-wrap: anywhere; } +dl > div { display: contents; } + +.draft-fields { + display: grid; + gap: 1rem; + margin: 1rem 0; +} + +.draft-fields fieldset { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); + gap: .9rem; + min-width: 0; + padding: .75rem; + border: 1px solid var(--rule); +} + +.draft-fields label { + display: grid; + align-content: start; + gap: .25rem; +} + +.field-key { color: var(--muted); font-size: .85rem; } + +.table-scroll { overflow-x: auto; } + +table { + width: 100%; + border-collapse: collapse; + font-variant-numeric: tabular-nums; +} + +th, +td { + padding: .45rem .7rem .45rem 0; + border-bottom: 1px solid var(--rule); + text-align: left; + white-space: nowrap; +} + +th { color: var(--muted); } +td:first-child { width: 100%; } +td:nth-child(n + 3), +th:nth-child(n + 3) { text-align: right; } + +.empty-row { color: var(--muted); } +.empty-row td { text-align: left; } + +.packet-toggle { + padding: 0; + border: 0; + background: transparent; + color: var(--link); + text-decoration: underline; + text-underline-offset: .15em; +} + +.packet-detail td { + padding: .5rem .75rem; + background: var(--subtle); + text-align: left; + white-space: normal; +} + +.packet-detail pre { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.log-entries { + max-height: 26rem; + margin: 0; + padding: 0; + overflow: auto; + border: 1px solid var(--rule); + list-style: none; + font-variant-numeric: tabular-nums; +} + +.log-entry { + display: grid; + grid-template-columns: 7.5rem 5.5rem minmax(0, 1fr); + gap: .75rem; + padding: .4rem .55rem; + border-bottom: 1px solid var(--rule); +} + +.log-entry:last-child { border-bottom: 0; } +.log-entry time { color: var(--muted); } +.log-severity { font-weight: 700; } +.log-text { white-space: pre-wrap; overflow-wrap: anywhere; } +.log-emergency, +.log-alert, +.log-critical, +.log-error { color: var(--error); } +.log-warning { color: var(--warning); } + +.empty-log { + padding: .55rem; + color: var(--muted); +} + +#validation:empty, +#validation-summary:empty, +#log-retention:empty, +#connect-error:empty { display: none; } + +#validation { color: var(--error); } + +@media (max-width: 38rem) { + body { width: min(100% - 1rem, 76rem); } + dl { grid-template-columns: 1fr; } + dd { margin-bottom: .5rem; } + .control-line { align-items: stretch; flex-direction: column; } + .control-line > * { width: 100%; } + .log-entry { grid-template-columns: 1fr auto; gap: .2rem .75rem; } + .log-text { grid-column: 1 / -1; } +}