Implemented DebugService with TCPLogger injection

This commit is contained in:
Martino Ferrari
2026-04-09 22:22:39 +02:00
parent b86ede99b9
commit 96d98dfc3d
9 changed files with 894 additions and 245 deletions
+508 -21
View File
@@ -155,12 +155,38 @@ struct PlotInstance {
auto_bounds: bool,
}
#[derive(Clone, PartialEq)]
enum MsgStatus {
Unknown,
Success,
Failure,
}
#[derive(Clone)]
struct MessageHistoryEntry {
time: String,
destination: String,
function: String,
payload: String,
wait_reply: bool,
raw_cmd: String,
response: String,
status: MsgStatus,
}
#[derive(Clone, PartialEq)]
enum MainTab {
Plots,
Config,
}
enum InternalEvent {
Log(LogEntry),
Discovery(Vec<Signal>),
Tree(TreeItem),
CommandResponse(String),
NodeInfo(String),
ConfigResponse(String),
Connected,
Disconnected,
InternalLog(String),
@@ -170,7 +196,7 @@ enum InternalEvent {
UdpDropped(u32),
RecordPathChosen(String, String), // SignalName, FilePath
RecordingError(String, String), // SignalName, ErrorMessage
TelemMatched(u32), // Signal ID
TelemMatched(u32),
ServiceConfig { udp_port: String, log_port: String },
}
@@ -236,7 +262,6 @@ struct MarteDebugApp {
node_info: String,
udp_packets: u64,
udp_dropped: u64,
telem_match_count: HashMap<u32, u64>,
forcing_dialog: Option<ForcingDialog>,
monitoring_dialog: Option<MonitorDialog>,
message_dialog: Option<MessageDialog>,
@@ -246,6 +271,11 @@ struct MarteDebugApp {
internal_tx: Sender<InternalEvent>,
shared_x_range: Option<[f64; 2]>,
scope: ScopeSettings,
active_main_tab: MainTab,
app_config_text: String,
message_history: Vec<MessageHistoryEntry>,
show_message_history: bool,
pending_msg_idx: Option<usize>,
}
impl MarteDebugApp {
@@ -317,7 +347,6 @@ impl MarteDebugApp {
node_info: "".to_string(),
udp_packets: 0,
udp_dropped: 0,
telem_match_count: HashMap::new(),
forcing_dialog: None,
monitoring_dialog: None,
message_dialog: None,
@@ -340,6 +369,11 @@ impl MarteDebugApp {
last_trigger_time: 0.0,
is_armed: true,
},
active_main_tab: MainTab::Plots,
app_config_text: String::new(),
message_history: Vec::new(),
show_message_history: true,
pending_msg_idx: None,
}
}
@@ -545,6 +579,76 @@ impl MarteDebugApp {
}
}
/// Emit the body of a JSON object as MARTe2 config lines at the given indent level.
/// `Class` is always emitted first; child objects with a `Class` key get `+` prefix.
fn json_node_to_marte(map: &serde_json::Map<String, serde_json::Value>, indent: usize) -> String {
let pad = " ".repeat(indent);
let mut out = String::new();
// Class first
if let Some(serde_json::Value::String(cls)) = map.get("Class") {
out.push_str(&format!("{}Class = {}\n", pad, cls));
}
// Collect and sort remaining keys so output is deterministic
let mut keys: Vec<&String> = map.keys().filter(|k| k.as_str() != "Class").collect();
keys.sort();
for key in keys {
let val = &map[key];
match val {
serde_json::Value::Object(child_map) => {
let prefix = if child_map.contains_key("Class") { "+" } else { "" };
out.push_str(&format!("{}{}{} = {{\n", pad, prefix, key));
out.push_str(&json_node_to_marte(child_map, indent + 1));
out.push_str(&format!("{}}}\n", pad));
}
serde_json::Value::String(s) => {
out.push_str(&format!("{}{} = {}\n", pad, key, s));
}
serde_json::Value::Null => {
out.push_str(&format!("{}{} =\n", pad, key));
}
other => {
out.push_str(&format!("{}{} = {}\n", pad, key, other));
}
}
}
out
}
fn convert_config_json(json_text: &str) -> String {
let root = match serde_json::from_str::<serde_json::Value>(json_text) {
Ok(v) => v,
Err(_) => return json_text.to_string(),
};
let map = match root.as_object() {
Some(m) => m,
None => return json_text.to_string(),
};
let mut out = String::new();
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for key in keys {
let val = &map[key];
match val {
serde_json::Value::Object(child_map) => {
// Top-level nodes always get + (they are named MARTe2 objects)
out.push_str(&format!("+{} = {{\n", key));
out.push_str(&json_node_to_marte(child_map, 1));
out.push_str("}\n\n");
}
serde_json::Value::String(s) => {
out.push_str(&format!("{} = {}\n", key, s));
}
other => {
out.push_str(&format!("{} = {}\n", key, other));
}
}
}
out
}
fn tcp_command_worker(
shared_config: Arc<Mutex<ConnectionConfig>>,
rx_cmd: Receiver<String>,
@@ -630,6 +734,12 @@ fn tcp_command_worker(
let _ = tx_events_inner
.send(InternalEvent::NodeInfo(json_clean.to_string()));
json_acc.clear();
} else if trimmed.contains("OK CONFIG") {
in_json = false;
let text = json_acc.split("OK CONFIG").next().unwrap_or("").trim();
let _ = tx_events_inner
.send(InternalEvent::ConfigResponse(text.to_string()));
json_acc.clear();
}
} else {
if trimmed.starts_with("OK SERVICE_INFO") {
@@ -882,6 +992,23 @@ fn udp_worker(
last_warning_time = std::time::Instant::now();
}
// Resolve the base timestamp once per packet, not per signal.
// BASE_TELEM_TS is a global lazy Mutex; locking it inside the
// inner loop at 1 kHz × N_signals/packet was a major bottleneck.
let packet_base_ts: Option<u64> = {
let mut guard = BASE_TELEM_TS.lock().unwrap();
if guard.is_none() {
// Peek at the first signal's timestamp to initialise base
if n >= 20 + 12 {
let first_ts = u64::from_le_bytes(
buf[20 + 4..20 + 12].try_into().unwrap(),
);
*guard = Some(first_ts);
}
}
*guard
};
for _ in 0..count {
if offset + 16 > n {
break;
@@ -898,21 +1025,15 @@ fn udp_worker(
}
let data_slice = &buf[offset..offset + size as usize];
let mut base_ts_guard = BASE_TELEM_TS.lock().unwrap();
if base_ts_guard.is_none() {
*base_ts_guard = Some(ts_raw);
}
let base = base_ts_guard.unwrap();
let ts_s = if ts_raw >= base {
(ts_raw - base) as f64 / 1000000.0
let ts_s = if let Some(base) = packet_base_ts {
if ts_raw >= base { (ts_raw - base) as f64 / 1_000_000.0 } else { 0.0 }
} else {
0.0 // Avoid huge jitter wrap-around
0.0
};
drop(base_ts_guard);
if let Some(meta) = metas.get(&id) {
let _ = tx_events.send(InternalEvent::TelemMatched(id));
// Do NOT send a channel event per signal — at 1kHz×N this
// floods the event queue and causes the UI spiral slowdown.
let t = meta.sig_type.as_str();
let type_size = if meta.elements > 0 { size / meta.elements } else { size };
@@ -1110,15 +1231,30 @@ impl eframe::App for MarteDebugApp {
});
}
InternalEvent::CommandResponse(resp) => {
// Resolve pending MSG history entry
if let Some(idx) = self.pending_msg_idx {
if resp.contains("MSG") {
if let Some(entry) = self.message_history.get_mut(idx) {
entry.response = resp.clone();
entry.status = if resp.starts_with("OK") {
MsgStatus::Success
} else {
MsgStatus::Failure
};
}
self.pending_msg_idx = None;
}
}
self.logs.push_back(LogEntry {
time: Local::now().format("%H:%M:%S").to_string(),
level: "CMD_RESP".to_string(),
message: resp,
});
}
InternalEvent::TelemMatched(id) => {
*self.telem_match_count.entry(id).or_insert(0) += 1;
InternalEvent::ConfigResponse(text) => {
self.app_config_text = convert_config_json(&text);
}
InternalEvent::TelemMatched(_) => {}
InternalEvent::RecordPathChosen(name, path) => {
let mut data_map = self.traced_signals.lock().unwrap();
if let Some(entry) = data_map.get_mut(&name) {
@@ -1275,16 +1411,23 @@ impl eframe::App for MarteDebugApp {
ui.horizontal(|ui| {
if ui.button("🚀 Send").clicked() {
let wait = if dialog.expect_reply { "1" } else { "0" };
// Replace actual newlines with literal '\n' for the server-side tokenizer if needed,
// or ensure the server handles the raw multi-line stream if the protocol allows it.
// Given HandleCommand reads line-by-line, we must send it carefully.
// Actually, our Server loop reads up to \n.
// So we should encode newlines in payload if we want to send them in one go.
let encoded_payload = dialog.payload.replace('\n', "\\n");
let cmd = format!(
"MSG {} {} {} {}",
dialog.destination, dialog.function, wait, encoded_payload
);
let idx = self.message_history.len();
self.message_history.push(MessageHistoryEntry {
time: Local::now().format("%H:%M:%S").to_string(),
destination: dialog.destination.clone(),
function: dialog.function.clone(),
payload: dialog.payload.clone(),
wait_reply: dialog.expect_reply,
raw_cmd: cmd.clone(),
response: String::new(),
status: MsgStatus::Unknown,
});
self.pending_msg_idx = Some(idx);
let _ = self.tx_cmd.send(cmd);
close = true;
}
@@ -1346,6 +1489,7 @@ impl eframe::App for MarteDebugApp {
ui.horizontal(|ui| {
ui.toggle_value(&mut self.show_left_panel, "🗂 Tree");
ui.toggle_value(&mut self.show_right_panel, "📊 Signals");
ui.toggle_value(&mut self.show_message_history, "💬 Msgs");
ui.toggle_value(&mut self.show_bottom_panel, "📜 Logs");
ui.separator();
if ui.button(" Plot").clicked() {
@@ -1629,6 +1773,85 @@ impl eframe::App for MarteDebugApp {
for key in to_delete.iter() {
self.forced_signals.remove(key);
}
if self.show_message_history {
ui.separator();
ui.horizontal(|ui| {
ui.heading("Message History");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("🗑 Clear").clicked() {
self.message_history.clear();
self.pending_msg_idx = None;
}
});
});
if self.message_history.is_empty() {
ui.label(egui::RichText::new("No messages sent yet").italics().color(egui::Color32::GRAY));
} else {
egui::ScrollArea::vertical()
.id_salt("msg_history_scroll")
.max_height(300.0)
.auto_shrink([false, true])
.show(ui, |ui| {
let mut resend_cmd: Option<String> = None;
let mut edit_dialog: Option<MessageDialog> = None;
for entry in self.message_history.iter().rev() {
let (status_icon, status_color) = match entry.status {
MsgStatus::Success => ("", egui::Color32::from_rgb(100, 220, 100)),
MsgStatus::Failure => ("", egui::Color32::from_rgb(255, 100, 100)),
MsgStatus::Unknown => ("", egui::Color32::from_rgb(255, 220, 50)),
};
ui.group(|ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(status_icon).color(status_color).strong());
ui.label(egui::RichText::new(&entry.time).color(egui::Color32::GRAY).monospace().small());
});
ui.label(format!("{}.{}", entry.destination, entry.function));
if !entry.payload.is_empty() {
ui.label(egui::RichText::new(&entry.payload).monospace().small().color(egui::Color32::from_rgb(180, 180, 255)));
}
if !entry.response.is_empty() {
ui.label(egui::RichText::new(&entry.response).small().color(status_color));
}
ui.horizontal(|ui| {
if ui.small_button("↩ Resend").clicked() {
resend_cmd = Some(entry.raw_cmd.clone());
}
if ui.small_button("✏ Edit").clicked() {
edit_dialog = Some(MessageDialog {
destination: entry.destination.clone(),
function: entry.function.clone(),
payload: entry.payload.clone(),
expect_reply: entry.wait_reply,
});
}
});
});
}
if let Some(cmd) = resend_cmd {
let idx = self.message_history.len();
// find the matching history entry to clone metadata
if let Some(orig) = self.message_history.iter().find(|e| e.raw_cmd == cmd) {
self.message_history.push(MessageHistoryEntry {
time: Local::now().format("%H:%M:%S").to_string(),
destination: orig.destination.clone(),
function: orig.function.clone(),
payload: orig.payload.clone(),
wait_reply: orig.wait_reply,
raw_cmd: cmd.clone(),
response: String::new(),
status: MsgStatus::Unknown,
});
}
self.pending_msg_idx = Some(idx);
let _ = self.tx_cmd.send(cmd);
}
if let Some(dialog) = edit_dialog {
self.message_dialog = Some(dialog);
}
});
}
}
});
}
@@ -1712,6 +1935,35 @@ impl eframe::App for MarteDebugApp {
}
egui::CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
ui.selectable_value(&mut self.active_main_tab, MainTab::Plots, "📈 Plots");
ui.selectable_value(&mut self.active_main_tab, MainTab::Config, "⚙ Config");
});
ui.separator();
if self.active_main_tab == MainTab::Config {
ui.horizontal(|ui| {
if ui.button("🔄 Refresh").clicked() {
let _ = self.tx_cmd.send("CONFIG".to_string());
}
ui.label(egui::RichText::new("Application Configuration").color(egui::Color32::GRAY));
});
ui.separator();
egui::ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| {
if self.app_config_text.is_empty() {
ui.label(egui::RichText::new("Press Refresh to load the configuration").italics().color(egui::Color32::GRAY));
} else {
ui.add(
egui::Label::new(
egui::RichText::new(&self.app_config_text).monospace(),
)
.selectable(true),
);
}
});
return;
}
let n_plots = self.plots.len();
if n_plots > 0 {
let plot_height = ui.available_height() / n_plots as f32;
@@ -1909,6 +2161,241 @@ impl eframe::App for MarteDebugApp {
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Parse MARTe2 config text into a serde_json Value tree so tests can do
/// structural comparisons without caring about whitespace or key ordering.
///
/// Rules:
/// `[+]Name = {` → start of a named block
/// `}` → close current block
/// `Name = Value` → leaf key-value (value stripped of surrounding quotes)
/// `Name = {value}` (e.g. `Functions = {GAM1}`) → leaf, not a block
fn parse_marte_config(text: &str) -> serde_json::Value {
let mut stack: Vec<serde_json::Map<String, serde_json::Value>> =
vec![serde_json::Map::new()];
let mut name_stack: Vec<String> = vec![];
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("/*") {
continue;
}
if trimmed == "}" {
if stack.len() > 1 {
let child = stack.pop().unwrap();
let name = name_stack.pop().unwrap();
stack.last_mut().unwrap().insert(name, serde_json::Value::Object(child));
}
continue;
}
let clean = trimmed.trim_start_matches('+');
if let Some(eq_pos) = clean.find('=') {
let key = clean[..eq_pos].trim().to_string();
let val = clean[eq_pos + 1..].trim();
// A block opens only if the value is exactly `{`
if val == "{" {
stack.push(serde_json::Map::new());
name_stack.push(key);
} else {
let val_clean = val.trim_matches('"').to_string();
stack.last_mut().unwrap().insert(key, serde_json::Value::String(val_clean));
}
}
}
serde_json::Value::Object(stack.remove(0))
}
/// Recursively assert that every object key with a `Class` value in `expected`
/// also exists with the same `Class` in `actual`. Missing non-Class keys are
/// tolerated because `ExportData()` does not re-emit config-file-only fields
/// (e.g. signal configurations, Frequency, Samples).
fn assert_classes_match(
expected: &serde_json::Value,
actual: &serde_json::Value,
path: &str,
) {
use serde_json::Value;
if let (Value::Object(exp_map), Value::Object(act_map)) = (expected, actual) {
if let Some(Value::String(exp_class)) = exp_map.get("Class") {
let act_class = act_map
.get("Class")
.and_then(|v| v.as_str())
.unwrap_or("<missing>");
assert_eq!(
exp_class.as_str(),
act_class,
"Class mismatch at '{}': expected '{}', got '{}'",
path,
exp_class,
act_class
);
}
for (key, exp_child) in exp_map {
if key == "Class" {
continue;
}
if let Some(act_child) = act_map.get(key) {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{}.{}", path, key)
};
assert_classes_match(exp_child, act_child, &child_path);
}
// Extra keys added by MARTe2 runtime are allowed
}
}
}
// ----- Unit tests for the JSON → MARTe2 converter -----
#[test]
fn test_convert_basic_structure() {
let json = r#"{"App":{"Class":"RealTimeApplication","Functions":{"Class":"ReferenceContainer","GAM1":{"Class":"IOGAM"}}}}"#;
let out = convert_config_json(json);
let parsed = parse_marte_config(&out);
assert_eq!(
parsed["App"]["Class"],
serde_json::Value::String("RealTimeApplication".into())
);
assert_eq!(
parsed["App"]["Functions"]["Class"],
serde_json::Value::String("ReferenceContainer".into())
);
assert_eq!(
parsed["App"]["Functions"]["GAM1"]["Class"],
serde_json::Value::String("IOGAM".into())
);
// Objects with Class must get + prefix
assert!(out.contains("+App = {"), "top-level object missing +");
assert!(out.contains(" +Functions = {"), "nested object with Class missing +");
assert!(out.contains(" +GAM1 = {"), "leaf object with Class missing +");
}
#[test]
fn test_no_plus_for_blocks_without_class() {
let json = r#"{"App":{"Class":"RealTimeApplication","Signals":{"Counter":{"Type":"uint32"}}}}"#;
let out = convert_config_json(json);
// Signals block has no Class → no + prefix
assert!(out.contains(" Signals = {"), "plain block should not have +");
assert!(!out.contains(" +Signals"), "plain block must not have + prefix");
}
#[test]
fn test_class_emitted_first() {
let json = r#"{"App":{"ZZZKey":"last","Class":"RealTimeApplication","AAA":"first_alpha"}}"#;
let out = convert_config_json(json);
let class_pos = out.find("Class = RealTimeApplication").unwrap();
let zzz_pos = out.find("ZZZKey = last").unwrap();
assert!(
class_pos < zzz_pos,
"Class must appear before other keys in the block"
);
}
#[test]
fn test_leaf_values_preserved() {
let json = r#"{"App":{"Class":"RealTimeApplication","ControlPort":"8080","StreamIP":"127.0.0.1"}}"#;
let out = convert_config_json(json);
assert!(out.contains("ControlPort = 8080"));
assert!(out.contains("StreamIP = 127.0.0.1"));
}
#[test]
fn test_round_trip_parse() {
// Build a JSON that mirrors what the server produces for debug_test.cfg
let json = r#"{
"App": {
"Class": "RealTimeApplication",
"Data": {"Class": "ReferenceContainer",
"DDB": {"Class": "GAMDataSource"},
"Timer": {"Class": "LinuxTimer"}
},
"Functions": {"Class": "ReferenceContainer",
"GAM1": {"Class": "IOGAM"},
"GAM2": {"Class": "IOGAM"}
},
"Scheduler": {"Class": "GAMScheduler", "TimingDataSource": "DAMS"},
"States": {"Class": "ReferenceContainer",
"State1": {"Class": "RealTimeState"}
}
},
"DebugService": {
"Class": "DebugService",
"ControlPort": "8080",
"UdpPort": "8081"
}
}"#;
let out = convert_config_json(json);
let cfg_path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../Test/Configurations/debug_test.cfg"
);
let original = std::fs::read_to_string(cfg_path).expect("debug_test.cfg not found");
// Parse both into trees and check that all Class values from the
// converted output are consistent with the original config
let converted_tree = parse_marte_config(&out);
let original_tree = parse_marte_config(&original);
assert_classes_match(&converted_tree, &original_tree, "");
// And vice-versa for the subset present in the JSON fixture
assert_classes_match(&original_tree, &converted_tree, "");
}
// ----- Integration test (requires running MARTe2 app) -----
#[test]
#[ignore = "requires running MARTe2 debug app — start with ./run_debug_app.sh first"]
fn test_live_config_matches_debug_test_cfg() {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::time::Duration;
let mut stream = TcpStream::connect("127.0.0.1:8080")
.expect("Could not connect to DebugService on 127.0.0.1:8080");
stream
.set_read_timeout(Some(Duration::from_secs(15)))
.unwrap();
stream.write_all(b"CONFIG\n").unwrap();
// Accumulate lines until the "OK CONFIG" sentinel
let reader = BufReader::new(stream);
let mut json_lines: Vec<String> = Vec::new();
for line in reader.lines() {
let line = line.expect("read error while receiving CONFIG response");
if line.trim() == "OK CONFIG" {
break;
}
json_lines.push(line);
}
let json_text = json_lines.join("\n");
assert!(!json_text.is_empty(), "Received empty CONFIG response from server");
// Convert server JSON to MARTe2 syntax
let converted = convert_config_json(&json_text);
assert!(!converted.is_empty(), "convert_config_json produced empty output");
// Load the reference config
let cfg_path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../Test/Configurations/debug_test.cfg"
);
let original = std::fs::read_to_string(cfg_path).expect("debug_test.cfg not found");
let expected_tree = parse_marte_config(&original);
let actual_tree = parse_marte_config(&converted);
// Every named object present in the original config must appear in the live
// config with the same Class. Config-file-only fields (InputSignals,
// OutputSignals, Frequency, etc.) are intentionally not checked because
// ExportData() does not re-emit them after ConfigureApplication().
assert_classes_match(&expected_tree, &actual_tree, "");
}
}
fn main() -> Result<(), eframe::Error> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([1280.0, 800.0]),