Files
marte-debug/Tools/gui_client/src/main.rs
T

3012 lines
133 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use arrow::array::Float64Array;
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use chrono::Local;
use crossbeam_channel::{unbounded, Receiver, Sender};
use eframe::egui;
use egui_plot::{Line, LineStyle, MarkerShape, Plot, PlotBounds, PlotPoints, VLine};
use once_cell::sync::Lazy;
use parquet::arrow::arrow_writer::ArrowWriter;
use parquet::file::properties::WriterProperties;
use regex::Regex;
use rfd::FileDialog;
use serde::{Deserialize, Serialize};
use socket2::{Domain, Protocol, Socket, Type};
use std::collections::{HashMap, VecDeque};
use std::fs::File;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpStream, UdpSocket};
use std::sync::{Arc, Mutex};
use std::thread;
static BASE_TELEM_TS: Lazy<Mutex<Option<u64>>> = Lazy::new(|| Mutex::new(None));
// --- Models ---
#[derive(Serialize, Deserialize, Clone, Debug)]
struct Signal {
name: String,
id: u32,
#[serde(rename = "type")]
sig_type: String,
#[serde(default)]
dimensions: u8,
#[serde(default = "default_elements")]
elements: u32,
}
fn default_elements() -> u32 { 1 }
#[derive(Deserialize)]
struct DiscoverResponse {
#[serde(rename = "Signals")]
signals: Vec<Signal>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct TreeItem {
#[serde(rename = "Name")]
name: String,
#[serde(rename = "Class")]
class: String,
#[serde(rename = "Children")]
children: Option<Vec<TreeItem>>,
#[serde(rename = "Type")]
sig_type: Option<String>,
#[serde(rename = "Dimensions")]
dimensions: Option<u8>,
#[serde(rename = "Elements")]
elements: Option<u32>,
#[serde(rename = "IsTraceable")]
is_traceable: Option<bool>,
#[serde(rename = "IsForcable")]
is_forcable: Option<bool>,
}
#[derive(Clone)]
struct LogEntry {
time: String,
level: String,
message: String,
}
struct TraceData {
values: VecDeque<[f64; 2]>,
last_value: f64,
recording_tx: Option<Sender<[f64; 2]>>,
recording_path: Option<String>,
is_monitored: bool,
}
struct SignalMetadata {
names: Vec<String>,
sig_type: String,
dimensions: u8,
elements: u32,
}
#[derive(Clone)]
struct ConnectionConfig {
ip: String,
tcp_port: String,
udp_port: String,
log_port: String,
version: u64,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum PlotType {
Normal,
LogicAnalyzer,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum AcquisitionMode {
FreeRun,
Triggered,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum TriggerEdge {
Rising,
Falling,
Both,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum TriggerType {
Single,
Continuous,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum MarkerType {
None,
Circle,
Square,
}
impl MarkerType {
fn to_shape(&self) -> Option<MarkerShape> {
match self {
MarkerType::None => None,
MarkerType::Circle => Some(MarkerShape::Circle),
MarkerType::Square => Some(MarkerShape::Square),
}
}
}
#[derive(Clone)]
struct SignalPlotConfig {
source_name: String,
label: String,
unit: String,
color: egui::Color32,
line_style: LineStyle,
marker_type: MarkerType,
gain: f64,
offset: f64,
}
struct PlotInstance {
id: String,
plot_type: PlotType,
signals: Vec<SignalPlotConfig>,
auto_bounds: bool,
max_points: usize,
follow: bool,
reset_view: 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),
TraceRequested(String, bool), // Name, IsMonitored
ClearTrace(String),
UdpStats(u64),
UdpDropped(u32),
RecordPathChosen(String, String), // SignalName, FilePath
RecordingError(String, String), // SignalName, ErrorMessage
TelemMatched(u32),
ServiceConfig { udp_port: String, log_port: String },
StepStatus { paused: bool, paused_at_gam: String, step_remaining: u32, step_thread: String },
SignalValue { path: String, value_text: String, found: bool },
}
// --- App State ---
struct ForcingDialog {
signal_path: String,
value: String,
}
struct MonitorDialog {
signal_path: String,
period_ms: String,
}
struct BreakDialog {
signal_path: String,
op: String, // ">", "<", "==", ">=", "<=", "!="
threshold: String,
}
struct MessageDialog {
destination: String,
function: String,
payload: String,
expect_reply: bool,
}
struct InfoDialog {
path: String,
is_signal: bool,
config_text: String,
is_loading: bool,
value_text: Option<String>,
value_loading: bool,
}
struct LogFilters {
show_debug: bool,
show_info: bool,
show_warning: bool,
show_error: bool,
paused: bool,
content_regex: String,
}
struct ScopeSettings {
enabled: bool,
window_ms: f64,
mode: AcquisitionMode,
paused: bool,
trigger_type: TriggerType,
trigger_source: String,
trigger_edge: TriggerEdge,
trigger_threshold: f64,
pre_trigger_percent: f64,
trigger_active: bool,
last_trigger_time: f64,
is_armed: bool,
}
struct MarteDebugApp {
connected: bool,
is_breaking: bool,
config: ConnectionConfig,
shared_config: Arc<Mutex<ConnectionConfig>>,
app_tree: Option<TreeItem>,
id_to_meta: Arc<Mutex<HashMap<u32, SignalMetadata>>>,
traced_signals: Arc<Mutex<HashMap<String, TraceData>>>,
plots: Vec<PlotInstance>,
forced_signals: HashMap<String, String>,
break_conditions: HashMap<String, (String, f64)>, // signal -> (op, threshold)
break_dialog: Option<BreakDialog>,
step_status: Option<(bool, String, u32)>, // (paused, paused_at_gam, step_remaining)
last_step_poll: std::time::Instant,
step_thread: String,
info_dialog: Option<InfoDialog>,
logs: VecDeque<LogEntry>,
log_filters: LogFilters,
show_left_panel: bool,
show_right_panel: bool,
show_bottom_panel: bool,
selected_node: String,
node_info: String,
udp_packets: u64,
udp_dropped: u64,
forcing_dialog: Option<ForcingDialog>,
monitoring_dialog: Option<MonitorDialog>,
message_dialog: Option<MessageDialog>,
style_editor: Option<(usize, usize)>,
tx_cmd: Sender<String>,
rx_events: Receiver<InternalEvent>,
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 {
fn new(_cc: &eframe::CreationContext<'_>) -> Self {
let (tx_cmd, rx_cmd_internal) = unbounded::<String>();
let (tx_events, rx_events) = unbounded::<InternalEvent>();
let internal_tx = tx_events.clone();
let config = ConnectionConfig {
ip: "127.0.0.1".to_string(),
tcp_port: "8080".to_string(),
udp_port: "8081".to_string(),
log_port: "8082".to_string(),
version: 0,
};
let shared_config = Arc::new(Mutex::new(config.clone()));
let id_to_meta = Arc::new(Mutex::new(HashMap::new()));
let traced_signals = Arc::new(Mutex::new(HashMap::new()));
let id_to_meta_clone = id_to_meta.clone();
let traced_signals_clone = traced_signals.clone();
let shared_config_cmd = shared_config.clone();
let shared_config_log = shared_config.clone();
let shared_config_udp = shared_config.clone();
let tx_events_c = tx_events.clone();
thread::spawn(move || {
tcp_command_worker(shared_config_cmd, rx_cmd_internal, tx_events_c);
});
let tx_events_log = tx_events.clone();
thread::spawn(move || {
tcp_log_worker(shared_config_log, tx_events_log);
});
let tx_events_udp = tx_events.clone();
thread::spawn(move || {
udp_worker(
shared_config_udp,
id_to_meta_clone,
traced_signals_clone,
tx_events_udp,
);
});
Self {
connected: false,
is_breaking: false,
config,
shared_config,
app_tree: None,
id_to_meta,
traced_signals,
plots: vec![PlotInstance {
id: "Plot 1".to_string(),
plot_type: PlotType::Normal,
signals: Vec::new(),
auto_bounds: true,
max_points: 5000,
follow: true,
reset_view: false,
}],
forced_signals: HashMap::new(),
break_conditions: HashMap::new(),
break_dialog: None,
step_status: None,
last_step_poll: std::time::Instant::now(),
step_thread: String::new(),
info_dialog: None,
logs: VecDeque::with_capacity(2000),
log_filters: LogFilters {
show_debug: true,
show_info: true,
show_warning: true,
show_error: true,
paused: false,
content_regex: "".to_string(),
},
show_left_panel: true,
show_right_panel: true,
show_bottom_panel: true,
selected_node: "".to_string(),
node_info: "".to_string(),
udp_packets: 0,
udp_dropped: 0,
forcing_dialog: None,
monitoring_dialog: None,
message_dialog: None,
style_editor: None,
tx_cmd,
rx_events,
internal_tx,
shared_x_range: None,
scope: ScopeSettings {
enabled: false,
window_ms: 1000.0,
mode: AcquisitionMode::FreeRun,
paused: false,
trigger_type: TriggerType::Continuous,
trigger_source: "".to_string(),
trigger_edge: TriggerEdge::Rising,
trigger_threshold: 0.0,
pre_trigger_percent: 25.0,
trigger_active: false,
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,
}
}
fn next_color(idx: usize) -> egui::Color32 {
let colors = [
egui::Color32::from_rgb(100, 200, 255),
egui::Color32::from_rgb(255, 100, 100),
egui::Color32::from_rgb(100, 255, 100),
egui::Color32::from_rgb(255, 200, 100),
egui::Color32::from_rgb(255, 100, 255),
egui::Color32::from_rgb(100, 255, 255),
egui::Color32::from_rgb(200, 255, 100),
egui::Color32::WHITE,
];
colors[idx % colors.len()]
}
fn apply_trigger_logic(&mut self) {
if self.scope.mode != AcquisitionMode::Triggered || !self.scope.is_armed {
return;
}
if self.scope.trigger_source.is_empty() {
return;
}
let data_map = self.traced_signals.lock().unwrap();
if let Some(data) = data_map.get(&self.scope.trigger_source) {
if data.values.len() < 2 {
return;
}
let start_idx = if data.values.len() > 100 {
data.values.len() - 100
} else {
0
};
for i in (start_idx + 1..data.values.len()).rev() {
let v_prev = data.values[i - 1][1];
let v_curr = data.values[i][1];
let t_curr = data.values[i][0];
if t_curr <= self.scope.last_trigger_time {
continue;
}
let triggered = match self.scope.trigger_edge {
TriggerEdge::Rising => {
v_prev < self.scope.trigger_threshold
&& v_curr >= self.scope.trigger_threshold
}
TriggerEdge::Falling => {
v_prev > self.scope.trigger_threshold
&& v_curr <= self.scope.trigger_threshold
}
TriggerEdge::Both => {
(v_prev < self.scope.trigger_threshold
&& v_curr >= self.scope.trigger_threshold)
|| (v_prev > self.scope.trigger_threshold
&& v_curr <= self.scope.trigger_threshold)
}
};
if triggered {
self.scope.last_trigger_time = t_curr;
self.scope.trigger_active = true;
if self.scope.trigger_type == TriggerType::Single {
self.scope.is_armed = false;
}
break;
}
}
}
}
fn get_all_objects(&self) -> Vec<String> {
let mut objects = Vec::new();
if let Some(tree) = &self.app_tree {
fn collect(item: &TreeItem, path: String, objects: &mut Vec<String>) {
let current_path = if path.is_empty() {
if item.name == "Root" {
"".to_string()
} else {
item.name.clone()
}
} else {
format!("{}.{}", path, item.name)
};
if !current_path.is_empty() && !item.class.contains("Signal") {
objects.push(current_path.clone());
}
if let Some(children) = &item.children {
for child in children {
collect(child, current_path.clone(), objects);
}
}
}
collect(tree, "".to_string(), &mut objects);
}
objects.sort();
objects
}
fn get_threads(&self) -> Vec<String> {
let mut threads = Vec::new();
if let Some(tree) = &self.app_tree {
fn collect(item: &TreeItem, out: &mut Vec<String>) {
if item.class == "RealTimeThread" {
out.push(item.name.clone());
}
if let Some(children) = &item.children {
for child in children { collect(child, out); }
}
}
collect(tree, &mut threads);
}
threads
}
fn render_tree(&mut self, ui: &mut egui::Ui, item: &TreeItem, path: String) {
let current_path = if path.is_empty() {
if item.name == "Root" {
"".to_string()
} else {
item.name.clone()
}
} else {
if path.is_empty() {
item.name.clone()
} else {
format!("{}.{}", path, item.name)
}
};
let label = if item.class == "Signal" {
format!("📈 {}", item.name)
} else {
item.name.clone()
};
if let Some(children) = &item.children {
let header = egui::CollapsingHeader::new(format!("{} [{}]", label, item.class))
.id_salt(&current_path);
header.show(ui, |ui| {
ui.horizontal(|ui| {
if !current_path.is_empty() {
if ui
.selectable_label(self.selected_node == current_path, " Info")
.clicked()
{
self.selected_node = current_path.clone();
let _ = self.tx_cmd.send(format!("INFO {}", current_path));
self.info_dialog = Some(InfoDialog {
path: current_path.clone(),
is_signal: false,
config_text: String::new(),
is_loading: true,
value_text: None,
value_loading: false,
});
}
}
});
for child in children {
self.render_tree(ui, child, current_path.clone());
}
});
} else {
ui.horizontal(|ui| {
let resp = ui.selectable_label(
self.selected_node == current_path,
format!("{} [{}]", label, item.class),
);
if resp.clicked() {
self.selected_node = current_path.clone();
let _ = self.tx_cmd.send(format!("INFO {}", current_path));
}
if resp.double_clicked() {
self.selected_node = current_path.clone();
let _ = self.tx_cmd.send(format!("INFO {}", current_path));
let is_sig = item.class.contains("Signal");
let mut value_loading = false;
if is_sig {
let _ = self.tx_cmd.send(format!("VALUE {}", current_path));
value_loading = true;
}
self.info_dialog = Some(InfoDialog {
path: current_path.clone(),
is_signal: is_sig,
config_text: String::new(),
is_loading: true,
value_text: None,
value_loading,
});
}
if !resp.double_clicked() && !resp.clicked() {
// keep the existing Info button for non-signal leaf nodes
if !item.class.contains("Signal") && ui.selectable_label(self.selected_node == current_path, " Info").clicked() {
self.selected_node = current_path.clone();
let _ = self.tx_cmd.send(format!("INFO {}", current_path));
self.info_dialog = Some(InfoDialog {
path: current_path.clone(),
is_signal: false,
config_text: String::new(),
is_loading: true,
value_text: None,
value_loading: false,
});
}
}
if item.class.contains("Signal") {
let elements = item.elements.unwrap_or(1);
if elements > 1 {
let header = egui::CollapsingHeader::new(format!("{} [{}] ({} elems)", label, item.class, elements))
.id_salt(&current_path);
header.show(ui, |ui| {
for i in 0..elements {
let elem_path = format!("{}[{}]", current_path, i);
ui.horizontal(|ui| {
ui.label(format!("{}[{}]", item.name, i));
if ui.button("Trace").clicked() {
let _ = self.tx_cmd.send(format!("TRACE {} 1", current_path));
let _ = self.internal_tx.send(InternalEvent::TraceRequested(elem_path.clone(), false));
}
if item.class == "Signal" {
if ui.button("Monitor").clicked() {
self.monitoring_dialog = Some(MonitorDialog {
signal_path: current_path.clone(),
period_ms: "100".to_string(),
});
// Note: internal monitoring logic will handle individual elements via naming convention
let _ = self.internal_tx.send(InternalEvent::TraceRequested(elem_path.clone(), true));
}
}
});
}
});
} else {
let traceable = item.is_traceable.unwrap_or(false);
let forcable = item.is_forcable.unwrap_or(false);
if traceable && ui.button("Trace").clicked() {
let _ = self.tx_cmd.send(format!("TRACE {} 1", current_path));
let _ = self
.internal_tx
.send(InternalEvent::TraceRequested(current_path.clone(), false));
}
if item.class == "Signal" {
if ui.button("Monitor").clicked() {
self.monitoring_dialog = Some(MonitorDialog {
signal_path: current_path.clone(),
period_ms: "100".to_string(),
});
}
}
if forcable && ui.button("⚡ Force").clicked() {
self.forcing_dialog = Some(ForcingDialog {
signal_path: current_path.clone(),
value: "".to_string(),
});
}
if traceable {
let has_break = self.break_conditions.contains_key(&current_path);
let btn_text = if has_break { "🔴 Break*" } else { "🔴 Break" };
if ui.button(btn_text).clicked() {
let (op, thr) = self.break_conditions
.get(&current_path)
.cloned()
.unwrap_or_else(|| (">".to_string(), 0.0));
self.break_dialog = Some(BreakDialog {
signal_path: current_path.clone(),
op,
threshold: thr.to_string(),
});
}
}
}
}
});
}
}
}
/// 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>,
tx_events: Sender<InternalEvent>,
) {
let mut current_version = 0;
let mut current_addr = String::new();
loop {
{
let config = shared_config.lock().unwrap();
if config.version != current_version {
current_version = config.version;
current_addr = format!("{}:{}", config.ip, config.tcp_port);
}
}
if current_addr.is_empty() || current_addr.starts_with(":") {
thread::sleep(std::time::Duration::from_secs(1));
continue;
}
if let Ok(mut stream) = TcpStream::connect(&current_addr) {
let _ = stream.set_nodelay(true);
let mut reader = BufReader::new(stream.try_clone().unwrap());
let _ = tx_events.send(InternalEvent::Connected);
let stop_flag = Arc::new(Mutex::new(false));
let stop_flag_reader = stop_flag.clone();
let tx_events_inner = tx_events.clone();
thread::spawn(move || {
let mut line = String::new();
let mut json_acc = String::new();
let mut in_json = false;
while reader.read_line(&mut line).is_ok() {
if *stop_flag_reader.lock().unwrap() {
break;
}
let trimmed = line.trim();
if trimmed.is_empty() {
line.clear();
continue;
}
if !in_json && trimmed.starts_with("{") {
in_json = true;
json_acc.clear();
}
if in_json {
json_acc.push_str(trimmed);
if trimmed.contains("OK DISCOVER") {
in_json = false;
let json_clean =
json_acc.split("OK DISCOVER").next().unwrap_or("").trim();
match serde_json::from_str::<DiscoverResponse>(json_clean) {
Ok(resp) => {
let _ = tx_events_inner
.send(InternalEvent::Discovery(resp.signals));
}
Err(e) => {
let _ =
tx_events_inner.send(InternalEvent::InternalLog(format!(
"Discovery JSON Error: {} | Payload: {}",
e, json_clean
)));
}
}
json_acc.clear();
} else if trimmed.contains("OK TREE") {
in_json = false;
let json_clean = json_acc.split("OK TREE").next().unwrap_or("").trim();
match serde_json::from_str::<TreeItem>(json_clean) {
Ok(resp) => {
let _ = tx_events_inner.send(InternalEvent::Tree(resp));
}
Err(e) => {
let _ = tx_events_inner.send(InternalEvent::InternalLog(
format!("Tree JSON Error: {}", e),
));
}
}
json_acc.clear();
} else if trimmed.contains("OK INFO") {
in_json = false;
let json_clean = json_acc.split("OK INFO").next().unwrap_or("").trim();
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.contains("OK STEP_STATUS") {
in_json = false;
let json_clean = json_acc.split("OK STEP_STATUS").next().unwrap_or("").trim();
let paused = json_clean.contains("\"Paused\": true");
let gam = json_clean.split("\"PausedAtGam\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
let remaining = json_clean.split("\"StepRemaining\": ")
.nth(1)
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
let step_thread = json_clean.split("\"StepThread\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
let _ = tx_events_inner.send(InternalEvent::StepStatus {
paused,
paused_at_gam: gam,
step_remaining: remaining,
step_thread,
});
json_acc.clear();
} else if trimmed.contains("OK VALUE") {
in_json = false;
let json_clean = json_acc.split("OK VALUE").next().unwrap_or("").trim();
let found = !json_clean.contains("\"Error\"");
let path = json_clean.split("\"Name\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
// Value is a quoted string: "Value": "..."
let value_text = json_clean.split("\"Value\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
let _ = tx_events_inner.send(InternalEvent::SignalValue { path, value_text, found });
json_acc.clear();
}
} else {
if trimmed.starts_with("OK SERVICE_INFO") {
// OK SERVICE_INFO TCP_CTRL:8110 UDP_STREAM:8111 TCP_LOG:8082 STATE:RUNNING
let parts: Vec<&str> = trimmed.split_whitespace().collect();
let mut udp = String::new();
let mut log = String::new();
for p in parts {
if p.starts_with("UDP_STREAM:") {
udp = p.split(':').nth(1).unwrap_or("").to_string();
}
if p.starts_with("TCP_LOG:") {
log = p.split(':').nth(1).unwrap_or("").to_string();
}
}
if !udp.is_empty() || !log.is_empty() {
let _ = tx_events_inner.send(InternalEvent::ServiceConfig {
udp_port: udp,
log_port: log,
});
}
}
let _ = tx_events_inner
.send(InternalEvent::CommandResponse(trimmed.to_string()));
}
line.clear();
}
});
while let Ok(cmd) = rx_cmd.recv() {
{
let config = shared_config.lock().unwrap();
if config.version != current_version {
*stop_flag.lock().unwrap() = true;
let _ = tx_events.send(InternalEvent::Disconnected);
break;
}
}
if stream.write_all(format!("{}\n", cmd).as_bytes()).is_err() {
let _ = tx_events.send(InternalEvent::Disconnected);
break;
}
// Small delay to allow server to process and send response
thread::sleep(std::time::Duration::from_millis(100));
}
let _ = tx_events.send(InternalEvent::Disconnected);
}
thread::sleep(std::time::Duration::from_secs(2));
}
}
fn tcp_log_worker(shared_config: Arc<Mutex<ConnectionConfig>>, tx_events: Sender<InternalEvent>) {
let mut current_version = 0;
let mut current_addr = String::new();
loop {
{
let config = shared_config.lock().unwrap();
if config.version != current_version {
current_version = config.version;
current_addr = format!("{}:{}", config.ip, config.log_port);
}
}
if current_addr.is_empty() || current_addr.starts_with(":") {
thread::sleep(std::time::Duration::from_secs(1));
continue;
}
if let Ok(stream) = TcpStream::connect(&current_addr) {
let mut reader = BufReader::new(stream);
let mut line = String::new();
while reader.read_line(&mut line).is_ok() {
if shared_config.lock().unwrap().version != current_version {
break;
}
let trimmed = line.trim();
if trimmed.starts_with("LOG ") {
let parts: Vec<&str> = trimmed[4..].splitn(2, ' ').collect();
if parts.len() == 2 {
let _ = tx_events.send(InternalEvent::Log(LogEntry {
time: Local::now().format("%H:%M:%S%.3f").to_string(),
level: parts[0].to_string(),
message: parts[1].to_string(),
}));
}
}
line.clear();
}
}
thread::sleep(std::time::Duration::from_secs(2));
}
}
fn recording_worker(
rx: Receiver<[f64; 2]>,
path: String,
signal_name: String,
tx_events: Sender<InternalEvent>,
) {
let file = match File::create(&path) {
Ok(f) => f,
Err(e) => {
let _ = tx_events.send(InternalEvent::RecordingError(
signal_name,
format!("File Error: {}", e),
));
return;
}
};
let schema = Arc::new(Schema::new(vec![
Field::new("timestamp", DataType::Float64, false),
Field::new("value", DataType::Float64, false),
]));
let mut writer = match ArrowWriter::try_new(
file,
schema.clone(),
Some(WriterProperties::builder().build()),
) {
Ok(w) => w,
Err(e) => {
let _ = tx_events.send(InternalEvent::RecordingError(
signal_name,
format!("Parquet Error: {}", e),
));
return;
}
};
let (mut t_acc, mut v_acc) = (Vec::with_capacity(1000), Vec::with_capacity(1000));
while let Ok([t, v]) = rx.recv() {
t_acc.push(t);
v_acc.push(v);
if t_acc.len() >= 1000 {
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Float64Array::from(t_acc.clone())),
Arc::new(Float64Array::from(v_acc.clone())),
],
)
.unwrap();
let _ = writer.write(&batch);
t_acc.clear();
v_acc.clear();
}
}
if !t_acc.is_empty() {
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Float64Array::from(t_acc)),
Arc::new(Float64Array::from(v_acc)),
],
)
.unwrap();
let _ = writer.write(&batch);
}
let _ = writer.close();
}
fn udp_worker(
shared_config: Arc<Mutex<ConnectionConfig>>,
id_to_meta: Arc<Mutex<HashMap<u32, SignalMetadata>>>,
traced_data: Arc<Mutex<HashMap<String, TraceData>>>,
tx_events: Sender<InternalEvent>,
) {
let mut current_version = 0;
let mut socket: Option<UdpSocket> = None;
let mut last_seq: Option<u32> = None;
let mut last_warning_time = std::time::Instant::now();
loop {
let (ver, port) = {
let config = shared_config.lock().unwrap();
(config.version, config.udp_port.clone())
};
if ver != current_version || socket.is_none() {
current_version = ver;
{
let mut base = BASE_TELEM_TS.lock().unwrap();
*base = None;
}
if port.is_empty() {
socket = None;
continue;
}
let port_num: u16 = port.parse().unwrap_or(8081);
let s = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)).ok();
let mut bound = false;
if let Some(sock) = s {
let _ = sock.set_reuse_address(true);
#[cfg(all(unix, not(target_os = "solaris"), not(target_os = "illumos")))]
let _ = sock.set_reuse_port(true);
let _ = sock.set_recv_buffer_size(10 * 1024 * 1024);
let addr = format!("0.0.0.0:{}", port_num)
.parse::<std::net::SocketAddr>()
.unwrap();
if sock.bind(&addr.into()).is_ok() {
socket = Some(sock.into());
bound = true;
}
}
if !bound {
thread::sleep(std::time::Duration::from_secs(5));
continue;
}
let _ = socket
.as_ref()
.unwrap()
.set_read_timeout(Some(std::time::Duration::from_millis(500)));
last_seq = None;
}
let s = if let Some(sock) = socket.as_ref() {
sock
} else {
thread::sleep(std::time::Duration::from_secs(1));
continue;
};
let mut buf = [0u8; 4096];
let mut total_packets = 0u64;
loop {
if shared_config.lock().unwrap().version != current_version {
break;
}
if let Ok(n) = s.recv(&mut buf) {
total_packets += 1;
if (total_packets % 500) == 0 {
let _ = tx_events.send(InternalEvent::UdpStats(total_packets));
}
if n < 20 {
continue;
}
if u32::from_le_bytes(buf[0..4].try_into().unwrap()) != 0xDA7A57AD {
continue;
}
let seq = u32::from_le_bytes(buf[4..8].try_into().unwrap());
if let Some(last) = last_seq {
if seq != last + 1 && seq > last {
let _ = tx_events.send(InternalEvent::UdpDropped(seq - last - 1));
}
}
last_seq = Some(seq);
let count = u32::from_le_bytes(buf[16..20].try_into().unwrap());
let mut offset = 20;
let mut local_updates: HashMap<String, Vec<[f64; 2]>> = HashMap::new();
let mut last_values: HashMap<String, f64> = HashMap::new();
let metas = id_to_meta.lock().unwrap();
if metas.is_empty() && count > 0 && last_warning_time.elapsed().as_secs() > 5 {
let _ = tx_events.send(InternalEvent::InternalLog(
"UDP received but Metadata empty. Still discovering?".to_string(),
));
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;
}
let id = u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap());
let ts_raw =
u64::from_le_bytes(buf[offset + 4..offset + 12].try_into().unwrap());
let size =
u32::from_le_bytes(buf[offset + 12..offset + 16].try_into().unwrap());
offset += 16;
if offset + size as usize > n {
break;
}
let data_slice = &buf[offset..offset + size as usize];
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
};
if let Some(meta) = metas.get(&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 };
for i in 0..meta.elements {
let elem_offset = (i * type_size) as usize;
if elem_offset + type_size as usize > data_slice.len() { break; }
let elem_data = &data_slice[elem_offset..elem_offset + type_size as usize];
let val = match type_size {
1 => {
if t.contains('u') {
elem_data[0] as f64
} else {
(elem_data[0] as i8) as f64
}
}
2 => {
let b = elem_data[0..2].try_into().unwrap();
if t.contains('u') {
u16::from_le_bytes(b) as f64
} else {
i16::from_le_bytes(b) as f64
}
}
4 => {
let b = elem_data[0..4].try_into().unwrap();
if t.contains("float") {
f32::from_le_bytes(b) as f64
} else if t.contains('u') {
u32::from_le_bytes(b) as f64
} else {
i32::from_le_bytes(b) as f64
}
}
8 => {
let b = elem_data[0..8].try_into().unwrap();
if t.contains("float") {
f64::from_le_bytes(b)
} else if t.contains('u') {
u64::from_le_bytes(b) as f64
} else {
i64::from_le_bytes(b) as f64
}
}
_ => 0.0,
};
for name in &meta.names {
let target_name = if meta.elements > 1 {
format!("{}[{}]", name, i)
} else {
name.clone()
};
local_updates
.entry(target_name.clone())
.or_default()
.push([ts_s, val]);
last_values.insert(target_name, val);
}
}
}
offset += size as usize;
}
drop(metas);
if !local_updates.is_empty() {
let mut data_map = traced_data.lock().unwrap();
for (name, new_points) in local_updates {
if let Some(entry) = data_map.get_mut(&name) {
for point in new_points {
entry.values.push_back(point);
if let Some(tx) = &entry.recording_tx {
let _ = tx.send(point);
}
}
if let Some(lv) = last_values.get(&name) {
entry.last_value = *lv;
}
while entry.values.len() > 100000 {
entry.values.pop_front();
}
}
}
}
}
}
}
}
impl eframe::App for MarteDebugApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
while let Ok(event) = self.rx_events.try_recv() {
match event {
InternalEvent::Log(log) => {
if !self.log_filters.paused {
self.logs.push_back(log);
if self.logs.len() > 2000 {
self.logs.pop_front();
}
}
}
InternalEvent::Discovery(signals) => {
let mut metas = self.id_to_meta.lock().unwrap();
metas.clear();
for s in &signals {
let meta = metas.entry(s.id).or_insert_with(|| SignalMetadata {
names: Vec::new(),
sig_type: s.sig_type.clone(),
dimensions: s.dimensions,
elements: s.elements,
});
if !meta.names.contains(&s.name) {
meta.names.push(s.name.clone());
}
}
self.logs.push_back(LogEntry {
time: Local::now().format("%H:%M:%S").to_string(),
level: "GUI_INFO".to_string(),
message: format!("Discovery complete: {} signals mapped", signals.len()),
});
}
InternalEvent::Tree(tree) => {
self.app_tree = Some(tree);
}
InternalEvent::NodeInfo(info) => {
self.node_info = info.clone();
if let Some(dialog) = &mut self.info_dialog {
// Try to pretty-print as MARTe2 config if it's a JSON object
let display = if info.trim_start().starts_with('{') {
let converted = convert_config_json(&info);
if converted.is_empty() { info } else { converted }
} else {
info
};
dialog.config_text = display;
dialog.is_loading = false;
}
}
InternalEvent::TraceRequested(name, is_monitored) => {
let mut data_map = self.traced_signals.lock().unwrap();
let entry = data_map.entry(name.clone()).or_insert_with(|| TraceData {
values: VecDeque::with_capacity(10000),
last_value: 0.0,
recording_tx: None,
recording_path: None,
is_monitored,
});
entry.is_monitored = is_monitored;
self.logs.push_back(LogEntry {
time: Local::now().format("%H:%M:%S").to_string(),
level: "GUI_INFO".to_string(),
message: format!("Trace requested for: {}", name),
});
}
InternalEvent::ClearTrace(name) => {
let mut data_map = self.traced_signals.lock().unwrap();
data_map.remove(&name);
for plot in &mut self.plots {
plot.signals.retain(|s| s.source_name != name);
}
}
InternalEvent::UdpStats(count) => {
self.udp_packets = count;
}
InternalEvent::UdpDropped(dropped) => {
self.udp_dropped += dropped as u64;
}
InternalEvent::Connected => {
self.connected = true;
// Wait for connection to stabilize before sending commands
std::thread::sleep(std::time::Duration::from_millis(200));
let _ = self.tx_cmd.send("SERVICE_INFO".to_string());
std::thread::sleep(std::time::Duration::from_millis(100));
let _ = self.tx_cmd.send("TREE".to_string());
// Wait for TREE response before sending next command
std::thread::sleep(std::time::Duration::from_millis(500));
let _ = self.tx_cmd.send("DISCOVER".to_string());
}
InternalEvent::ServiceConfig { udp_port, log_port } => {
let mut changed = false;
if !udp_port.is_empty() && self.config.udp_port != udp_port {
self.config.udp_port = udp_port;
changed = true;
}
if !log_port.is_empty() && self.config.log_port != log_port {
self.config.log_port = log_port;
changed = true;
}
if changed {
self.config.version += 1;
*self.shared_config.lock().unwrap() = self.config.clone();
self.logs.push_back(LogEntry {
time: Local::now().format("%H:%M:%S").to_string(),
level: "GUI_INFO".to_string(),
message: format!("Config updated from server: UDP={}, LOG={}", self.config.udp_port, self.config.log_port),
});
}
}
InternalEvent::Disconnected => {
self.connected = false;
}
InternalEvent::InternalLog(msg) => {
self.logs.push_back(LogEntry {
time: Local::now().format("%H:%M:%S").to_string(),
level: "GUI_ERROR".to_string(),
message: msg,
});
}
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::ConfigResponse(text) => {
self.app_config_text = convert_config_json(&text);
}
InternalEvent::TelemMatched(_) => {}
InternalEvent::StepStatus { paused, paused_at_gam, step_remaining, step_thread: _ } => {
self.step_status = Some((paused, paused_at_gam, step_remaining));
if paused {
self.is_breaking = true;
}
}
InternalEvent::SignalValue { path, value_text, found } => {
if let Some(dialog) = &mut self.info_dialog {
if dialog.path == path {
dialog.value_loading = false;
dialog.value_text = if found { Some(value_text) } else { None };
}
}
}
InternalEvent::RecordPathChosen(name, path) => {
let mut data_map = self.traced_signals.lock().unwrap();
if let Some(entry) = data_map.get_mut(&name) {
let (tx, rx) = unbounded();
entry.recording_tx = Some(tx);
entry.recording_path = Some(path.clone());
let tx_err = self.internal_tx.clone();
thread::spawn(move || {
recording_worker(rx, path, name, tx_err);
});
}
}
InternalEvent::RecordingError(name, err) => {
self.logs.push_back(LogEntry {
time: Local::now().format("%H:%M:%S").to_string(),
level: "REC_ERROR".to_string(),
message: format!("{}: {}", name, err),
});
}
}
}
// Poll STEP_STATUS: fast while debugging, slow background poll to catch conditional breaks
if self.connected {
let elapsed = self.last_step_poll.elapsed();
let interval = if self.is_breaking {
std::time::Duration::from_millis(500)
} else {
std::time::Duration::from_secs(2)
};
if elapsed >= interval {
let _ = self.tx_cmd.send("STEP_STATUS".to_string());
self.last_step_poll = std::time::Instant::now();
}
if self.is_breaking {
ctx.request_repaint_after(std::time::Duration::from_millis(500));
}
}
if self.scope.enabled {
self.apply_trigger_logic();
}
if let Some(dragged_name) =
ctx.data_mut(|d| d.get_temp::<String>(egui::Id::new("drag_signal")))
{
egui::Area::new(egui::Id::new("drag_ghost"))
.fixed_pos(ctx.input(|i| i.pointer.hover_pos().unwrap_or(egui::Pos2::ZERO)))
.order(egui::Order::Tooltip)
.show(ctx, |ui| {
ui.group(|ui| {
ui.label(format!("📈 {}", dragged_name));
});
});
}
if let Some(dialog) = &mut self.forcing_dialog {
let mut close = false;
egui::Window::new("Force Signal").show(ctx, |ui| {
ui.label(&dialog.signal_path);
ui.text_edit_singleline(&mut dialog.value);
ui.horizontal(|ui| {
if ui.button("Apply").clicked() {
let _ = self
.tx_cmd
.send(format!("FORCE {} {}", dialog.signal_path, dialog.value));
self.forced_signals
.insert(dialog.signal_path.clone(), dialog.value.clone());
close = true;
}
if ui.button("Cancel").clicked() {
close = true;
}
});
});
if close {
self.forcing_dialog = None;
}
}
if self.break_dialog.is_some() {
let mut dialog = self.break_dialog.take().unwrap();
let mut close = false;
let ops = [">", "<", "==", ">=", "<=", "!="];
egui::Window::new("Set Conditional Break").show(ctx, |ui| {
ui.label(egui::RichText::new(&dialog.signal_path).monospace().strong());
ui.separator();
ui.horizontal(|ui| {
ui.label("Condition:");
egui::ComboBox::from_id_salt("break_op_combo")
.selected_text(&dialog.op)
.width(60.0)
.show_ui(ui, |ui| {
for op in &ops {
ui.selectable_value(&mut dialog.op, op.to_string(), *op);
}
});
ui.text_edit_singleline(&mut dialog.threshold);
});
ui.label(egui::RichText::new(format!(
"Pause RT when: signal {} {}",
dialog.op, dialog.threshold
)).small().color(egui::Color32::GRAY));
ui.separator();
ui.horizontal(|ui| {
if ui.button("✔ Apply").clicked() {
if let Ok(thr) = dialog.threshold.parse::<f64>() {
let _ = self.tx_cmd.send(format!(
"BREAK {} {} {}",
dialog.signal_path, dialog.op, thr
));
self.break_conditions.insert(
dialog.signal_path.clone(),
(dialog.op.clone(), thr),
);
}
close = true;
}
if ui.button("🗑 Clear Break").clicked() {
let _ = self.tx_cmd.send(format!("BREAK {} OFF", dialog.signal_path));
self.break_conditions.remove(&dialog.signal_path);
close = true;
}
if ui.button("Cancel").clicked() {
close = true;
}
});
});
if !close {
self.break_dialog = Some(dialog);
}
}
if let Some(dialog) = &mut self.monitoring_dialog {
let mut close = false;
egui::Window::new("Monitor Signal").show(ctx, |ui| {
ui.label(&dialog.signal_path);
ui.horizontal(|ui| {
ui.label("Period (ms):");
ui.text_edit_singleline(&mut dialog.period_ms);
});
ui.horizontal(|ui| {
if ui.button("Apply").clicked() {
let period = dialog.period_ms.parse::<u32>().unwrap_or(100);
let _ = self
.tx_cmd
.send(format!("MONITOR SIGNAL {} {}", dialog.signal_path, period));
let _ = self.tx_cmd.send("DISCOVER".to_string());
// Check if it's an array signal to add all elements to view
let mut elements = 1;
if let Some(tree) = &self.app_tree {
// Helper to find item in tree
fn find_item<'a>(item: &'a TreeItem, target: &str, current: &str) -> Option<&'a TreeItem> {
let path = if current.is_empty() { item.name.clone() } else { format!("{}.{}", current, item.name) };
if path == target || (current.is_empty() && item.name == "Root" && target.is_empty()) { return Some(item); }
if let Some(children) = &item.children {
for child in children {
if let Some(found) = find_item(child, target, &path) { return Some(found); }
}
}
None
}
if let Some(found) = find_item(tree, &dialog.signal_path, "") {
elements = found.elements.unwrap_or(1);
}
}
if elements > 1 {
for i in 0..elements {
let elem_path = format!("{}[{}]", dialog.signal_path, i);
let _ = self.internal_tx.send(InternalEvent::TraceRequested(elem_path, true));
}
} else {
let _ = self
.internal_tx
.send(InternalEvent::TraceRequested(dialog.signal_path.clone(), true));
}
close = true;
}
if ui.button("Cancel").clicked() {
close = true;
}
});
});
if close {
self.monitoring_dialog = None;
}
}
if self.message_dialog.is_some() {
let mut dialog = self.message_dialog.take().unwrap();
let mut close = false;
let objects = self.get_all_objects();
egui::Window::new("Send MARTe Message").show(ctx, |ui| {
egui::Grid::new("msg_grid").num_columns(2).show(ui, |ui| {
ui.label("Destination:");
egui::ComboBox::from_id_salt("dest_combo")
.selected_text(&dialog.destination)
.width(200.0)
.show_ui(ui, |ui| {
for obj in objects {
ui.selectable_value(&mut dialog.destination, obj.clone(), obj);
}
});
ui.end_row();
ui.label("Function:");
ui.text_edit_singleline(&mut dialog.function);
ui.end_row();
ui.label("Payload:");
ui.vertical(|ui| {
ui.text_edit_multiline(&mut dialog.payload);
ui.label(egui::RichText::new("Format: Key = Value (one per line)").small().weak());
});
ui.end_row();
ui.label("Wait Reply:");
ui.checkbox(&mut dialog.expect_reply, "");
ui.end_row();
});
ui.horizontal(|ui| {
if ui.button("🚀 Send").clicked() {
let wait = if dialog.expect_reply { "1" } else { "0" };
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;
}
if ui.button("Cancel").clicked() {
close = true;
}
});
});
if !close {
self.message_dialog = Some(dialog);
}
}
if let Some((p_idx, s_idx)) = self.style_editor {
let mut close = false;
egui::Window::new("Signal Style").show(ctx, |ui| {
if let Some(plot) = self.plots.get_mut(p_idx) {
if let Some(sig) = plot.signals.get_mut(s_idx) {
ui.horizontal(|ui| {
ui.label("Label:");
ui.text_edit_singleline(&mut sig.label);
});
ui.horizontal(|ui| {
ui.label("Unit:");
ui.text_edit_singleline(&mut sig.unit);
});
ui.horizontal(|ui| {
ui.label("Color:");
let mut color = sig.color.to_array();
if ui
.color_edit_button_srgba_unmultiplied(&mut color)
.changed()
{
sig.color = egui::Color32::from_rgba_unmultiplied(
color[0], color[1], color[2], color[3],
);
}
});
ui.horizontal(|ui| {
ui.label("Gain:");
ui.add(egui::DragValue::new(&mut sig.gain).speed(0.1));
});
ui.horizontal(|ui| {
ui.label("Offset:");
ui.add(egui::DragValue::new(&mut sig.offset).speed(1.0));
});
if ui.button("Close").clicked() {
close = true;
}
}
}
});
if close {
self.style_editor = None;
}
}
if self.info_dialog.is_some() {
let mut close = false;
let mut send_value_cmd: Option<String> = None;
{
let dialog = self.info_dialog.as_mut().unwrap();
let max_h = ctx.available_rect().height() * 0.75;
let title = format!(" {}", dialog.path);
egui::Window::new(title)
.resizable(true)
.default_width(480.0)
.max_height(max_h)
.show(ctx, |ui| {
// Always-visible close button at top-right
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("✖ Close").clicked() {
close = true;
}
});
});
ui.separator();
if dialog.is_signal {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Value:").color(egui::Color32::GRAY));
if dialog.value_loading {
ui.spinner();
} else {
match &dialog.value_text {
Some(v) => {
ui.label(egui::RichText::new(v.as_str())
.monospace().strong()
.color(egui::Color32::from_rgb(100, 220, 255)));
}
None => {
ui.label(egui::RichText::new("—").color(egui::Color32::GRAY));
}
}
}
if ui.small_button("🔄").on_hover_text("Refresh value").clicked() {
send_value_cmd = Some(dialog.path.clone());
dialog.value_loading = true;
}
});
ui.separator();
}
if dialog.is_loading {
ui.spinner();
ui.label(egui::RichText::new("Loading config…").italics().color(egui::Color32::GRAY));
} else if dialog.config_text.is_empty() {
ui.label(egui::RichText::new("No config available").italics().color(egui::Color32::GRAY));
} else {
egui::ScrollArea::both()
.auto_shrink([true, true])
.max_height(max_h - 120.0)
.show(ui, |ui| {
ui.add(
egui::Label::new(
egui::RichText::new(&dialog.config_text).monospace().small()
).selectable(true),
);
});
}
});
}
if let Some(path) = send_value_cmd {
let _ = self.tx_cmd.send(format!("VALUE {}", path));
}
if close {
self.info_dialog = None;
}
}
egui::TopBottomPanel::top("top").show(ctx, |ui| {
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() {
self.plots.push(PlotInstance {
id: format!("Plot {}", self.plots.len() + 1),
plot_type: PlotType::Normal,
signals: Vec::new(),
auto_bounds: true,
max_points: 5000,
follow: true,
reset_view: false,
});
}
ui.separator();
let (btn_text, btn_color) = if self.is_breaking {
("▶ Resume App", egui::Color32::GREEN)
} else {
("⏸ Pause App", egui::Color32::YELLOW)
};
if ui
.button(egui::RichText::new(btn_text).color(btn_color))
.clicked()
{
self.is_breaking = !self.is_breaking;
let _ = self.tx_cmd.send(if self.is_breaking {
"PAUSE".to_string()
} else {
"RESUME".to_string()
});
if !self.is_breaking {
self.step_status = None;
}
self.last_step_poll = std::time::Instant::now() - std::time::Duration::from_secs(1);
}
ui.separator();
ui.checkbox(&mut self.scope.enabled, "🔭 Scope");
if self.scope.enabled {
egui::ComboBox::from_id_salt("window_size")
.selected_text(format!("{}ms", self.scope.window_ms))
.show_ui(ui, |ui| {
for ms in [
10.0, 20.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0,
10000.0,
] {
ui.selectable_value(
&mut self.scope.window_ms,
ms,
format!("{}ms", ms),
);
}
});
ui.selectable_value(&mut self.scope.mode, AcquisitionMode::FreeRun, "Free");
ui.selectable_value(&mut self.scope.mode, AcquisitionMode::Triggered, "Trig");
if self.scope.mode == AcquisitionMode::FreeRun {
if ui
.button(if self.scope.paused {
"▶ Resume"
} else {
"⏸ Pause"
})
.clicked()
{
self.scope.paused = !self.scope.paused;
}
} else {
if ui
.button(if self.scope.is_armed {
"🔴 Armed"
} else {
"⚪ Single"
})
.clicked()
{
self.scope.is_armed = true;
self.scope.trigger_active = false;
}
ui.menu_button("⚙ Trig", |ui| {
egui::Grid::new("trig").num_columns(2).show(ui, |ui| {
ui.label("Source:");
ui.text_edit_singleline(&mut self.scope.trigger_source);
ui.end_row();
ui.label("Edge:");
egui::ComboBox::from_id_salt("edge")
.selected_text(format!("{:?}", self.scope.trigger_edge))
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.scope.trigger_edge,
TriggerEdge::Rising,
"Rising",
);
ui.selectable_value(
&mut self.scope.trigger_edge,
TriggerEdge::Falling,
"Falling",
);
ui.selectable_value(
&mut self.scope.trigger_edge,
TriggerEdge::Both,
"Both",
);
});
ui.end_row();
ui.label("Thresh:");
ui.add(
egui::DragValue::new(&mut self.scope.trigger_threshold)
.speed(0.1),
);
ui.end_row();
ui.label("Pre %:");
ui.add(egui::Slider::new(
&mut self.scope.pre_trigger_percent,
0.0..=100.0,
));
ui.end_row();
ui.label("Type:");
ui.selectable_value(
&mut self.scope.trigger_type,
TriggerType::Single,
"Single",
);
ui.selectable_value(
&mut self.scope.trigger_type,
TriggerType::Continuous,
"Cont",
);
ui.end_row();
});
});
}
}
ui.separator();
ui.menu_button("🔌 Conn", |ui| {
egui::Grid::new("conn_grid").num_columns(2).show(ui, |ui| {
ui.label("IP:");
ui.text_edit_singleline(&mut self.config.ip);
ui.end_row();
ui.label("Control:");
ui.text_edit_singleline(&mut self.config.tcp_port);
ui.end_row();
ui.label("Telemetry (Auto):");
ui.label(&self.config.udp_port);
ui.end_row();
ui.label("Logs (Auto):");
ui.label(&self.config.log_port);
ui.end_row();
});
if ui.button("🔄 Apply").clicked() {
self.config.version += 1;
*self.shared_config.lock().unwrap() = self.config.clone();
ui.close_menu();
}
if ui.button("📡 Re-Discover").clicked() {
let _ = self.tx_cmd.send("DISCOVER".to_string());
ui.close_menu();
}
if ui.button("❌ Off").clicked() {
self.config.version += 1;
let mut cfg = self.config.clone();
cfg.ip = "".to_string();
*self.shared_config.lock().unwrap() = cfg;
ui.close_menu();
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("✉ Send Msg").clicked() {
self.message_dialog = Some(MessageDialog {
destination: "".to_string(),
function: "".to_string(),
payload: "".to_string(),
expect_reply: false,
});
}
ui.separator();
ui.label(format!(
"UDP: OK[{}] DROP[{}]",
self.udp_packets, self.udp_dropped
));
});
});
});
if self.show_left_panel {
egui::SidePanel::left("left")
.resizable(true)
.width_range(200.0..=500.0)
.show(ctx, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
if let Some(tree) = self.app_tree.clone() {
self.render_tree(ui, &tree, "".to_string());
}
});
});
}
if self.show_right_panel {
egui::SidePanel::right("right")
.resizable(true)
.width_range(250.0..=400.0)
.show(ctx, |ui| {
// Debug Controls pane — shown when app is paused / breaking
if self.is_breaking {
let paused = self.step_status.as_ref().map(|(p, _, _)| *p).unwrap_or(false);
let gam = self.step_status.as_ref().map(|(_, g, _)| g.clone()).unwrap_or_default();
let remaining = self.step_status.as_ref().map(|(_, _, r)| *r).unwrap_or(0);
let threads = self.get_threads();
ui.group(|ui| {
ui.label(egui::RichText::new("⏸ Debug Controls").strong().color(egui::Color32::YELLOW));
ui.separator();
if paused {
if gam.is_empty() {
ui.label(egui::RichText::new("Paused (waiting for break)").color(egui::Color32::from_rgb(255, 200, 100)));
} else {
ui.label(egui::RichText::new("Paused at GAM:").color(egui::Color32::GRAY).small());
ui.label(egui::RichText::new(&gam).monospace().strong().color(egui::Color32::from_rgb(255, 180, 80)));
}
} else {
ui.label(egui::RichText::new(format!("Running ({} steps left)", remaining)).color(egui::Color32::from_rgb(100, 220, 100)));
}
if !threads.is_empty() {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Thread:").color(egui::Color32::GRAY).small());
let sel = if self.step_thread.is_empty() { "(all)" } else { &self.step_thread };
egui::ComboBox::from_id_salt("step_thread_combo")
.selected_text(sel)
.width(140.0)
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.step_thread, String::new(), "(all)");
for t in &threads {
ui.selectable_value(&mut self.step_thread, t.clone(), t.as_str());
}
});
});
}
ui.add_space(4.0);
ui.horizontal(|ui| {
let thread_arg = if self.step_thread.is_empty() {
String::new()
} else {
format!(" {}", self.step_thread)
};
if ui.button("Step 1").on_hover_text("Run one output broker cycle, then pause").clicked() {
let _ = self.tx_cmd.send(format!("STEP 1{}", thread_arg));
self.step_status = self.step_status.as_ref().map(|(_, g, _)| (false, g.clone(), 1));
}
if ui.button("Step 5").on_hover_text("Run 5 output broker cycles, then pause").clicked() {
let _ = self.tx_cmd.send(format!("STEP 5{}", thread_arg));
self.step_status = self.step_status.as_ref().map(|(_, g, _)| (false, g.clone(), 5));
}
if ui.button(egui::RichText::new("▶ Resume").color(egui::Color32::GREEN)).clicked() {
let _ = self.tx_cmd.send("RESUME".to_string());
self.is_breaking = false;
self.step_status = None;
}
});
});
ui.separator();
}
ui.heading("Traced Signals");
let mut names: Vec<_> = {
let data_map = self.traced_signals.lock().unwrap();
data_map.keys().cloned().collect()
};
names.sort();
let mut open_info_signal: Option<(String, f64)> = None;
egui::ScrollArea::vertical()
.id_salt("traced_scroll")
.show(ui, |ui| {
for key in names {
let (last_val, is_recording, is_monitored) = {
let dm = self.traced_signals.lock().unwrap();
if let Some(e) = dm.get(&key) {
(e.last_value, e.recording_tx.is_some(), e.is_monitored)
} else {
continue;
}
};
ui.horizontal(|ui| {
if is_recording {
ui.label(egui::RichText::new("●").color(egui::Color32::RED));
}
let response = ui.add(
egui::Label::new(format!("{}: {:.2}", key, last_val))
.sense(egui::Sense::drag().union(egui::Sense::click())),
);
if response.drag_started() {
ctx.data_mut(|d| {
d.insert_temp(egui::Id::new("drag_signal"), key.clone())
});
}
if response.double_clicked() {
open_info_signal = Some((key.clone(), last_val));
}
if ui.small_button("").on_hover_text("Show info / last value").clicked() {
open_info_signal = Some((key.clone(), last_val));
}
response.context_menu(|ui| {
if !is_recording {
if ui.button("⏺ Record to Parquet").clicked() {
let tx = self.internal_tx.clone();
let name_clone = key.clone();
thread::spawn(move || {
if let Some(path) = FileDialog::new()
.add_filter("Parquet", &["parquet"])
.save_file()
{
let _ = tx.send(InternalEvent::RecordPathChosen(
name_clone,
path.to_string_lossy().to_string(),
));
}
});
ui.close_menu();
}
} else {
if ui.button("⏹ Stop").clicked() {
let mut dm = self.traced_signals.lock().unwrap();
if let Some(e) = dm.get_mut(&key) {
e.recording_tx = None;
}
ui.close_menu();
}
}
});
if ui.button("❌").clicked() {
if is_monitored {
let _ = self.tx_cmd.send(format!("UNMONITOR SIGNAL {}", key));
} else {
let _ = self.tx_cmd.send(format!("TRACE {} 0", key));
}
let _ = self.internal_tx.send(InternalEvent::ClearTrace(key.clone()));
}
});
}
});
if let Some((sig_path, last_val)) = open_info_signal {
let _ = self.tx_cmd.send(format!("INFO {}", sig_path));
let _ = self.tx_cmd.send(format!("VALUE {}", sig_path));
self.info_dialog = Some(InfoDialog {
path: sig_path,
is_signal: true,
config_text: String::new(),
is_loading: true,
value_text: None,
value_loading: true,
});
}
ui.separator();
ui.heading("Forced Signals");
let mut to_delete = Vec::new();
for (path, val) in &self.forced_signals {
ui.horizontal(|ui| {
ui.label(format!("{}: {}", path, val));
if ui.button("❌").clicked() {
let _ = self.tx_cmd.send(format!("UNFORCE {}", path));
to_delete.push(path.to_owned());
}
});
}
for key in to_delete.iter() {
self.forced_signals.remove(key);
}
ui.separator();
ui.heading("Breakpoints");
if self.break_conditions.is_empty() {
ui.label(egui::RichText::new("No active breakpoints").italics().color(egui::Color32::GRAY));
} else {
let mut to_clear: Vec<String> = Vec::new();
let mut to_edit: Option<String> = None;
let mut sorted_breaks: Vec<_> = self.break_conditions.iter().collect();
sorted_breaks.sort_by_key(|(k, _)| k.as_str());
for (path, (op, thr)) in &sorted_breaks {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("🔴 {} {} {}", path, op, thr))
.color(egui::Color32::from_rgb(255, 120, 120))
.monospace()
.small(),
);
if ui.small_button("✏").on_hover_text("Edit").clicked() {
to_edit = Some(path.to_string());
}
if ui.small_button("❌").on_hover_text("Clear break").clicked() {
to_clear.push(path.to_string());
}
});
}
for path in to_clear {
let _ = self.tx_cmd.send(format!("BREAK {} OFF", path));
self.break_conditions.remove(&path);
}
if let Some(path) = to_edit {
let (op, thr) = self.break_conditions[&path].clone();
self.break_dialog = Some(BreakDialog {
signal_path: path,
op,
threshold: thr.to_string(),
});
}
}
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);
}
});
}
}
});
}
if self.show_bottom_panel {
egui::TopBottomPanel::bottom("log_panel")
.resizable(true)
.default_height(150.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.heading("Logs");
ui.separator();
ui.checkbox(&mut self.log_filters.show_debug, "Debug");
ui.checkbox(&mut self.log_filters.show_info, "Info");
ui.checkbox(&mut self.log_filters.show_warning, "Warn");
ui.checkbox(&mut self.log_filters.show_error, "Error");
ui.separator();
ui.label("Filter:");
ui.text_edit_singleline(&mut self.log_filters.content_regex);
if ui.button("🗑 Clear").clicked() {
self.logs.clear();
}
});
ui.separator();
let regex = if !self.log_filters.content_regex.is_empty() {
Regex::new(&self.log_filters.content_regex).ok()
} else {
None
};
egui::ScrollArea::vertical()
.stick_to_bottom(true)
.auto_shrink([false, false])
.show(ui, |ui| {
for log in &self.logs {
let show = match log.level.as_str() {
"Debug" => self.log_filters.show_debug,
"Information" | "GUI_INFO" | "GUI_WARN" | "CMD_RESP" => {
self.log_filters.show_info
}
"Warning" => self.log_filters.show_warning,
"FatalError" | "OSError" | "ParametersError" | "GUI_ERROR"
| "REC_ERROR" => self.log_filters.show_error,
_ => true,
};
if !show {
continue;
}
if let Some(re) = &regex {
if !re.is_match(&log.message) && !re.is_match(&log.level) {
continue;
}
}
let color = match log.level.as_str() {
"FatalError" | "OSError" | "ParametersError" | "GUI_ERROR"
| "REC_ERROR" => egui::Color32::from_rgb(255, 100, 100),
"Warning" | "GUI_WARN" => {
egui::Color32::from_rgb(255, 255, 100)
}
"Information" | "GUI_INFO" => {
egui::Color32::from_rgb(100, 255, 100)
}
"Debug" => egui::Color32::from_rgb(100, 100, 255),
"CMD_RESP" => egui::Color32::from_rgb(255, 255, 255),
_ => egui::Color32::WHITE,
};
ui.horizontal_wrapped(|ui| {
ui.label(
egui::RichText::new(&log.time)
.color(egui::Color32::GRAY)
.monospace(),
);
ui.label(
egui::RichText::new(format!("[{}]", log.level))
.color(color)
.strong(),
);
ui.add(egui::Label::new(&log.message).wrap());
});
}
});
});
}
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;
let mut to_remove = None;
let mut current_range = None;
for (p_idx, plot_inst) in self.plots.iter_mut().enumerate() {
ui.group(|ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&plot_inst.id).strong());
ui.selectable_value(&mut plot_inst.plot_type, PlotType::Normal, "Series");
ui.selectable_value(&mut plot_inst.plot_type, PlotType::LogicAnalyzer, "Logic");
ui.separator();
let follow_text = if plot_inst.follow {
egui::RichText::new("▶ Follow").color(egui::Color32::from_rgb(100, 220, 100))
} else {
egui::RichText::new("▶ Follow").color(egui::Color32::GRAY)
};
if ui.toggle_value(&mut plot_inst.follow, follow_text)
.on_hover_text("Follow latest data (auto-scroll X axis)")
.changed()
{
if plot_inst.follow {
plot_inst.auto_bounds = true;
self.shared_x_range = None;
}
}
if ui.button("↺ Reset").on_hover_text("Reset view to fit all data").clicked() {
plot_inst.auto_bounds = true;
plot_inst.follow = true;
plot_inst.reset_view = true;
self.shared_x_range = None;
}
ui.separator();
ui.label(egui::RichText::new("Pts:").color(egui::Color32::GRAY).small());
ui.add(egui::DragValue::new(&mut plot_inst.max_points).range(100..=100000).speed(100.0))
.on_hover_text("Max points per line (lower = faster rendering)");
ui.separator();
if ui.button("🗑").clicked() {
to_remove = Some(p_idx);
}
});
let mut plot = Plot::new(&plot_inst.id)
.height(plot_height - 40.0)
.show_axes([true, true]);
plot = plot.x_axis_formatter(|mark, _range| {
let val = mark.value;
let hours = (val / 3600.0) as u32;
let mins = ((val % 3600.0) / 60.0) as u32;
let secs = val % 60.0;
format!("{:02}:{:02}:{:05.2}", hours, mins, secs)
});
let data_map = self.traced_signals.lock().unwrap();
let mut latest_t = 0.0;
for sig_cfg in &plot_inst.signals {
if let Some(data) = data_map.get(&sig_cfg.source_name) {
if let Some(last) = data.values.back() {
if last[0] > latest_t {
latest_t = last[0];
}
}
}
}
if self.scope.enabled {
let window_s = self.scope.window_ms / 1000.0;
let center_t = if self.scope.mode == AcquisitionMode::Triggered {
if self.scope.trigger_active {
self.scope.last_trigger_time
} else {
latest_t
}
} else {
latest_t
};
let x_min =
center_t - (self.scope.pre_trigger_percent / 100.0) * window_s;
plot = plot.include_x(x_min).include_x(x_min + window_s);
if !self.scope.paused {
plot = plot.auto_bounds(egui::Vec2b::new(true, true));
}
} else {
if let Some(range) = self.shared_x_range {
if !plot_inst.auto_bounds {
plot = plot.include_x(range[0]).include_x(range[1]);
}
}
if plot_inst.auto_bounds {
plot = plot.auto_bounds(egui::Vec2b::new(true, true));
}
}
// Pre-compute fit bounds if a reset was requested (reset_view flag).
// Must be done before plot.show() so we can call set_plot_bounds inside.
let fit_bounds: Option<PlotBounds> = if plot_inst.reset_view {
let mut min_t = f64::INFINITY;
let mut max_t = f64::NEG_INFINITY;
let mut min_v = f64::INFINITY;
let mut max_v = f64::NEG_INFINITY;
let max_pts = plot_inst.max_points;
for (s_idx, sig_cfg) in plot_inst.signals.iter().enumerate() {
if let Some(data) = data_map.get(&sig_cfg.source_name) {
for [t, v] in data.values.iter().rev().take(max_pts) {
let mut fv = *v * sig_cfg.gain + sig_cfg.offset;
if plot_inst.plot_type == PlotType::LogicAnalyzer {
fv = (s_idx as f64 * 1.5) + (if fv > 0.5 { 1.0 } else { 0.0 });
}
if *t < min_t { min_t = *t; }
if *t > max_t { max_t = *t; }
if fv < min_v { min_v = fv; }
if fv > max_v { max_v = fv; }
}
}
}
if min_t.is_finite() && max_t.is_finite() && min_v.is_finite() && max_v.is_finite() {
let pad_t = (max_t - min_t).abs() * 0.02 + 0.01;
let pad_v = (max_v - min_v).abs() * 0.05 + 0.01;
Some(PlotBounds::from_min_max(
[min_t - pad_t, min_v - pad_v],
[max_t + pad_t, max_v + pad_v],
))
} else {
None
}
} else {
None
};
let plot_resp = plot.show(ui, |plot_ui| {
if let Some(bounds) = fit_bounds {
plot_ui.set_plot_bounds(bounds);
plot_inst.reset_view = false;
}
if !self.scope.enabled && !plot_inst.auto_bounds {
if let Some(range) = self.shared_x_range {
let bounds = plot_ui.plot_bounds();
plot_ui.set_plot_bounds(PlotBounds::from_min_max(
[range[0], bounds.min()[1]],
[range[1], bounds.max()[1]],
));
}
}
if self.scope.enabled
&& self.scope.mode == AcquisitionMode::Triggered
&& self.scope.trigger_active
{
plot_ui.vline(
VLine::new(self.scope.last_trigger_time)
.color(egui::Color32::YELLOW)
.style(LineStyle::Dashed { length: 5.0 }),
);
}
let max_pts = plot_inst.max_points;
for (s_idx, sig_cfg) in plot_inst.signals.iter().enumerate() {
if let Some(data) = data_map.get(&sig_cfg.source_name) {
let points_iter =
data.values.iter().rev().take(max_pts).rev().map(|[t, v]| {
let mut final_v = *v * sig_cfg.gain + sig_cfg.offset;
if plot_inst.plot_type == PlotType::LogicAnalyzer {
final_v = (s_idx as f64 * 1.5)
+ (if final_v > 0.5 { 1.0 } else { 0.0 });
}
[*t, final_v]
});
plot_ui.line(
Line::new(PlotPoints::from_iter(points_iter))
.name(&sig_cfg.label)
.color(sig_cfg.color),
);
}
}
if p_idx == 0 || current_range.is_none() {
let b = plot_ui.plot_bounds();
current_range = Some([b.min()[0], b.max()[0]]);
}
});
drop(data_map);
if plot_resp.response.hovered() && ctx.input(|i| i.pointer.any_released()) {
if let Some(dropped) =
ctx.data_mut(|d| d.get_temp::<String>(egui::Id::new("drag_signal")))
{
let color = Self::next_color(plot_inst.signals.len());
plot_inst.signals.push(SignalPlotConfig {
source_name: dropped.clone(),
label: dropped.clone(),
unit: "".to_string(),
color,
line_style: LineStyle::Solid,
marker_type: MarkerType::None,
gain: 1.0,
offset: 0.0,
});
ctx.data_mut(|d| {
d.remove_temp::<String>(egui::Id::new("drag_signal"))
});
}
}
if plot_resp.response.dragged()
|| ctx.input(|i| i.smooth_scroll_delta.y != 0.0)
{
if plot_resp.response.hovered() {
plot_inst.auto_bounds = false;
plot_inst.follow = false;
let b = plot_resp.transform.bounds();
self.shared_x_range = Some([b.min()[0], b.max()[0]]);
}
}
// Re-apply follow on each frame when follow is active
if plot_inst.follow && !self.scope.enabled {
plot_inst.auto_bounds = true;
}
plot_resp.response.context_menu(|ui| {
if ui.button("🔍 Fit View").clicked() {
plot_inst.auto_bounds = true;
plot_inst.follow = true;
plot_inst.reset_view = true;
self.shared_x_range = None;
ui.close_menu();
}
ui.separator();
let mut sig_to_remove = None;
for (s_idx, sig) in plot_inst.signals.iter().enumerate() {
ui.horizontal(|ui| {
ui.label(&sig.label);
if ui.button("🎨 Style").clicked() {
self.style_editor = Some((p_idx, s_idx));
ui.close_menu();
}
if ui.button("❌ Remove").clicked() {
sig_to_remove = Some(s_idx);
ui.close_menu();
}
});
}
if let Some(idx) = sig_to_remove {
plot_inst.signals.remove(idx);
}
});
});
}
if let Some(idx) = to_remove {
self.plots.remove(idx);
}
if !self.scope.enabled {
if let Some(range) = current_range {
if self.shared_x_range.is_none() {
self.shared_x_range = Some(range);
}
}
}
} else {
ui.centered_and_justified(|ui| {
ui.label("Add a plot panel to begin analysis");
});
}
});
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
#[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, "");
}
// ----- Unit tests for STEP_STATUS / VALUE JSON parsing -----
// These mirror the parsing logic inside tcp_command_worker's reader thread.
fn parse_step_status(json: &str) -> (bool, String, u32, String) {
let paused = json.contains("\"Paused\": true");
let gam = json.split("\"PausedAtGam\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
let remaining = json.split("\"StepRemaining\": ")
.nth(1)
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
let thread = json.split("\"StepThread\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
(paused, gam, remaining, thread)
}
fn parse_value_response(json: &str) -> (bool, String, String) {
let found = !json.contains("\"Error\"");
let path = json.split("\"Name\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
// Value is a quoted string: "Value": "..."
let value_text = json.split("\"Value\": \"")
.nth(1)
.and_then(|s| s.split('"').next())
.unwrap_or("")
.to_string();
(found, path, value_text)
}
#[test]
fn test_step_status_paused_with_thread() {
let json = r#"{"Paused": true, "PausedAtGam": "App.Functions.GAM2", "StepRemaining": 0, "StepThread": "Thread1"}"#;
let (paused, gam, remaining, thread) = parse_step_status(json);
assert!(paused);
assert_eq!(gam, "App.Functions.GAM2");
assert_eq!(remaining, 0);
assert_eq!(thread, "Thread1");
}
#[test]
fn test_step_status_running_no_thread() {
let json = r#"{"Paused": false, "PausedAtGam": "", "StepRemaining": 3, "StepThread": ""}"#;
let (paused, gam, remaining, thread) = parse_step_status(json);
assert!(!paused);
assert_eq!(gam, "");
assert_eq!(remaining, 3);
assert_eq!(thread, "");
}
#[test]
fn test_step_status_step_remaining_not_confused_with_thread_port() {
// "StepRemaining" must not accidentally parse digits from "StepThread"
let json = r#"{"Paused": false, "PausedAtGam": "", "StepRemaining": 7, "StepThread": "RT1"}"#;
let (_, _, remaining, thread) = parse_step_status(json);
assert_eq!(remaining, 7);
assert_eq!(thread, "RT1");
}
#[test]
fn test_value_response_found() {
let json = r#"{"Name": "App.Data.DDB.Counter", "Value": "42.5", "Elements": 1}"#;
let (found, path, value_text) = parse_value_response(json);
assert!(found);
assert_eq!(path, "App.Data.DDB.Counter");
assert_eq!(value_text, "42.5");
}
#[test]
fn test_value_response_not_found() {
let json = r#"{"Error": "Signal not found: BadSignal"}"#;
let (found, _path, _value_text) = parse_value_response(json);
assert!(!found);
}
#[test]
fn test_value_response_array() {
let json = r#"{"Name": "App.Data.DDB.Vec", "Value": "1, 2, 3", "Elements": 3}"#;
let (found, path, value_text) = parse_value_response(json);
assert!(found);
assert_eq!(path, "App.Data.DDB.Vec");
assert_eq!(value_text, "1, 2, 3");
}
#[test]
fn test_value_response_negative() {
let json = r#"{"Name": "App.Data.DDB.Err", "Value": "-3.14159", "Elements": 1}"#;
let (found, _, value_text) = parse_value_response(json);
assert!(found);
assert_eq!(value_text, "-3.14159");
}
}
fn main() -> Result<(), eframe::Error> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([1280.0, 800.0]),
..Default::default()
};
eframe::run_native(
"MARTe2 Debug Explorer",
options,
Box::new(|cc| Ok(Box::new(MarteDebugApp::new(cc)))),
)
}