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