feat(configurator): add web configuration console

This commit is contained in:
Onur Özkan committed 2026-08-22 18:40:43 +03:00
1 parent ceb2134392
commit b7631c0bc7
14 files changed
+4513

No files matched your search

+20
View File
@@ -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]]
+1
View File
@@ -26,6 +26,7 @@ members = [
"libs/kaptan-tx",
"platforms/stm32f7",
"tools/sitl",
"tools/configurator",
]
resolver = "2"
+18
View File
@@ -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"
+214
View File
@@ -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
+8
View File
@@ -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");
}
}
+124
View File
@@ -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);
}
}
+493
View File
@@ -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());
}
}
+427
View File
@@ -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>
+254
View File
@@ -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; }
}