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
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user