feat(mavlink): adopt the kaptan-native dialect
This commit is contained in:
1 parent
1c90870634
commit
5f91465e66
87 files changed
+7733
-6850
No files matched your search
@@ -10,10 +10,10 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
kaptan-config = { path = "../../libs/kaptan-config" }
|
||||
kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink" }
|
||||
kaptan-file-mavlink = { path = "../../libs/kaptan-file-mavlink" }
|
||||
kaptan-log = { path = "../../libs/kaptan-log" }
|
||||
kaptan-mavlink = { path = "../../libs/kaptan-mavlink", default-features = false, features = ["dialect-kaptan"] }
|
||||
mavlink-core = { version = "0.18", default-features = false }
|
||||
kaptan-mavlink = { path = "../../libs/kaptan-mavlink", default-features = false }
|
||||
libc = "0.2"
|
||||
mavlink-core = { version = "0.18", default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -196,7 +196,7 @@ mod tests {
|
||||
assert!(!APP.contains("HEARTBEAT"));
|
||||
assert!(APP.contains("field.id"));
|
||||
assert!(
|
||||
APP.contains("field.minimum, field.maximum, field.default, field.help, field.options")
|
||||
APP.contains("field.minimum, field.maximum, field.factory, field.help, field.options")
|
||||
);
|
||||
assert!(APP.contains("document.createElement('select')"));
|
||||
assert!(APP.contains("snapshot.packets"));
|
||||
@@ -213,4 +213,23 @@ mod tests {
|
||||
assert!(APP.contains("truncated.textContent = 'TRUNCATED'"));
|
||||
assert!(INDEX.contains("id=\"log-entries\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_clears_recovered_errors_and_retries_failed_configuration_fetches() {
|
||||
assert!(APP.contains("showError(state.error ?? '')"));
|
||||
assert!(APP.contains("q('#configuration').hidden = !state.has_config"));
|
||||
|
||||
let fetch = APP
|
||||
.find("const config = await api('/api/config')")
|
||||
.expect("configuration refresh fetches the latest document");
|
||||
let render = APP[fetch..]
|
||||
.find("renderConfig(config)")
|
||||
.map(|offset| fetch + offset)
|
||||
.expect("a fetched configuration is rendered");
|
||||
let revision = APP[render..]
|
||||
.find("configRevision = state.config_revision")
|
||||
.map(|offset| render + offset)
|
||||
.expect("a successful render advances the observed revision");
|
||||
assert!(fetch < render && render < revision);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,12 @@
|
||||
//! 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;
|
||||
mod api;
|
||||
mod config_client;
|
||||
mod log_monitor;
|
||||
mod packet_monitor;
|
||||
mod serial_worker;
|
||||
mod state;
|
||||
|
||||
pub use api::serve_connection;
|
||||
pub use serial_worker::{SerialBaud, SerialController};
|
||||
pub use state::SharedState;
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::{collections::VecDeque, time::SystemTime};
|
||||
|
||||
use kaptan_log::Level;
|
||||
use kaptan_mavlink::generated::kaptan::LogLevel;
|
||||
use serde::{Serialize, Serializer};
|
||||
|
||||
const MAX_LOG_ENTRIES: usize = 512;
|
||||
@@ -14,30 +15,23 @@ where
|
||||
serializer.serialize_str(level.as_str())
|
||||
}
|
||||
|
||||
/// Decodes the exact Kaptan log-level subset from its MAVLink representation.
|
||||
///
|
||||
/// Kaptan emits Error, Warn, Info, and Debug on the conventional MAVLink
|
||||
/// numbers 3, 4, 6, and 7. Trace is Debug plus the dedicated trace flag. The
|
||||
/// other MAVLink severity numbers are not Kaptan log levels.
|
||||
pub const fn level_from_wire(level: u8, trace: bool) -> Option<Level> {
|
||||
if trace && level != 7 {
|
||||
return None;
|
||||
}
|
||||
/// Decodes a wire event level, rejecting the filter-only Off value.
|
||||
pub const fn event_level_from_wire(level: LogLevel) -> Option<Level> {
|
||||
match level {
|
||||
3 => Some(Level::Error),
|
||||
4 => Some(Level::Warn),
|
||||
6 => Some(Level::Info),
|
||||
7 if trace => Some(Level::Trace),
|
||||
7 => Some(Level::Debug),
|
||||
_ => None,
|
||||
LogLevel::LOG_LEVEL_OFF => None,
|
||||
LogLevel::LOG_LEVEL_TRACE => Some(Level::Trace),
|
||||
LogLevel::LOG_LEVEL_DEBUG => Some(Level::Debug),
|
||||
LogLevel::LOG_LEVEL_INFO => Some(Level::Info),
|
||||
LogLevel::LOG_LEVEL_WARN => Some(Level::Warn),
|
||||
LogLevel::LOG_LEVEL_ERROR => Some(Level::Error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable packet-view representation, including malformed values.
|
||||
pub fn wire_level_label(level: u8, trace: bool) -> String {
|
||||
match level_from_wire(level, trace) {
|
||||
Some(level) => level.as_str().into(),
|
||||
None => format!("UNKNOWN ({level})"),
|
||||
/// Human-readable packet-view representation.
|
||||
pub const fn wire_level_label(level: LogLevel) -> &'static str {
|
||||
match event_level_from_wire(level) {
|
||||
Some(level) => level.as_str(),
|
||||
None => "OFF",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,27 +188,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_level_accepts_only_the_exact_kaptan_contract() {
|
||||
fn wire_level_accepts_only_event_values() {
|
||||
assert_eq!(event_level_from_wire(LogLevel::LOG_LEVEL_OFF), None);
|
||||
for (wire, expected) in [
|
||||
(0, None),
|
||||
(1, None),
|
||||
(2, None),
|
||||
(3, Some(Level::Error)),
|
||||
(4, Some(Level::Warn)),
|
||||
(5, None),
|
||||
(6, Some(Level::Info)),
|
||||
(7, Some(Level::Debug)),
|
||||
(8, None),
|
||||
(u8::MAX, None),
|
||||
(LogLevel::LOG_LEVEL_TRACE, Level::Trace),
|
||||
(LogLevel::LOG_LEVEL_DEBUG, Level::Debug),
|
||||
(LogLevel::LOG_LEVEL_INFO, Level::Info),
|
||||
(LogLevel::LOG_LEVEL_WARN, Level::Warn),
|
||||
(LogLevel::LOG_LEVEL_ERROR, Level::Error),
|
||||
] {
|
||||
assert_eq!(level_from_wire(wire, false), expected, "wire {wire}");
|
||||
assert_eq!(event_level_from_wire(wire), Some(expected));
|
||||
}
|
||||
assert_eq!(level_from_wire(7, true), Some(Level::Trace));
|
||||
for invalid_trace_level in [0, 1, 2, 3, 4, 5, 6, 8, u8::MAX] {
|
||||
assert_eq!(level_from_wire(invalid_trace_level, true), None);
|
||||
}
|
||||
assert_eq!(wire_level_label(2, false), "UNKNOWN (2)");
|
||||
assert_eq!(wire_level_label(9, false), "UNKNOWN (9)");
|
||||
assert_eq!(wire_level_label(LogLevel::LOG_LEVEL_TRACE), "TRACE");
|
||||
assert_eq!(wire_level_label(LogLevel::LOG_LEVEL_OFF), "OFF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
|
||||
use std::{net::TcpListener, sync::Arc};
|
||||
|
||||
use kaptan_configurator::{
|
||||
api::serve_connection,
|
||||
serial_worker::{SerialBaud, SerialController},
|
||||
state::SharedState,
|
||||
};
|
||||
use kaptan_configurator::{SerialBaud, SerialController, SharedState, serve_connection};
|
||||
use kaptan_file_mavlink::Principal;
|
||||
|
||||
const LISTEN_ADDRESS: &str = "127.0.0.1:4861";
|
||||
const GCS_COMPONENT_ID: u8 = 190;
|
||||
|
||||
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)?;
|
||||
let serial = SerialController::new(
|
||||
Arc::clone(&state),
|
||||
arguments.baud,
|
||||
Principal::new(arguments.source_system, GCS_COMPONENT_ID),
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
if let Some(path) = arguments.serial_path {
|
||||
serial.connect(path).map_err(std::io::Error::other)?;
|
||||
}
|
||||
@@ -39,6 +41,7 @@ fn main() -> std::io::Result<()> {
|
||||
struct Arguments {
|
||||
serial_path: Option<String>,
|
||||
baud: SerialBaud,
|
||||
source_system: u8,
|
||||
}
|
||||
|
||||
fn arguments() -> std::io::Result<Arguments> {
|
||||
@@ -49,6 +52,7 @@ fn arguments_from(mut values: impl Iterator<Item = String>) -> std::io::Result<A
|
||||
let mut serial_path = None;
|
||||
let mut baud = SerialBaud::DEFAULT;
|
||||
let mut baud_seen = false;
|
||||
let mut source_system = None;
|
||||
while let Some(flag) = values.next() {
|
||||
match flag.as_str() {
|
||||
"--serial" if serial_path.is_none() => {
|
||||
@@ -67,16 +71,30 @@ fn arguments_from(mut values: impl Iterator<Item = String>) -> std::io::Result<A
|
||||
.ok_or_else(|| invalid_arguments(&flag))?;
|
||||
baud_seen = true;
|
||||
}
|
||||
"--source-system" if source_system.is_none() => {
|
||||
let value = values.next().ok_or_else(|| invalid_arguments(&flag))?;
|
||||
source_system = value.parse::<u8>().ok().filter(|value| *value != 0);
|
||||
if source_system.is_none() {
|
||||
return Err(invalid_arguments(&flag));
|
||||
}
|
||||
}
|
||||
_ => return Err(invalid_arguments(&flag)),
|
||||
}
|
||||
}
|
||||
Ok(Arguments { serial_path, baud })
|
||||
let source_system = source_system.ok_or_else(|| invalid_arguments("--source-system"))?;
|
||||
Ok(Arguments {
|
||||
serial_path,
|
||||
baud,
|
||||
source_system,
|
||||
})
|
||||
}
|
||||
|
||||
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]"),
|
||||
format!(
|
||||
"invalid {flag}; usage: kaptan-configurator --source-system ID [--serial PATH] [--baud RATE]"
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,31 +108,60 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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");
|
||||
fn command_line_defaults_to_115200_and_requires_an_exact_source_system() {
|
||||
let accepted = arguments_from(
|
||||
["--source-system", "255", "--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);
|
||||
assert_eq!(accepted.baud, SerialBaud::try_new(115_200).unwrap());
|
||||
assert_eq!(accepted.source_system, 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_accepts_a_supported_baud_in_either_order() {
|
||||
for values in [
|
||||
["--baud", "57600", "--serial", "/dev/ttyUSB0"],
|
||||
["--serial", "/dev/ttyUSB0", "--baud", "57600"],
|
||||
[
|
||||
"--source-system",
|
||||
"254",
|
||||
"--baud",
|
||||
"57600",
|
||||
"--serial",
|
||||
"/dev/ttyUSB0",
|
||||
],
|
||||
[
|
||||
"--serial",
|
||||
"/dev/ttyUSB0",
|
||||
"--baud",
|
||||
"57600",
|
||||
"--source-system",
|
||||
"254",
|
||||
],
|
||||
] {
|
||||
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);
|
||||
assert_eq!(accepted.baud, SerialBaud::try_new(57_600).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_rejects_unsupported_or_duplicate_baud_arguments() {
|
||||
for values in [
|
||||
vec!["--baud", "12345"],
|
||||
vec!["--baud", "115200", "--baud", "57600"],
|
||||
vec!["--baud"],
|
||||
vec!["--source-system", "255", "--baud", "12345"],
|
||||
vec![
|
||||
"--source-system",
|
||||
"255",
|
||||
"--baud",
|
||||
"115200",
|
||||
"--baud",
|
||||
"57600",
|
||||
],
|
||||
vec!["--source-system", "255", "--baud"],
|
||||
vec!["--source-system", "0"],
|
||||
vec!["--source-system", "255", "--source-system", "254"],
|
||||
vec![],
|
||||
] {
|
||||
let rejected = arguments_from(values.into_iter().map(String::from))
|
||||
.expect_err("invalid baud arguments must be rejected");
|
||||
|
||||
@@ -166,12 +166,15 @@ mod tests {
|
||||
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(51_027, "HEARTBEAT"), packet(51_027, "HEARTBEAT")],
|
||||
start,
|
||||
);
|
||||
monitor.observe(
|
||||
[
|
||||
packet(0, "HEARTBEAT"),
|
||||
packet(0, "HEARTBEAT"),
|
||||
packet(0, "HEARTBEAT"),
|
||||
packet(51_027, "HEARTBEAT"),
|
||||
packet(51_027, "HEARTBEAT"),
|
||||
packet(51_027, "HEARTBEAT"),
|
||||
],
|
||||
start + Duration::from_millis(500),
|
||||
);
|
||||
@@ -180,14 +183,14 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(snapshot.packets[0].values["sequence"], 51_027);
|
||||
}
|
||||
|
||||
#[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);
|
||||
monitor.observe([packet(51_027, "HEARTBEAT")], start);
|
||||
assert_eq!(
|
||||
monitor.snapshot(start + RATE_WINDOW).packets[0].frequency_hz,
|
||||
1.0
|
||||
@@ -201,9 +204,9 @@ mod tests {
|
||||
#[test]
|
||||
fn retains_only_the_latest_decoded_values_for_each_identity() {
|
||||
let start = Instant::now();
|
||||
let mut first = packet(51021, "KAPTAN_ERROR");
|
||||
let mut first = packet(51021, "ERROR");
|
||||
first.values = serde_json::json!({"path": "first"});
|
||||
let mut second = packet(51021, "KAPTAN_ERROR");
|
||||
let mut second = packet(51021, "ERROR");
|
||||
second.values = serde_json::json!({"path": "second"});
|
||||
let mut monitor = PacketMonitor::default();
|
||||
|
||||
@@ -240,7 +243,7 @@ mod tests {
|
||||
fn clear_starts_a_new_link_session() {
|
||||
let start = Instant::now();
|
||||
let mut monitor = PacketMonitor::default();
|
||||
monitor.observe([packet(0, "HEARTBEAT")], start);
|
||||
monitor.observe([packet(51_027, "HEARTBEAT")], start);
|
||||
monitor.clear();
|
||||
assert!(monitor.snapshot(start + RATE_WINDOW).packets.is_empty());
|
||||
}
|
||||
|
||||
@@ -14,18 +14,19 @@ use std::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config_client::{ClientEvent, ConfigClient},
|
||||
log_monitor::{LogObservation, level_from_wire, wire_level_label},
|
||||
config_client::{ConfigClient, Edit, Outcome},
|
||||
log_monitor::{LogObservation, event_level_from_wire, wire_level_label},
|
||||
packet_monitor::PacketObservation,
|
||||
state::{SaveRequest, SharedState},
|
||||
state::SharedState,
|
||||
};
|
||||
use kaptan_file_mavlink::{Principal, Target};
|
||||
use kaptan_log::Level;
|
||||
use kaptan_mavlink::{
|
||||
MavlinkFrame,
|
||||
message::{self, Message},
|
||||
generated::kaptan::{HeartbeatState, LogFlags, MavMessage},
|
||||
receiver::IntegrityMavlinkReceiver,
|
||||
};
|
||||
use mavlink_core::MavlinkVersion;
|
||||
use mavlink_core::Message;
|
||||
|
||||
const COMMAND_CAPACITY: usize = 8;
|
||||
const IDLE_POLL: Duration = Duration::from_millis(20);
|
||||
@@ -48,11 +49,6 @@ impl SerialBaud {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -68,10 +64,10 @@ impl SerialBaud {
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Command {
|
||||
pub(crate) enum Command {
|
||||
Connect { path: String, baud: SerialBaud },
|
||||
Disconnect,
|
||||
Save(SaveRequest),
|
||||
Save(Vec<Edit>),
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
@@ -82,11 +78,15 @@ pub struct SerialController {
|
||||
}
|
||||
|
||||
impl SerialController {
|
||||
pub fn new(state: Arc<SharedState>, baud: SerialBaud) -> Result<Self, String> {
|
||||
pub fn new(
|
||||
state: Arc<SharedState>,
|
||||
baud: SerialBaud,
|
||||
local: Principal,
|
||||
) -> 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))
|
||||
.spawn(move || worker(receiver, state, local))
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Self {
|
||||
commands,
|
||||
@@ -103,7 +103,7 @@ impl SerialController {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn send(&self, command: Command) -> Result<(), String> {
|
||||
pub(crate) fn send(&self, command: Command) -> Result<(), String> {
|
||||
self.commands
|
||||
.try_send(command)
|
||||
.map_err(|error| match error {
|
||||
@@ -112,7 +112,7 @@ impl SerialController {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
fn shutdown(&self) {
|
||||
let _ = self.commands.send(Command::Shutdown);
|
||||
if let Some(worker) = lock(&self.worker).take() {
|
||||
let _ = worker.join();
|
||||
@@ -126,27 +126,30 @@ impl Drop for SerialController {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Session {
|
||||
link: Option<File>,
|
||||
receiver: IntegrityMavlinkReceiver<1>,
|
||||
client: ConfigClient,
|
||||
target: Option<(u8, u8)>,
|
||||
sequence: u8,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn new(local: Principal) -> Self {
|
||||
Self {
|
||||
link: None,
|
||||
receiver: IntegrityMavlinkReceiver::default(),
|
||||
client: ConfigClient::new(local),
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
fn worker(commands: Receiver<Command>, state: Arc<SharedState>, local: Principal) {
|
||||
let mut session = Session::new(local);
|
||||
loop {
|
||||
loop {
|
||||
match commands.try_recv() {
|
||||
@@ -190,8 +193,8 @@ fn handle_command(command: Command, session: &mut Session, state: &SharedState)
|
||||
session.close();
|
||||
state.disconnected(None);
|
||||
}
|
||||
Command::Save(save) => {
|
||||
let request = session.client.save(save.edits, &mut session.sequence);
|
||||
Command::Save(edits) => {
|
||||
let request = session.client.save(edits);
|
||||
match request {
|
||||
Ok(request) => {
|
||||
if let Err(error) = write_raw(session, request.raw_bytes()) {
|
||||
@@ -233,40 +236,30 @@ fn service_link(session: &mut Session, state: &SharedState) -> Result<(), String
|
||||
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)
|
||||
let Some(message) = frame.decode() else {
|
||||
continue;
|
||||
};
|
||||
let raw = frame.raw();
|
||||
let (packet, log) = decoded_frame_observations(raw, &message);
|
||||
state.observe_packets([packet]);
|
||||
state.observe_logs(log);
|
||||
if session.client.target().is_err()
|
||||
&& let Some(target) = decoded_heartbeat_target(raw, &message)
|
||||
{
|
||||
session.target = Some(target);
|
||||
state.discovering();
|
||||
let request = session.client.discover(target, &mut session.sequence)?;
|
||||
let request = session.client.discover(target)?;
|
||||
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 Some(source) = Target::try_new(raw.system_id(), raw.component_id()) else {
|
||||
continue;
|
||||
};
|
||||
let outcome = session.client.observe(source, &message);
|
||||
handle_client_outcome(session, state, outcome)?;
|
||||
}
|
||||
|
||||
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())?;
|
||||
}
|
||||
let outcome = session.client.retry_due();
|
||||
handle_client_outcome(session, state, outcome)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -275,66 +268,46 @@ fn packet_observation(frame: &MavlinkFrame) -> PacketObservation {
|
||||
frame_observations(frame).0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn parse_test_message(frame: &MavlinkFrame) -> Option<MavMessage> {
|
||||
let mut receiver = IntegrityMavlinkReceiver::<1>::new();
|
||||
for byte in frame.raw_bytes() {
|
||||
receiver.receive_byte(*byte);
|
||||
}
|
||||
receiver.take_received()?.decode()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn frame_observations(frame: &MavlinkFrame) -> (PacketObservation, Option<LogObservation>) {
|
||||
let message_id = frame.message_id();
|
||||
let (name, values, log) = decoded_observations(frame);
|
||||
let message = parse_test_message(frame).expect("test frame must be CRC-valid Kaptan MAVLink");
|
||||
decoded_frame_observations(frame, &message)
|
||||
}
|
||||
|
||||
fn decoded_frame_observations(
|
||||
frame: &MavlinkFrame,
|
||||
message: &MavMessage,
|
||||
) -> (PacketObservation, Option<LogObservation>) {
|
||||
let packet = PacketObservation {
|
||||
system_id: frame.system_id(),
|
||||
component_id: frame.component_id(),
|
||||
message_id,
|
||||
name,
|
||||
values,
|
||||
message_id: frame.message_id(),
|
||||
name: message.message_name(),
|
||||
values: kaptan_message_values(message),
|
||||
};
|
||||
(packet, log)
|
||||
(packet, log_observation(frame, message))
|
||||
}
|
||||
|
||||
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> {
|
||||
fn log_observation(frame: &MavlinkFrame, message: &MavMessage) -> Option<LogObservation> {
|
||||
let (level, truncated, text) = match message {
|
||||
message::kaptan::MavMessage::KAPTAN_LOG(log) => {
|
||||
let trace = log
|
||||
.flags
|
||||
.contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE);
|
||||
(
|
||||
level_from_wire(log.level, trace)?,
|
||||
log.flags
|
||||
.contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRUNCATED),
|
||||
bounded_text(&log.message, log.message_length)?,
|
||||
)
|
||||
}
|
||||
message::kaptan::MavMessage::KAPTAN_ERROR(error) => {
|
||||
MavMessage::LOG(log) => (
|
||||
event_level_from_wire(log.level)?,
|
||||
log.flags.contains(LogFlags::LOG_FLAG_TRUNCATED),
|
||||
bounded_text(&log.message, log.message_length)?,
|
||||
),
|
||||
MavMessage::ERROR(error) => {
|
||||
if error.error_id == 0 {
|
||||
return None;
|
||||
}
|
||||
let raised_at = zero_padded_text(&error.raised_at)?;
|
||||
let text = if raised_at.is_empty() {
|
||||
format!("Kaptan error {}", error.error_id)
|
||||
@@ -354,29 +327,59 @@ fn log_observation(
|
||||
})
|
||||
}
|
||||
|
||||
fn kaptan_message_values(message: &message::kaptan::MavMessage) -> serde_json::Value {
|
||||
fn kaptan_message_values(message: &MavMessage) -> serde_json::Value {
|
||||
match message {
|
||||
message::kaptan::MavMessage::KAPTAN_ERROR(error) => serde_json::json!({
|
||||
MavMessage::ERROR(error) => serde_json::json!({
|
||||
"error_id": error.error_id,
|
||||
"raised_at": packet_zero_padded_text(&error.raised_at),
|
||||
}),
|
||||
message::kaptan::MavMessage::KAPTAN_LOG(log) => serde_json::json!({
|
||||
"level": wire_level_label(
|
||||
log.level,
|
||||
log.flags.contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE),
|
||||
),
|
||||
MavMessage::LOG(log) => serde_json::json!({
|
||||
"level": wire_level_label(log.level),
|
||||
"flags": format!("{:?}", log.flags),
|
||||
"flags_bits": log.flags.bits(),
|
||||
"message": packet_text(&log.message, log.message_length),
|
||||
}),
|
||||
_ => decoded_fields(message),
|
||||
MavMessage::HEARTBEAT(heartbeat) => serde_json::json!({
|
||||
"state": format!("{:?}", heartbeat.state),
|
||||
}),
|
||||
MavMessage::IMU(imu) => serde_json::json!({
|
||||
"time_us": imu.time_us,
|
||||
"acceleration_milligravity": imu.acceleration_milligravity,
|
||||
"angular_rate_milliradians_per_second": imu.angular_rate_milliradians_per_second,
|
||||
"temperature_centidegrees_celsius": imu.temperature_centidegrees_celsius,
|
||||
}),
|
||||
MavMessage::RANGE(range) => serde_json::json!({
|
||||
"time_us": range.time_us,
|
||||
"distance_millimetres": range.distance_millimetres,
|
||||
}),
|
||||
MavMessage::OPTICAL_FLOW(flow) => serde_json::json!({
|
||||
"time_us": flow.time_us,
|
||||
"velocity_millimetres_per_second": flow.velocity_millimetres_per_second,
|
||||
"ground_distance_millimetres": flow.ground_distance_millimetres,
|
||||
"quality": flow.quality,
|
||||
}),
|
||||
MavMessage::FILE_CONTROL(control) => serde_json::json!({
|
||||
"target_system": control.target_system,
|
||||
"target_component": control.target_component,
|
||||
"operation": format!("{:?}", control.operation),
|
||||
"status": format!("{:?}", control.status),
|
||||
"transfer_id": control.transfer_id,
|
||||
"position": control.position,
|
||||
"file_size": control.file_size,
|
||||
"checksum_crc32c": control.checksum_crc32c,
|
||||
"content_hex": hex_bytes(&control.content[..usize::from(control.content_length)
|
||||
.min(control.content.len())]),
|
||||
}),
|
||||
MavMessage::FILE_DATA(data) => serde_json::json!({
|
||||
"target_system": data.target_system,
|
||||
"target_component": data.target_component,
|
||||
"status": format!("{:?}", data.status),
|
||||
"transfer_id": data.transfer_id,
|
||||
"position": data.position,
|
||||
"data_hex": hex_bytes(&data.data),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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) -> Option<String> {
|
||||
let text = core::str::from_utf8(bytes.get(..usize::from(length))?).ok()?;
|
||||
Some(text.into())
|
||||
@@ -405,11 +408,19 @@ fn hex_bytes(bytes: &[u8]) -> String {
|
||||
encoded
|
||||
}
|
||||
|
||||
fn handle_client_event(session: &mut Session, state: &SharedState, event: ClientEvent) {
|
||||
if matches!(event, ClientEvent::RediscoveryRequired(_)) {
|
||||
session.target = None;
|
||||
fn handle_client_outcome(
|
||||
session: &mut Session,
|
||||
state: &SharedState,
|
||||
outcome: Outcome,
|
||||
) -> Result<(), String> {
|
||||
match outcome {
|
||||
Outcome::Idle => Ok(()),
|
||||
Outcome::Event(event) => {
|
||||
state.configuration_event(event);
|
||||
Ok(())
|
||||
}
|
||||
Outcome::Send(request) => write_raw(session, request.raw_bytes()),
|
||||
}
|
||||
state.configuration_event(event);
|
||||
}
|
||||
|
||||
fn write_raw(session: &mut Session, bytes: &[u8]) -> Result<(), String> {
|
||||
@@ -421,17 +432,20 @@ fn write_raw(session: &mut Session, bytes: &[u8]) -> Result<(), String> {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn heartbeat_target(frame: &MavlinkFrame) -> Option<(u8, u8)> {
|
||||
if !frame.has_valid_crc::<message::MavMessage>() {
|
||||
fn decoded_heartbeat_target(frame: &MavlinkFrame, message: &MavMessage) -> Option<Target> {
|
||||
let MavMessage::HEARTBEAT(heartbeat) = message else {
|
||||
return None;
|
||||
};
|
||||
if heartbeat.state != HeartbeatState::HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED {
|
||||
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)
|
||||
Target::try_new(frame.system_id(), frame.component_id())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn heartbeat_target(frame: &MavlinkFrame) -> Option<Target> {
|
||||
let message = parse_test_message(frame)?;
|
||||
decoded_heartbeat_target(frame, &message)
|
||||
}
|
||||
|
||||
fn open_serial(path: &str, baud: SerialBaud) -> Result<File, String> {
|
||||
@@ -481,11 +495,18 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kaptan_mavlink::message::{
|
||||
HEARTBEAT_DATA, MavAutopilot, MavHeader, MavModeFlag, MavState, MavType,
|
||||
use kaptan_file_mavlink::DATA_BYTES;
|
||||
use kaptan_mavlink::generated::kaptan::{
|
||||
ERROR_DATA, FILE_DATA_DATA, FileStatus as WireFileStatus, HEARTBEAT_DATA, IMU_DATA,
|
||||
LOG_DATA, LogLevel,
|
||||
};
|
||||
use mavlink_core::{MavHeader, MessageData};
|
||||
|
||||
fn heartbeat(system_id: u8, component_id: u8, autopilot: MavAutopilot) -> MavlinkFrame {
|
||||
fn frame<M: MessageData<Message = MavMessage>>(
|
||||
system_id: u8,
|
||||
component_id: u8,
|
||||
data: &M,
|
||||
) -> MavlinkFrame {
|
||||
let mut raw = MavlinkFrame::new();
|
||||
raw.serialize_message_data(
|
||||
MavHeader {
|
||||
@@ -493,31 +514,31 @@ mod tests {
|
||||
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,
|
||||
},
|
||||
data,
|
||||
);
|
||||
raw
|
||||
}
|
||||
|
||||
fn heartbeat(system_id: u8, component_id: u8) -> MavlinkFrame {
|
||||
frame(
|
||||
system_id,
|
||||
component_id,
|
||||
&HEARTBEAT_DATA {
|
||||
state: HeartbeatState::HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[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)));
|
||||
fn point_to_point_discovery_accepts_the_exact_kaptan_heartbeat() {
|
||||
let frame = heartbeat(42, 7);
|
||||
assert_eq!(heartbeat_target(&frame), Some(Target::new(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);
|
||||
assert_eq!(heartbeat_target(&heartbeat(0, 7)), None);
|
||||
let frame = heartbeat(42, 7);
|
||||
let mut bytes = frame.into_inner();
|
||||
bytes[10] ^= 1;
|
||||
let unverified = MavlinkFrame::from_bytes_unparsed(bytes);
|
||||
@@ -526,16 +547,34 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn packet_monitor_uses_the_decoded_dialect_name() {
|
||||
let observation =
|
||||
packet_observation(&heartbeat(42, 7, MavAutopilot::MAV_AUTOPILOT_GENERIC));
|
||||
let observation = packet_observation(&heartbeat(42, 7));
|
||||
assert_eq!(observation.system_id, 42);
|
||||
assert_eq!(observation.component_id, 7);
|
||||
assert_eq!(observation.message_id, 0);
|
||||
assert_eq!(observation.message_id, 51_027);
|
||||
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'));
|
||||
assert_eq!(
|
||||
observation.values["state"],
|
||||
"HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_data_packet_exposes_its_direction() {
|
||||
let observation = packet_observation(&frame(
|
||||
42,
|
||||
7,
|
||||
&FILE_DATA_DATA {
|
||||
position: 246,
|
||||
transfer_id: 9,
|
||||
target_system: 255,
|
||||
target_component: 190,
|
||||
status: WireFileStatus::FILE_STATUS_OK,
|
||||
data: [0; DATA_BYTES],
|
||||
},
|
||||
));
|
||||
|
||||
assert_eq!(observation.name, "FILE_DATA");
|
||||
assert_eq!(observation.values["status"], "FILE_STATUS_OK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -550,14 +589,14 @@ mod tests {
|
||||
system_id: 42,
|
||||
component_id: 7,
|
||||
},
|
||||
&message::kaptan::KAPTAN_ERROR_DATA {
|
||||
&ERROR_DATA {
|
||||
error_id: 1234,
|
||||
raised_at,
|
||||
},
|
||||
);
|
||||
|
||||
let (observation, log) = frame_observations(&frame);
|
||||
assert_eq!(observation.name, "KAPTAN_ERROR");
|
||||
assert_eq!(observation.name, "ERROR");
|
||||
assert!(observation.values.get("level").is_none());
|
||||
assert_eq!(observation.values["error_id"], 1234);
|
||||
assert_eq!(observation.values["raised_at"], raised_at_text);
|
||||
@@ -589,16 +628,16 @@ mod tests {
|
||||
system_id: 42,
|
||||
component_id: 7,
|
||||
},
|
||||
&message::kaptan::KAPTAN_LOG_DATA {
|
||||
level: 6,
|
||||
flags: message::kaptan::KaptanLogFlags::empty(),
|
||||
&LOG_DATA {
|
||||
level: LogLevel::LOG_LEVEL_INFO,
|
||||
flags: LogFlags::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.name, "LOG");
|
||||
assert_eq!(packet.values["message"], "estimator initialized");
|
||||
assert_eq!(
|
||||
log,
|
||||
@@ -612,6 +651,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_level_is_visible_as_a_packet_but_not_a_log_event() {
|
||||
let mut frame = MavlinkFrame::new();
|
||||
frame.serialize_message_data(
|
||||
MavHeader {
|
||||
sequence: 1,
|
||||
system_id: 42,
|
||||
component_id: 7,
|
||||
},
|
||||
&LOG_DATA {
|
||||
level: LogLevel::LOG_LEVEL_OFF,
|
||||
flags: LogFlags::empty(),
|
||||
message_length: 0,
|
||||
message: [0; 160],
|
||||
},
|
||||
);
|
||||
|
||||
let (packet, log) = frame_observations(&frame);
|
||||
assert_eq!(packet.values["level"], "OFF");
|
||||
assert_eq!(log, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_level_runtime_log_remains_kaptan_log() {
|
||||
let message_bytes = [b'x'; 160];
|
||||
@@ -623,16 +684,16 @@ mod tests {
|
||||
system_id: 42,
|
||||
component_id: 7,
|
||||
},
|
||||
&message::kaptan::KAPTAN_LOG_DATA {
|
||||
level: 3,
|
||||
flags: message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRUNCATED,
|
||||
&LOG_DATA {
|
||||
level: LogLevel::LOG_LEVEL_ERROR,
|
||||
flags: LogFlags::LOG_FLAG_TRUNCATED,
|
||||
message_length: message_bytes.len() as u8,
|
||||
message: message_bytes,
|
||||
},
|
||||
);
|
||||
|
||||
let (packet, log) = frame_observations(&frame);
|
||||
assert_eq!(packet.name, "KAPTAN_LOG");
|
||||
assert_eq!(packet.name, "LOG");
|
||||
assert_eq!(
|
||||
log,
|
||||
Some(LogObservation {
|
||||
@@ -646,23 +707,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_log_text_or_level_is_not_added_to_history() {
|
||||
fn malformed_log_text_is_not_added_to_history() {
|
||||
let mut invalid_utf8 = [0_u8; 160];
|
||||
invalid_utf8[0] = 0xff;
|
||||
for (level, flags, length, message) in [
|
||||
(
|
||||
6,
|
||||
message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE,
|
||||
0,
|
||||
[0_u8; 160],
|
||||
),
|
||||
(
|
||||
6,
|
||||
message::kaptan::KaptanLogFlags::empty(),
|
||||
LogLevel::LOG_LEVEL_INFO,
|
||||
LogFlags::empty(),
|
||||
161,
|
||||
[0_u8; 160],
|
||||
),
|
||||
(6, message::kaptan::KaptanLogFlags::empty(), 1, invalid_utf8),
|
||||
(LogLevel::LOG_LEVEL_INFO, LogFlags::empty(), 1, invalid_utf8),
|
||||
] {
|
||||
let mut frame = MavlinkFrame::new();
|
||||
frame.serialize_message_data(
|
||||
@@ -671,7 +726,7 @@ mod tests {
|
||||
system_id: 42,
|
||||
component_id: 7,
|
||||
},
|
||||
&message::kaptan::KAPTAN_LOG_DATA {
|
||||
&LOG_DATA {
|
||||
level,
|
||||
flags,
|
||||
message_length: length,
|
||||
@@ -683,6 +738,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sensor_packet_values_are_structured() {
|
||||
let observation = packet_observation(&frame(
|
||||
42,
|
||||
7,
|
||||
&IMU_DATA {
|
||||
time_us: 123,
|
||||
acceleration_milligravity: [1, 2, 3],
|
||||
angular_rate_milliradians_per_second: [-4, 5, -6],
|
||||
temperature_centidegrees_celsius: 2_500,
|
||||
},
|
||||
));
|
||||
|
||||
assert_eq!(observation.name, "IMU");
|
||||
assert_eq!(observation.values["time_us"], 123);
|
||||
assert_eq!(
|
||||
observation.values["angular_rate_milliradians_per_second"],
|
||||
serde_json::json!([-4, 5, -6])
|
||||
);
|
||||
assert!(observation.values.as_str().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_reportable_error_location_is_not_added_to_history() {
|
||||
let mut raised_at = [0_u8; 96];
|
||||
@@ -694,7 +771,7 @@ mod tests {
|
||||
system_id: 42,
|
||||
component_id: 7,
|
||||
},
|
||||
&message::kaptan::KAPTAN_ERROR_DATA {
|
||||
&ERROR_DATA {
|
||||
error_id: 1,
|
||||
raised_at,
|
||||
},
|
||||
@@ -703,13 +780,33 @@ mod tests {
|
||||
assert_eq!(log, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_zero_error_id_is_visible_as_a_packet_but_not_an_event() {
|
||||
let mut frame = MavlinkFrame::new();
|
||||
frame.serialize_message_data(
|
||||
MavHeader {
|
||||
sequence: 1,
|
||||
system_id: 42,
|
||||
component_id: 7,
|
||||
},
|
||||
&ERROR_DATA {
|
||||
error_id: 0,
|
||||
raised_at: [0; 96],
|
||||
},
|
||||
);
|
||||
|
||||
let (packet, log) = frame_observations(&frame);
|
||||
assert_eq!(packet.values["error_id"], 0);
|
||||
assert_eq!(log, None);
|
||||
}
|
||||
|
||||
#[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_eq!(baud, SerialBaud::try_new(rate).unwrap());
|
||||
assert_ne!(baud.termios_speed(), 0);
|
||||
}
|
||||
assert_eq!(SerialBaud::try_new(0), None);
|
||||
|
||||
@@ -6,13 +6,11 @@ use std::{
|
||||
time::{Instant, SystemTime},
|
||||
};
|
||||
|
||||
use kaptan_config::{ConfigValue, FieldId, ValueKind};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
config_client::{
|
||||
ClientEvent, Edit, RemoteConfig, RemoteField, Scalar, ValueKind, scalar_json,
|
||||
scalar_less_than,
|
||||
},
|
||||
config_client::{ClientEvent, Edit, RemoteConfig, RemoteField, scalar_json, scalar_less_than},
|
||||
log_monitor::{LogMonitor, LogObservation, LogSnapshot},
|
||||
packet_monitor::{PacketMonitor, PacketObservation, PacketSnapshot},
|
||||
};
|
||||
@@ -23,6 +21,7 @@ pub struct StateSnapshot {
|
||||
pub status: String,
|
||||
pub error: Option<String>,
|
||||
pub config_revision: u64,
|
||||
pub has_config: bool,
|
||||
pub can_edit: bool,
|
||||
pub can_save: bool,
|
||||
pub can_disconnect: bool,
|
||||
@@ -46,7 +45,7 @@ pub struct FieldView {
|
||||
pub kind: &'static str,
|
||||
pub minimum: Option<serde_json::Value>,
|
||||
pub maximum: Option<serde_json::Value>,
|
||||
pub default: serde_json::Value,
|
||||
pub factory: serde_json::Value,
|
||||
pub value: serde_json::Value,
|
||||
pub help: String,
|
||||
pub options: Vec<EnumOptionView>,
|
||||
@@ -58,11 +57,6 @@ pub struct EnumOptionView {
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SaveRequest {
|
||||
pub edits: Vec<Edit>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum Operation {
|
||||
Disconnected,
|
||||
@@ -70,6 +64,7 @@ enum Operation {
|
||||
Discovering,
|
||||
Ready,
|
||||
Saving,
|
||||
DurableStateUnknown,
|
||||
}
|
||||
|
||||
struct State {
|
||||
@@ -78,7 +73,7 @@ struct State {
|
||||
status: String,
|
||||
error: Option<String>,
|
||||
config: Option<RemoteConfig>,
|
||||
draft: BTreeMap<u16, Scalar>,
|
||||
draft: BTreeMap<FieldId, ConfigValue>,
|
||||
config_revision: u64,
|
||||
packets: PacketMonitor,
|
||||
logs: LogMonitor,
|
||||
@@ -111,6 +106,7 @@ impl SharedState {
|
||||
status: state.status.clone(),
|
||||
error: state.error.clone(),
|
||||
config_revision: state.config_revision,
|
||||
has_config: state.config.is_some(),
|
||||
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),
|
||||
@@ -128,8 +124,13 @@ impl SharedState {
|
||||
};
|
||||
};
|
||||
ConfigSnapshot {
|
||||
schema_id: Some(hex(&config.info.schema_id)),
|
||||
state: config.info.state.clone(),
|
||||
schema_id: Some(hex(&config.schema_id)),
|
||||
state: if config.restart_required {
|
||||
"restart required"
|
||||
} else {
|
||||
"active"
|
||||
}
|
||||
.into(),
|
||||
fields: config
|
||||
.fields
|
||||
.iter()
|
||||
@@ -195,16 +196,16 @@ impl SharedState {
|
||||
pub fn discovering(&self) {
|
||||
let mut state = lock(&self.0);
|
||||
state.operation = Operation::Discovering;
|
||||
state.status = "Reading configuration schema and values…".into();
|
||||
state.status = "Exporting configuration file…".into();
|
||||
state.error = None;
|
||||
}
|
||||
|
||||
pub fn configuration_event(&self, event: ClientEvent) {
|
||||
pub(crate) 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" {
|
||||
state.status = if config.restart_required {
|
||||
"Saved. Restart the controller to activate the new configuration.".into()
|
||||
} else {
|
||||
"Configuration ready.".into()
|
||||
@@ -224,8 +225,17 @@ impl SharedState {
|
||||
}
|
||||
ClientEvent::RediscoveryRequired(detail) => {
|
||||
state.operation = Operation::Discovering;
|
||||
state.status = "Waiting to rediscover configuration…".into();
|
||||
state.error = Some(detail);
|
||||
state.config = None;
|
||||
state.draft.clear();
|
||||
state.config_revision = state.config_revision.wrapping_add(1);
|
||||
}
|
||||
ClientEvent::DurableStateUnknown(detail) => {
|
||||
state.operation = Operation::DurableStateUnknown;
|
||||
state.status =
|
||||
"Controller state changed; waiting to rediscover configuration…".into();
|
||||
"Configuration state is unknown. Power-cycle the controller, then disconnect and reconnect."
|
||||
.into();
|
||||
state.error = Some(detail);
|
||||
state.config = None;
|
||||
state.draft.clear();
|
||||
@@ -235,6 +245,8 @@ impl SharedState {
|
||||
}
|
||||
|
||||
pub fn edit(&self, field_id: u16, value: &serde_json::Value) -> Result<ConfigSnapshot, String> {
|
||||
let field_id =
|
||||
FieldId::try_new(field_id).ok_or_else(|| format!("unknown field id {field_id}"))?;
|
||||
let mut state = lock(&self.0);
|
||||
if state.operation != Operation::Ready {
|
||||
return Err("configuration is not ready for edits".into());
|
||||
@@ -243,7 +255,7 @@ impl SharedState {
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|config| config.fields.iter().find(|field| field.id == field_id))
|
||||
.ok_or_else(|| format!("unknown field id {field_id}"))?;
|
||||
.ok_or_else(|| format!("unknown field id {}", field_id.get()))?;
|
||||
let next = parse_scalar(field.kind, value)?;
|
||||
validate_bounds(field, next)?;
|
||||
if next == field.value {
|
||||
@@ -257,7 +269,7 @@ impl SharedState {
|
||||
Ok(self.configuration())
|
||||
}
|
||||
|
||||
pub fn begin_save(&self) -> Result<SaveRequest, String> {
|
||||
pub(crate) fn begin_save(&self) -> Result<Vec<Edit>, String> {
|
||||
let mut state = lock(&self.0);
|
||||
if state.operation != Operation::Ready {
|
||||
return Err("configuration is busy".into());
|
||||
@@ -269,20 +281,18 @@ impl SharedState {
|
||||
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(),
|
||||
};
|
||||
let 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)
|
||||
Ok(edits)
|
||||
}
|
||||
|
||||
pub fn worker_failed(&self, detail: String) {
|
||||
@@ -298,13 +308,13 @@ impl SharedState {
|
||||
}
|
||||
}
|
||||
|
||||
fn field_view(field: &RemoteField, draft: Option<Scalar>) -> FieldView {
|
||||
fn field_view(field: &RemoteField, draft: Option<ConfigValue>) -> FieldView {
|
||||
let (group, tail) = field
|
||||
.key
|
||||
.rsplit_once('.')
|
||||
.unwrap_or(("configuration", field.key.as_str()));
|
||||
FieldView {
|
||||
id: field.id,
|
||||
id: field.id.get(),
|
||||
key: field.key.clone(),
|
||||
label: humanize(tail),
|
||||
group: humanize(group),
|
||||
@@ -312,25 +322,27 @@ fn field_view(field: &RemoteField, draft: Option<Scalar>) -> FieldView {
|
||||
kind: match field.kind {
|
||||
ValueKind::Bool => "bool",
|
||||
ValueKind::F32 | ValueKind::F64 => "decimal",
|
||||
ValueKind::I32 | ValueKind::U32 | ValueKind::U64 => "integer",
|
||||
ValueKind::U8 | ValueKind::U16 | ValueKind::I32 | ValueKind::U32 | ValueKind::U64 => {
|
||||
"integer"
|
||||
}
|
||||
},
|
||||
minimum: field.minimum.map(scalar_json),
|
||||
maximum: field.maximum.map(scalar_json),
|
||||
default: scalar_json(field.default),
|
||||
factory: scalar_json(field.factory),
|
||||
value: scalar_json(draft.unwrap_or(field.value)),
|
||||
help: format!("Stable field ID {}.", field.id),
|
||||
help: format!("Stable field ID {}.", field.id.get()),
|
||||
options: field
|
||||
.options
|
||||
.iter()
|
||||
.map(|option| EnumOptionView {
|
||||
value: scalar_json(option.value),
|
||||
value: option.value.into(),
|
||||
label: option.label.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<Scalar, String> {
|
||||
fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<ConfigValue, String> {
|
||||
let text = value
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
@@ -338,19 +350,27 @@ fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<Scalar, St
|
||||
match kind {
|
||||
ValueKind::Bool => value
|
||||
.as_bool()
|
||||
.map(Scalar::Bool)
|
||||
.map(ConfigValue::Bool)
|
||||
.ok_or_else(|| "expected a boolean".into()),
|
||||
ValueKind::U8 => text
|
||||
.parse()
|
||||
.map(ConfigValue::U8)
|
||||
.map_err(|_| "expected an unsigned 8-bit integer".into()),
|
||||
ValueKind::U16 => text
|
||||
.parse()
|
||||
.map(ConfigValue::U16)
|
||||
.map_err(|_| "expected an unsigned 16-bit integer".into()),
|
||||
ValueKind::I32 => text
|
||||
.parse()
|
||||
.map(Scalar::I32)
|
||||
.map(ConfigValue::I32)
|
||||
.map_err(|_| "expected a signed 32-bit integer".into()),
|
||||
ValueKind::U32 => text
|
||||
.parse()
|
||||
.map(Scalar::U32)
|
||||
.map(ConfigValue::U32)
|
||||
.map_err(|_| "expected an unsigned 32-bit integer".into()),
|
||||
ValueKind::U64 => text
|
||||
.parse()
|
||||
.map(Scalar::U64)
|
||||
.map(ConfigValue::U64)
|
||||
.map_err(|_| "expected an unsigned 64-bit integer".into()),
|
||||
ValueKind::F32 => text
|
||||
.parse::<f32>()
|
||||
@@ -358,7 +378,7 @@ fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<Scalar, St
|
||||
.and_then(|value| {
|
||||
value
|
||||
.is_finite()
|
||||
.then_some(Scalar::F32(value))
|
||||
.then_some(ConfigValue::F32(value))
|
||||
.ok_or_else(|| "decimal must be finite".into())
|
||||
}),
|
||||
ValueKind::F64 => text
|
||||
@@ -367,14 +387,20 @@ fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<Scalar, St
|
||||
.and_then(|value| {
|
||||
value
|
||||
.is_finite()
|
||||
.then_some(Scalar::F64(value))
|
||||
.then_some(ConfigValue::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) {
|
||||
fn validate_bounds(field: &RemoteField, value: ConfigValue) -> Result<(), String> {
|
||||
if !field.options.is_empty()
|
||||
&& !matches!(
|
||||
value,
|
||||
ConfigValue::U8(value)
|
||||
if field.options.iter().any(|option| option.value == value)
|
||||
)
|
||||
{
|
||||
return Err(format!("{} requires a named choice", field.key));
|
||||
}
|
||||
if field
|
||||
@@ -425,23 +451,21 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config_client::{RemoteEnumOption, RemoteInfo, ValueKind};
|
||||
use crate::config_client::RemoteEnumOption;
|
||||
|
||||
fn unknown_board() -> RemoteConfig {
|
||||
RemoteConfig {
|
||||
info: RemoteInfo {
|
||||
schema_id: *b"UNKNOWN-SCHEMA01",
|
||||
state: "active".into(),
|
||||
},
|
||||
schema_id: *b"UNKNOWN-SCHEMA01",
|
||||
restart_required: false,
|
||||
fields: vec![RemoteField {
|
||||
id: 401,
|
||||
id: FieldId::new(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),
|
||||
minimum: Some(ConfigValue::U32(1)),
|
||||
maximum: Some(ConfigValue::U32(100)),
|
||||
factory: ConfigValue::U32(20),
|
||||
value: ConfigValue::U32(25),
|
||||
options: Vec::new(),
|
||||
}],
|
||||
}
|
||||
@@ -455,7 +479,24 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(save[0].field_id, FieldId::new(401));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_exposes_configuration_availability_across_link_states() {
|
||||
let state = SharedState::default();
|
||||
assert!(!state.snapshot().has_config);
|
||||
|
||||
state.connected("test".into());
|
||||
state.configuration_event(ClientEvent::Configuration(unknown_board()));
|
||||
assert!(state.snapshot().has_config);
|
||||
|
||||
state.edit(401, &serde_json::json!(30)).unwrap();
|
||||
state.begin_save().unwrap();
|
||||
assert!(state.snapshot().has_config);
|
||||
|
||||
state.disconnected(None);
|
||||
assert!(!state.snapshot().has_config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -469,13 +510,18 @@ mod tests {
|
||||
#[test]
|
||||
fn enum_choices_are_projected_and_validated_from_remote_metadata() {
|
||||
let mut config = unknown_board();
|
||||
config.fields[0].kind = ValueKind::U8;
|
||||
config.fields[0].minimum = Some(ConfigValue::U8(1));
|
||||
config.fields[0].maximum = Some(ConfigValue::U8(100));
|
||||
config.fields[0].factory = ConfigValue::U8(20);
|
||||
config.fields[0].value = ConfigValue::U8(25);
|
||||
config.fields[0].options = vec![
|
||||
RemoteEnumOption {
|
||||
value: Scalar::U32(20),
|
||||
value: 20,
|
||||
label: "Standard".into(),
|
||||
},
|
||||
RemoteEnumOption {
|
||||
value: Scalar::U32(25),
|
||||
value: 25,
|
||||
label: "Fast".into(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -81,7 +81,7 @@ q('#save').addEventListener('click', () => {
|
||||
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,
|
||||
field.minimum, field.maximum, field.factory, field.help, field.options,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ function buildEditor(fields) {
|
||||
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(' ');
|
||||
const helpText = [field.help, `Factory: ${field.factory}.`, bounds].filter(Boolean).join(' ');
|
||||
if (helpText) {
|
||||
const help = document.createElement('small');
|
||||
help.id = `help-${field.id}`;
|
||||
@@ -402,8 +402,8 @@ function renderLogs(snapshot) {
|
||||
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;
|
||||
showError(state.error ?? '');
|
||||
q('#configuration').hidden = !state.has_config;
|
||||
q('#packet-monitor').hidden = !state.can_disconnect;
|
||||
q('#log-monitor').hidden = !state.can_disconnect;
|
||||
renderPermissions();
|
||||
@@ -420,8 +420,9 @@ async function refresh() {
|
||||
renderPackets(packets);
|
||||
renderLogs(logs);
|
||||
if (state.config_revision !== configRevision) {
|
||||
const config = await api('/api/config');
|
||||
renderConfig(config);
|
||||
configRevision = state.config_revision;
|
||||
renderConfig(await api('/api/config'));
|
||||
q('#configuration').hidden = latestConfig.schema_id === null;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in new issue
Block a user