mecha10-cli 0.7.0

Mecha10 CLI tool
Documentation
//! Entering teleop control mode for dev mode

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;

/// Whether a teleop node's PID is tracked in `registry`, under either its scoped
/// (`@mecha10/teleop`) or unscoped key - the pure detection logic `enter_teleop_mode` branches
/// on, split out so it's unit-testable without driving the rest of that function's live
/// Redis/TUI side effects.
fn teleop_already_running(registry: &NodeRegistry) -> bool {
    registry
        .pids()
        .keys()
        .any(|name| super::strip_node_scope(name) == "teleop")
}

/// Read the project's own `teleop` node config (if any, at the first existing path from
/// `crate::paths::config::node_config_candidates("teleop")`) and resolve the `joint_names` this
/// instance is configured for - empty for drivetrain mode (the default) or if no config file
/// exists, matching `mecha10-nodes-teleop`'s own `controller()` resolution
/// (`ControllerConfig::Actuator(ActuatorControllerConfig { joint_names })`). This TUI has no
/// dependency on the `mecha10-nodes-teleop` crate, so it parses just the one field it needs
/// directly rather than pulling in that node's config type.
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()
}

/// Enter teleop mode
///
/// # Arguments
///
/// * `config` - Project configuration
/// * `registry` - Node PID/failure registry
/// * `redis_url` - Redis connection URL
/// * `running` - Shared running flag for clean shutdown
/// * `log_buffer` - Log buffer for TUI logs
/// * `spawn_failure` - If the caller just ran a lifecycle mode change that was
///   supposed to start teleop (e.g. pressing `s` to enter simulation mode,
///   which reuses this UI once simulator + teleop are up) and teleop was
///   among the nodes that failed to spawn, the `(node_name, error)` pair
///   for that failure. `None` means either no mode change just ran, or it
///   ran and teleop wasn't one of the nodes it attempted to start — in
///   both cases a plain "not running" is the right message, not a
///   spawn-failure one.
///
/// # Returns
///
/// The `ScreenOutcome` the teleop screen exited with (`ScreenOutcome::ReturnToParent` if the
/// screen never actually ran — teleop not configured/not running — since there's nothing to
/// unwind beyond showing the message above). The caller knows whether it reached this via `'s'`
/// (simulation hand-off) or `'t'` (standalone teleop) and interprets `ReturnToParent`
/// accordingly — see `handle_simulation_key`/`handle_teleop_key` in
/// `ops/interactive/keys.rs`.
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, &registry, "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, &registry, "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(&registry),
            "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(&registry),
            "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,
            &registry,
            "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:?}"
        );
    }
}