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",
|
"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]]
|
[[package]]
|
||||||
name = "kaptan-control"
|
name = "kaptan-control"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -363,6 +376,12 @@ version = "0.1.0"
|
|||||||
name = "kaptan-tx"
|
name = "kaptan-tx"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.33"
|
version = "0.4.33"
|
||||||
@@ -622,6 +641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ members = [
|
|||||||
"libs/kaptan-tx",
|
"libs/kaptan-tx",
|
||||||
"platforms/stm32f7",
|
"platforms/stm32f7",
|
||||||
"tools/sitl",
|
"tools/sitl",
|
||||||
|
"tools/configurator",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
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