1use std::env;
25use std::fs;
26use std::io::{self, BufRead, Write};
27use std::os::unix::fs::PermissionsExt;
28use std::os::unix::net::UnixStream;
29use std::path::{Path, PathBuf};
30use std::process::{Child, Command, Stdio};
31use std::time::{Duration, Instant};
32
33pub type DynError = Box<dyn std::error::Error>;
35pub type Result<T> = std::result::Result<T, DynError>;
37
38const DOCKER_IMAGE_TAG: &str = "babble-bridge:latest";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum InstallMode {
43 BuildFromSource,
45 FetchPrebuilt,
47}
48
49pub fn cli_main() {
54 if let Err(err) = run() {
55 eprintln!("Error: {err}");
56 std::process::exit(1);
57 }
58}
59
60fn run() -> Result<()> {
61 let mut args = env::args().skip(1);
62 let Some(cmd) = args.next() else {
63 print_usage();
64 return Ok(());
65 };
66
67 match cmd.as_str() {
68 "zephyr-setup" => {
69 require_linux("zephyr-setup")?;
70 let args: Vec<String> = args.collect();
71 let clean = args.iter().any(|a| a == "--clean");
72 let mode = if args.iter().any(|a| a == "--prebuilt") {
73 InstallMode::FetchPrebuilt
74 } else if args.iter().any(|a| a == "--build-from-source") {
75 InstallMode::BuildFromSource
76 } else {
77 prompt_install_mode()?
78 };
79 let root = workspace_root()?;
80 zephyr_setup(&root, clean, mode)
81 }
82 "run-bsim" => {
83 require_linux("run-bsim")?;
84 let args: Vec<String> = args.collect();
85 let nrf_rpc_server = args.iter().any(|a| a == "--nrf-rpc-server");
86 let cgm_peripheral = args.iter().any(|a| a == "--cgm-peripheral");
87 run_bsim(nrf_rpc_server, cgm_peripheral)
88 }
89 "start-sim" => {
90 let args: Vec<String> = args.collect();
91 let sim_id = parse_sim_flag(&args, "--sim-id").unwrap_or("sim");
92 let use_docker = args.iter().any(|a| a == "--container");
93 if use_docker {
94 cmd_start_sim_in_docker(sim_id)
95 } else {
96 require_linux("start-sim")?;
97 let root = workspace_root()?;
98 let sim_dir = parse_sim_flag(&args, "--sim-dir")
99 .map(PathBuf::from)
100 .unwrap_or_else(|| root.join("tests/sockets"));
101 cmd_start_sim(sim_id, &sim_dir)
102 }
103 }
104 "stop-sim" => {
105 require_linux("stop-sim")?;
106 let args: Vec<String> = args.collect();
107 let sim_id = parse_sim_flag(&args, "--sim-id").unwrap_or("insulin_pump");
108 cmd_stop_sim(sim_id)
109 }
110 "clean-sockets" => {
111 let root = workspace_root()?;
112 cmd_clean_sockets(&root)
113 }
114 "exec" => {
115 let rest: Vec<String> = args.collect();
116 let cmd_args: Vec<&str> = rest
117 .iter()
118 .skip_while(|a| a.as_str() == "--")
119 .map(String::as_str)
120 .collect();
121 if cmd_args.is_empty() {
122 return Err("exec requires a command to run inside the container".into());
123 }
124 cmd_exec_in_container(&cmd_args)
125 }
126 "docker-build" => docker_build(),
127 "docker-attach" => docker_attach(),
128 "docker-run" => {
129 let rest: Vec<String> = args.collect();
130 let cmd_args: Vec<&str> = rest
132 .iter()
133 .skip_while(|a| a.as_str() == "--")
134 .map(String::as_str)
135 .collect();
136 if cmd_args.is_empty() {
137 return Err("docker-run requires a command to run inside the container".into());
138 }
139 docker_run(&cmd_args)
140 }
141 "-h" | "--help" | "help" => {
142 print_usage();
143 Ok(())
144 }
145 _ => Err(format!("Unknown command: {cmd}").into()),
146 }
147}
148
149fn print_usage() {
150 println!("Usage: cargo xtask <command> [options]");
151 println!();
152 println!("Commands:");
153 println!(" docker-build Build the dev-container image");
154 println!(" docker-attach Open an interactive shell in the container");
155 println!(" docker-run [--] <cmd> [args...] Run a command non-interactively in the container (for CI)");
156 println!();
157 println!(" zephyr-setup [--clean] Set up Zephyr/BabbleSim (prompts for install mode)");
158 println!(" --prebuilt Fetch prebuilt binaries from GitHub Releases");
159 println!(" --build-from-source Build from source (non-interactive, for CI)");
160 println!();
161 println!(" run-bsim Run BabbleSim simulation (Linux only)");
162 println!(" --nrf-rpc-server Launch the nRF RPC server (default: on)");
163 println!(" --cgm-peripheral Launch the CGM peripheral sample (default: on)");
164 println!();
165 println!(" start-sim Start simulation stack in the background (Linux only)");
166 println!(" --sim-id <id> Simulation identifier (default: sim)");
167 println!(" --sim-dir <path> Directory for the socket file (default: <workspace>/tests/sockets)");
168 println!(" --container Build image if needed and run inside a container (macOS)");
169 println!(" Prints the socket path on success.");
170 println!();
171 println!(" stop-sim Stop a running simulation (Linux only)");
172 println!(" --sim-id <id> Simulation identifier to stop (default: insulin_pump)");
173 println!();
174 println!(" clean-sockets Remove all *.sock files from <workspace>/tests/sockets/");
175 println!();
176 println!(" exec [--] <cmd> [args...] Run a command inside the sim container (where the socket is reachable)");
177}
178
179fn require_linux(cmd: &str) -> Result<()> {
180 if !cfg!(target_os = "linux") {
181 return Err(format!(
182 "`xtask {cmd}` requires Linux. \
183 Use `cargo xtask docker-build` to build the dev-container image, \
184 then work inside it."
185 )
186 .into());
187 }
188 Ok(())
189}
190
191fn prompt_install_mode() -> Result<InstallMode> {
194 println!();
195 println!("How would you like to set up the Zephyr/BabbleSim environment?");
196 println!(" [1] Build from source (slow, ~30 min; requires a full Zephyr toolchain)");
197 println!(" [2] Fetch prebuilt binaries (fast; downloads a release archive from GitHub)");
198 print!("Enter choice [1/2] (default: 2): ");
199 io::stdout().flush()?;
200
201 let line = io::stdin()
202 .lock()
203 .lines()
204 .next()
205 .ok_or("No input received — stdin was empty")??;
206
207 match line.trim() {
208 "1" => {
209 println!("Selected: build from source.");
210 Ok(InstallMode::BuildFromSource)
211 }
212 "2" | "" => {
213 println!("Selected: fetch prebuilt binaries.");
214 Ok(InstallMode::FetchPrebuilt)
215 }
216 other => Err(format!("Invalid choice '{other}'. Please enter 1 or 2.").into()),
217 }
218}
219
220fn docker_build() -> Result<()> {
223 let root = workspace_root()?;
224 let dockerfile = root.join(".devcontainer/Dockerfile");
225 if !dockerfile.exists() {
226 return Err(format!(
227 "Dockerfile not found at {}",
228 dockerfile.display()
229 )
230 .into());
231 }
232
233 let uid = std::env::var("UID").unwrap_or_else(|_| "1000".into());
234 let gid = std::env::var("GID").unwrap_or_else(|_| "1000".into());
235
236 println!("Building Docker image {DOCKER_IMAGE_TAG} …");
237 run_cmd(
238 "docker",
239 &[
240 "build",
241 "--platform", "linux/amd64",
242 "-f", ".devcontainer/Dockerfile",
243 "--build-arg", &format!("USER_UID={uid}"),
244 "--build-arg", &format!("USER_GID={gid}"),
245 "-t", DOCKER_IMAGE_TAG,
246 ".",
247 ],
248 Some(&root),
249 )?;
250 println!("Image built: {DOCKER_IMAGE_TAG}");
251 Ok(())
252}
253
254fn docker_attach() -> Result<()> {
255 let root = workspace_root()?;
256 let workspace = root
257 .to_str()
258 .ok_or("Workspace path contains non-UTF-8 characters")?;
259
260 run_cmd(
261 "docker",
262 &[
263 "run",
264 "--rm",
265 "--interactive",
266 "--tty",
267 "--platform", "linux/amd64",
268 "-v", &format!("{workspace}:/workspace"),
269 "-w", "/workspace",
270 DOCKER_IMAGE_TAG,
271 "bash",
272 ],
273 Some(&root),
274 )
275}
276
277fn docker_run(cmd_args: &[&str]) -> Result<()> {
284 let root = workspace_root()?;
285 let workspace = root
286 .to_str()
287 .ok_or("Workspace path contains non-UTF-8 characters")?;
288
289 let mount = format!("{workspace}:/workspace");
290 let mut docker_args: Vec<&str> = vec![
291 "run",
292 "--rm",
293 "--platform", "linux/amd64",
294 "-v", &mount,
295 "-w", "/workspace",
296 DOCKER_IMAGE_TAG,
297 ];
298 docker_args.extend_from_slice(cmd_args);
299
300 run_cmd("docker", &docker_args, Some(&root))
301}
302
303pub fn workspace_root() -> Result<PathBuf> {
311 let mut dir = env::current_dir()?;
312 loop {
313 if dir.join("Cargo.toml").exists() {
314 return Ok(dir);
315 }
316 if !dir.pop() {
317 return Err("Could not find workspace root (no Cargo.toml found in any parent directory)".into());
318 }
319 }
320}
321
322fn run_cmd(cmd: &str, args: &[&str], cwd: Option<&Path>) -> Result<()> {
323 let mut command = Command::new(cmd);
324 command.args(args);
325 if let Some(cwd) = cwd {
326 command.current_dir(cwd);
327 }
328 command.stdin(Stdio::inherit());
329 command.stdout(Stdio::inherit());
330 command.stderr(Stdio::inherit());
331
332 let status = command.status()?;
333 if !status.success() {
334 return Err(format!("Command failed: {cmd} {}", args.join(" ")).into());
335 }
336 Ok(())
337}
338
339fn create_sockets_dir(root: &Path) -> Result<()> {
344 let sockets_dir = root.join("tests/sockets");
345
346 if sockets_dir.exists() && sockets_dir.symlink_metadata()?.file_type().is_symlink() {
348 return Err(format!(
349 "Refusing to use '{}': it is a symlink. \
350 Remove it manually before running zephyr-setup.",
351 sockets_dir.display()
352 )
353 .into());
354 }
355
356 if !sockets_dir.exists() {
357 fs::create_dir_all(&sockets_dir)?;
358 println!("Created {}", sockets_dir.display());
359 }
360
361 fs::set_permissions(&sockets_dir, fs::Permissions::from_mode(0o700))?;
363 Ok(())
364}
365
366fn clean_dir(dir: &Path) -> Result<()> {
367 if !dir.exists() {
368 return Ok(());
369 }
370 for entry in fs::read_dir(dir)? {
371 let entry = entry?;
372 if entry.file_name() == ".gitignore" {
373 continue;
374 }
375 let path = entry.path();
376 if path.is_dir() {
377 fs::remove_dir_all(path)?;
378 } else {
379 fs::remove_file(path)?;
380 }
381 }
382 Ok(())
383}
384
385fn ensure_external_nrf_checkout(root: &Path, external_dir: &Path) -> Result<()> {
386 let nrf_dir = external_dir.join("nrf");
387 if nrf_dir.exists() {
388 if nrf_dir.is_dir() {
389 println!("Using existing {}", nrf_dir.display());
390 return Ok(());
391 }
392 return Err(format!(
393 "Expected '{}' to be a directory, but it is not. Remove it and re-run setup.",
394 nrf_dir.display()
395 )
396 .into());
397 }
398
399 if gitmodules_declares_external_nrf(root)? {
400 println!("Setting up nrf submodule...");
401 run_cmd(
402 "git",
403 &["submodule", "update", "--init", "external/nrf"],
404 Some(root),
405 )?;
406 return Ok(());
407 }
408
409 let repo = env::var("BABBLE_BRIDGE_NRF_REPO").map_err(|_| {
410 "No external/nrf checkout found and no submodule metadata for external/nrf in .gitmodules. \
411Set BABBLE_BRIDGE_NRF_REPO (and optionally BABBLE_BRIDGE_NRF_REF) so babble-bridge can clone sdk-nrf into external/nrf."
412 })?;
413
414 println!("Cloning nrf into external/nrf from BABBLE_BRIDGE_NRF_REPO...");
415 run_cmd("git", &["clone", &repo, "external/nrf"], Some(root))?;
416
417 if let Ok(nrf_ref) = env::var("BABBLE_BRIDGE_NRF_REF") {
418 if !nrf_ref.trim().is_empty() {
419 println!("Checking out BABBLE_BRIDGE_NRF_REF={nrf_ref}...");
420 run_cmd(
421 "git",
422 &["-C", "external/nrf", "checkout", nrf_ref.trim()],
423 Some(root),
424 )?;
425 }
426 }
427
428 Ok(())
429}
430
431fn gitmodules_declares_external_nrf(root: &Path) -> Result<bool> {
432 let gitmodules = root.join(".gitmodules");
433 if !gitmodules.exists() {
434 return Ok(false);
435 }
436
437 let output = Command::new("git")
438 .args([
439 "config",
440 "-f",
441 ".gitmodules",
442 "--get",
443 "submodule.external/nrf.path",
444 ])
445 .current_dir(root)
446 .output()?;
447
448 if !output.status.success() {
449 return Ok(false);
450 }
451
452 Ok(String::from_utf8(output.stdout)?.trim() == "external/nrf")
453}
454
455const PREBUILT_RELEASE_URL_BASE: &str =
461 "https://github.com/tyler-potyondy/nrf-sim-bridge/releases/latest/download";
462const PREBUILT_TARBALL_NAME: &str = "bsim-prebuilt.tar.gz";
463const PREBUILT_SHA256_NAME: &str = "bsim-prebuilt.tar.gz.sha256";
464
465pub fn fetch_prebuilt_binaries(root: &Path, external_dir: &Path) -> Result<()> {
480 let _ = root;
481 let bsim_dir = external_dir.join("tools/bsim");
482 fs::create_dir_all(&bsim_dir)?;
483
484 let download_dir = external_dir.join(".prebuilt-download");
485 if download_dir.exists() {
486 fs::remove_dir_all(&download_dir)?;
487 }
488 fs::create_dir_all(&download_dir)?;
489
490 let tarball = download_dir.join(PREBUILT_TARBALL_NAME);
491 let sha_file = download_dir.join(PREBUILT_SHA256_NAME);
492 let tarball_url = format!("{PREBUILT_RELEASE_URL_BASE}/{PREBUILT_TARBALL_NAME}");
493 let sha_url = format!("{PREBUILT_RELEASE_URL_BASE}/{PREBUILT_SHA256_NAME}");
494
495 let tarball_str = tarball.to_str().ok_or("Invalid UTF-8 path for tarball")?;
496 let sha_file_str = sha_file.to_str().ok_or("Invalid UTF-8 path for sha file")?;
497 let bsim_dir_str = bsim_dir.to_str().ok_or("Invalid UTF-8 path for bsim dir")?;
498
499 println!("Downloading {tarball_url} ...");
500 run_cmd(
501 "curl",
502 &["--fail", "--location", "--show-error", "--silent",
503 "--output", tarball_str, &tarball_url],
504 None,
505 )?;
506
507 println!("Downloading {sha_url} ...");
508 run_cmd(
509 "curl",
510 &["--fail", "--location", "--show-error", "--silent",
511 "--output", sha_file_str, &sha_url],
512 None,
513 )?;
514
515 println!("Verifying SHA-256 ...");
516 run_cmd(
519 "sha256sum",
520 &["--check", "--strict", PREBUILT_SHA256_NAME],
521 Some(&download_dir),
522 )?;
523
524 for sub in ["bin", "lib", "components"] {
527 let p = bsim_dir.join(sub);
528 if p.exists() {
529 fs::remove_dir_all(&p)?;
530 }
531 }
532
533 println!("Extracting into {} ...", bsim_dir.display());
534 run_cmd(
535 "tar",
536 &["-xzf", tarball_str, "-C", bsim_dir_str],
537 None,
538 )?;
539
540 fs::remove_dir_all(&download_dir)?;
541
542 println!("Prebuilt binaries installed to {}", bsim_dir.display());
543 println!(" bin/ — BabbleSim + Zephyr app binaries");
544 println!(" lib/ — shared libraries (LD_LIBRARY_PATH)");
545 println!(" components/ — BabbleSim runtime components");
546 Ok(())
547}
548
549pub fn zephyr_setup(root: &Path, clean: bool, mode: InstallMode) -> Result<()> {
567 let external_dir = root.join("external");
568
569 if clean {
570 println!("Cleaning up {}...", external_dir.display());
571 clean_dir(&external_dir)?;
572 }
573
574 fs::create_dir_all(&external_dir)?;
575 create_sockets_dir(root)?;
576
577 if let InstallMode::FetchPrebuilt = mode {
578 return fetch_prebuilt_binaries(root, &external_dir);
579 }
580
581 ensure_external_nrf_checkout(root, &external_dir)?;
582
583 let venv_dir = external_dir.join(".venv");
584 let venv_python = venv_dir.join("bin/python3");
585 let venv_stamp = venv_dir.join(".requirements_installed");
586
587 let python_ok = venv_python.exists()
588 && Command::new(&venv_python)
589 .arg("--version")
590 .stdout(Stdio::null())
591 .stderr(Stdio::null())
592 .status()
593 .map(|s| s.success())
594 .unwrap_or(false);
595 let venv_valid = python_ok && venv_stamp.exists();
596
597 if !venv_valid {
598 if venv_dir.exists() {
599 if !python_ok {
600 println!("Existing venv is stale or from a different Python, recreating...");
601 } else {
602 println!("Existing venv has incomplete requirements, recreating...");
603 }
604 fs::remove_dir_all(&venv_dir)?;
605 } else {
606 println!("Creating venv...");
607 }
608 run_cmd("python3", &["-m", "venv", ".venv"], Some(&external_dir))?;
609 }
610
611 let pip = external_dir.join(".venv/bin/pip");
612 let west = external_dir.join(".venv/bin/west");
613 let pip_str = pip.to_str().ok_or("Invalid UTF-8 path for pip")?;
614 let west_str = west.to_str().ok_or("Invalid UTF-8 path for west")?;
615
616 let venv_bin = venv_dir.join("bin");
617 let venv_bin_str = venv_bin.to_str().ok_or("Invalid UTF-8 path for venv bin")?;
618 let path = env::var("PATH").unwrap_or_default();
619 std::env::set_var("PATH", format!("{venv_bin_str}:{path}"));
620 std::env::set_var("VIRTUAL_ENV", &venv_dir);
621
622 run_cmd(pip_str, &["install", "west"], Some(&external_dir))?;
623
624 let west_state = external_dir.join(".west");
625 if west_state.exists() {
626 println!("Previous west workspace found, resetting...");
627 fs::remove_dir_all(west_state)?;
628 }
629
630 run_cmd(west_str, &["init", "-l", "nrf"], Some(&external_dir))?;
631 println!("Fetching west dependencies (BabbleSim + Zephyr)...");
632 run_cmd(
633 west_str,
634 &["config", "manifest.group-filter", "--", "+babblesim"],
635 Some(&external_dir),
636 )?;
637 run_cmd(west_str, &["update"], Some(&external_dir))?;
638
639 run_cmd(
640 pip_str,
641 &["install", "-r", "nrf/scripts/requirements.txt"],
642 Some(&external_dir),
643 )?;
644 run_cmd(
645 pip_str,
646 &["install", "-r", "zephyr/scripts/requirements.txt"],
647 Some(&external_dir),
648 )?;
649
650 println!("Verifying all requirements are installed...");
651 let dry_run = Command::new(pip_str)
652 .args([
653 "install",
654 "-r", "nrf/scripts/requirements.txt",
655 "-r", "zephyr/scripts/requirements.txt",
656 "--dry-run",
657 ])
658 .current_dir(&external_dir)
659 .stdout(Stdio::piped())
660 .stderr(Stdio::piped())
661 .output()?;
662
663 let dry_run_out = String::from_utf8_lossy(&dry_run.stdout);
664 let dry_run_err = String::from_utf8_lossy(&dry_run.stderr);
665 let combined = format!("{dry_run_out}{dry_run_err}");
666 if combined.contains("Would install") {
667 return Err(format!(
668 "Requirements are not fully installed after pip install — \
669 the following packages are still missing or out of range:\n{combined}\n\
670 Re-run with --clean to start fresh."
671 )
672 .into());
673 }
674
675 fs::write(&venv_stamp, "")?;
676
677 println!("Building BabbleSim...");
678 run_cmd(
679 "make",
680 &["-C", "tools/bsim", "everything", "-j", "4"],
681 Some(&external_dir),
682 )?;
683
684 println!("Checking out cgm-bsim branch in nrf...");
685 let current_branch = Command::new("git")
686 .args(["-C", "nrf", "rev-parse", "--abbrev-ref", "HEAD"])
687 .current_dir(&external_dir)
688 .output()?;
689 if !current_branch.status.success() {
690 return Err("Failed to read current nrf branch".into());
691 }
692 if String::from_utf8(current_branch.stdout)?.trim() != "cgm-bsim" {
693 run_cmd(
694 "git",
695 &["-C", "nrf", "fetch", "origin", "cgm-bsim"],
696 Some(&external_dir),
697 )?;
698 run_cmd(
699 "git",
700 &["-C", "nrf", "checkout", "-B", "cgm-bsim", "FETCH_HEAD"],
701 Some(&external_dir),
702 )?;
703 }
704
705 println!("Building Zephyr server app...");
706 run_cmd(
707 west_str,
708 &[
709 "build", "-b", "nrf52_bsim", "-p", "always",
710 "--build-dir", "build/zephyr_server_app",
711 "nrf/samples/nrf_rpc/protocols_serialization/server",
712 "-S", "ble",
713 ],
714 Some(&external_dir),
715 )?;
716
717 println!("Building CGM peripheral sample...");
718 run_cmd(
719 west_str,
720 &[
721 "build", "-b", "nrf52_bsim", "-p", "always",
722 "--build-dir", "build/cgm_peripheral_sample",
723 "nrf/samples/bluetooth/peripheral_cgms",
724 ],
725 Some(&external_dir),
726 )?;
727
728 fs::copy(
729 external_dir.join("build/zephyr_server_app/server/zephyr/zephyr.exe"),
730 external_dir.join("tools/bsim/bin/zephyr_rpc_server_app"),
731 )?;
732 fs::copy(
733 external_dir.join("build/cgm_peripheral_sample/peripheral_cgms/zephyr/zephyr.exe"),
734 external_dir.join("tools/bsim/bin/cgm_peripheral_sample"),
735 )?;
736
737 println!("Done. Build artifacts copied to external/tools/bsim/bin/");
738 Ok(())
739}
740
741fn parse_sim_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
745 args.windows(2)
746 .find(|w| w[0] == flag)
747 .map(|w| w[1].as_str())
748}
749
750fn cmd_start_sim(sim_id: &str, sim_dir: &Path) -> Result<()> {
756 let bsim_bin = Path::new("external/tools/bsim/bin");
758 let required = ["bs_2G4_phy_v1", "zephyr_rpc_server_app", "cgm_peripheral_sample"];
759 let missing: Vec<&str> = required
760 .iter()
761 .copied()
762 .filter(|name| !bsim_bin.join(name).is_file())
763 .collect();
764 if !missing.is_empty() {
765 return Err(format!(
766 "Missing required binaries in {}:\n{}\n\
767 Run `cargo xtask zephyr-setup` to install them.",
768 bsim_bin.display(),
769 missing.iter().map(|n| format!(" - {n}")).collect::<Vec<_>>().join("\n"),
770 )
771 .into());
772 }
773
774 let (processes, socket_path) =
775 crate::spawn_zephyr_rpc_server_with_socat(sim_dir, sim_id);
776
777 let deadline = Instant::now() + Duration::from_secs(15);
780 loop {
781 if UnixStream::connect(&socket_path).is_ok() {
782 break;
783 }
784 if Instant::now() >= deadline {
785 return Err(format!(
786 "timed out waiting for socket {} to become connectable",
787 socket_path.display()
788 )
789 .into());
790 }
791 std::thread::sleep(Duration::from_millis(100));
792 }
793
794 std::mem::forget(processes);
798
799 println!("{}", socket_path.display());
800 Ok(())
801}
802
803fn container_port(workspace: &str) -> u16 {
807 let hash = workspace
808 .bytes()
809 .fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64));
810 49152 + (hash % (65535 - 49152)) as u16
811}
812
813fn cmd_start_sim_in_docker(sim_id: &str) -> Result<()> {
818 let root = workspace_root()?;
819 let workspace = root
820 .to_str()
821 .ok_or("Workspace path contains non-UTF-8 characters")?;
822
823 let image_exists = Command::new("docker")
825 .args(["image", "inspect", "--format", ".", DOCKER_IMAGE_TAG])
826 .stdout(Stdio::null())
827 .stderr(Stdio::null())
828 .status()
829 .map(|s| s.success())
830 .unwrap_or(false);
831
832 if !image_exists {
833 println!("Docker image {DOCKER_IMAGE_TAG} not found — building...");
834 docker_build()?;
835 }
836
837 let hash = format!("{:x}", workspace.bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)));
840 let container_name = format!("babble-bridge-{}", &hash[..8]);
841 let container_name = container_name.as_str();
842 let port = container_port(workspace);
843 let port_mapping = format!("127.0.0.1:{port}:{port}");
844
845 let container_running = Command::new("docker")
847 .args(["inspect", "--format", "{{.State.Running}}", container_name])
848 .stdout(Stdio::piped())
849 .stderr(Stdio::null())
850 .output()
851 .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
852 .unwrap_or(false);
853
854 if !container_running {
855 let _ = Command::new("docker")
857 .args(["rm", "-f", container_name])
858 .stdout(Stdio::null())
859 .stderr(Stdio::null())
860 .status();
861
862 println!("Starting container {container_name} (TCP bridge port {port})...");
863 let mount = format!("{workspace}:/workspace");
864 run_cmd(
865 "docker",
866 &[
867 "run",
868 "--detach",
869 "--tty", "--name", container_name,
871 "--platform", "linux/amd64",
872 "-p", &port_mapping, "-v", &mount,
874 "-w", "/workspace",
875 DOCKER_IMAGE_TAG,
876 "sleep", "infinity", ],
878 Some(&root),
879 )?;
880 } else {
881 println!("Container {container_name} is already running.");
882 }
883
884 println!("Running start-sim inside container...");
885 let _ = run_cmd(
888 "docker",
889 &[
890 "exec",
891 container_name,
892 "bash", "-lc", &format!("cargo xtask stop-sim --sim-id {sim_id}"),
893 ],
894 Some(&root),
895 );
896 let _ = run_cmd(
898 "docker",
899 &[
900 "exec",
901 container_name,
902 "bash", "-lc", &format!("pkill -f 'socat TCP-LISTEN:{port}' || true"),
903 ],
904 Some(&root),
905 );
906 run_cmd(
907 "docker",
908 &[
909 "exec",
910 container_name,
911 "bash", "-lc", &format!("cargo xtask start-sim --sim-id {sim_id}"),
912 ],
913 Some(&root),
914 )?;
915
916 let socket_path = format!("/workspace/tests/sockets/{sim_id}.sock");
919 run_cmd(
920 "docker",
921 &[
922 "exec",
923 "--detach",
924 container_name,
925 "bash", "-lc",
926 &format!("socat TCP-LISTEN:{port},reuseaddr,fork UNIX-CLIENT:{socket_path}"),
927 ],
928 Some(&root),
929 )?;
930
931 println!("TCP bridge ready: connect from macOS at 127.0.0.1:{port}");
932 Ok(())
933}
934
935fn cmd_exec_in_container(cmd_args: &[&str]) -> Result<()> {
943 let root = workspace_root()?;
944 let workspace = root
945 .to_str()
946 .ok_or("Workspace path contains non-UTF-8 characters")?;
947 let hash = format!("{:x}", workspace.bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)));
948 let container_name = format!("babble-bridge-{}", &hash[..8]);
949
950 let container_running = Command::new("docker")
951 .args(["inspect", "--format", "{{.State.Running}}", &container_name])
952 .stdout(Stdio::piped())
953 .stderr(Stdio::null())
954 .output()
955 .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
956 .unwrap_or(false);
957
958 if !container_running {
959 return Err(format!(
960 "Container {container_name} is not running. \
961 Start it first with `cargo xtask start-sim --container`."
962 ).into());
963 }
964
965 let shell_cmd = cmd_args.join(" ");
966 run_cmd(
967 "docker",
968 &["exec", &container_name, "bash", "-lc", &shell_cmd],
969 Some(&root),
970 )
971}
972
973fn cmd_stop_sim(sim_id: &str) -> Result<()> {
975 crate::kill_stale_sim_processes(sim_id);
976 println!("Stopped simulation '{sim_id}'");
977 Ok(())
978}
979
980fn cmd_clean_sockets(root: &Path) -> Result<()> {
982 let sockets_dir = root.join("tests/sockets");
983 if !sockets_dir.exists() {
984 create_sockets_dir(root)?;
985 println!("No socket files found in {}.", sockets_dir.display());
986 return Ok(());
987 }
988
989 let mut removed = 0usize;
990 for entry in fs::read_dir(&sockets_dir)? {
991 let entry = entry?;
992 let path = entry.path();
993 if path.extension().and_then(|e| e.to_str()) == Some("sock") {
994 fs::remove_file(&path)?;
995 println!("Removed {}", path.display());
996 removed += 1;
997 }
998 }
999
1000 if removed == 0 {
1001 println!("No socket files found in {}.", sockets_dir.display());
1002 } else {
1003 println!("Removed {removed} socket file(s).");
1004 }
1005 Ok(())
1006}
1007
1008fn bsim_ld_library_path() -> String {
1011 match env::var("LD_LIBRARY_PATH") {
1012 Ok(existing) => format!("external/tools/bsim/lib:{existing}"),
1013 Err(_) => "external/tools/bsim/lib".to_string(),
1014 }
1015}
1016
1017fn spawn_in_bsim_bin(sim_id: &str, exe: &str, args: &[&str]) -> Result<Child> {
1018 let bsim_bin = Path::new("external/tools/bsim/bin");
1019 Command::new(exe)
1020 .args(args)
1021 .current_dir(bsim_bin)
1022 .stdin(Stdio::inherit())
1023 .stdout(Stdio::inherit())
1024 .stderr(Stdio::inherit())
1025 .env("BSIM_OUT_PATH", "external/tools/bsim")
1026 .env("BSIM_COMPONENTS_PATH", "external/tools/bsim/components")
1027 .env("LD_LIBRARY_PATH", bsim_ld_library_path())
1028 .spawn()
1029 .map_err(|e| format!("Failed to spawn '{exe}' for sim '{sim_id}': {e}").into())
1030}
1031
1032fn pkill_sim(sim_id: &str) {
1033 for process in ["bs_2G4_phy_v1", "zephyr_rpc_server_app", "cgm_peripheral_sample"] {
1034 let pattern = format!("{process} -s={sim_id}");
1035 let _ = Command::new("pkill").args(["-f", &pattern]).status();
1036 let _ = Command::new("pkill").args(["-9", "-f", &pattern]).status();
1037 }
1038}
1039
1040fn generate_sim_id() -> String {
1041 use std::time::{SystemTime, UNIX_EPOCH};
1042 let nanos = SystemTime::now()
1043 .duration_since(UNIX_EPOCH)
1044 .unwrap_or_default()
1045 .subsec_nanos();
1046 let pid = std::process::id();
1047 format!("sim_{:08x}", nanos ^ (pid << 16))
1048}
1049
1050fn run_bsim(nrf_rpc_server: bool, cgm_peripheral: bool) -> Result<()> {
1051 let (run_nrf, run_cgm) = if !nrf_rpc_server && !cgm_peripheral {
1052 (true, true)
1053 } else {
1054 (nrf_rpc_server, cgm_peripheral)
1055 };
1056
1057 let sim_id = generate_sim_id();
1058 pkill_sim(&sim_id);
1059
1060 let _ = fs::remove_dir_all(format!(
1061 "/tmp/bs_{}/{}",
1062 env::var("USER").unwrap_or_default(),
1063 &sim_id
1064 ));
1065
1066 let device_count = (run_nrf as u32) + (run_cgm as u32);
1067
1068 const SEP: &str = "────────────────────────────────────────────────────────────";
1069
1070 println!(" Starting PHY simulator...");
1071 let _phy = spawn_in_bsim_bin(
1072 &sim_id,
1073 "./bs_2G4_phy_v1",
1074 &[
1075 &format!("-s={sim_id}"),
1076 &format!("-D={device_count}"),
1077 "-sim_length=86400e6",
1078 ],
1079 )?;
1080
1081 let nrf_device_idx: u32 = 0;
1082 let cgm_device_idx: u32 = run_nrf as u32;
1083
1084 let mut nrf_proc = if run_nrf {
1085 println!(" Starting nRF RPC server (device {nrf_device_idx})...");
1086 Some(spawn_in_bsim_bin(
1087 &sim_id,
1088 "./zephyr_rpc_server_app",
1089 &[
1090 &format!("-s={sim_id}"),
1091 &format!("-d={nrf_device_idx}"),
1092 "-uart0_pty",
1093 "-uart_pty_pollT=1000",
1094 ],
1095 )?)
1096 } else {
1097 None
1098 };
1099
1100 let mut cgm_proc = if run_cgm {
1101 println!(" Starting CGM peripheral (device {cgm_device_idx})...");
1102 let cgm_log = fs::File::create("external/tools/bsim/bin/cgm_peripheral_sample.log")?;
1103 Some(
1104 Command::new("./cgm_peripheral_sample")
1105 .args([&format!("-s={sim_id}"), &format!("-d={cgm_device_idx}")])
1106 .current_dir("external/tools/bsim/bin")
1107 .stdin(Stdio::null())
1108 .stdout(cgm_log.try_clone()?)
1109 .stderr(cgm_log)
1110 .env("BSIM_OUT_PATH", "external/tools/bsim")
1111 .env("BSIM_COMPONENTS_PATH", "external/tools/bsim/components")
1112 .env("LD_LIBRARY_PATH", bsim_ld_library_path())
1113 .spawn()?,
1114 )
1115 } else {
1116 None
1117 };
1118
1119 let mut device_list = Vec::new();
1120 if run_nrf { device_list.push(format!("nrf-rpc-server [d={nrf_device_idx}]")); }
1121 if run_cgm { device_list.push(format!("cgm-peripheral [d={cgm_device_idx}]")); }
1122 let device_str = device_list.join(", ");
1123
1124 println!();
1125 println!("{SEP}");
1126 println!(" Simulation ID : {sim_id}");
1127 println!(" Devices : {device_str}");
1128 println!(" Duration : 86400 s (~24 h simulated, ~39 s real time)");
1129 println!("{SEP}");
1130
1131 if run_nrf {
1132 println!();
1133 println!(" To test RX, run in another terminal:");
1134 println!();
1135 println!(" socat UNIX-LISTEN:/tmp/nrf_rpc_server.sock,fork /dev/pts/XX,raw,echo=0");
1136 println!(" printf '\\x04\\x00\\xff\\x00\\xff\\x00\\x62\\x74\\x5f\\x72\\x70\\x63' \\");
1137 println!(" | socat - UNIX-CONNECT:/tmp/nrf_rpc_server.sock");
1138 }
1139
1140 println!();
1141 println!(" Press Ctrl+C to stop.");
1142 println!();
1143
1144 if let Some(ref mut proc) = nrf_proc {
1145 let status = proc.wait()?;
1146 if let Some(ref mut cgm) = cgm_proc {
1147 let _ = cgm.kill();
1148 }
1149 if !status.success() {
1150 return Err(format!("zephyr_rpc_server_app exited with status: {status}").into());
1151 }
1152 } else if let Some(ref mut proc) = cgm_proc {
1153 let status = proc.wait()?;
1154 if !status.success() {
1155 return Err(format!("cgm_peripheral_sample exited with status: {status}").into());
1156 }
1157 }
1158
1159 Ok(())
1160}