use super::models::ensure_node_models_available;
use crate::context::CliContext;
use crate::services::CredentialsService;
use crate::types::{turn_config, ProjectConfig, TurnConfig};
use crate::ui::LogBuffer;
use anyhow::Result;
use serde::Deserialize;
use std::collections::HashMap;
pub const SIM_VISIBLE_ENV_VAR: &str = "MECHA10_DEV_SIM_VISIBLE";
const SIMULATOR_VISIBLE_ENV_VAR: &str = "MECHA10_SIMULATOR_VISIBLE";
const LOCOMOTION_MAX_LINEAR_SPEED_ENV_VAR: &str = "MECHA10_LOCOMOTION_MAX_LINEAR_SPEED";
const LOCOMOTION_MAX_ANGULAR_SPEED_ENV_VAR: &str = "MECHA10_LOCOMOTION_MAX_ANGULAR_SPEED";
const LOCOMOTION_ACCELERATION_ENV_VAR: &str = "MECHA10_LOCOMOTION_ACCELERATION";
const DEFAULT_LOCOMOTION_MAX_LINEAR_SPEED: f32 = 0.5;
const DEFAULT_LOCOMOTION_MAX_ANGULAR_SPEED: f32 = 1.0;
const TURN_URL_ENV_VAR: &str = "MECHA10_WEBRTC_TURN_URL";
const TURN_USERNAME_ENV_VAR: &str = "MECHA10_WEBRTC_TURN_USERNAME";
const TURN_CREDENTIAL_ENV_VAR: &str = "MECHA10_WEBRTC_TURN_CREDENTIAL";
#[derive(Debug, Deserialize)]
struct TurnCredentialsResponse {
url: String,
username: String,
credential: String,
}
fn batch_needs_turn_credentials(node_names: &[String]) -> bool {
node_names
.iter()
.any(|n| matches!(super::strip_node_scope(n), "camera" | "video-streamer"))
}
async fn fetch_turn_credentials(control_plane_url: &str, api_key: &str) -> Result<Option<TurnCredentialsResponse>> {
let url = format!(
"{}/api/webrtc/turn-credentials",
control_plane_url.trim_end_matches('/')
);
let response = reqwest::Client::new().get(url).bearer_auth(api_key).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let creds = response.error_for_status()?.json::<TurnCredentialsResponse>().await?;
Ok(Some(creds))
}
async fn maybe_inject_turn_credentials(
node_names: &[String],
control_plane_url: &str,
api_key: Option<&str>,
project_env: &mut HashMap<String, String>,
) {
maybe_inject_turn_credentials_with_baked_resolver(node_names, control_plane_url, api_key, project_env, turn_config)
.await
}
async fn maybe_inject_turn_credentials_with_baked_resolver(
node_names: &[String],
control_plane_url: &str,
api_key: Option<&str>,
project_env: &mut HashMap<String, String>,
resolve_baked: impl Fn() -> Option<TurnConfig>,
) {
if std::env::var(TURN_URL_ENV_VAR).is_ok() {
return;
}
if !batch_needs_turn_credentials(node_names) {
return;
}
if let Some(baked) = resolve_baked() {
project_env.insert(TURN_URL_ENV_VAR.to_string(), baked.url);
project_env.insert(TURN_USERNAME_ENV_VAR.to_string(), baked.username);
project_env.insert(TURN_CREDENTIAL_ENV_VAR.to_string(), baked.credential);
return;
}
let Some(api_key) = api_key else {
return;
};
match fetch_turn_credentials(control_plane_url, api_key).await {
Ok(Some(creds)) => {
project_env.insert(TURN_URL_ENV_VAR.to_string(), creds.url);
project_env.insert(TURN_USERNAME_ENV_VAR.to_string(), creds.username);
project_env.insert(TURN_CREDENTIAL_ENV_VAR.to_string(), creds.credential);
}
Ok(None) => {
println!("\u{2139}\u{fe0f} TURN server not available, using STUN only");
}
Err(e) => {
tracing::debug!("TURN credentials fetch failed, using STUN only: {}", e);
println!("\u{2139}\u{fe0f} TURN server not available, using STUN only");
}
}
}
fn maybe_inject_sim_visible_env(node_names: &[String], project_env: &mut HashMap<String, String>) {
let sim_visible_requested = std::env::var(SIM_VISIBLE_ENV_VAR).is_ok();
inject_sim_visible_env(sim_visible_requested, node_names, project_env);
}
fn inject_sim_visible_env(
sim_visible_requested: bool,
node_names: &[String],
project_env: &mut HashMap<String, String>,
) {
if !sim_visible_requested {
return;
}
let has_simulator = node_names.iter().any(|n| super::strip_node_scope(n) == "simulator");
if has_simulator {
project_env.insert(SIMULATOR_VISIBLE_ENV_VAR.to_string(), "1".to_string());
} else if !node_names.is_empty() {
println!(
"\u{2139}\u{fe0f} --sim-visible has no effect: no `simulator` node in this dev session ({}).",
node_names.join(", ")
);
}
}
pub(crate) fn maybe_inject_teleop_locomotion_env(node_names: &[String], project_env: &mut HashMap<String, String>) {
inject_teleop_locomotion_env(node_names, super::teleop::resolve_teleop_velocity_limits(), project_env)
}
fn inject_teleop_locomotion_env(
node_names: &[String],
resolved_teleop_limits: Option<super::teleop::TeleopPhysicsLimits>,
project_env: &mut HashMap<String, String>,
) {
let has_simulator = node_names.iter().any(|n| super::strip_node_scope(n) == "simulator");
if !has_simulator {
return;
}
let has_teleop = node_names.iter().any(|n| super::strip_node_scope(n) == "teleop");
let (max_linear, max_angular, acceleration) = match (has_teleop, resolved_teleop_limits) {
(true, Some(limits)) => (limits.max_linear_vel, limits.max_angular_vel, limits.acceleration),
_ => (
DEFAULT_LOCOMOTION_MAX_LINEAR_SPEED,
DEFAULT_LOCOMOTION_MAX_ANGULAR_SPEED,
None,
),
};
project_env.insert(LOCOMOTION_MAX_LINEAR_SPEED_ENV_VAR.to_string(), max_linear.to_string());
project_env.insert(
LOCOMOTION_MAX_ANGULAR_SPEED_ENV_VAR.to_string(),
max_angular.to_string(),
);
if let Some(acceleration) = acceleration {
project_env.insert(LOCOMOTION_ACCELERATION_ENV_VAR.to_string(), acceleration.to_string());
}
}
#[derive(Debug, Default)]
pub struct SpawnOutcome {
pub pids: HashMap<String, u32>,
pub failures: Vec<(String, String)>,
}
pub fn spawn_nodes_by_name(
ctx: &mut CliContext,
node_names: &[String],
project_name: &str,
project_config: &ProjectConfig,
log_buffer: Option<&LogBuffer>,
) -> Result<SpawnOutcome> {
use crate::services::ProcessService;
println!("Starting nodes...");
println!();
let shared_process = ctx.shared_process();
let mut process_service = shared_process.lock().unwrap();
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("/tmp/command-listener.log")
.and_then(|mut f| {
use std::io::Write;
writeln!(
f,
"[spawn_nodes_by_name] Using shared ProcessService, ptr={:p}",
&*process_service
)
});
let use_node_runner = ProcessService::is_node_runner_available();
if let Err(e) = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(ensure_node_models_available(node_names))
}) {
eprintln!("⚠️ Warning: Failed to check/download models: {}", e);
}
if !use_node_runner {
println!(" (standalone mode - spawning nodes directly)");
println!();
}
let project_env = {
let credentials_service = CredentialsService::new();
let api_key = match credentials_service.get_api_key() {
Ok(Some(api_key)) => {
tracing::debug!("🔑 Loaded API key from credentials: {}", &api_key[..20]);
Some(api_key)
}
_ => {
tracing::warn!("⚠️ No API key found in credentials - did you run 'mecha10 auth login'?");
None
}
};
let mut env: HashMap<String, String> = mecha10_core::config::robot_process_env(
&project_config.environments.control_plane_url(),
&project_config.environments.relay_url(),
&project_config.environments.redis_url(),
&project_config.robot.id,
api_key.as_deref(),
)
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
env.insert("LOG_LEVEL".to_string(), ctx.log_level.to_string().to_lowercase());
{
let control_plane_url = project_config.environments.control_plane_url();
let api_key_for_turn = env.get("ROBOT_API_KEY").cloned();
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(maybe_inject_turn_credentials(
node_names,
&control_plane_url,
api_key_for_turn.as_deref(),
&mut env,
))
});
}
maybe_inject_sim_visible_env(node_names, &mut env);
maybe_inject_teleop_locomotion_env(node_names, &mut env);
tracing::debug!("📋 Environment variables to inject:");
tracing::debug!(
" CONTROL_PLANE_URL: {}",
env.get("CONTROL_PLANE_URL").unwrap_or(&"<missing>".to_string())
);
tracing::debug!(
" ROBOT_ID: {}",
env.get("ROBOT_ID").unwrap_or(&"<missing>".to_string())
);
tracing::debug!(
" ROBOT_API_KEY: {}",
if env.contains_key("ROBOT_API_KEY") {
"<present>"
} else {
"<missing>"
}
);
tracing::debug!(
" WEBRTC_RELAY_URL: {}",
env.get("WEBRTC_RELAY_URL").unwrap_or(&"<missing>".to_string())
);
tracing::debug!(
" REDIS_URL: {}",
env.get("REDIS_URL").unwrap_or(&"<missing>".to_string())
);
Some(env)
};
let mut pids = HashMap::new();
let mut failures = Vec::new();
for node_name in node_names {
let should_use_node_runner = use_node_runner;
tracing::debug!(
"🚀 Spawning node: {} (use_node_runner: {})",
node_name,
should_use_node_runner
);
let result = if should_use_node_runner {
process_service.spawn_node_runner(node_name, project_name, project_env.clone())
} else {
process_service.spawn_node_direct(node_name, project_name, project_env.clone())
};
match result {
Ok(pid) => {
pids.insert(node_name.clone(), pid);
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open("/tmp/command-listener.log")
.and_then(|mut f| {
use std::io::Write;
writeln!(f, "[spawn_nodes_by_name] Spawned '{}' with PID {}", node_name, pid)
});
if let Some(buffer) = log_buffer {
buffer.push(format!("✅ STARTED: {} (PID: {})", node_name, pid));
}
}
Err(e) => {
eprintln!("Failed to spawn {}: {}", node_name, e);
if let Some(buffer) = log_buffer {
buffer.push(format!("ERROR: Failed to start {}: {}", node_name, e));
}
failures.push((node_name.clone(), e.to_string()));
}
}
}
for (node, pid) in &pids {
println!(" ✅ {} (PID: {})", node, pid);
}
println!();
Ok(SpawnOutcome { pids, failures })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn injects_visible_env_var_when_sim_visible_and_simulator_present() {
let mut env = HashMap::new();
inject_sim_visible_env(true, &["simulator".to_string(), "teleop".to_string()], &mut env);
assert_eq!(env.get(SIMULATOR_VISIBLE_ENV_VAR), Some(&"1".to_string()));
}
#[test]
fn injects_visible_env_var_for_scoped_simulator_node_name() {
let mut env = HashMap::new();
inject_sim_visible_env(true, &["@mecha10/simulator".to_string()], &mut env);
assert_eq!(env.get(SIMULATOR_VISIBLE_ENV_VAR), Some(&"1".to_string()));
}
#[test]
fn does_not_inject_when_sim_visible_not_requested() {
let mut env = HashMap::new();
inject_sim_visible_env(false, &["simulator".to_string()], &mut env);
assert!(!env.contains_key(SIMULATOR_VISIBLE_ENV_VAR));
}
#[test]
fn does_not_inject_when_no_simulator_node_in_batch() {
let mut env = HashMap::new();
inject_sim_visible_env(true, &["teleop".to_string(), "camera".to_string()], &mut env);
assert!(!env.contains_key(SIMULATOR_VISIBLE_ENV_VAR));
}
#[test]
fn does_not_inject_when_node_list_is_empty() {
let mut env = HashMap::new();
inject_sim_visible_env(true, &[], &mut env);
assert!(!env.contains_key(SIMULATOR_VISIBLE_ENV_VAR));
}
fn stub_teleop_limits() -> super::super::teleop::TeleopPhysicsLimits {
super::super::teleop::TeleopPhysicsLimits {
max_linear_vel: 10.0,
max_angular_vel: 2.5,
acceleration: None,
}
}
fn stub_teleop_limits_with_acceleration(acceleration: f32) -> super::super::teleop::TeleopPhysicsLimits {
super::super::teleop::TeleopPhysicsLimits {
acceleration: Some(acceleration),
..stub_teleop_limits()
}
}
#[test]
fn injects_both_env_vars_when_teleop_config_resolves_and_both_nodes_present() {
let mut env = HashMap::new();
inject_teleop_locomotion_env(
&["simulator".to_string(), "teleop".to_string()],
Some(stub_teleop_limits()),
&mut env,
);
assert_eq!(env.get(LOCOMOTION_MAX_LINEAR_SPEED_ENV_VAR), Some(&"10".to_string()));
assert_eq!(env.get(LOCOMOTION_MAX_ANGULAR_SPEED_ENV_VAR), Some(&"2.5".to_string()));
}
#[test]
fn injects_for_scoped_simulator_and_teleop_node_names() {
let mut env = HashMap::new();
inject_teleop_locomotion_env(
&["@mecha10/simulator".to_string(), "@mecha10/teleop".to_string()],
Some(stub_teleop_limits()),
&mut env,
);
assert_eq!(env.get(LOCOMOTION_MAX_LINEAR_SPEED_ENV_VAR), Some(&"10".to_string()));
assert_eq!(env.get(LOCOMOTION_MAX_ANGULAR_SPEED_ENV_VAR), Some(&"2.5".to_string()));
}
#[test]
fn injects_rust_side_defaults_when_teleop_config_resolution_falls_back_to_defaults() {
let mut env = HashMap::new();
inject_teleop_locomotion_env(&["simulator".to_string(), "teleop".to_string()], None, &mut env);
assert_eq!(
env.get(LOCOMOTION_MAX_LINEAR_SPEED_ENV_VAR),
Some(&DEFAULT_LOCOMOTION_MAX_LINEAR_SPEED.to_string())
);
assert_eq!(
env.get(LOCOMOTION_MAX_ANGULAR_SPEED_ENV_VAR),
Some(&DEFAULT_LOCOMOTION_MAX_ANGULAR_SPEED.to_string())
);
}
#[test]
fn does_not_inject_when_no_simulator_node_present() {
let mut env = HashMap::new();
inject_teleop_locomotion_env(&["teleop".to_string()], Some(stub_teleop_limits()), &mut env);
assert!(!env.contains_key(LOCOMOTION_MAX_LINEAR_SPEED_ENV_VAR));
assert!(!env.contains_key(LOCOMOTION_MAX_ANGULAR_SPEED_ENV_VAR));
}
#[test]
fn injects_rust_side_defaults_when_no_teleop_node_present() {
let mut env = HashMap::new();
inject_teleop_locomotion_env(&["simulator".to_string()], Some(stub_teleop_limits()), &mut env);
assert_eq!(
env.get(LOCOMOTION_MAX_LINEAR_SPEED_ENV_VAR),
Some(&DEFAULT_LOCOMOTION_MAX_LINEAR_SPEED.to_string())
);
assert_eq!(
env.get(LOCOMOTION_MAX_ANGULAR_SPEED_ENV_VAR),
Some(&DEFAULT_LOCOMOTION_MAX_ANGULAR_SPEED.to_string())
);
}
#[test]
fn injects_acceleration_when_teleop_present_with_explicit_override() {
let mut env = HashMap::new();
inject_teleop_locomotion_env(
&["simulator".to_string(), "teleop".to_string()],
Some(stub_teleop_limits_with_acceleration(3.5)),
&mut env,
);
assert_eq!(env.get(LOCOMOTION_ACCELERATION_ENV_VAR), Some(&"3.5".to_string()));
}
#[test]
fn does_not_inject_acceleration_when_teleop_present_without_explicit_override() {
let mut env = HashMap::new();
inject_teleop_locomotion_env(
&["simulator".to_string(), "teleop".to_string()],
Some(stub_teleop_limits()),
&mut env,
);
assert!(!env.contains_key(LOCOMOTION_ACCELERATION_ENV_VAR));
}
#[test]
fn does_not_inject_acceleration_when_no_teleop_node_present() {
let mut env = HashMap::new();
inject_teleop_locomotion_env(
&["simulator".to_string()],
Some(stub_teleop_limits_with_acceleration(3.5)),
&mut env,
);
assert!(!env.contains_key(LOCOMOTION_ACCELERATION_ENV_VAR));
}
#[test]
fn does_not_inject_acceleration_when_teleop_config_resolution_falls_back_to_defaults() {
let mut env = HashMap::new();
inject_teleop_locomotion_env(&["simulator".to_string(), "teleop".to_string()], None, &mut env);
assert!(!env.contains_key(LOCOMOTION_ACCELERATION_ENV_VAR));
}
#[test]
fn injects_rust_side_defaults_for_scoped_simulator_node_name_without_teleop() {
let mut env = HashMap::new();
inject_teleop_locomotion_env(&["@mecha10/simulator".to_string()], None, &mut env);
assert_eq!(
env.get(LOCOMOTION_MAX_LINEAR_SPEED_ENV_VAR),
Some(&DEFAULT_LOCOMOTION_MAX_LINEAR_SPEED.to_string())
);
assert_eq!(
env.get(LOCOMOTION_MAX_ANGULAR_SPEED_ENV_VAR),
Some(&DEFAULT_LOCOMOTION_MAX_ANGULAR_SPEED.to_string())
);
}
#[test]
fn needs_turn_credentials_when_camera_node_present() {
assert!(batch_needs_turn_credentials(&[
"teleop".to_string(),
"camera".to_string()
]));
}
#[test]
fn needs_turn_credentials_for_scoped_video_streamer_node_name() {
assert!(batch_needs_turn_credentials(&["@mecha10/video-streamer".to_string()]));
}
#[test]
fn does_not_need_turn_credentials_when_no_video_relevant_node() {
assert!(!batch_needs_turn_credentials(&[
"teleop".to_string(),
"motor".to_string()
]));
}
#[test]
fn does_not_need_turn_credentials_when_batch_is_empty() {
assert!(!batch_needs_turn_credentials(&[]));
}
fn spawn_one_shot_http_server(status_line: &'static str, body: &'static str) -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let addr = listener.local_addr().expect("local_addr");
std::thread::spawn(move || {
use std::io::{Read, Write};
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let response = format!(
"{status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
format!("http://{}", addr)
}
fn unreachable_base_url() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let addr = listener.local_addr().expect("local_addr");
drop(listener);
format!("http://{}", addr)
}
#[tokio::test]
async fn successful_fetch_populates_project_env() {
let base_url = spawn_one_shot_http_server(
"HTTP/1.1 200 OK",
"{\"url\":\"turn:turn.example.com:3478\",\"username\":\"u\",\"credential\":\"c\"}",
);
let mut env = HashMap::new();
maybe_inject_turn_credentials(&["camera".to_string()], &base_url, Some("test-api-key"), &mut env).await;
assert_eq!(
env.get(TURN_URL_ENV_VAR),
Some(&"turn:turn.example.com:3478".to_string())
);
assert_eq!(env.get(TURN_USERNAME_ENV_VAR), Some(&"u".to_string()));
assert_eq!(env.get(TURN_CREDENTIAL_ENV_VAR), Some(&"c".to_string()));
}
#[tokio::test]
async fn not_configured_404_leaves_project_env_unchanged() {
let base_url = spawn_one_shot_http_server("HTTP/1.1 404 Not Found", "");
let mut env = HashMap::new();
maybe_inject_turn_credentials(&["camera".to_string()], &base_url, Some("test-api-key"), &mut env).await;
assert!(env.is_empty());
}
#[tokio::test]
async fn network_failure_leaves_project_env_unchanged() {
let base_url = unreachable_base_url();
let mut env = HashMap::new();
maybe_inject_turn_credentials(&["camera".to_string()], &base_url, Some("test-api-key"), &mut env).await;
assert!(env.is_empty());
}
#[tokio::test]
async fn skips_fetch_when_batch_has_no_video_relevant_node() {
let base_url = unreachable_base_url();
let mut env = HashMap::new();
maybe_inject_turn_credentials(&["teleop".to_string()], &base_url, Some("test-api-key"), &mut env).await;
assert!(env.is_empty());
}
#[tokio::test]
#[serial_test::serial(turn_url_env_var)]
async fn explicit_override_skips_fetch_and_project_env_is_preserved_unchanged() {
std::env::set_var(TURN_URL_ENV_VAR, "turn:operator-set.example.com:3478");
let base_url = unreachable_base_url();
let mut env = HashMap::new();
maybe_inject_turn_credentials(&["camera".to_string()], &base_url, Some("test-api-key"), &mut env).await;
std::env::remove_var(TURN_URL_ENV_VAR);
assert!(env.is_empty());
}
fn stub_baked_config() -> TurnConfig {
TurnConfig {
url: "turn:baked.example.com:3478".to_string(),
username: "baked-user".to_string(),
credential: "baked-secret".to_string(),
}
}
#[tokio::test]
async fn tier2_baked_default_populates_project_env_when_no_explicit_override() {
let base_url = unreachable_base_url();
let mut env = HashMap::new();
maybe_inject_turn_credentials_with_baked_resolver(
&["camera".to_string()],
&base_url,
Some("test-api-key"),
&mut env,
|| Some(stub_baked_config()),
)
.await;
assert_eq!(
env.get(TURN_URL_ENV_VAR),
Some(&"turn:baked.example.com:3478".to_string())
);
assert_eq!(env.get(TURN_USERNAME_ENV_VAR), Some(&"baked-user".to_string()));
assert_eq!(env.get(TURN_CREDENTIAL_ENV_VAR), Some(&"baked-secret".to_string()));
}
#[tokio::test]
#[serial_test::serial(turn_url_env_var)]
async fn tier1_explicit_env_var_wins_over_tier2_baked_default() {
std::env::set_var(TURN_URL_ENV_VAR, "turn:operator-set.example.com:3478");
let base_url = unreachable_base_url();
let mut env = HashMap::new();
maybe_inject_turn_credentials_with_baked_resolver(
&["camera".to_string()],
&base_url,
Some("test-api-key"),
&mut env,
|| Some(stub_baked_config()),
)
.await;
std::env::remove_var(TURN_URL_ENV_VAR);
assert!(env.is_empty());
}
#[tokio::test]
async fn tier3_fetch_still_runs_when_no_explicit_override_and_no_baked_default() {
let base_url = spawn_one_shot_http_server(
"HTTP/1.1 200 OK",
"{\"url\":\"turn:fetched.example.com:3478\",\"username\":\"fetched-user\",\"credential\":\"fetched-secret\"}",
);
let mut env = HashMap::new();
maybe_inject_turn_credentials_with_baked_resolver(
&["camera".to_string()],
&base_url,
Some("test-api-key"),
&mut env,
|| None,
)
.await;
assert_eq!(
env.get(TURN_URL_ENV_VAR),
Some(&"turn:fetched.example.com:3478".to_string())
);
assert_eq!(env.get(TURN_USERNAME_ENV_VAR), Some(&"fetched-user".to_string()));
assert_eq!(env.get(TURN_CREDENTIAL_ENV_VAR), Some(&"fetched-secret".to_string()));
}
}