use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::fmt;
use tokio::process::Command;
static COUNT_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(\d+)\s+(?:publisher|subscriber)s?").unwrap());
static HZ_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"average rate: ([\d.]+)").unwrap());
static DELAY_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"average delay: ([\d.]+)").unwrap());
static STD_DEV_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"std dev: ([\d.]+)s").unwrap());
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicInfo {
pub name: String,
pub msg_type: String,
pub publisher_count: usize,
pub subscriber_count: usize,
pub hz: Option<f64>,
pub hz_std_dev: Option<f64>,
pub delay: Option<f64>,
pub delay_std_dev: Option<f64>,
pub watched: bool,
pub hz_status: MeasurementStatus,
pub delay_status: MeasurementStatus,
pub hz_history: VecDeque<f64>,
pub hz_std_dev_history: VecDeque<f64>,
pub delay_history: VecDeque<f64>,
pub delay_std_dev_history: VecDeque<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum MeasurementStatus {
NotMeasuring, Loading(usize), HasValue, NoStamp, }
impl Default for TopicInfo {
fn default() -> Self {
Self {
name: String::new(),
msg_type: String::new(),
publisher_count: 0,
subscriber_count: 0,
hz: None,
hz_std_dev: None,
delay: None,
delay_std_dev: None,
watched: false,
hz_status: MeasurementStatus::NotMeasuring,
delay_status: MeasurementStatus::NotMeasuring,
hz_history: VecDeque::new(),
hz_std_dev_history: VecDeque::new(),
delay_history: VecDeque::new(),
delay_std_dev_history: VecDeque::new(),
}
}
}
#[derive(Debug)]
pub enum TopicError {
Io(std::io::Error),
ParseError(String),
}
impl fmt::Display for TopicError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TopicError::Io(e) => write!(f, "IO error: {}", e),
TopicError::ParseError(msg) => write!(f, "Parse error: {}", msg),
}
}
}
impl std::error::Error for TopicError {}
impl From<std::io::Error> for TopicError {
fn from(error: std::io::Error) -> Self {
TopicError::Io(error)
}
}
pub async fn get_topic_list() -> Result<Vec<TopicInfo>, TopicError> {
crate::debug_log("Executing: ros2 topic list -v --spin-time 0.5");
let start = std::time::Instant::now();
let output = Command::new("ros2")
.arg("topic")
.arg("list")
.arg("-v")
.arg("--spin-time")
.arg("0.5")
.output()
.await?;
let duration = start.elapsed();
crate::debug_log(&format!("ros2 topic list -v took {:?}", duration));
if !output.status.success() {
return Err(TopicError::ParseError(format!(
"ros2 topic list failed: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
let output_str = String::from_utf8_lossy(&output.stdout);
let topic_infos = parse_topic_list_verbose(&output_str);
crate::debug_log(&format!(
"Parsed {} topics with publisher/subscriber counts",
topic_infos.len()
));
Ok(topic_infos)
}
pub async fn start_topic_hz_stream(topic_name: &str) -> Result<tokio::process::Child, TopicError> {
crate::debug_log(&format!(
"Starting continuous hz stream for topic: '{}'",
topic_name
));
let child = Command::new("ros2")
.arg("topic")
.arg("hz")
.arg(topic_name)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
Ok(child)
}
pub async fn parse_hz_stream_line(line: &str) -> (Option<f64>, Option<f64>, MeasurementStatus) {
parse_topic_hz(line)
}
pub async fn start_topic_delay_stream(
topic_name: &str,
use_sim_time: bool,
) -> Result<tokio::process::Child, TopicError> {
crate::debug_log(&format!(
"Starting continuous delay stream for topic: '{}' (sim_time={})",
topic_name, use_sim_time
));
let mut command = Command::new("ros2");
command.arg("topic").arg("delay");
if use_sim_time {
command.arg("--use-sim-time");
}
let child = command
.arg(topic_name)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
Ok(child)
}
pub async fn parse_delay_stream_line(line: &str) -> (Option<f64>, Option<f64>, MeasurementStatus) {
parse_topic_delay(line)
}
pub async fn start_topic_echo_stream(
topic_name: &str,
) -> Result<tokio::process::Child, TopicError> {
crate::debug_log(&format!("Starting echo stream for topic: '{}'", topic_name));
let child = Command::new("ros2")
.arg("topic")
.arg("echo")
.arg(topic_name)
.arg("--no-arr") .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
Ok(child)
}
pub fn parse_topic_list_verbose(output: &str) -> Vec<TopicInfo> {
use std::collections::HashMap;
let mut topics: HashMap<String, TopicInfo> = HashMap::new();
let mut current_section = "";
for line in output.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if line.starts_with("Published topics:") {
current_section = "published";
continue;
} else if line.starts_with("Subscribed topics:") {
current_section = "subscribed";
continue;
}
if !line.starts_with("* /") {
continue;
}
let line_without_bullet = &line[2..].trim();
if let Some(bracket_start) = line_without_bullet.find('[') {
let topic_name = line_without_bullet[..bracket_start].trim();
if let Some(bracket_end) = line_without_bullet.find(']') {
let msg_type = line_without_bullet[bracket_start + 1..bracket_end].trim();
let after_bracket = &line_without_bullet[bracket_end + 1..].trim();
let count = if let Some(caps) = COUNT_REGEX.captures(after_bracket) {
if let Some(count_match) = caps.get(1) {
count_match.as_str().parse().unwrap_or(0)
} else {
0
}
} else {
0
};
let topic_info =
topics
.entry(topic_name.to_string())
.or_insert_with(|| TopicInfo {
name: topic_name.to_string(),
msg_type: msg_type.to_string(),
..Default::default()
});
match current_section {
"published" => topic_info.publisher_count = count,
"subscribed" => topic_info.subscriber_count = count,
_ => {}
}
}
}
}
let mut topic_list: Vec<TopicInfo> = topics.into_values().collect();
topic_list.sort_by(|a, b| a.name.cmp(&b.name)); topic_list
}
pub fn parse_topic_hz(output: &str) -> (Option<f64>, Option<f64>, MeasurementStatus) {
if output.contains("does not appear to be published yet") || output.contains("WARNING: topic") {
crate::debug_log(&format!(
"Detected unpublished topic in Hz output: '{}'",
output
));
return (None, None, MeasurementStatus::NotMeasuring);
}
let mut hz_val = None;
let mut std_dev_val = None;
if let Some(caps) = HZ_REGEX.captures(output) {
if let Some(rate) = caps.get(1) {
if let Ok(hz) = rate.as_str().parse() {
hz_val = Some(hz);
}
}
}
if let Some(caps) = STD_DEV_REGEX.captures(output) {
if let Some(std_dev) = caps.get(1) {
if let Ok(std_dev) = std_dev.as_str().parse() {
std_dev_val = Some(std_dev);
}
}
}
if hz_val.is_some() {
(hz_val, std_dev_val, MeasurementStatus::HasValue)
} else if std_dev_val.is_some() {
(None, std_dev_val, MeasurementStatus::Loading(0))
} else {
(None, None, MeasurementStatus::Loading(0)) }
}
pub fn parse_topic_delay(output: &str) -> (Option<f64>, Option<f64>, MeasurementStatus) {
crate::debug_log(&format!("Parsing delay output: '{}'", output));
if output.contains("does not appear to be published yet") || output.contains("WARNING: topic") {
crate::debug_log(&format!(
"Detected unpublished topic in Delay output: '{}'",
output
));
return (None, None, MeasurementStatus::NotMeasuring);
}
if output.contains("msg does not have header") {
crate::debug_log("Detected 'msg does not have header' - returning NoStamp");
return (None, None, MeasurementStatus::NoStamp);
}
let mut delay_val = None;
let mut std_dev_val = None;
if let Some(caps) = DELAY_REGEX.captures(output) {
if let Some(delay) = caps.get(1) {
if let Ok(delay) = delay.as_str().parse() {
delay_val = Some(delay);
crate::debug_log(&format!("Parsed delay value: {}", delay));
}
}
}
if let Some(caps) = STD_DEV_REGEX.captures(output) {
if let Some(std_dev) = caps.get(1) {
if let Ok(std_dev) = std_dev.as_str().parse() {
std_dev_val = Some(std_dev);
crate::debug_log(&format!("Parsed delay std dev value: {}", std_dev));
}
}
}
if delay_val.is_some() {
(delay_val, std_dev_val, MeasurementStatus::HasValue)
} else if std_dev_val.is_some() {
(None, std_dev_val, MeasurementStatus::Loading(0))
} else {
crate::debug_log("No delay match found - returning Loading");
(None, None, MeasurementStatus::Loading(0)) }
}