1use std::path::PathBuf;
5
6use clap::{CommandFactory, FromArgMatches, Parser, ValueEnum};
7
8use crate::{BraidArgs, StandaloneArgs, StandaloneOrBraid, StrandCamArgs, run_strand_cam_app};
9
10use crate::APP_INFO;
11
12use eyre::{Result, WrapErr, eyre};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
21pub enum CameraBackend {
22 #[default]
24 Pylon,
25 Vimba,
27 Webcam,
29 Sim,
33}
34
35fn list_cameras<M, C, G>(mymod: &ci2_async::ThreadedAsyncCameraModule<M, C, G>) -> Result<()>
41where
42 M: ci2::CameraModule<CameraType = C, Guard = G>,
43 C: ci2::Camera,
44 G: Send,
45{
46 use ci2::CameraModule;
47 let name = mymod.name();
50 let backend = name.strip_prefix("async-").unwrap_or(name);
51 let infos = mymod
52 .camera_infos()
53 .with_context(|| format!("enumerating cameras for the '{backend}' backend"))?;
54 if infos.is_empty() {
55 println!("No cameras found for the '{backend}' backend.");
56 return Ok(());
57 }
58 println!(
59 "Found {} camera(s) for the '{backend}' backend:",
60 infos.len()
61 );
62 println!();
63 println!("Use a camera name below with `--camera-name`, or as a `name` in a Braid config.");
64 println!();
65 for info in &infos {
66 println!(
67 " {} (model: {}, serial: {})",
68 info.name(),
69 info.model(),
70 info.serial()
71 );
72 }
73 Ok(())
74}
75
76fn init_process() {
78 std::panic::set_hook(Box::new(tracing_panic::panic_hook));
79 dotenv::dotenv().ok();
80
81 if std::env::var_os("RUST_LOG").is_none() {
82 unsafe {
84 std::env::set_var(
85 "RUST_LOG",
86 "strand_cam=info,flydra_feature_detector=info,bg_movie_writer=info,warn",
87 )
88 };
89 }
90}
91
92fn command(app_name: &str) -> clap::Command {
98 CliArgs::command().name(app_name.to_string())
99}
100
101fn parse_cli(app_name: &str) -> CliArgs {
107 let matches = command(app_name).get_matches();
108 CliArgs::from_arg_matches(&matches).unwrap_or_else(|err| err.exit())
109}
110
111pub fn cli_main_dispatch(app_name: &'static str) -> Result<()> {
120 init_process();
121
122 let cli = parse_cli(app_name);
123
124 match cli.camera_backend {
125 CameraBackend::Pylon => {
126 let module: &'static ci2_pylon::WrappedModule =
127 Box::leak(Box::new(ci2_pylon::new_module()?));
128 let guard = ci2_pylon::make_singleton_guard(&module)?;
129 let mymod = ci2_async::into_threaded_async(module, &guard);
130 cli_main(mymod, cli, app_name)?;
131 }
132 CameraBackend::Vimba => {
133 let module: &'static ci2_vimba::WrappedModule =
134 Box::leak(Box::new(ci2_vimba::new_module()?));
135 let guard = ci2_vimba::make_singleton_guard(&module)?;
136 let mymod = ci2_async::into_threaded_async(module, &guard);
137 cli_main(mymod, cli, app_name)?;
138 }
139 CameraBackend::Webcam => {
140 let module: &'static ci2_webcam::WrappedModule =
141 Box::leak(Box::new(ci2_webcam::new_module()?));
142 let guard = ci2_webcam::make_singleton_guard(&module)?;
143 let mymod = ci2_async::into_threaded_async(module, &guard);
144 cli_main(mymod, cli, app_name)?;
145 }
146 CameraBackend::Sim => {
147 let module: &'static ci2_sim::WrappedModule =
148 Box::leak(Box::new(ci2_sim::new_module()?));
149 let guard = ci2_sim::make_singleton_guard(&module)?;
150 let mymod = ci2_async::into_threaded_async(module, &guard);
151 cli_main(mymod, cli, app_name)?;
152 }
153 }
154 Ok(())
155}
156
157pub fn cli_main<M, C, G>(
162 mymod: ci2_async::ThreadedAsyncCameraModule<M, C, G>,
163 cli: CliArgs,
164 app_name: &'static str,
165) -> Result<ci2_async::ThreadedAsyncCameraModule<M, C, G>>
166where
167 M: ci2::CameraModule<CameraType = C, Guard = G> + 'static,
168 C: 'static + ci2::Camera + Send,
169 G: Send + 'static,
170{
171 if cli.list_cameras {
174 return list_cameras(&mymod).map(|()| mymod);
175 }
176
177 let args = cli
178 .into_strand_cam_args()
179 .with_context(|| "interpreting command-line arguments".to_string())?;
180
181 run_strand_cam_app(mymod, args, app_name)
182}
183
184fn get_tracker_cfg() -> Result<crate::ImPtDetectCfgSource> {
185 let ai = (&APP_INFO, "object-detection".to_string());
186 let tracker_cfg_src = crate::ImPtDetectCfgSource::ChangedSavedToDisk(ai);
187 Ok(tracker_cfg_src)
188}
189
190#[derive(Parser, Debug)]
201#[command(version, about = None, long_about = None)]
202pub struct CliArgs {
203 #[arg(long)]
205 browser: bool,
206
207 #[arg(long, conflicts_with = "browser")]
209 no_browser: bool,
210
211 #[arg(long, default_value = crate::MP4_FILENAME_TEMPLATE_DEFAULT)]
213 mp4_filename_template: String,
214
215 #[arg(long, default_value = crate::FMF_FILENAME_TEMPLATE_DEFAULT)]
217 fmf_filename_template: String,
218
219 #[arg(long, default_value = crate::UFMF_FILENAME_TEMPLATE_DEFAULT)]
221 ufmf_filename_template: String,
222
223 #[arg(long)]
225 camera_name: Option<String>,
226
227 #[arg(long, default_value_t, value_enum)]
230 camera_backend: CameraBackend,
231
232 #[arg(long)]
235 list_cameras: bool,
236
237 #[arg(long)]
239 camera_settings_filename: Option<PathBuf>,
240
241 #[arg(long)]
257 http_server_addr: Option<String>,
258
259 #[arg(long, default_value = "~/DATA")]
261 csv_save_dir: String,
262
263 #[arg(long)]
265 pixel_format: Option<String>,
266
267 #[arg(long, env = "STRAND_CAM_COOKIE_SECRET")]
269 strand_cam_cookie_secret: Option<String>,
270
271 #[arg(
275 long = "trusted-network",
276 value_name = "TRUSTED_NETWORK",
277 env = "STRAND_CAM_TRUSTED_NETWORKS",
278 value_delimiter = ','
279 )]
280 trusted_networks: Vec<String>,
281
282 #[arg(long)]
284 force_camera_sync_mode: bool,
285
286 #[arg(long)]
288 braid_url: Option<String>,
289
290 #[arg(long = "led-box")]
292 led_box_device: Option<String>,
293
294 #[cfg(feature = "flydratrax")]
296 #[arg(long)]
297 camera_xml_calibration: Option<String>,
298
299 #[cfg(feature = "flydratrax")]
301 #[arg(long)]
302 camera_pymvg_calibration: Option<String>,
303
304 #[cfg(feature = "flydratrax")]
306 #[arg(long)]
307 no_save_empty_data2d: bool,
308
309 #[cfg(feature = "flydratrax")]
311 #[arg(long, default_value = braid_types::DEFAULT_MODEL_SERVER_ADDR)]
312 model_server_addr: std::net::SocketAddr,
313
314 #[cfg(target_os = "linux")]
316 #[arg(long)]
317 v4l2loopback: Option<PathBuf>,
318
319 #[arg(long)]
321 data_dir: Option<PathBuf>,
322}
323
324impl CliArgs {
325 fn into_strand_cam_args(self) -> Result<StrandCamArgs> {
332 let standalone_or_braid = if let Some(braid_url) = self.braid_url {
333 for (flag, is_set) in [
337 ("--pixel-format", self.pixel_format.is_some()),
338 (
339 "--strand-cam-cookie-secret",
340 self.strand_cam_cookie_secret.is_some(),
341 ),
342 (
343 "--camera-settings-filename",
344 self.camera_settings_filename.is_some(),
345 ),
346 ("--http-server-addr", self.http_server_addr.is_some()),
347 ("--force-camera-sync-mode", self.force_camera_sync_mode),
348 ] {
349 if is_set {
350 eyre::bail!(
351 "{flag} cannot be set on the command line when running under Braid"
352 );
353 }
354 }
355
356 let camera_name = self
357 .camera_name
358 .ok_or_else(|| eyre!("--camera-name must be set when running under Braid"))?;
359
360 StandaloneOrBraid::Braid(BraidArgs {
361 braid_url,
362 camera_name,
363 })
364 } else {
365 let tracker_cfg_src = get_tracker_cfg()?;
366
367 #[cfg(not(feature = "flydra_feat_detect"))]
368 let _ = tracker_cfg_src; StandaloneOrBraid::Standalone(StandaloneArgs {
371 camera_name: self.camera_name,
372 pixel_format: self.pixel_format,
373 force_camera_sync_mode: self.force_camera_sync_mode,
374 software_limit_framerate: braid_types::StartSoftwareFrameRateLimit::NoChange,
375 acquisition_duration_allowed_imprecision_msec:
376 braid_types::DEFAULT_ACQUISITION_DURATION_ALLOWED_IMPRECISION_MSEC,
377 camera_settings_filename: self.camera_settings_filename,
378 #[cfg(feature = "flydra_feat_detect")]
379 tracker_cfg_src,
380 http_server_addr: self.http_server_addr,
381 })
382 };
383
384 let no_browser = if self.no_browser {
387 true
388 } else if self.browser {
389 false
390 } else {
391 matches!(standalone_or_braid, StandaloneOrBraid::Braid(_))
392 };
393
394 let csv_save_dir = shellexpand::full(&self.csv_save_dir)
395 .map_err(|e| eyre!("{e}"))?
396 .into_owned();
397
398 #[cfg(feature = "flydratrax")]
399 let flydratrax_calibration_source =
400 match (self.camera_xml_calibration, self.camera_pymvg_calibration) {
401 (None, None) => crate::CalSource::PseudoCal,
402 (Some(xml), None) => crate::CalSource::XmlFile(PathBuf::from(xml)),
403 (None, Some(json)) => crate::CalSource::PymvgJsonFile(PathBuf::from(json)),
404 (Some(_), Some(_)) => {
405 eyre::bail!("Can only specify xml or pymvg calibration, not both.");
406 }
407 };
408
409 #[cfg(feature = "fiducial")]
410 let apriltag_csv_filename_template =
411 strand_cam_storetype::APRILTAG_CSV_TEMPLATE_DEFAULT.to_string();
412
413 Ok(StrandCamArgs {
414 standalone_or_braid,
415 secret: self.strand_cam_cookie_secret,
416 trusted_networks: self.trusted_networks,
417 no_browser,
418 mp4_filename_template: self.mp4_filename_template,
419 fmf_filename_template: self.fmf_filename_template,
420 ufmf_filename_template: self.ufmf_filename_template,
421 csv_save_dir,
422 led_box_device_path: self.led_box_device,
423 #[cfg(feature = "flydratrax")]
424 flydratrax_calibration_source,
425 #[cfg(feature = "flydratrax")]
426 save_empty_data2d: !self.no_save_empty_data2d,
427 #[cfg(feature = "flydratrax")]
428 model_server_addr: self.model_server_addr,
429 #[cfg(feature = "fiducial")]
430 apriltag_csv_filename_template,
431 #[cfg(target_os = "linux")]
432 v4l2loopback: self.v4l2loopback,
433 data_dir: self.data_dir,
434 ..Default::default()
435 })
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
448
449 fn parse_cli_args(args: &[&str]) -> Result<CliArgs> {
451 let argv: Vec<String> = std::iter::once("strand-cam")
452 .chain(args.iter().copied())
453 .map(String::from)
454 .collect();
455 let matches = command("strand-cam").try_get_matches_from(argv)?;
456 Ok(CliArgs::from_arg_matches(&matches)?)
457 }
458
459 fn parse(args: &[&str]) -> Result<StrandCamArgs> {
461 parse_cli_args(args)?.into_strand_cam_args()
462 }
463
464 fn parse_standalone(args: &[&str]) -> StandaloneArgs {
466 match parse(args).unwrap().standalone_or_braid {
467 StandaloneOrBraid::Standalone(s) => s,
468 StandaloneOrBraid::Braid(_) => panic!("expected standalone, got braid"),
469 }
470 }
471
472 fn parse_braid(args: &[&str]) -> BraidArgs {
474 match parse(args).unwrap().standalone_or_braid {
475 StandaloneOrBraid::Braid(b) => b,
476 StandaloneOrBraid::Standalone(_) => panic!("expected braid, got standalone"),
477 }
478 }
479
480 #[test]
481 fn defaults_are_standalone() {
482 let args = parse(&[]).unwrap();
483 assert!(matches!(
484 args.standalone_or_braid,
485 StandaloneOrBraid::Standalone(_)
486 ));
487 assert!(!args.no_browser);
489 assert_eq!(
490 args.mp4_filename_template,
491 "movie%Y%m%d_%H%M%S.%f_{CAMNAME}.mp4"
492 );
493 assert_eq!(
494 args.fmf_filename_template,
495 "movie%Y%m%d_%H%M%S.%f_{CAMNAME}.fmf"
496 );
497 assert_eq!(
498 args.ufmf_filename_template,
499 "movie%Y%m%d_%H%M%S.%f_{CAMNAME}.ufmf"
500 );
501 assert!(!args.csv_save_dir.contains('~'));
503 assert!(args.csv_save_dir.ends_with("DATA"));
504 assert!(args.led_box_device_path.is_none());
505 assert!(args.data_dir.is_none());
506 }
507
508 #[test]
509 fn camera_backend_defaults_to_pylon() {
510 assert_eq!(
511 parse_cli_args(&[]).unwrap().camera_backend,
512 CameraBackend::Pylon
513 );
514 }
515
516 #[test]
517 fn camera_backend_parses_known_values() {
518 for (text, expected) in [
519 ("pylon", CameraBackend::Pylon),
520 ("vimba", CameraBackend::Vimba),
521 ("webcam", CameraBackend::Webcam),
522 ("sim", CameraBackend::Sim),
523 ] {
524 assert_eq!(
525 parse_cli_args(&["--camera-backend", text])
526 .unwrap()
527 .camera_backend,
528 expected
529 );
530 }
531 }
532
533 #[test]
534 fn camera_backend_rejects_unknown_value() {
535 assert!(parse_cli_args(&["--camera-backend", "nonsense"]).is_err());
536 }
537
538 #[test]
539 fn list_cameras_flag() {
540 assert!(parse_cli_args(&["--list-cameras"]).unwrap().list_cameras);
541 assert!(!parse_cli_args(&[]).unwrap().list_cameras);
542 }
543
544 #[test]
545 fn camera_name_standalone() {
546 let s = parse_standalone(&["--camera-name", "Basler-1234"]);
547 assert_eq!(s.camera_name.as_deref(), Some("Basler-1234"));
548 }
549
550 #[test]
551 fn browser_flag_forces_browser() {
552 let args = parse(&["--browser"]).unwrap();
553 assert!(!args.no_browser);
554 }
555
556 #[test]
557 fn no_browser_flag_prevents_browser() {
558 let args = parse(&["--no-browser"]).unwrap();
559 assert!(args.no_browser);
560 }
561
562 #[test]
563 fn browser_and_no_browser_conflict() {
564 assert!(parse(&["--browser", "--no-browser"]).is_err());
565 }
566
567 #[test]
568 fn filename_templates_override() {
569 let args = parse(&[
570 "--mp4-filename-template",
571 "a_{CAMNAME}.mp4",
572 "--fmf-filename-template",
573 "b_{CAMNAME}.fmf",
574 "--ufmf-filename-template",
575 "c_{CAMNAME}.ufmf",
576 ])
577 .unwrap();
578 assert_eq!(args.mp4_filename_template, "a_{CAMNAME}.mp4");
579 assert_eq!(args.fmf_filename_template, "b_{CAMNAME}.fmf");
580 assert_eq!(args.ufmf_filename_template, "c_{CAMNAME}.ufmf");
581 }
582
583 #[test]
584 fn pixel_format_standalone() {
585 let s = parse_standalone(&["--pixel-format", "Mono8"]);
586 assert_eq!(s.pixel_format.as_deref(), Some("Mono8"));
587 }
588
589 #[test]
590 fn force_camera_sync_mode_standalone() {
591 let s = parse_standalone(&["--force-camera-sync-mode"]);
592 assert!(s.force_camera_sync_mode);
593 let s = parse_standalone(&[]);
595 assert!(!s.force_camera_sync_mode);
596 }
597
598 #[test]
599 fn http_server_addr_standalone() {
600 let s = parse_standalone(&["--http-server-addr", "127.0.0.1:8080"]);
601 assert_eq!(s.http_server_addr.as_deref(), Some("127.0.0.1:8080"));
602 }
603
604 #[test]
605 fn camera_settings_filename_standalone() {
606 let s = parse_standalone(&["--camera-settings-filename", "/etc/cam.pfs"]);
607 assert_eq!(
608 s.camera_settings_filename,
609 Some(PathBuf::from("/etc/cam.pfs"))
610 );
611 }
612
613 #[test]
614 fn csv_save_dir_override() {
615 let args = parse(&["--csv-save-dir", "/tmp/strand-data"]).unwrap();
616 assert_eq!(args.csv_save_dir, "/tmp/strand-data");
617 }
618
619 #[test]
620 fn led_box_device() {
621 let args = parse(&["--led-box", "/dev/ttyUSB0"]).unwrap();
622 assert_eq!(args.led_box_device_path.as_deref(), Some("/dev/ttyUSB0"));
623 }
624
625 #[test]
626 fn cookie_secret_from_cli() {
627 let args = parse(&["--strand-cam-cookie-secret", "abc123"]).unwrap();
628 assert_eq!(args.secret.as_deref(), Some("abc123"));
629 }
630
631 #[test]
632 fn trusted_networks_split_and_appended() {
633 let args = parse(&[
635 "--trusted-network",
636 "100.64.0.0/10,10.0.0.0/8",
637 "--trusted-network",
638 "192.168.0.0/16",
639 ])
640 .unwrap();
641 assert_eq!(
642 args.trusted_networks,
643 vec![
644 "100.64.0.0/10".to_string(),
645 "10.0.0.0/8".to_string(),
646 "192.168.0.0/16".to_string(),
647 ]
648 );
649 }
650
651 #[cfg(target_os = "linux")]
652 #[test]
653 fn data_dir_and_v4l2loopback() {
654 let args = parse(&[
655 "--data-dir",
656 "/var/strand",
657 "--v4l2loopback",
658 "/dev/video10",
659 ])
660 .unwrap();
661 assert_eq!(args.data_dir, Some(PathBuf::from("/var/strand")));
662 assert_eq!(args.v4l2loopback, Some(PathBuf::from("/dev/video10")));
663 }
664
665 #[test]
666 fn braid_url_selects_braid_mode() {
667 let b = parse_braid(&[
668 "--braid-url",
669 "http://127.0.0.1:1234/",
670 "--camera-name",
671 "Basler-1",
672 ]);
673 assert_eq!(b.braid_url, "http://127.0.0.1:1234/");
674 assert_eq!(b.camera_name, "Basler-1");
675 }
676
677 #[test]
678 fn braid_defaults_to_no_browser() {
679 let args = parse(&[
680 "--braid-url",
681 "http://127.0.0.1:1234/",
682 "--camera-name",
683 "Basler-1",
684 ])
685 .unwrap();
686 assert!(args.no_browser);
687 }
688
689 #[test]
690 fn braid_requires_camera_name() {
691 assert!(parse(&["--braid-url", "http://127.0.0.1:1234/"]).is_err());
692 }
693
694 #[test]
695 fn braid_conflicts_with_pixel_format() {
696 let err = parse(&[
697 "--braid-url",
698 "http://127.0.0.1:1234/",
699 "--camera-name",
700 "Basler-1",
701 "--pixel-format",
702 "Mono8",
703 ])
704 .unwrap_err();
705 assert!(err.to_string().contains("--pixel-format"));
706 }
707
708 #[test]
709 fn braid_conflicts_with_http_server_addr() {
710 assert!(
711 parse(&[
712 "--braid-url",
713 "http://127.0.0.1:1234/",
714 "--camera-name",
715 "Basler-1",
716 "--http-server-addr",
717 "127.0.0.1:8080",
718 ])
719 .is_err()
720 );
721 }
722
723 #[test]
724 fn braid_conflicts_with_force_camera_sync_mode() {
725 assert!(
726 parse(&[
727 "--braid-url",
728 "http://127.0.0.1:1234/",
729 "--camera-name",
730 "Basler-1",
731 "--force-camera-sync-mode",
732 ])
733 .is_err()
734 );
735 }
736
737 #[test]
738 fn unknown_argument_is_an_error() {
739 assert!(parse(&["--this-does-not-exist"]).is_err());
740 }
741
742 #[test]
743 fn help_omits_struct_docstring() {
744 let short = command("strand-cam").render_help().to_string();
747 let long = command("strand-cam").render_long_help().to_string();
748 for help in [&short, &long] {
749 assert!(
750 !help.contains("flat, one-field-per-argument"),
751 "help text leaked the struct docstring:\n{help}"
752 );
753 }
754 assert!(short.contains("--no-browser"));
756 }
757}