use super::registry::NodeRegistry;
use crate::dev::termination::ScreenOutcome;
use crate::types::ProjectConfig;
use crate::ui::{LogBuffer, TeleopUi};
use anyhow::Result;
use serde::Deserialize;
use std::path::Path;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
#[derive(Debug, Deserialize)]
pub(super) struct TeleopPhysicsLimits {
#[serde(default = "default_max_linear_vel")]
pub(super) max_linear_vel: f32,
#[serde(default = "default_max_angular_vel")]
pub(super) max_angular_vel: f32,
#[serde(default)]
pub(super) acceleration: Option<f32>,
}
fn default_max_linear_vel() -> f32 {
10.0
}
fn default_max_angular_vel() -> f32 {
5.0
}
impl Default for TeleopPhysicsLimits {
fn default() -> Self {
Self {
max_linear_vel: default_max_linear_vel(),
max_angular_vel: default_max_angular_vel(),
acceleration: None,
}
}
}
#[derive(Deserialize)]
struct EnvironmentAwareTeleopConfig {
dev: Option<TeleopPhysicsLimits>,
production: Option<TeleopPhysicsLimits>,
}
pub(super) fn resolve_teleop_velocity_limits() -> Option<TeleopPhysicsLimits> {
let candidate_paths = crate::paths::config::node_config_candidates("teleop");
let profile = std::env::var("MECHA10_ENVIRONMENT").unwrap_or_else(|_| "dev".to_string());
for path in &candidate_paths {
let path = Path::new(path);
if !path.exists() {
continue;
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
if let Ok(env_config) = serde_json::from_str::<EnvironmentAwareTeleopConfig>(&content) {
let chosen = match profile.as_str() {
"production" | "prod" => env_config.production,
_ => env_config.dev,
};
if let Some(limits) = chosen {
return Some(limits);
}
}
if let Ok(limits) = serde_json::from_str::<TeleopPhysicsLimits>(&content) {
return Some(limits);
}
}
None
}
fn teleop_already_running(registry: &NodeRegistry) -> bool {
registry
.pids()
.keys()
.any(|name| super::strip_node_scope(name) == "teleop")
}
pub async fn enter_teleop_mode(
config: &ProjectConfig,
registry: &NodeRegistry,
redis_url: &str,
running: &Arc<AtomicBool>,
log_buffer: &LogBuffer,
spawn_failure: Option<&(String, String)>,
) -> Result<ScreenOutcome> {
let has_teleop = config.nodes.contains("teleop");
let teleop_running = teleop_already_running(registry);
if !has_teleop {
println!("\n⚠️ Teleop node not found in project");
println!();
println!("To add teleop to your project:");
println!(" mecha10 add teleop");
println!();
println!("The teleop coordination node manages multiple");
println!("control sources with priority arbitration.");
println!();
log_buffer.push("TELEOP: Teleop node not found in project (run `mecha10 add teleop`)".to_string());
return Ok(ScreenOutcome::ReturnToParent);
} else if !teleop_running {
if let Some((_, err)) = spawn_failure {
println!("\n⚠️ Teleop node failed to start");
println!();
println!("It's configured in mecha10.json and mecha10 just tried to start it");
println!("as part of this mode change, but the spawn failed:");
println!();
println!(" {}", err);
println!();
println!("Run 'mecha10 dev teleop' directly to see the full startup output.");
println!();
log_buffer.push(format!("TELEOP: teleop failed to start during mode change: {}", err));
} else {
println!("\n⚠️ Teleop node not running");
println!();
println!("Start the teleop node by adding it to dev nodes:");
println!(" mecha10 dev teleop");
println!();
log_buffer.push("TELEOP: Teleop node not running (run `mecha10 dev teleop`)".to_string());
}
return Ok(ScreenOutcome::ReturnToParent);
}
println!("\n🎮 Entering teleop control mode...\n");
let dashboard_url = config.environments.dashboard_url();
let velocity_limits = resolve_teleop_velocity_limits().unwrap_or_default();
let mut teleop_ui = TeleopUi::new(
redis_url.to_string(),
log_buffer.clone(),
dashboard_url,
velocity_limits.max_linear_vel,
velocity_limits.max_angular_vel,
);
match teleop_ui.run_with_interrupt(running.clone()).await {
Ok(outcome) => {
println!("\n✅ Exited teleop mode\n");
Ok(outcome)
}
Err(e) => {
eprintln!("\n❌ Teleop UI error: {}\n", e);
Ok(ScreenOutcome::ReturnToParent)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::project::{NodesConfig, ProjectConfig, RobotConfig};
use std::collections::HashMap as StdHashMap;
use std::sync::atomic::AtomicBool;
fn project_config_with_nodes(node_names: &[&str]) -> ProjectConfig {
let mut nodes = StdHashMap::new();
for name in node_names {
nodes.insert(format!("@mecha10/{}", name), "0.1.0".to_string());
}
ProjectConfig {
name: "test".to_string(),
version: "0.1.0".to_string(),
robot: RobotConfig {
id: "test".to_string(),
name: "test".to_string(),
platform: None,
description: None,
},
simulation: None,
nodes: NodesConfig(nodes),
targets: Default::default(),
behaviors: None,
docker: Default::default(),
environments: Default::default(),
}
}
#[tokio::test]
async fn not_configured_reports_add_teleop_regardless_of_spawn_failure() {
let config = project_config_with_nodes(&["camera"]);
let registry = NodeRegistry::new();
let log_buffer = LogBuffer::new(100);
let running = Arc::new(AtomicBool::new(true));
enter_teleop_mode(&config, ®istry, "redis://localhost", &running, &log_buffer, None)
.await
.unwrap();
let logs = log_buffer.get_all();
assert!(
logs.iter().any(|l| l.contains("not found in project")),
"expected a 'not found in project' log entry, got: {logs:?}"
);
}
#[tokio::test]
async fn configured_but_not_running_without_spawn_failure_suggests_dev_teleop() {
let config = project_config_with_nodes(&["teleop"]);
let registry = NodeRegistry::new();
let log_buffer = LogBuffer::new(100);
let running = Arc::new(AtomicBool::new(true));
enter_teleop_mode(&config, ®istry, "redis://localhost", &running, &log_buffer, None)
.await
.unwrap();
let logs = log_buffer.get_all();
assert!(
logs.iter().any(|l| l.contains("Teleop node not running")),
"expected a plain 'not running' log entry, got: {logs:?}"
);
assert!(
!logs.iter().any(|l| l.contains("failed to start during mode change")),
"should not claim a spawn failure when none occurred, got: {logs:?}"
);
}
#[test]
fn running_under_scoped_pid_key_is_detected_as_running() {
let mut registry = NodeRegistry::new();
registry.mark_started("@mecha10/teleop", 12345);
assert!(
teleop_already_running(®istry),
"a teleop pid tracked under the scoped '@mecha10/teleop' key should be recognized as running"
);
}
#[test]
fn no_tracked_pid_is_not_detected_as_running() {
let registry = NodeRegistry::new();
assert!(
!teleop_already_running(®istry),
"a registry with no teleop pid tracked should not be recognized as running"
);
}
#[tokio::test]
async fn configured_but_not_running_with_spawn_failure_surfaces_the_real_error() {
let config = project_config_with_nodes(&["teleop"]);
let registry = NodeRegistry::new();
let log_buffer = LogBuffer::new(100);
let running = Arc::new(AtomicBool::new(true));
let spawn_failure = ("teleop".to_string(), "binary download failed: HTTP 404".to_string());
enter_teleop_mode(
&config,
®istry,
"redis://localhost",
&running,
&log_buffer,
Some(&spawn_failure),
)
.await
.unwrap();
let logs = log_buffer.get_all();
assert!(
logs.iter()
.any(|l| l.contains("failed to start during mode change") && l.contains("HTTP 404")),
"expected the actual spawn error to be surfaced, got: {logs:?}"
);
assert!(
!logs.iter().any(|l| l.contains("Teleop node not running (run")),
"should not show the generic 'not running' message when the real cause is known, got: {logs:?}"
);
}
}