use super::registry::NodeRegistry;
use crate::dev::termination::ScreenOutcome;
use crate::types::ProjectConfig;
use crate::ui::{LogBuffer, TeleopUi};
use anyhow::Result;
use mecha10_core::context::split_base_and_custom;
use std::collections::HashMap;
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_custom_config() -> Option<serde_json::Value> {
let profile = std::env::var("MECHA10_ENVIRONMENT").unwrap_or_else(|_| "dev".to_string());
let env_key = match profile.as_str() {
"production" | "prod" => "production",
_ => "dev",
};
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;
};
let Some(obj) = value.as_object() else {
continue;
};
let Some(section) = obj.get(env_key) else {
continue;
};
let Ok(merged) = split_base_and_custom(section) else {
continue;
};
return Some(merged);
}
None
}
fn resolve_teleop_joint_names() -> Vec<String> {
let Some(merged) = resolve_teleop_custom_config() else {
return Vec::new();
};
merged
.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())
.map(|names| names.iter().filter_map(|n| n.as_str().map(str::to_string)).collect())
.unwrap_or_default()
}
fn resolve_teleop_key_bindings() -> HashMap<String, String> {
let Some(merged) = resolve_teleop_custom_config() else {
return HashMap::new();
};
merged
.get("key_bindings")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.to_lowercase(), s.to_string())))
.collect()
})
.unwrap_or_default()
}
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(),
resolve_teleop_key_bindings(),
);
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:?}"
);
}
mod config_resolution {
use super::super::*;
use serial_test::serial;
use std::fs;
fn write_project_tier_config(project_dir: &std::path::Path, content: &str) {
let node_dir = project_dir.join("configs").join("nodes").join("teleop");
fs::create_dir_all(&node_dir).unwrap();
fs::write(node_dir.join("config.json"), content).unwrap();
}
fn with_scratch_project_dir<T>(f: impl FnOnce(&std::path::Path) -> T) -> T {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
std::env::set_current_dir(project.path()).unwrap();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(project.path())));
std::env::set_current_dir(original_dir).unwrap();
match result {
Ok(value) => value,
Err(payload) => std::panic::resume_unwind(payload),
}
}
#[test]
#[serial]
fn joint_names_resolve_from_the_realistic_env_nested_shape() {
with_scratch_project_dir(|project_dir| {
write_project_tier_config(
project_dir,
r#"{"dev": {"controller": {"type": "actuator", "joint_names": ["pan", "tilt"]}}}"#,
);
assert_eq!(
resolve_teleop_joint_names(),
vec!["pan".to_string(), "tilt".to_string()]
);
});
}
#[test]
#[serial]
fn flat_top_level_controller_does_not_falsely_match() {
with_scratch_project_dir(|project_dir| {
write_project_tier_config(
project_dir,
r#"{"controller": {"type": "actuator", "joint_names": ["pan", "tilt"]}}"#,
);
assert_eq!(
resolve_teleop_joint_names(),
Vec::<String>::new(),
"a flat top-level controller must not be read as if it were the active environment section"
);
});
}
#[test]
#[serial]
fn drivetrain_mode_resolves_to_no_joint_names() {
with_scratch_project_dir(|project_dir| {
write_project_tier_config(project_dir, r#"{"dev": {"controller": {"type": "drivetrain"}}}"#);
assert_eq!(resolve_teleop_joint_names(), Vec::<String>::new());
});
}
#[test]
#[serial]
fn absent_controller_resolves_to_no_joint_names() {
with_scratch_project_dir(|project_dir| {
write_project_tier_config(project_dir, r#"{"dev": {}}"#);
assert_eq!(resolve_teleop_joint_names(), Vec::<String>::new());
});
}
#[test]
#[serial]
fn no_config_file_at_all_resolves_to_no_joint_names() {
with_scratch_project_dir(|_project_dir| {
assert_eq!(resolve_teleop_joint_names(), Vec::<String>::new());
});
}
#[test]
#[serial]
fn key_bindings_resolve_from_custom_when_configured() {
with_scratch_project_dir(|project_dir| {
write_project_tier_config(
project_dir,
r#"{"dev": {"custom": {"key_bindings": {"j": "tilt-", "k": "tilt+"}}}}"#,
);
let bindings = resolve_teleop_key_bindings();
assert_eq!(bindings.get("j").map(String::as_str), Some("tilt-"));
assert_eq!(bindings.get("k").map(String::as_str), Some("tilt+"));
assert_eq!(bindings.len(), 2);
});
}
#[test]
#[serial]
fn key_bindings_default_to_empty_when_absent() {
with_scratch_project_dir(|project_dir| {
write_project_tier_config(
project_dir,
r#"{"dev": {"controller": {"type": "actuator", "joint_names": ["pan"]}}}"#,
);
assert!(resolve_teleop_key_bindings().is_empty());
});
}
}
}