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
385const PREBUILT_RELEASE_URL_BASE: &str =
391 "https://github.com/tyler-potyondy/nrf-sim-bridge/releases/latest/download";
392const PREBUILT_TARBALL_NAME: &str = "bsim-prebuilt.tar.gz";
393const PREBUILT_SHA256_NAME: &str = "bsim-prebuilt.tar.gz.sha256";
394
395pub fn fetch_prebuilt_binaries(root: &Path, external_dir: &Path) -> Result<()> {
410 let _ = root;
411 let bsim_dir = external_dir.join("tools/bsim");
412 fs::create_dir_all(&bsim_dir)?;
413
414 let download_dir = external_dir.join(".prebuilt-download");
415 if download_dir.exists() {
416 fs::remove_dir_all(&download_dir)?;
417 }
418 fs::create_dir_all(&download_dir)?;
419
420 let tarball = download_dir.join(PREBUILT_TARBALL_NAME);
421 let sha_file = download_dir.join(PREBUILT_SHA256_NAME);
422 let tarball_url = format!("{PREBUILT_RELEASE_URL_BASE}/{PREBUILT_TARBALL_NAME}");
423 let sha_url = format!("{PREBUILT_RELEASE_URL_BASE}/{PREBUILT_SHA256_NAME}");
424
425 let tarball_str = tarball.to_str().ok_or("Invalid UTF-8 path for tarball")?;
426 let sha_file_str = sha_file.to_str().ok_or("Invalid UTF-8 path for sha file")?;
427 let bsim_dir_str = bsim_dir.to_str().ok_or("Invalid UTF-8 path for bsim dir")?;
428
429 println!("Downloading {tarball_url} ...");
430 run_cmd(
431 "curl",
432 &["--fail", "--location", "--show-error", "--silent",
433 "--output", tarball_str, &tarball_url],
434 None,
435 )?;
436
437 println!("Downloading {sha_url} ...");
438 run_cmd(
439 "curl",
440 &["--fail", "--location", "--show-error", "--silent",
441 "--output", sha_file_str, &sha_url],
442 None,
443 )?;
444
445 println!("Verifying SHA-256 ...");
446 run_cmd(
449 "sha256sum",
450 &["--check", "--strict", PREBUILT_SHA256_NAME],
451 Some(&download_dir),
452 )?;
453
454 for sub in ["bin", "lib", "components"] {
457 let p = bsim_dir.join(sub);
458 if p.exists() {
459 fs::remove_dir_all(&p)?;
460 }
461 }
462
463 println!("Extracting into {} ...", bsim_dir.display());
464 run_cmd(
465 "tar",
466 &["-xzf", tarball_str, "-C", bsim_dir_str],
467 None,
468 )?;
469
470 fs::remove_dir_all(&download_dir)?;
471
472 println!("Prebuilt binaries installed to {}", bsim_dir.display());
473 println!(" bin/ — BabbleSim + Zephyr app binaries");
474 println!(" lib/ — shared libraries (LD_LIBRARY_PATH)");
475 println!(" components/ — BabbleSim runtime components");
476 Ok(())
477}
478
479pub fn zephyr_setup(root: &Path, clean: bool, mode: InstallMode) -> Result<()> {
497 let external_dir = root.join("external");
498
499 if clean {
500 println!("Cleaning up {}...", external_dir.display());
501 clean_dir(&external_dir)?;
502 }
503
504 fs::create_dir_all(&external_dir)?;
505 create_sockets_dir(root)?;
506
507 if let InstallMode::FetchPrebuilt = mode {
508 return fetch_prebuilt_binaries(root, &external_dir);
509 }
510
511 println!("Setting up nrf submodule...");
516 run_cmd(
517 "git",
518 &["submodule", "update", "--init", "external/nrf"],
519 Some(root),
520 )?;
521
522 let venv_dir = external_dir.join(".venv");
523 let venv_python = venv_dir.join("bin/python3");
524 let venv_stamp = venv_dir.join(".requirements_installed");
525
526 let python_ok = venv_python.exists()
527 && Command::new(&venv_python)
528 .arg("--version")
529 .stdout(Stdio::null())
530 .stderr(Stdio::null())
531 .status()
532 .map(|s| s.success())
533 .unwrap_or(false);
534 let venv_valid = python_ok && venv_stamp.exists();
535
536 if !venv_valid {
537 if venv_dir.exists() {
538 if !python_ok {
539 println!("Existing venv is stale or from a different Python, recreating...");
540 } else {
541 println!("Existing venv has incomplete requirements, recreating...");
542 }
543 fs::remove_dir_all(&venv_dir)?;
544 } else {
545 println!("Creating venv...");
546 }
547 run_cmd("python3", &["-m", "venv", ".venv"], Some(&external_dir))?;
548 }
549
550 let pip = external_dir.join(".venv/bin/pip");
551 let west = external_dir.join(".venv/bin/west");
552 let pip_str = pip.to_str().ok_or("Invalid UTF-8 path for pip")?;
553 let west_str = west.to_str().ok_or("Invalid UTF-8 path for west")?;
554
555 let venv_bin = venv_dir.join("bin");
556 let venv_bin_str = venv_bin.to_str().ok_or("Invalid UTF-8 path for venv bin")?;
557 let path = env::var("PATH").unwrap_or_default();
558 std::env::set_var("PATH", format!("{venv_bin_str}:{path}"));
559 std::env::set_var("VIRTUAL_ENV", &venv_dir);
560
561 run_cmd(pip_str, &["install", "west"], Some(&external_dir))?;
562
563 let west_state = external_dir.join(".west");
564 if west_state.exists() {
565 println!("Previous west workspace found, resetting...");
566 fs::remove_dir_all(west_state)?;
567 }
568
569 run_cmd(west_str, &["init", "-l", "nrf"], Some(&external_dir))?;
570 println!("Fetching west dependencies (BabbleSim + Zephyr)...");
571 run_cmd(
572 west_str,
573 &["config", "manifest.group-filter", "--", "+babblesim"],
574 Some(&external_dir),
575 )?;
576 run_cmd(west_str, &["update"], Some(&external_dir))?;
577
578 run_cmd(
579 pip_str,
580 &["install", "-r", "nrf/scripts/requirements.txt"],
581 Some(&external_dir),
582 )?;
583 run_cmd(
584 pip_str,
585 &["install", "-r", "zephyr/scripts/requirements.txt"],
586 Some(&external_dir),
587 )?;
588
589 println!("Verifying all requirements are installed...");
590 let dry_run = Command::new(pip_str)
591 .args([
592 "install",
593 "-r", "nrf/scripts/requirements.txt",
594 "-r", "zephyr/scripts/requirements.txt",
595 "--dry-run",
596 ])
597 .current_dir(&external_dir)
598 .stdout(Stdio::piped())
599 .stderr(Stdio::piped())
600 .output()?;
601
602 let dry_run_out = String::from_utf8_lossy(&dry_run.stdout);
603 let dry_run_err = String::from_utf8_lossy(&dry_run.stderr);
604 let combined = format!("{dry_run_out}{dry_run_err}");
605 if combined.contains("Would install") {
606 return Err(format!(
607 "Requirements are not fully installed after pip install — \
608 the following packages are still missing or out of range:\n{combined}\n\
609 Re-run with --clean to start fresh."
610 )
611 .into());
612 }
613
614 fs::write(&venv_stamp, "")?;
615
616 println!("Building BabbleSim...");
617 run_cmd(
618 "make",
619 &["-C", "tools/bsim", "everything", "-j", "4"],
620 Some(&external_dir),
621 )?;
622
623 println!("Checking out cgm-bsim branch in nrf...");
624 let current_branch = Command::new("git")
625 .args(["-C", "nrf", "rev-parse", "--abbrev-ref", "HEAD"])
626 .current_dir(&external_dir)
627 .output()?;
628 if !current_branch.status.success() {
629 return Err("Failed to read current nrf branch".into());
630 }
631 if String::from_utf8(current_branch.stdout)?.trim() != "cgm-bsim" {
632 run_cmd(
633 "git",
634 &["-C", "nrf", "fetch", "origin", "cgm-bsim"],
635 Some(&external_dir),
636 )?;
637 run_cmd(
638 "git",
639 &["-C", "nrf", "checkout", "-B", "cgm-bsim", "FETCH_HEAD"],
640 Some(&external_dir),
641 )?;
642 }
643
644 println!("Building Zephyr server app...");
645 run_cmd(
646 west_str,
647 &[
648 "build", "-b", "nrf52_bsim", "-p", "always",
649 "--build-dir", "build/zephyr_server_app",
650 "nrf/samples/nrf_rpc/protocols_serialization/server",
651 "-S", "ble",
652 ],
653 Some(&external_dir),
654 )?;
655
656 println!("Building CGM peripheral sample...");
657 run_cmd(
658 west_str,
659 &[
660 "build", "-b", "nrf52_bsim", "-p", "always",
661 "--build-dir", "build/cgm_peripheral_sample",
662 "nrf/samples/bluetooth/peripheral_cgms",
663 ],
664 Some(&external_dir),
665 )?;
666
667 fs::copy(
668 external_dir.join("build/zephyr_server_app/server/zephyr/zephyr.exe"),
669 external_dir.join("tools/bsim/bin/zephyr_rpc_server_app"),
670 )?;
671 fs::copy(
672 external_dir.join("build/cgm_peripheral_sample/peripheral_cgms/zephyr/zephyr.exe"),
673 external_dir.join("tools/bsim/bin/cgm_peripheral_sample"),
674 )?;
675
676 println!("Done. Build artifacts copied to external/tools/bsim/bin/");
677 Ok(())
678}
679
680fn parse_sim_flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
684 args.windows(2)
685 .find(|w| w[0] == flag)
686 .map(|w| w[1].as_str())
687}
688
689fn cmd_start_sim(sim_id: &str, sim_dir: &Path) -> Result<()> {
695 let bsim_bin = Path::new("external/tools/bsim/bin");
697 let required = ["bs_2G4_phy_v1", "zephyr_rpc_server_app", "cgm_peripheral_sample"];
698 let missing: Vec<&str> = required
699 .iter()
700 .copied()
701 .filter(|name| !bsim_bin.join(name).is_file())
702 .collect();
703 if !missing.is_empty() {
704 return Err(format!(
705 "Missing required binaries in {}:\n{}\n\
706 Run `cargo xtask zephyr-setup` to install them.",
707 bsim_bin.display(),
708 missing.iter().map(|n| format!(" - {n}")).collect::<Vec<_>>().join("\n"),
709 )
710 .into());
711 }
712
713 let (processes, socket_path) =
714 crate::spawn_zephyr_rpc_server_with_socat(sim_dir, sim_id);
715
716 let deadline = Instant::now() + Duration::from_secs(15);
719 loop {
720 if UnixStream::connect(&socket_path).is_ok() {
721 break;
722 }
723 if Instant::now() >= deadline {
724 return Err(format!(
725 "timed out waiting for socket {} to become connectable",
726 socket_path.display()
727 )
728 .into());
729 }
730 std::thread::sleep(Duration::from_millis(100));
731 }
732
733 std::mem::forget(processes);
737
738 println!("{}", socket_path.display());
739 Ok(())
740}
741
742fn container_port(workspace: &str) -> u16 {
746 let hash = workspace
747 .bytes()
748 .fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64));
749 49152 + (hash % (65535 - 49152)) as u16
750}
751
752fn cmd_start_sim_in_docker(sim_id: &str) -> Result<()> {
757 let root = workspace_root()?;
758 let workspace = root
759 .to_str()
760 .ok_or("Workspace path contains non-UTF-8 characters")?;
761
762 let image_exists = Command::new("docker")
764 .args(["image", "inspect", "--format", ".", DOCKER_IMAGE_TAG])
765 .stdout(Stdio::null())
766 .stderr(Stdio::null())
767 .status()
768 .map(|s| s.success())
769 .unwrap_or(false);
770
771 if !image_exists {
772 println!("Docker image {DOCKER_IMAGE_TAG} not found — building...");
773 docker_build()?;
774 }
775
776 let hash = format!("{:x}", workspace.bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)));
779 let container_name = format!("babble-bridge-{}", &hash[..8]);
780 let container_name = container_name.as_str();
781 let port = container_port(workspace);
782 let port_mapping = format!("127.0.0.1:{port}:{port}");
783
784 let container_running = Command::new("docker")
786 .args(["inspect", "--format", "{{.State.Running}}", container_name])
787 .stdout(Stdio::piped())
788 .stderr(Stdio::null())
789 .output()
790 .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
791 .unwrap_or(false);
792
793 if !container_running {
794 let _ = Command::new("docker")
796 .args(["rm", "-f", container_name])
797 .stdout(Stdio::null())
798 .stderr(Stdio::null())
799 .status();
800
801 println!("Starting container {container_name} (TCP bridge port {port})...");
802 let mount = format!("{workspace}:/workspace");
803 run_cmd(
804 "docker",
805 &[
806 "run",
807 "--detach",
808 "--tty", "--name", container_name,
810 "--platform", "linux/amd64",
811 "-p", &port_mapping, "-v", &mount,
813 "-w", "/workspace",
814 DOCKER_IMAGE_TAG,
815 "sleep", "infinity", ],
817 Some(&root),
818 )?;
819 } else {
820 println!("Container {container_name} is already running.");
821 }
822
823 println!("Running start-sim inside container...");
824 let _ = run_cmd(
827 "docker",
828 &[
829 "exec",
830 container_name,
831 "bash", "-lc", &format!("cargo xtask stop-sim --sim-id {sim_id}"),
832 ],
833 Some(&root),
834 );
835 let _ = run_cmd(
837 "docker",
838 &[
839 "exec",
840 container_name,
841 "bash", "-lc", &format!("pkill -f 'socat TCP-LISTEN:{port}' || true"),
842 ],
843 Some(&root),
844 );
845 run_cmd(
846 "docker",
847 &[
848 "exec",
849 container_name,
850 "bash", "-lc", &format!("cargo xtask start-sim --sim-id {sim_id}"),
851 ],
852 Some(&root),
853 )?;
854
855 let socket_path = format!("/workspace/tests/sockets/{sim_id}.sock");
858 run_cmd(
859 "docker",
860 &[
861 "exec",
862 "--detach",
863 container_name,
864 "bash", "-lc",
865 &format!("socat TCP-LISTEN:{port},reuseaddr,fork UNIX-CLIENT:{socket_path}"),
866 ],
867 Some(&root),
868 )?;
869
870 println!("TCP bridge ready: connect from macOS at 127.0.0.1:{port}");
871 Ok(())
872}
873
874fn cmd_exec_in_container(cmd_args: &[&str]) -> Result<()> {
882 let root = workspace_root()?;
883 let workspace = root
884 .to_str()
885 .ok_or("Workspace path contains non-UTF-8 characters")?;
886 let hash = format!("{:x}", workspace.bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)));
887 let container_name = format!("babble-bridge-{}", &hash[..8]);
888
889 let container_running = Command::new("docker")
890 .args(["inspect", "--format", "{{.State.Running}}", &container_name])
891 .stdout(Stdio::piped())
892 .stderr(Stdio::null())
893 .output()
894 .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
895 .unwrap_or(false);
896
897 if !container_running {
898 return Err(format!(
899 "Container {container_name} is not running. \
900 Start it first with `cargo xtask start-sim --container`."
901 ).into());
902 }
903
904 let shell_cmd = cmd_args.join(" ");
905 run_cmd(
906 "docker",
907 &["exec", &container_name, "bash", "-lc", &shell_cmd],
908 Some(&root),
909 )
910}
911
912fn cmd_stop_sim(sim_id: &str) -> Result<()> {
914 crate::kill_stale_sim_processes(sim_id);
915 println!("Stopped simulation '{sim_id}'");
916 Ok(())
917}
918
919fn cmd_clean_sockets(root: &Path) -> Result<()> {
921 let sockets_dir = root.join("tests/sockets");
922 if !sockets_dir.exists() {
923 create_sockets_dir(root)?;
924 println!("No socket files found in {}.", sockets_dir.display());
925 return Ok(());
926 }
927
928 let mut removed = 0usize;
929 for entry in fs::read_dir(&sockets_dir)? {
930 let entry = entry?;
931 let path = entry.path();
932 if path.extension().and_then(|e| e.to_str()) == Some("sock") {
933 fs::remove_file(&path)?;
934 println!("Removed {}", path.display());
935 removed += 1;
936 }
937 }
938
939 if removed == 0 {
940 println!("No socket files found in {}.", sockets_dir.display());
941 } else {
942 println!("Removed {removed} socket file(s).");
943 }
944 Ok(())
945}
946
947fn bsim_ld_library_path() -> String {
950 match env::var("LD_LIBRARY_PATH") {
951 Ok(existing) => format!("external/tools/bsim/lib:{existing}"),
952 Err(_) => "external/tools/bsim/lib".to_string(),
953 }
954}
955
956fn spawn_in_bsim_bin(sim_id: &str, exe: &str, args: &[&str]) -> Result<Child> {
957 let bsim_bin = Path::new("external/tools/bsim/bin");
958 Command::new(exe)
959 .args(args)
960 .current_dir(bsim_bin)
961 .stdin(Stdio::inherit())
962 .stdout(Stdio::inherit())
963 .stderr(Stdio::inherit())
964 .env("BSIM_OUT_PATH", "external/tools/bsim")
965 .env("BSIM_COMPONENTS_PATH", "external/tools/bsim/components")
966 .env("LD_LIBRARY_PATH", bsim_ld_library_path())
967 .spawn()
968 .map_err(|e| format!("Failed to spawn '{exe}' for sim '{sim_id}': {e}").into())
969}
970
971fn pkill_sim(sim_id: &str) {
972 for process in ["bs_2G4_phy_v1", "zephyr_rpc_server_app", "cgm_peripheral_sample"] {
973 let pattern = format!("{process} -s={sim_id}");
974 let _ = Command::new("pkill").args(["-f", &pattern]).status();
975 let _ = Command::new("pkill").args(["-9", "-f", &pattern]).status();
976 }
977}
978
979fn generate_sim_id() -> String {
980 use std::time::{SystemTime, UNIX_EPOCH};
981 let nanos = SystemTime::now()
982 .duration_since(UNIX_EPOCH)
983 .unwrap_or_default()
984 .subsec_nanos();
985 let pid = std::process::id();
986 format!("sim_{:08x}", nanos ^ (pid << 16))
987}
988
989fn run_bsim(nrf_rpc_server: bool, cgm_peripheral: bool) -> Result<()> {
990 let (run_nrf, run_cgm) = if !nrf_rpc_server && !cgm_peripheral {
991 (true, true)
992 } else {
993 (nrf_rpc_server, cgm_peripheral)
994 };
995
996 let sim_id = generate_sim_id();
997 pkill_sim(&sim_id);
998
999 let _ = fs::remove_dir_all(format!(
1000 "/tmp/bs_{}/{}",
1001 env::var("USER").unwrap_or_default(),
1002 &sim_id
1003 ));
1004
1005 let device_count = (run_nrf as u32) + (run_cgm as u32);
1006
1007 const SEP: &str = "────────────────────────────────────────────────────────────";
1008
1009 println!(" Starting PHY simulator...");
1010 let _phy = spawn_in_bsim_bin(
1011 &sim_id,
1012 "./bs_2G4_phy_v1",
1013 &[
1014 &format!("-s={sim_id}"),
1015 &format!("-D={device_count}"),
1016 "-sim_length=86400e6",
1017 ],
1018 )?;
1019
1020 let nrf_device_idx: u32 = 0;
1021 let cgm_device_idx: u32 = run_nrf as u32;
1022
1023 let mut nrf_proc = if run_nrf {
1024 println!(" Starting nRF RPC server (device {nrf_device_idx})...");
1025 Some(spawn_in_bsim_bin(
1026 &sim_id,
1027 "./zephyr_rpc_server_app",
1028 &[
1029 &format!("-s={sim_id}"),
1030 &format!("-d={nrf_device_idx}"),
1031 "-uart0_pty",
1032 "-uart_pty_pollT=1000",
1033 ],
1034 )?)
1035 } else {
1036 None
1037 };
1038
1039 let mut cgm_proc = if run_cgm {
1040 println!(" Starting CGM peripheral (device {cgm_device_idx})...");
1041 let cgm_log = fs::File::create("external/tools/bsim/bin/cgm_peripheral_sample.log")?;
1042 Some(
1043 Command::new("./cgm_peripheral_sample")
1044 .args([&format!("-s={sim_id}"), &format!("-d={cgm_device_idx}")])
1045 .current_dir("external/tools/bsim/bin")
1046 .stdin(Stdio::null())
1047 .stdout(cgm_log.try_clone()?)
1048 .stderr(cgm_log)
1049 .env("BSIM_OUT_PATH", "external/tools/bsim")
1050 .env("BSIM_COMPONENTS_PATH", "external/tools/bsim/components")
1051 .env("LD_LIBRARY_PATH", bsim_ld_library_path())
1052 .spawn()?,
1053 )
1054 } else {
1055 None
1056 };
1057
1058 let mut device_list = Vec::new();
1059 if run_nrf { device_list.push(format!("nrf-rpc-server [d={nrf_device_idx}]")); }
1060 if run_cgm { device_list.push(format!("cgm-peripheral [d={cgm_device_idx}]")); }
1061 let device_str = device_list.join(", ");
1062
1063 println!();
1064 println!("{SEP}");
1065 println!(" Simulation ID : {sim_id}");
1066 println!(" Devices : {device_str}");
1067 println!(" Duration : 86400 s (~24 h simulated, ~39 s real time)");
1068 println!("{SEP}");
1069
1070 if run_nrf {
1071 println!();
1072 println!(" To test RX, run in another terminal:");
1073 println!();
1074 println!(" socat UNIX-LISTEN:/tmp/nrf_rpc_server.sock,fork /dev/pts/XX,raw,echo=0");
1075 println!(" printf '\\x04\\x00\\xff\\x00\\xff\\x00\\x62\\x74\\x5f\\x72\\x70\\x63' \\");
1076 println!(" | socat - UNIX-CONNECT:/tmp/nrf_rpc_server.sock");
1077 }
1078
1079 println!();
1080 println!(" Press Ctrl+C to stop.");
1081 println!();
1082
1083 if let Some(ref mut proc) = nrf_proc {
1084 let status = proc.wait()?;
1085 if let Some(ref mut cgm) = cgm_proc {
1086 let _ = cgm.kill();
1087 }
1088 if !status.success() {
1089 return Err(format!("zephyr_rpc_server_app exited with status: {status}").into());
1090 }
1091 } else if let Some(ref mut proc) = cgm_proc {
1092 let status = proc.wait()?;
1093 if !status.success() {
1094 return Err(format!("cgm_peripheral_sample exited with status: {status}").into());
1095 }
1096 }
1097
1098 Ok(())
1099}