use super::registry::NodeRegistry;
use crate::dev::termination::ScreenOutcome;
use crate::types::ProjectConfig;
use crate::ui::{LogBuffer, TeleopUi};
use anyhow::Result;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
fn teleop_already_running(registry: &NodeRegistry) -> bool {
registry
.pids()
.keys()
.any(|name| super::strip_node_scope(name) == "teleop")
}
fn resolve_teleop_joint_names() -> Vec<String> {
for candidate in crate::paths::config::node_config_candidates("teleop") {
let Ok(content) = std::fs::read_to_string(&candidate) else {
continue;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else {
continue;
};
if let Some(names) = value
.get("controller")
.filter(|c| c.get("type").and_then(|t| t.as_str()) == Some("actuator"))
.and_then(|c| c.get("joint_names"))
.and_then(|n| n.as_array())
{
return names.iter().filter_map(|n| n.as_str().map(str::to_string)).collect();
}
return Vec::new();
}
Vec::new()
}
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 mut teleop_ui = TeleopUi::new(
redis_url.to_string(),
log_buffer.clone(),
dashboard_url,
config.actuator.locomotion.max_linear_vel,
config.actuator.locomotion.max_angular_vel,
resolve_teleop_joint_names(),
);
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(),
template_version: None,
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(),
actuator: 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:?}"
);
}
}