use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::mocap::timing::StampPolicy;
use crate::mocap::transform::UpAxis;
use crate::ros::context::RmwZenohCompat;
use crate::ros::names;
use crate::ros::qos::{Durability, Reliability};
use crate::source::live::LiveConfig;
use crate::source::replay::ReplayConfig;
use crate::source::synthetic::SyntheticConfig;
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub source: SourceConfig,
pub zenoh: ZenohConfig,
pub ros: RosConfig,
pub native: NativeConfig,
pub transform: TransformConfig,
pub timing: TimingConfig,
pub rigid_bodies: RigidBodyConfig,
pub markers: MarkerConfig,
pub skeletons: SkeletonConfig,
pub analog: AnalogConfig,
pub diagnostics: DiagnosticsConfig,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
#[default]
Live,
Replay,
Synthetic,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SourceConfig {
pub kind: SourceKind,
pub queue: usize,
pub live: LiveConfig,
pub replay: ReplayConfig,
pub synthetic: SyntheticConfig,
pub exit_on_end: bool,
}
impl Default for SourceConfig {
fn default() -> Self {
Self {
kind: SourceKind::Live,
queue: 64,
live: LiveConfig::default(),
replay: ReplayConfig::default(),
synthetic: SyntheticConfig::default(),
exit_on_end: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ZenohConfig {
pub config_file: Option<PathBuf>,
pub config_json5: Option<String>,
pub overrides: Vec<String>,
pub router_check_attempts: Option<u64>,
}
pub const MAX_QUEUE: usize = 65_536;
pub const MAX_DEPTH: usize = 10_000;
pub const MAX_PATH_LENGTH: usize = 100_000;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RosConfig {
pub enabled: bool,
pub domain_id: Option<u32>,
pub node_name: String,
pub namespace: String,
pub enclave: String,
pub rmw_zenoh: RmwZenohCompat,
pub world_frame: String,
pub frame_prefix: String,
pub rosout: bool,
pub parameters: bool,
pub type_description_service: bool,
pub static_transforms: Vec<StaticTransform>,
pub stream_qos: QosConfig,
}
impl Default for RosConfig {
fn default() -> Self {
Self {
enabled: true,
domain_id: None,
node_name: "natnet".into(),
namespace: "/".into(),
enclave: "/".into(),
rmw_zenoh: RmwZenohCompat::Current,
world_frame: "mocap".into(),
frame_prefix: String::new(),
rosout: true,
parameters: true,
type_description_service: true,
static_transforms: Vec::new(),
stream_qos: QosConfig::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StaticTransform {
pub parent: String,
pub child: String,
#[serde(default)]
pub translation: [f64; 3],
#[serde(default)]
pub rpy: [f64; 3],
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct QosConfig {
pub reliability: Reliability,
pub durability: Durability,
pub depth: usize,
}
impl Default for QosConfig {
fn default() -> Self {
Self {
reliability: Reliability::Reliable,
durability: Durability::Volatile,
depth: 10,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NativeConfig {
pub enabled: bool,
pub prefix: String,
pub frame_json: bool,
pub rigid_body_json: bool,
pub raw: bool,
}
impl Default for NativeConfig {
fn default() -> Self {
Self {
enabled: false,
prefix: "natnet".into(),
frame_json: true,
rigid_body_json: true,
raw: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TransformConfig {
pub up_axis: UpAxis,
pub scale: f64,
pub offset_translation: [f64; 3],
pub offset_rpy: [f64; 3],
}
impl Default for TransformConfig {
fn default() -> Self {
Self {
up_axis: UpAxis::Y,
scale: 1.0,
offset_translation: [0.0; 3],
offset_rpy: [0.0; 3],
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TimingConfig {
pub policy: StampPolicy,
pub window_secs: f64,
pub max_rate_hz: f64,
}
impl Default for TimingConfig {
fn default() -> Self {
Self {
policy: StampPolicy::ClockSync,
window_secs: 10.0,
max_rate_hz: 0.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RigidBodyConfig {
pub tf: bool,
pub pose: bool,
pub odometry: bool,
pub twist: bool,
pub path: bool,
pub path_length: usize,
pub pose_array: bool,
pub mocap4r2: bool,
pub publish_untracked: bool,
pub include: Vec<String>,
pub exclude: Vec<String>,
pub rename: BTreeMap<String, String>,
pub velocity_alpha: f64,
pub velocity_max_gap: f64,
}
impl Default for RigidBodyConfig {
fn default() -> Self {
Self {
tf: true,
pose: true,
odometry: true,
twist: false,
path: false,
path_length: 500,
pose_array: true,
mocap4r2: false,
publish_untracked: false,
include: Vec::new(),
exclude: Vec::new(),
rename: BTreeMap::new(),
velocity_alpha: 0.5,
velocity_max_gap: 0.1,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MarkerConfig {
pub point_cloud: bool,
pub include_unlabeled: bool,
pub mocap4r2: bool,
pub visualization: bool,
pub visualization_rate_hz: f64,
pub axis_length: f64,
}
impl Default for MarkerConfig {
fn default() -> Self {
Self {
point_cloud: true,
include_unlabeled: true,
mocap4r2: false,
visualization: true,
visualization_rate_hz: 30.0,
axis_length: 0.1,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BoneCoordinates {
#[default]
Global,
Local,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SkeletonConfig {
pub tf: bool,
pub pose_array: bool,
pub coordinates: BoneCoordinates,
}
impl Default for SkeletonConfig {
fn default() -> Self {
Self {
tf: true,
pose_array: true,
coordinates: BoneCoordinates::Global,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AnalogConfig {
pub force_plate_wrench: bool,
pub force_plate_channels: bool,
pub device_channels: bool,
}
impl Default for AnalogConfig {
fn default() -> Self {
Self {
force_plate_wrench: true,
force_plate_channels: true,
device_channels: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DiagnosticsConfig {
pub enabled: bool,
pub period_secs: f64,
pub drop_warn_ratio: f64,
pub latency_warn_secs: f64,
}
impl Default for DiagnosticsConfig {
fn default() -> Self {
Self {
enabled: true,
period_secs: 1.0,
drop_warn_ratio: 0.05,
latency_warn_secs: 0.05,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("{path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("{path}: {source}")]
Parse {
path: PathBuf,
source: Box<toml::de::Error>,
},
#[error("invalid configuration: {0}")]
Invalid(String),
}
impl Config {
pub fn load(path: &Path) -> Result<Self, ConfigError> {
let cfg = Self::read(path)?;
cfg.validate()?;
Ok(cfg)
}
pub fn read(path: &Path) -> Result<Self, ConfigError> {
let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: path.to_owned(),
source,
})?;
toml::from_str(&text).map_err(|source| ConfigError::Parse {
path: path.to_owned(),
source: Box::new(source),
})
}
#[must_use]
pub fn default_toml() -> String {
toml::to_string_pretty(&Self::default()).unwrap_or_default()
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.validate_sections(false)
}
pub fn validate_all(&self) -> Result<(), ConfigError> {
self.validate_sections(true)
}
fn validate_sections(&self, all: bool) -> Result<(), ConfigError> {
let bad = |m: String| Err(ConfigError::Invalid(m));
let kind = self.source.kind;
if all || kind == SourceKind::Live {
self.source
.live
.validate()
.map_err(|e| ConfigError::Invalid(format!("source.live: {e}")))?;
}
if kind == SourceKind::Replay || (all && !self.source.replay.path.as_os_str().is_empty()) {
self.source
.replay
.validate()
.map_err(|e| ConfigError::Invalid(format!("source.replay: {e}")))?;
}
if all || kind == SourceKind::Synthetic {
crate::source::synthetic::Scene::new(self.source.synthetic.clone())
.map_err(|e| ConfigError::Invalid(format!("source.synthetic: {e}")))?;
}
if !(1..=MAX_QUEUE).contains(&self.source.queue) {
return bad(format!("source.queue must be in 1..={MAX_QUEUE}"));
}
if all || self.ros.enabled {
names::validate_node_name(&self.ros.node_name)
.map_err(|e| ConfigError::Invalid(format!("ros.node_name: {e}")))?;
names::validate_namespace(&names::normalize_namespace(&self.ros.namespace))
.map_err(|e| ConfigError::Invalid(format!("ros.namespace: {e}")))?;
if self.ros.world_frame.is_empty() {
return bad("ros.world_frame must not be empty".into());
}
if !(1..=MAX_DEPTH).contains(&self.ros.stream_qos.depth) {
return bad(format!("ros.stream_qos.depth must be in 1..={MAX_DEPTH}"));
}
}
if (all || self.native.enabled)
&& (zenoh::key_expr::keyexpr::new(self.native.prefix.as_str()).is_err()
|| self.native.prefix.contains(['*', '$', '#', '?']))
{
return bad(
"native.prefix must be a key expression without wildcards or empty chunks".into(),
);
}
if !(self.transform.scale.is_finite() && self.transform.scale > 0.0) {
return bad("transform.scale must be positive".into());
}
if !(self.timing.window_secs.is_finite() && self.timing.window_secs > 0.0) {
return bad("timing.window_secs must be positive".into());
}
let rate_or_off = |r: f64| r == 0.0 || (r.is_finite() && (1e-3..=1e5).contains(&r));
if !rate_or_off(self.timing.max_rate_hz) {
return bad("timing.max_rate_hz must be 0 (off) or in [0.001, 100000]".into());
}
if self.timing.window_secs > 1e6 {
return bad("timing.window_secs must be at most 1e6".into());
}
let finite = |v: &[f64]| v.iter().all(|x| x.is_finite());
if !finite(&self.transform.offset_translation) || !finite(&self.transform.offset_rpy) {
return bad("transform offsets must be finite".into());
}
for t in &self.ros.static_transforms {
if t.parent.is_empty() || t.child.is_empty() {
return bad("ros.static_transforms: parent and child must not be empty".into());
}
if !finite(&t.translation) || !finite(&t.rpy) {
return bad(format!(
"ros.static_transforms {}: values must be finite",
t.child
));
}
}
let rb = &self.rigid_bodies;
if !(rb.velocity_alpha > 0.0 && rb.velocity_alpha <= 1.0) {
return bad("rigid_bodies.velocity_alpha must be in (0, 1]".into());
}
if !(rb.velocity_max_gap.is_finite()
&& rb.velocity_max_gap > 0.0
&& rb.velocity_max_gap <= 1e6)
{
return bad("rigid_bodies.velocity_max_gap must be positive".into());
}
if rb.path && !(1..=MAX_PATH_LENGTH).contains(&rb.path_length) {
return bad(format!(
"rigid_bodies.path_length must be in 1..={MAX_PATH_LENGTH}"
));
}
for (from, to) in &rb.rename {
if to.trim().is_empty() {
return bad(format!("rigid_bodies.rename: empty name for {from:?}"));
}
}
if !rate_or_off(self.markers.visualization_rate_hz) {
return bad(
"markers.visualization_rate_hz must be 0 (every frame) or in [0.001, 100000]"
.into(),
);
}
if !(self.markers.axis_length.is_finite()
&& self.markers.axis_length > 0.0
&& self.markers.axis_length <= 1e3)
{
return bad("markers.axis_length must be in (0, 1000] m".into());
}
let d = &self.diagnostics;
if !(d.drop_warn_ratio.is_finite() && d.drop_warn_ratio >= 0.0) {
return bad("diagnostics.drop_warn_ratio must be a non-negative number".into());
}
if !(d.latency_warn_secs.is_finite() && d.latency_warn_secs >= 0.0) {
return bad("diagnostics.latency_warn_secs must be a non-negative number".into());
}
if !(self.diagnostics.period_secs.is_finite()
&& (1e-3..=1e6).contains(&self.diagnostics.period_secs))
{
return bad("diagnostics.period_secs must be in [0.001, 1e6]".into());
}
Ok(())
}
}
#[must_use]
pub fn glob_match(pattern: &str, name: &str) -> bool {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == name;
}
let mut rest = name;
for (i, part) in parts.iter().enumerate() {
if i == 0 {
let Some(r) = rest.strip_prefix(part) else {
return false;
};
rest = r;
} else if i == parts.len() - 1 {
return rest.ends_with(part);
} else if let Some(pos) = rest.find(part) {
rest = rest.get(pos + part.len()..).unwrap_or_default();
} else {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_round_trip_and_validate() {
let text = Config::default_toml();
let back: Config = toml::from_str(&text).unwrap();
assert_eq!(back, Config::default());
back.validate().unwrap();
let empty: Config = toml::from_str("").unwrap();
assert_eq!(empty, Config::default());
}
#[test]
fn rejects_unknown_keys_and_bad_values() {
assert!(toml::from_str::<Config>("[ros]\nnode = 'x'").is_err());
let mut c = Config::default();
c.ros.node_name = "bad name".into();
assert!(c.validate().is_err());
let mut c = Config::default();
c.rigid_bodies.velocity_alpha = 0.0;
assert!(c.validate().is_err());
let c: Config = toml::from_str("[source]\nkind = 'replay'").unwrap();
assert!(c.validate().is_err(), "replay needs a path");
let mut c = Config::default();
c.native.prefix = "natnet/".into();
c.validate().unwrap();
assert!(c.validate_all().is_err());
c.native.prefix = "lab/natnet".into();
c.validate_all().unwrap();
let mut c = Config::default();
c.source.kind = SourceKind::Synthetic;
c.source.live.command_port = 0;
c.validate().unwrap();
assert!(c.validate_all().is_err());
}
#[test]
fn rejects_values_that_would_panic_or_poison_poses() {
let invalid = |edit: fn(&mut Config)| {
let mut c = Config::default();
edit(&mut c);
c.validate().is_err()
};
assert!(invalid(|c| c.timing.max_rate_hz = 1e-20));
assert!(invalid(|c| c.markers.visualization_rate_hz = f64::INFINITY));
assert!(invalid(|c| c.diagnostics.period_secs = 1e20));
assert!(invalid(|c| c.transform.offset_translation[0] = f64::NAN));
assert!(invalid(|c| c.rigid_bodies.velocity_max_gap = 1e300));
assert!(invalid(|c| c.markers.axis_length = f64::NAN));
assert!(invalid(|c| c.diagnostics.drop_warn_ratio = f64::NAN));
assert!(invalid(|c| c.diagnostics.latency_warn_secs = -1.0));
assert!(invalid(|c| {
c.rigid_bodies.path = true;
c.rigid_bodies.path_length = usize::MAX;
}));
assert!(invalid(|c| c.ros.stream_qos.depth = usize::MAX));
assert!(!invalid(|c| c.timing.max_rate_hz = 0.0));
}
#[test]
fn partial_file() {
let c: Config = toml::from_str(
r#"
[source]
kind = "synthetic"
[source.synthetic]
rigid_bodies = 5
[ros]
namespace = "/lab"
[[ros.static_transforms]]
parent = "map"
child = "mocap"
translation = [1.0, 2.0, 0.0]
[rigid_bodies.rename]
"Rover 1" = "rover1"
"#,
)
.unwrap();
c.validate().unwrap();
assert_eq!(c.source.synthetic.rigid_bodies, 5);
assert_eq!(c.source.synthetic.markers_per_body, 4);
assert_eq!(c.ros.static_transforms[0].rpy, [0.0; 3]);
}
#[test]
fn globs() {
assert!(glob_match("rover*", "rover_1"));
assert!(glob_match("*_1", "rover_1"));
assert!(glob_match("r*v*1", "rover_1"));
assert!(glob_match("*", ""));
assert!(!glob_match("rover", "rover_1"));
assert!(!glob_match("a*b", "ab_c"));
}
}