feat(configurator): add web configuration console
This commit is contained in:
1 parent
ceb2134392
commit
b7631c0bc7
14 files changed
+4513
No files matched your search
Generated
+20
@@ -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]]
|
||||
|
||||
@@ -26,6 +26,7 @@ members = [
|
||||
"libs/kaptan-tx",
|
||||
"platforms/stm32f7",
|
||||
"tools/sitl",
|
||||
"tools/configurator",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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<SharedState>,
|
||||
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::<usize>().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::<Connect>(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::<EditDraft>(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<T: Serialize>(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\""));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
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<LogView>,
|
||||
}
|
||||
|
||||
/// 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<LogView>,
|
||||
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<Item = LogObservation>,
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
baud: SerialBaud,
|
||||
}
|
||||
|
||||
fn arguments() -> std::io::Result<Arguments> {
|
||||
arguments_from(std::env::args().skip(1))
|
||||
}
|
||||
|
||||
fn arguments_from(mut values: impl Iterator<Item = String>) -> std::io::Result<Arguments> {
|
||||
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::<u32>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PacketView>,
|
||||
}
|
||||
|
||||
/// 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<PacketKey, PacketAggregate>,
|
||||
window_started: Option<Instant>,
|
||||
}
|
||||
|
||||
impl PacketMonitor {
|
||||
/// Records a serial receive batch without retaining individual frames.
|
||||
pub fn observe(
|
||||
&mut self,
|
||||
observations: impl IntoIterator<Item = PacketObservation>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<Command>,
|
||||
worker: Mutex<Option<JoinHandle<()>>>,
|
||||
baud: SerialBaud,
|
||||
}
|
||||
|
||||
impl SerialController {
|
||||
pub fn new(state: Arc<SharedState>, baud: SerialBaud) -> Result<Self, String> {
|
||||
let (commands, receiver) = sync_channel(COMMAND_CAPACITY);
|
||||
let worker = thread::Builder::new()
|
||||
.name("kaptan-config-serial".into())
|
||||
.spawn(move || worker(receiver, state))
|
||||
.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<File>,
|
||||
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<Command>, state: Arc<SharedState>) {
|
||||
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<LogObservation>) {
|
||||
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<LogObservation>) {
|
||||
if frame.has_valid_crc::<message::kaptan::MavMessage>()
|
||||
&& let Ok(message) = message::kaptan::MavMessage::parse(
|
||||
MavlinkVersion::V2,
|
||||
frame.message_id(),
|
||||
frame.payload(),
|
||||
)
|
||||
{
|
||||
return (
|
||||
message.message_name(),
|
||||
kaptan_message_values(&message),
|
||||
log_observation(frame, &message),
|
||||
);
|
||||
}
|
||||
if frame.has_valid_crc::<message::MavMessage>()
|
||||
&& let Ok(message) =
|
||||
message::MavMessage::parse(MavlinkVersion::V2, frame.message_id(), frame.payload())
|
||||
{
|
||||
let name = message.message_name();
|
||||
return (name, decoded_fields(&message), None);
|
||||
}
|
||||
(
|
||||
"UNKNOWN",
|
||||
serde_json::json!({"payload_hex": hex_bytes(frame.payload())}),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn log_observation(
|
||||
frame: &MavlinkFrame,
|
||||
message: &message::kaptan::MavMessage,
|
||||
) -> Option<LogObservation> {
|
||||
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<const N: usize>(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::<message::MavMessage>() {
|
||||
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<File, String> {
|
||||
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<T>(mutex: &Mutex<T>) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub status: String,
|
||||
pub error: Option<String>,
|
||||
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<String>,
|
||||
pub state: String,
|
||||
pub fields: Vec<FieldView>,
|
||||
pub validation: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<serde_json::Value>,
|
||||
pub maximum: Option<serde_json::Value>,
|
||||
pub default: serde_json::Value,
|
||||
pub value: serde_json::Value,
|
||||
pub help: String,
|
||||
pub options: Vec<EnumOptionView>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct EnumOptionView {
|
||||
pub value: serde_json::Value,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SaveRequest {
|
||||
pub edits: Vec<Edit>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum Operation {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Discovering,
|
||||
Ready,
|
||||
Saving,
|
||||
}
|
||||
|
||||
struct State {
|
||||
operation: Operation,
|
||||
path: Option<String>,
|
||||
status: String,
|
||||
error: Option<String>,
|
||||
config: Option<RemoteConfig>,
|
||||
draft: BTreeMap<u16, Scalar>,
|
||||
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<State>);
|
||||
|
||||
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<Item = PacketObservation>) {
|
||||
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<Item = LogObservation>) {
|
||||
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<String>) {
|
||||
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<ConfigSnapshot, String> {
|
||||
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<SaveRequest, String> {
|
||||
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<Scalar>) -> 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<Scalar, String> {
|
||||
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::<f32>()
|
||||
.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::<f64>()
|
||||
.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<T>(mutex: &Mutex<T>) -> 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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,74 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Kaptan configurator</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Skip to configurator</a>
|
||||
<main id="main-content">
|
||||
<section id="connection" aria-labelledby="connection-heading">
|
||||
<h2 id="connection-heading">Connection</h2>
|
||||
<form id="connect-form">
|
||||
<label for="path">Serial device path</label>
|
||||
<div class="control-line">
|
||||
<input id="path" required autocomplete="off" placeholder="/dev/ttyACM0">
|
||||
<button id="connect-serial" type="submit">Connect serial</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="connection-status" role="status" aria-live="polite">Disconnected.</p>
|
||||
<p id="connect-error" class="error" role="alert" aria-live="assertive"></p>
|
||||
<button id="disconnect" type="button" hidden>Disconnect</button>
|
||||
</section>
|
||||
|
||||
<section id="configuration" aria-labelledby="configuration-heading" hidden>
|
||||
<h2 id="configuration-heading">Board configuration</h2>
|
||||
<details open>
|
||||
<summary>Configuration status</summary>
|
||||
<dl>
|
||||
<dt>Schema</dt><dd><code id="schema-id">—</code></dd>
|
||||
<dt>State</dt><dd id="config-state">—</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
<aside id="restart-guidance" class="important" role="status" hidden>
|
||||
Saved configuration becomes active after the controller restarts.
|
||||
</aside>
|
||||
<ul id="validation"></ul>
|
||||
<form id="draft-fields" class="draft-fields"></form>
|
||||
<div class="actions" aria-label="Configuration actions">
|
||||
<button id="save" type="button">Save</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="log-monitor" aria-labelledby="log-monitor-heading" hidden>
|
||||
<h2 id="log-monitor-heading">Runtime logs</h2>
|
||||
<p id="log-retention" class="hint"></p>
|
||||
<ol id="log-entries" class="log-entries">
|
||||
<li class="empty-log">No Kaptan logs received on this link.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section id="packet-monitor" aria-labelledby="packet-monitor-heading" hidden>
|
||||
<h2 id="packet-monitor-heading">Incoming MAVLink</h2>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Message</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Message ID</th>
|
||||
<th scope="col">Frequency</th>
|
||||
<th scope="col">Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="packet-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user