1use std::process::ExitCode;
10use std::time::Duration;
11
12use clap::{Parser, Subcommand};
13use serde::Serialize;
14
15use crate::camera::{CameraClient, CameraError};
16use crate::client::{
17 ClientError, CommandOutcome, LanMqttClient, StatusSource, VerifyStage, WatchStep,
18};
19use crate::config::{self, Config, ConfigError, Overrides, Profile, ResolvedTarget};
20use crate::core::capability::{self, ControlAssessment, ControlRefusal};
21use crate::core::command::{
22 AmsControl, AmsFilamentSetting, Command as ProtoCommand, LedNode, SpeedLevel, TimelapseControl,
23};
24use crate::core::park::ParkTuning;
25use crate::core::project::{self, PlateInspection};
26use crate::core::report::ReportState;
27use crate::core::safety::{self, GcodeVerdict, TempLimits};
28use crate::core::stage::Stage;
29use crate::core::start::{self, PrintStartParams};
30use crate::core::status::{GcodeState, PrinterStatus};
31use crate::core::timelapse::{ActivityAction, CaptureAction, CaptureSession, PrintActivitySession};
32use crate::core::version::Module;
33use crate::ftp::{FtpError, FtpsClient};
34use crate::park::{DECODE_H, DECODE_W, ParkCapture, ParkEvent, run_park_camera};
35
36mod exit {
38 pub const GENERAL: u8 = 1;
39 pub const VALIDATION: u8 = 3;
40 pub const CONFIRM_REQUIRED: u8 = 4;
41 pub const PRINTER_BUSY: u8 = 5;
42 pub const VERIFY_TIMEOUT: u8 = 6;
43 pub const TRANSPORT: u8 = 7;
44 pub const DEVICE_REJECTED: u8 = 8;
45}
46
47#[derive(Parser)]
48#[command(
49 name = "bambu",
50 version,
51 about = "Monitor and drive Bambu Lab printers over the LAN"
52)]
53struct Cli {
54 #[arg(long, global = true)]
56 printer: Option<String>,
57 #[arg(long, global = true)]
59 ip: Option<String>,
60 #[arg(long, global = true)]
62 serial: Option<String>,
63 #[arg(long, global = true)]
65 access_code: Option<String>,
66 #[arg(long, global = true)]
68 model: Option<String>,
69 #[arg(long, global = true)]
71 json: bool,
72 #[cfg(feature = "server")]
78 #[arg(long, global = true, env = "BAMBU_SERVE_URL", value_name = "URL")]
79 via_serve: Option<String>,
80 #[command(subcommand)]
81 command: Command,
82}
83
84#[derive(Subcommand)]
85enum Command {
86 Config {
88 #[command(subcommand)]
89 action: ConfigAction,
90 },
91 Status {
93 #[arg(long)]
97 watch: bool,
98 #[arg(long)]
101 interval: Option<u64>,
102 #[arg(long, default_value_t = 120)]
105 timeout: u64,
106 },
107 Info,
109 Hms,
111 Job {
113 #[command(subcommand)]
114 action: JobAction,
115 },
116 File {
118 #[command(subcommand)]
119 action: FileAction,
120 },
121 Camera {
123 #[command(subcommand)]
124 action: CameraAction,
125 },
126 Timelapse {
129 #[command(subcommand)]
130 action: TimelapseAction,
131 },
132 Light {
134 #[arg(value_parser = ["on", "off"])]
136 state: String,
137 #[arg(long, default_value = "chamber", value_parser = ["chamber", "work"])]
140 node: String,
141 #[arg(long, default_value_t = 8)]
143 timeout: u64,
144 },
145 Speed {
147 #[arg(value_parser = ["silent", "standard", "sport", "ludicrous"])]
149 level: String,
150 #[arg(long, default_value_t = 8)]
152 timeout: u64,
153 },
154 Ams {
157 #[command(subcommand)]
158 action: AmsAction,
159 },
160 Calibrate {
163 #[command(flatten)]
164 args: CalibrateArgs,
165 },
166 Gcode {
168 line: String,
170 #[arg(long)]
172 confirm: bool,
173 #[arg(long)]
175 force: bool,
176 #[arg(long, default_value_t = 30)]
178 timeout: u64,
179 },
180 Reboot {
183 #[arg(long)]
185 confirm: bool,
186 },
187 #[cfg(feature = "server")]
190 #[command(alias = "dashboard")]
191 Serve {
192 #[arg(long, default_value = "127.0.0.1")]
195 host: String,
196 #[arg(long, default_value_t = 8088)]
198 port: u16,
199 #[arg(long, env = "BAMBU_SERVE_PASSWORD")]
202 password: Option<String>,
203 #[arg(long)]
205 fake: bool,
206 #[arg(long)]
208 interval: Option<u64>,
209 #[arg(long, env = "BAMBU_CAMERA_URL", value_delimiter = ',')]
215 camera_url: Vec<String>,
216 #[arg(long, value_name = "PATH")]
222 cameras_config: Option<std::path::PathBuf>,
223 },
224}
225
226#[derive(Subcommand)]
227enum ConfigAction {
228 Add {
230 #[arg(long)]
231 ip: String,
232 #[arg(long)]
233 serial: String,
234 #[arg(long)]
235 access_code: String,
236 #[arg(long)]
237 model: String,
238 #[arg(long)]
240 set_default: bool,
241 },
242 List,
244 Show,
246}
247
248#[derive(Subcommand)]
249enum JobAction {
250 Start {
253 file: String,
256 #[arg(long)]
259 upload: bool,
260 #[arg(long)]
262 dest: Option<String>,
263 #[arg(long)]
266 overwrite: bool,
267 #[arg(long, default_value_t = 1)]
269 plate: u32,
270 #[arg(long)]
273 ams_map: Option<String>,
274 #[arg(long, default_value = "auto")]
276 bed_type: String,
277 #[arg(long)]
280 timelapse: bool,
281 #[arg(long)]
283 dry_run: bool,
284 #[arg(long)]
286 confirm: bool,
287 #[arg(long)]
290 expect_md5: Option<String>,
291 #[arg(long)]
293 expect_plate: Option<u32>,
294 #[arg(long)]
297 watch: bool,
298 #[arg(long, default_value_t = 21600)]
300 watch_timeout: u64,
301 #[arg(long)]
304 interval: Option<u64>,
305 },
306 Pause {
308 #[arg(long)]
309 confirm: bool,
310 },
311 Resume {
313 #[arg(long)]
314 confirm: bool,
315 },
316 Stop {
318 #[arg(long)]
319 confirm: bool,
320 },
321 ClearError {
326 #[arg(long)]
327 confirm: bool,
328 },
329}
330
331#[derive(clap::Args)]
334struct CalibrateArgs {
335 #[arg(long)]
337 bed_level: bool,
338 #[arg(long)]
340 vibration: bool,
341 #[arg(long)]
343 motor_noise: bool,
344 #[arg(long)]
346 dry_run: bool,
347 #[arg(long)]
349 confirm: bool,
350 #[arg(long)]
352 watch: bool,
353 #[arg(long, default_value_t = 3600)]
355 watch_timeout: u64,
356 #[arg(long)]
359 interval: Option<u64>,
360}
361
362#[derive(Subcommand)]
363enum TimelapseAction {
364 Enable {
366 #[arg(long, default_value_t = 8)]
368 timeout: u64,
369 },
370 Disable {
372 #[arg(long, default_value_t = 8)]
373 timeout: u64,
374 },
375 List,
377 Get {
379 name: String,
381 #[arg(long)]
383 out: Option<std::path::PathBuf>,
384 },
385 Capture {
394 #[arg(long, default_value = "./timelapse")]
396 out_dir: std::path::PathBuf,
397 #[arg(long, default_value_t = 1)]
399 every: u64,
400 #[arg(long, default_value = "jpg")]
402 ext: String,
403 #[arg(long)]
406 interval: Option<u64>,
407 #[arg(long, default_value_t = 21600)]
409 timeout: u64,
410 #[arg(long)]
415 wait: bool,
416 #[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 1.., value_name = "CMD")]
419 on_layer_cmd: Vec<String>,
420 },
421 Park {
430 stream_url: String,
432 #[arg(long)]
436 config: std::path::PathBuf,
437 #[arg(long, default_value = "./park")]
439 out: std::path::PathBuf,
440 #[arg(long)]
442 assemble: Option<std::path::PathBuf>,
443 #[arg(long, default_value_t = 12)]
445 out_fps: u32,
446 #[arg(long, value_name = "SERVE_URL")]
450 serve: Option<String>,
451 #[arg(long, conflicts_with = "serve")]
455 watch_printer: bool,
456 #[arg(long, default_value_t = DECODE_W as u32)]
458 width: u32,
459 #[arg(long, default_value_t = DECODE_H as u32)]
461 height: u32,
462 #[arg(long)]
464 max_seconds: Option<u64>,
465 },
466 Encode {
471 input: std::path::PathBuf,
473 #[arg(long)]
475 out: Option<std::path::PathBuf>,
476 #[arg(long, default_value_t = 30)]
478 fps: u32,
479 #[arg(long, default_value_t = 1)]
481 speed: u32,
482 },
483}
484
485#[derive(Subcommand)]
486enum AmsAction {
487 Resume {
489 #[arg(long)]
490 confirm: bool,
491 },
492 Reset {
494 #[arg(long)]
495 confirm: bool,
496 },
497 Pause {
499 #[arg(long)]
500 confirm: bool,
501 },
502 Change {
504 #[arg(long)]
506 tray: u32,
507 #[arg(long)]
509 tar_temp: i64,
510 #[arg(long)]
512 curr_temp: Option<i64>,
513 #[arg(long)]
514 dry_run: bool,
515 #[arg(long)]
516 confirm: bool,
517 },
518 SetFilament {
520 #[arg(long, default_value_t = 0)]
521 ams: u32,
522 #[arg(long)]
523 tray: u32,
524 #[arg(long = "type")]
526 material: String,
527 #[arg(long, default_value = "000000FF")]
529 color: String,
530 #[arg(long)]
532 min: i64,
533 #[arg(long)]
534 max: i64,
535 #[arg(long, default_value = "")]
537 info_idx: String,
538 #[arg(long)]
539 dry_run: bool,
540 #[arg(long)]
541 confirm: bool,
542 },
543 Settings {
545 #[arg(long, default_value_t = 0)]
546 ams: u32,
547 #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
549 startup_read: bool,
550 #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
552 tray_read: bool,
553 #[arg(long)]
554 confirm: bool,
555 },
556}
557
558#[derive(Subcommand)]
559enum CameraAction {
560 Snapshot {
562 #[arg(long, default_value = "snapshot.jpg")]
564 out: std::path::PathBuf,
565 #[arg(long, default_value_t = 10)]
567 timeout: u64,
568 },
569}
570
571#[derive(Subcommand)]
572enum FileAction {
573 Ls {
575 #[arg(default_value = "/")]
576 dir: String,
577 },
578 Upload {
580 local: std::path::PathBuf,
582 #[arg(long, default_value = "/")]
585 dest: String,
586 },
587 Download {
589 remote: String,
591 #[arg(long)]
593 out: Option<std::path::PathBuf>,
594 },
595 Rm {
597 remote: String,
599 #[arg(long)]
600 confirm: bool,
601 },
602}
603
604#[derive(Debug)]
606struct CliError {
607 code: u8,
608 message: String,
609}
610
611impl CliError {
612 fn new(code: u8, message: impl Into<String>) -> Self {
613 Self {
614 code,
615 message: message.into(),
616 }
617 }
618}
619
620impl From<ConfigError> for CliError {
621 fn from(e: ConfigError) -> Self {
622 let code = match e {
623 ConfigError::MissingField(_) | ConfigError::UnknownProfile(_) => exit::VALIDATION,
624 _ => exit::GENERAL,
625 };
626 CliError::new(code, e.to_string())
627 }
628}
629
630impl From<ClientError> for CliError {
631 fn from(e: ClientError) -> Self {
632 let code = match e {
633 ClientError::Timeout(_) => exit::VERIFY_TIMEOUT,
634 _ => exit::TRANSPORT,
635 };
636 CliError::new(code, e.to_string())
637 }
638}
639
640impl From<FtpError> for CliError {
641 fn from(e: FtpError) -> Self {
642 CliError::new(exit::TRANSPORT, e.to_string())
643 }
644}
645
646impl From<CameraError> for CliError {
647 fn from(e: CameraError) -> Self {
648 CliError::new(exit::TRANSPORT, e.to_string())
649 }
650}
651
652pub fn run() -> ExitCode {
654 config::load_dotenv();
657 #[cfg(feature = "license-notice")]
660 let cli = {
661 use notalawyer_clap::{ParseExt, include_notice};
662 Cli::parse_with_license_notice(include_notice!())
663 };
664 #[cfg(not(feature = "license-notice"))]
665 let cli = Cli::parse();
666 match dispatch(&cli) {
667 Ok(()) => ExitCode::SUCCESS,
668 Err(e) => {
669 eprintln!("error: {}", e.message);
670 ExitCode::from(e.code)
671 }
672 }
673}
674
675fn dispatch(cli: &Cli) -> Result<(), CliError> {
676 match &cli.command {
677 Command::Config { action } => run_config(cli, action),
678 Command::Status {
679 watch,
680 interval,
681 timeout,
682 } => run_status(cli, *watch, *interval, *timeout),
683 Command::Info => run_info(cli),
684 Command::Hms => run_hms(cli),
685 Command::Job { action } => run_job(cli, action),
686 Command::File { action } => run_file(cli, action),
687 Command::Camera { action } => run_camera(cli, action),
688 Command::Timelapse { action } => run_timelapse(cli, action),
689 Command::Ams { action } => run_ams(cli, action),
690 Command::Light {
691 state,
692 node,
693 timeout,
694 } => run_light(cli, state == "on", node, *timeout),
695 Command::Speed { level, timeout } => run_speed(cli, level, *timeout),
696 Command::Calibrate { args } => run_calibrate(cli, args),
697 Command::Gcode {
698 line,
699 confirm,
700 force,
701 timeout,
702 } => run_gcode(cli, line, *confirm, *force, *timeout),
703 Command::Reboot { confirm } => run_reboot(cli, *confirm),
704 #[cfg(feature = "server")]
705 Command::Serve {
706 host,
707 port,
708 password,
709 fake,
710 interval,
711 camera_url,
712 cameras_config,
713 } => run_serve(
714 cli,
715 host,
716 *port,
717 password.clone(),
718 *fake,
719 *interval,
720 camera_url.clone(),
721 cameras_config.clone(),
722 ),
723 }
724}
725
726fn config_path() -> Result<std::path::PathBuf, CliError> {
727 config::default_config_path()
728 .ok_or_else(|| CliError::new(exit::GENERAL, "cannot determine config path (no HOME)"))
729}
730
731fn run_config(cli: &Cli, action: &ConfigAction) -> Result<(), CliError> {
732 let path = config_path()?;
733 let mut cfg = Config::load_or_default(&path)?;
734 match action {
735 ConfigAction::Add {
736 ip,
737 serial,
738 access_code,
739 model,
740 set_default,
741 } => {
742 let name = cli.printer.clone().ok_or_else(|| {
743 CliError::new(exit::VALIDATION, "config add needs --printer <name>")
744 })?;
745 let profile = Profile {
746 ip: ip.clone(),
747 serial: serial.clone(),
748 model: model.clone(),
749 mode: "lan".to_string(),
750 access_code: access_code.clone(),
751 };
752 cfg.printers.insert(name.clone(), profile);
753 if *set_default || cfg.default_printer.is_none() {
754 cfg.default_printer = Some(name.clone());
755 }
756 cfg.save(&path)?;
757 eprintln!("saved profile '{name}' to {}", path.display());
758 Ok(())
759 }
760 ConfigAction::List => {
761 if want_json(cli) {
762 let names: Vec<&String> = cfg.printers.keys().collect();
763 print_json(&serde_json::json!({
764 "default": cfg.default_printer,
765 "printers": names,
766 }));
767 } else if cfg.printers.is_empty() {
768 eprintln!("no profiles configured");
769 } else {
770 for name in cfg.printers.keys() {
771 let marker = if cfg.default_printer.as_deref() == Some(name) {
772 " (default)"
773 } else {
774 ""
775 };
776 println!("{name}{marker}");
777 }
778 }
779 Ok(())
780 }
781 ConfigAction::Show => {
782 let name = selected_profile_name(cli, &cfg)?.ok_or_else(|| {
783 CliError::new(
784 exit::VALIDATION,
785 "no printer selected: pass --printer or set a default",
786 )
787 })?;
788 let profile = cfg
789 .profile(&name)
790 .ok_or_else(|| CliError::from(ConfigError::UnknownProfile(name.clone())))?;
791 let view = RedactedProfile::from(&name, profile);
792 if want_json(cli) {
793 print_json(&view);
794 } else {
795 println!("{view}");
796 }
797 Ok(())
798 }
799 }
800}
801
802fn run_status(
803 cli: &Cli,
804 watch: bool,
805 interval_secs: Option<u64>,
806 timeout_secs: u64,
807) -> Result<(), CliError> {
808 #[cfg(feature = "server")]
812 if let Some(base) = cli.via_serve.clone() {
813 return run_status_via_serve(cli, &base, watch, interval_secs);
814 }
815 let cfg = Config::load_or_default(&config_path()?)?;
816 let profile_name = selected_profile_name(cli, &cfg)?;
817 let profile = profile_name.as_deref().and_then(|n| cfg.profile(n));
818 let overrides = flag_overrides(cli).over(Overrides::from_env());
819 let target = config::resolve(profile, &overrides)?;
820 let model = target.model.to_string();
821
822 if watch {
823 let client = LanMqttClient::new(target).with_timeout(Duration::from_secs(timeout_secs));
826 let interval = interval_secs.map(Duration::from_secs);
827 return watch_to_terminal(&client, cli, model, profile_name, false, interval, true);
828 }
829
830 let state = LanMqttClient::new(target).fetch_snapshot()?;
831 let status = PrinterStatus::from_state(state.get());
832 let output = StatusOutput {
833 printer: profile_name,
834 model,
835 status,
836 };
837 if want_json(cli) {
838 print_json(&output);
839 } else {
840 print_status_human(&output);
841 }
842 Ok(())
843}
844
845#[cfg(feature = "server")]
851fn run_status_via_serve(
852 cli: &Cli,
853 base: &str,
854 watch: bool,
855 interval_secs: Option<u64>,
856) -> Result<(), CliError> {
857 if watch {
858 return watch_via_serve(cli, base, interval_secs);
859 }
860 let (printer, model) = serve_display_identity(cli);
861 let status = fetch_serve_status(base)?;
862 let output = StatusOutput {
863 printer,
864 model,
865 status,
866 };
867 if want_json(cli) {
868 print_json(&output);
869 } else {
870 print_status_human(&output);
871 }
872 Ok(())
873}
874
875#[cfg(feature = "server")]
881fn watch_via_serve(cli: &Cli, base: &str, interval_secs: Option<u64>) -> Result<(), CliError> {
882 let interval = Duration::from_secs(interval_secs.unwrap_or(2).max(1));
883 let mut last: Option<WatchKey> = None;
884 loop {
885 let status = fetch_serve_status(base)?;
886 emit_watch_change(&status, &mut last, cli, true);
887 std::thread::sleep(interval);
888 }
889}
890
891#[cfg(feature = "server")]
894fn serve_status_url(base: &str) -> String {
895 format!("{}/api/status", base.trim_end_matches('/'))
896}
897
898#[cfg(feature = "server")]
902fn fetch_serve_status(base: &str) -> Result<PrinterStatus, CliError> {
903 let url = serve_status_url(base);
904 let resp = ureq::AgentBuilder::new()
905 .timeout(Duration::from_secs(5))
906 .build()
907 .get(&url)
908 .call()
909 .map_err(|e| {
910 CliError::new(
911 exit::TRANSPORT,
912 format!("couldn't reach serve at {url}: {e}"),
913 )
914 })?;
915 let body = resp.into_string().map_err(|e| {
918 CliError::new(
919 exit::TRANSPORT,
920 format!("couldn't read response from {url}: {e}"),
921 )
922 })?;
923 serde_json::from_str::<PrinterStatus>(&body).map_err(|e| {
924 CliError::new(
925 exit::TRANSPORT,
926 format!("unexpected status response from {url}: {e}"),
927 )
928 })
929}
930
931#[cfg(feature = "server")]
938fn serve_display_identity(cli: &Cli) -> (Option<String>, String) {
939 let cfg = config_path()
940 .ok()
941 .map(|p| Config::load_or_default(&p))
942 .and_then(Result::ok);
943 let printer = cli
946 .printer
947 .clone()
948 .or_else(|| cfg.as_ref().and_then(|c| c.default_printer.clone()));
949 let profile = printer
950 .as_deref()
951 .zip(cfg.as_ref())
952 .and_then(|(n, c)| c.profile(n));
953 let overrides = flag_overrides(cli).over(Overrides::from_env());
954 let model = overrides
955 .model
956 .or_else(|| profile.map(|p| p.model.clone()))
957 .filter(|s| !s.is_empty())
958 .unwrap_or_else(|| "unknown".to_string());
959 (printer, model)
960}
961
962#[derive(Serialize)]
964struct HmsView {
965 code: String,
966 code_hyphen: String,
967 severity: u16,
968 is_lidar: bool,
969 wiki: String,
970}
971
972fn run_hms(cli: &Cli) -> Result<(), CliError> {
973 let state = connect_client(cli, 10)?.fetch_snapshot()?;
974 let entries = crate::core::hms::decode_report_hms(state.get());
975 let views: Vec<HmsView> = entries
976 .iter()
977 .map(|e| HmsView {
978 code: e.code_string(),
979 code_hyphen: e.code_hyphen(),
980 severity: e.severity_raw(),
981 is_lidar: e.is_lidar(),
982 wiki: e.wiki_url(),
983 })
984 .collect();
985
986 if want_json(cli) {
987 print_json(&views);
988 } else if views.is_empty() {
989 println!("no active HMS alerts");
990 } else {
991 for v in &views {
992 println!("{} (severity {}) {}", v.code, v.severity, v.wiki);
993 }
994 }
995 Ok(())
996}
997
998#[derive(Serialize)]
1000struct ControlView {
1001 status: &'static str,
1003 expected_ok: bool,
1005 reason: Option<String>,
1007}
1008
1009impl ControlView {
1010 fn from(assessment: ControlAssessment) -> Self {
1011 let refusal = |r: ControlRefusal| match r {
1012 ControlRefusal::UnknownModel => "model not in the capability registry",
1013 ControlRefusal::FirmwareNewerThanKnown => "firmware newer than the registry knows",
1014 ControlRefusal::DeveloperModeUnavailable => {
1015 "Developer Mode unavailable on this firmware"
1016 }
1017 ControlRefusal::UnknownControlBoundary => {
1018 "no confirmed control boundary for this model"
1019 }
1020 };
1021 match assessment {
1022 ControlAssessment::Allowed => ControlView {
1023 status: "allowed",
1024 expected_ok: true,
1025 reason: None,
1026 },
1027 ControlAssessment::RequiresDeveloperMode => ControlView {
1028 status: "requires_developer_mode",
1029 expected_ok: true,
1030 reason: Some("control needs LAN-only + Developer Mode enabled".into()),
1031 },
1032 ControlAssessment::NewerFirmwareUntested => ControlView {
1033 status: "newer_firmware_untested",
1034 expected_ok: true,
1035 reason: Some(
1036 "firmware is newer than the tested range; control is very likely fine but \
1037 unverified against this version"
1038 .into(),
1039 ),
1040 },
1041 ControlAssessment::Refused(r) => ControlView {
1042 status: "refused",
1043 expected_ok: false,
1044 reason: Some(refusal(r).into()),
1045 },
1046 }
1047 }
1048}
1049
1050#[derive(Serialize)]
1052struct InfoOutput {
1053 printer: Option<String>,
1054 model: String,
1055 firmware: Option<String>,
1056 registry_status: &'static str,
1057 push_mode: Option<&'static str>,
1058 camera_transport: Option<&'static str>,
1059 developer_mode: Option<&'static str>,
1060 control: ControlView,
1061 modules: Vec<Module>,
1062}
1063
1064fn run_info(cli: &Cli) -> Result<(), CliError> {
1065 let cfg = Config::load_or_default(&config_path()?)?;
1066 let profile_name = selected_profile_name(cli, &cfg)?;
1067 let profile = profile_name.as_deref().and_then(|n| cfg.profile(n));
1068 let overrides = flag_overrides(cli).over(Overrides::from_env());
1069 let target = config::resolve(profile, &overrides)?;
1070 let model = target.model.clone();
1071
1072 let version = connect_client(cli, 10)?.fetch_version()?;
1073
1074 let registry = capability::default_registry();
1078 let output = match &version.firmware {
1079 Some(fw) => {
1080 let caps = capability::resolve(®istry, &model, fw);
1081 InfoOutput {
1082 printer: profile_name,
1083 model: model.to_string(),
1084 firmware: Some(fw.to_string()),
1085 registry_status: registry_status_str(caps.registry_status),
1086 push_mode: caps.push_mode.map(push_mode_str),
1087 camera_transport: caps.camera_transport.map(camera_transport_str),
1088 developer_mode: caps.developer_mode.map(developer_mode_str),
1089 control: ControlView::from(caps.control_assessment()),
1090 modules: version.modules.clone(),
1091 }
1092 }
1093 None => InfoOutput {
1094 printer: profile_name,
1095 model: model.to_string(),
1096 firmware: None,
1097 registry_status: "unknown_firmware",
1098 push_mode: None,
1099 camera_transport: None,
1100 developer_mode: None,
1101 control: ControlView {
1102 status: "unknown",
1103 expected_ok: false,
1104 reason: Some("could not read the firmware version (no `ota` module)".into()),
1105 },
1106 modules: version.modules.clone(),
1107 },
1108 };
1109
1110 if want_json(cli) {
1111 print_json(&output);
1112 } else {
1113 print_info_human(&output);
1114 }
1115 Ok(())
1116}
1117
1118fn registry_status_str(s: capability::RegistryStatus) -> &'static str {
1119 use capability::RegistryStatus::*;
1120 match s {
1121 Supported => "supported",
1122 FirmwareNewerThanKnown => "firmware_newer_than_known",
1123 UnknownModel => "unknown_model",
1124 }
1125}
1126
1127fn push_mode_str(m: capability::PushMode) -> &'static str {
1128 match m {
1129 capability::PushMode::Full => "full",
1130 capability::PushMode::DeltaOnly => "delta_only",
1131 }
1132}
1133
1134fn camera_transport_str(t: capability::CameraTransport) -> &'static str {
1135 use capability::CameraTransport::*;
1136 match t {
1137 Rtsp322 => "rtsp_322",
1138 JpegTcp6000 => "jpeg_tcp_6000",
1139 None => "none",
1140 }
1141}
1142
1143fn developer_mode_str(d: capability::DeveloperMode) -> &'static str {
1144 match d {
1145 capability::DeveloperMode::Available => "available",
1146 capability::DeveloperMode::Unavailable => "unavailable",
1147 }
1148}
1149
1150fn print_info_human(o: &InfoOutput) {
1151 println!(
1152 "printer: {} ({})",
1153 o.printer.as_deref().unwrap_or("-"),
1154 o.model
1155 );
1156 println!("firmware: {}", o.firmware.as_deref().unwrap_or("?"));
1157 println!("registry: {}", o.registry_status);
1158 if let Some(p) = o.push_mode {
1159 println!("push: {p}");
1160 }
1161 if let Some(c) = o.camera_transport {
1162 println!("camera: {c}");
1163 }
1164 match &o.control.reason {
1165 Some(r) => println!("control: {} — {r}", o.control.status),
1166 None => println!("control: {}", o.control.status),
1167 }
1168 if !o.modules.is_empty() {
1169 println!("modules:");
1170 for m in &o.modules {
1171 let hw = m.hw_ver.as_deref().unwrap_or("-");
1172 let sw = m.sw_ver.as_deref().unwrap_or("-");
1173 let prod = m
1174 .product_name
1175 .as_deref()
1176 .map(|p| format!(" {p}"))
1177 .unwrap_or_default();
1178 println!(" {:<10} hw {:<9} sw {}{prod}", m.name, hw, sw);
1179 }
1180 }
1181}
1182
1183#[derive(PartialEq)]
1187struct WatchKey {
1188 gcode_state: Option<String>,
1189 stg_cur: Option<i64>,
1190 mc_percent: Option<i64>,
1191 layer_num: Option<i64>,
1192 nozzle: Option<i64>,
1193 bed: Option<i64>,
1194 error: Option<i64>,
1195}
1196
1197fn watch_key(st: &PrinterStatus) -> WatchKey {
1200 WatchKey {
1201 gcode_state: st.gcode_state.clone(),
1202 stg_cur: st.stg_cur,
1203 mc_percent: st.mc_percent,
1204 layer_num: st.layer_num,
1205 nozzle: st.nozzle_temper.map(|v| v.round() as i64),
1206 bed: st.bed_temper.map(|v| v.round() as i64),
1207 error: st.error.as_ref().map(|e| e.code),
1208 }
1209}
1210
1211fn format_watch_line(st: &PrinterStatus) -> String {
1213 let stage = match (st.stg_cur, st.stage.as_deref()) {
1214 (Some(id), Some(name)) if !Stage(id).is_no_stage() => format!(" [{name}]"),
1215 _ => String::new(),
1216 };
1217 let err = match &st.error {
1218 Some(e) => format!(" ⚠ {}", e.hex),
1219 None => String::new(),
1220 };
1221 let temp = |cur: Option<f64>, tgt: Option<f64>| match cur {
1223 Some(c) => match tgt.filter(|t| *t > 0.0) {
1224 Some(t) => format!("{c:.0}/{t:.0}"),
1225 None => format!("{c:.0}"),
1226 },
1227 None => "-".to_string(),
1228 };
1229 let eta = match st.remaining_time_min.filter(|m| *m > 0) {
1230 Some(m) => format!(" ETA {}", fmt_eta(m)),
1231 None => String::new(),
1232 };
1233 format!(
1234 "{:<8} {:>3}% layer {}/{} N{} B{}{eta}{stage}{err}",
1235 st.gcode_state.as_deref().unwrap_or("?"),
1236 st.mc_percent.unwrap_or(0),
1237 st.layer_num.unwrap_or(0),
1238 st.total_layer_num.unwrap_or(0),
1239 temp(st.nozzle_temper, st.nozzle_target),
1240 temp(st.bed_temper, st.bed_target),
1241 )
1242}
1243
1244fn emit_watch_change(st: &PrinterStatus, last: &mut Option<WatchKey>, cli: &Cli, continuous: bool) {
1249 let key = watch_key(st);
1250 if last.as_ref() == Some(&key) {
1251 return;
1252 }
1253 *last = Some(key);
1254 if continuous {
1255 if want_json(cli) {
1256 if let Ok(j) = serde_json::to_string(st) {
1257 println!("{j}");
1258 }
1259 } else {
1260 println!("{}", format_watch_line(st));
1261 }
1262 } else {
1263 eprintln!("{}", format_watch_line(st));
1264 }
1265}
1266
1267fn watch_to_terminal(
1273 client: &LanMqttClient,
1274 cli: &Cli,
1275 model: String,
1276 profile_name: Option<String>,
1277 exit_status: bool,
1278 interval: Option<Duration>,
1279 continuous: bool,
1280) -> Result<(), CliError> {
1281 let mut last: Option<WatchKey> = None;
1282 let mut on_update = |state: &ReportState| -> WatchStep {
1283 let st = PrinterStatus::from_state(state.get());
1284 emit_watch_change(&st, &mut last, cli, continuous);
1285 if continuous {
1287 return WatchStep::Continue;
1288 }
1289 if st.error.is_some() {
1291 return WatchStep::Stop;
1292 }
1293 match st.state() {
1294 Some(s) if is_watch_terminal(s) => WatchStep::Stop,
1295 _ => WatchStep::Continue,
1296 }
1297 };
1298
1299 let result = if continuous {
1302 client.monitor(interval, &mut on_update)
1303 } else {
1304 client.watch(interval, &mut on_update)
1305 };
1306 let final_state = result?;
1307 if continuous {
1310 return Ok(());
1311 }
1312
1313 let status = PrinterStatus::from_state(final_state.get());
1314 let error = status.error.clone();
1315 let failed = status.state() == Some(GcodeState::Failed);
1316 let output = StatusOutput {
1317 printer: profile_name,
1318 model,
1319 status,
1320 };
1321 if want_json(cli) {
1322 print_json(&output);
1323 } else {
1324 print_status_human(&output);
1325 }
1326 if let Some(e) = error {
1327 return Err(CliError::new(
1328 exit::DEVICE_REJECTED,
1329 format!(
1330 "a device error appeared during the job: {} ({})",
1331 e.hex, e.code
1332 ),
1333 ));
1334 }
1335 if exit_status && failed {
1336 return Err(CliError::new(
1337 exit::GENERAL,
1338 "print ended in a FAILED state",
1339 ));
1340 }
1341 Ok(())
1342}
1343
1344fn watch_identity(cli: &Cli) -> Result<(String, Option<String>), CliError> {
1347 let cfg = Config::load_or_default(&config_path()?)?;
1348 let profile_name = selected_profile_name(cli, &cfg)?;
1349 let profile = profile_name.as_deref().and_then(|n| cfg.profile(n));
1350 let overrides = flag_overrides(cli).over(Overrides::from_env());
1351 let target = config::resolve(profile, &overrides)?;
1352 Ok((target.model.to_string(), profile_name))
1353}
1354
1355fn run_light(cli: &Cli, on: bool, node: &str, timeout_secs: u64) -> Result<(), CliError> {
1356 let node = match node {
1357 "chamber" => LedNode::ChamberLight,
1358 "work" => LedNode::WorkLight,
1359 other => {
1360 return Err(CliError::new(
1361 exit::VALIDATION,
1362 format!("unknown light {other:?}"),
1363 ));
1364 }
1365 };
1366 let client = connect_client(cli, timeout_secs)?;
1367 eprintln!(
1368 "setting {} {} …",
1369 node.as_str(),
1370 if on { "on" } else { "off" }
1371 );
1372 report_command_outcome(
1373 cli,
1374 client.send_and_verify(&ProtoCommand::Led { node, on })?,
1375 )
1376}
1377
1378fn run_speed(cli: &Cli, level: &str, timeout_secs: u64) -> Result<(), CliError> {
1379 let level = match level {
1380 "silent" => SpeedLevel::Silent,
1381 "standard" => SpeedLevel::Standard,
1382 "sport" => SpeedLevel::Sport,
1383 "ludicrous" => SpeedLevel::Ludicrous,
1384 other => {
1385 return Err(CliError::new(
1386 exit::VALIDATION,
1387 format!("unknown speed {other:?}"),
1388 ));
1389 }
1390 };
1391 let client = connect_client(cli, timeout_secs)?;
1392 eprintln!(
1393 "setting print speed to {} (level {}) …",
1394 level.as_str(),
1395 level.level()
1396 );
1397 report_command_outcome(
1398 cli,
1399 client.send_and_verify(&ProtoCommand::PrintSpeed(level))?,
1400 )
1401}
1402
1403fn run_reboot(cli: &Cli, confirm: bool) -> Result<(), CliError> {
1404 if !confirm {
1405 return Err(CliError::new(
1406 exit::CONFIRM_REQUIRED,
1407 "refusing to reboot without --confirm (the printer will disconnect and restart)",
1408 ));
1409 }
1410 let client = connect_client(cli, 10)?;
1411 eprintln!("sending reboot …");
1412 client.send_fire(&ProtoCommand::Reboot)?;
1414 eprintln!(
1415 "reboot sent — the printer will disconnect and restart (~1–2 min). \
1416 No ACK is expected; it may rejoin DHCP on a different IP."
1417 );
1418 Ok(())
1419}
1420
1421#[cfg(feature = "server")]
1422#[allow(clippy::too_many_arguments)]
1423fn run_serve(
1424 cli: &Cli,
1425 host: &str,
1426 port: u16,
1427 password: Option<String>,
1428 fake: bool,
1429 interval: Option<u64>,
1430 camera_url: Vec<String>,
1431 cameras_config: Option<std::path::PathBuf>,
1432) -> Result<(), CliError> {
1433 let target = if fake {
1435 None
1436 } else {
1437 Some(resolve_target(cli)?)
1438 };
1439 let mut external_cameras: Vec<crate::server::ExternalCamera> = Vec::new();
1443 for e in camera_url
1444 .iter()
1445 .map(|e| e.trim())
1446 .filter(|e| !e.is_empty())
1447 {
1448 if let Some(c) = crate::server::ExternalCamera::parse(e, external_cameras.len()) {
1449 external_cameras.push(c);
1450 }
1451 }
1452 if let Some(path) = &cameras_config {
1453 for seed in load_seed_cameras(path)? {
1454 let i = external_cameras.len();
1455 let (park, select) = match &seed.park_tuning {
1459 None => (None, None),
1460 Some(v) => {
1461 let park: ParkTuning = serde_json::from_value(v.clone()).map_err(|e| {
1462 CliError::new(
1463 exit::VALIDATION,
1464 format!("invalid park_tuning in --cameras-config: {e}"),
1465 )
1466 })?;
1467 let select = serde_json::from_value(v.clone()).ok();
1468 (Some(park), select)
1469 }
1470 };
1471 external_cameras.push(
1472 crate::server::ExternalCamera::new(seed.label, seed.url, seed.stream_url, i)
1473 .with_park_tuning(park)
1474 .with_select_tuning(select),
1475 );
1476 }
1477 }
1478 let opts = crate::server::ServeOpts {
1479 host: host.to_string(),
1480 port,
1481 password,
1482 fake,
1483 interval: interval.map(Duration::from_secs),
1484 external_cameras,
1485 };
1486 crate::server::serve(target, opts).map_err(|e| CliError::new(exit::GENERAL, e.to_string()))
1487}
1488
1489#[cfg(feature = "server")]
1492#[derive(serde::Deserialize)]
1493struct SeedCamera {
1494 #[serde(default)]
1495 label: Option<String>,
1496 url: String,
1497 #[serde(default)]
1498 stream_url: Option<String>,
1499 #[serde(default)]
1502 park_tuning: Option<serde_json::Value>,
1503}
1504
1505#[cfg(feature = "server")]
1508fn load_seed_cameras(path: &std::path::Path) -> Result<Vec<SeedCamera>, CliError> {
1509 let raw = std::fs::read_to_string(path)
1510 .map_err(|e| CliError::new(exit::VALIDATION, format!("reading {}: {e}", path.display())))?;
1511 serde_json::from_str(&raw).map_err(|e| {
1512 CliError::new(
1513 exit::VALIDATION,
1514 format!("invalid --cameras-config {}: {e}", path.display()),
1515 )
1516 })
1517}
1518
1519fn run_gcode(
1520 cli: &Cli,
1521 line: &str,
1522 confirm: bool,
1523 force: bool,
1524 timeout_secs: u64,
1525) -> Result<(), CliError> {
1526 if !confirm {
1527 return Err(CliError::new(
1528 exit::CONFIRM_REQUIRED,
1529 "refusing to send a control command without --confirm",
1530 ));
1531 }
1532 if !force && let GcodeVerdict::Block(reason) = safety::check_gcode(line, &TempLimits::default())
1535 {
1536 return Err(CliError::new(
1537 exit::VALIDATION,
1538 format!("refusing unsafe G-code: {reason}"),
1539 ));
1540 }
1541 let client = connect_client(cli, timeout_secs)?;
1542 eprintln!("sending gcode_line {line:?} …");
1543 report_command_outcome(
1544 cli,
1545 client.send_and_verify(&ProtoCommand::GcodeLine(line.to_string()))?,
1546 )
1547}
1548
1549fn run_file(cli: &Cli, action: &FileAction) -> Result<(), CliError> {
1550 let ftps = FtpsClient::new(resolve_target(cli)?);
1551 match action {
1552 FileAction::Ls { dir } => {
1553 let names = ftps.list(dir)?;
1554 if want_json(cli) {
1555 print_json(&names);
1556 } else {
1557 for name in &names {
1558 println!("{name}");
1559 }
1560 }
1561 Ok(())
1562 }
1563 FileAction::Upload { local, dest } => {
1564 let filename = local
1565 .file_name()
1566 .and_then(|s| s.to_str())
1567 .ok_or_else(|| CliError::new(exit::VALIDATION, "invalid local file name"))?;
1568 let remote = format!("{}/{filename}", dest.trim_end_matches('/'));
1569 let n = ftps.upload(local, &remote)?;
1570 eprintln!("uploaded {n} bytes to {remote}");
1571 Ok(())
1572 }
1573 FileAction::Download { remote, out } => {
1574 let local = match out {
1575 Some(p) => p.clone(),
1576 None => std::path::Path::new(remote)
1577 .file_name()
1578 .map(std::path::PathBuf::from)
1579 .ok_or_else(|| {
1580 CliError::new(
1581 exit::VALIDATION,
1582 format!("cannot derive an output name from {remote:?}; pass --out"),
1583 )
1584 })?,
1585 };
1586 let n = ftps.download(remote, &local)?;
1587 eprintln!("downloaded {n} bytes to {}", local.display());
1588 if want_json(cli) {
1589 print_json(&serde_json::json!({
1590 "path": local.to_string_lossy(),
1591 "bytes": n,
1592 }));
1593 } else {
1594 println!("{}", local.display());
1596 }
1597 Ok(())
1598 }
1599 FileAction::Rm { remote, confirm } => {
1600 if !*confirm {
1601 return Err(CliError::new(
1602 exit::CONFIRM_REQUIRED,
1603 "refusing to delete a file without --confirm",
1604 ));
1605 }
1606 ftps.delete(remote)?;
1607 eprintln!("deleted {remote}");
1608 if want_json(cli) {
1609 print_json(&serde_json::json!({ "deleted": true, "remote": remote }));
1610 }
1611 Ok(())
1612 }
1613 }
1614}
1615
1616fn run_job(cli: &Cli, action: &JobAction) -> Result<(), CliError> {
1617 match action {
1618 JobAction::Start {
1619 file,
1620 upload,
1621 dest,
1622 overwrite,
1623 plate,
1624 ams_map,
1625 bed_type,
1626 timelapse,
1627 dry_run,
1628 confirm,
1629 expect_md5,
1630 expect_plate,
1631 watch,
1632 watch_timeout,
1633 interval,
1634 } => {
1635 if *upload {
1639 if expect_md5.is_some() || expect_plate.is_some() {
1640 return Err(CliError::new(
1641 exit::VALIDATION,
1642 "--expect-md5 / --expect-plate don't apply with --upload \
1643 (you're providing the local file; its md5 is used directly)",
1644 ));
1645 }
1646 return run_job_start_upload(
1647 cli,
1648 file,
1649 *plate,
1650 dest.as_deref(),
1651 *overwrite,
1652 ams_map.as_deref(),
1653 bed_type,
1654 *timelapse,
1655 *dry_run,
1656 *confirm,
1657 *watch,
1658 *watch_timeout,
1659 *interval,
1660 );
1661 }
1662 let is_3mf = file.to_ascii_lowercase().ends_with(".3mf");
1663 if !is_3mf && (expect_md5.is_some() || expect_plate.is_some()) {
1666 return Err(CliError::new(
1667 exit::VALIDATION,
1668 "--expect-md5 / --expect-plate only apply to .3mf files",
1669 ));
1670 }
1671 let cmd = build_start_command(file, *plate, ams_map.as_deref(), bed_type, *timelapse)?;
1672
1673 let ams_mapping: Option<Vec<i32>> = match &cmd {
1675 ProtoCommand::ProjectFile(pf) if pf.use_ams => Some(pf.ams_mapping.clone()),
1676 _ => None,
1677 };
1678 if let Some(m) = &ams_mapping {
1682 validate_ams_map(m, None)?;
1683 }
1684
1685 let has_expect = expect_md5.is_some() || expect_plate.is_some();
1691 let mut inspection: Option<PlateInspection> = None;
1692 let mut inspect_error: Option<String> = None;
1695 if is_3mf && (has_expect || ams_mapping.is_some() || *dry_run) {
1696 let mandatory = has_expect || ams_mapping.is_some();
1697 match inspect_remote_plate(cli, file, *plate) {
1698 Ok(insp) => {
1699 project::verify_expectations(
1700 &insp,
1701 *plate,
1702 expect_md5.as_deref(),
1703 *expect_plate,
1704 )
1705 .map_err(|e| CliError::new(exit::VALIDATION, e.to_string()))?;
1706 if let Some(m) = &ams_mapping {
1711 match validate_ams_map(m, Some(insp.filament_colors.len())) {
1712 Ok(warns) => {
1713 for w in warns {
1714 eprintln!("warning: {w}");
1715 }
1716 }
1717 Err(e) if *dry_run => eprintln!("warning: {}", e.message),
1718 Err(e) => return Err(e),
1719 }
1720 }
1721 inspection = Some(insp);
1722 }
1723 Err(e) if mandatory => return Err(e),
1727 Err(e) => {
1728 eprintln!(
1729 "note: could not inspect the on-printer file ({}); \
1730 showing the payload only",
1731 e.message
1732 );
1733 inspect_error = Some(e.message);
1734 }
1735 }
1736 }
1737
1738 if *dry_run {
1739 print_json(&start_plan_json(
1741 &cmd,
1742 file,
1743 inspection.as_ref(),
1744 inspect_error.as_deref(),
1745 ams_mapping.as_deref(),
1746 *timelapse,
1747 ));
1748 return Ok(());
1749 }
1750 if !*confirm {
1751 return Err(CliError::new(
1752 exit::CONFIRM_REQUIRED,
1753 "refusing to start a print without --confirm (try --dry-run first)",
1754 ));
1755 }
1756 ensure_idle(cli)?;
1757 let client = connect_client(cli, 30)?;
1758 eprintln!("starting print: {file}");
1759 let outcome = client.send_and_verify(&cmd)?;
1760 if *watch && outcome == CommandOutcome::Verified {
1763 eprintln!("print started; watching for completion / anomalies …");
1764 let (model, profile_name) = watch_identity(cli)?;
1765 let watcher = connect_client(cli, *watch_timeout)?;
1766 let watch_interval = interval.map(Duration::from_secs);
1767 watch_to_terminal(
1768 &watcher,
1769 cli,
1770 model,
1771 profile_name,
1772 true,
1773 watch_interval,
1774 false,
1775 )
1776 } else {
1777 report_command_outcome(cli, outcome)
1778 }
1779 }
1780 JobAction::Pause { confirm } => job_control(cli, ProtoCommand::Pause, *confirm),
1781 JobAction::Resume { confirm } => job_control(cli, ProtoCommand::Resume, *confirm),
1782 JobAction::Stop { confirm } => job_control(cli, ProtoCommand::Stop, *confirm),
1783 JobAction::ClearError { confirm } => {
1784 job_control(cli, ProtoCommand::CleanPrintError, *confirm)
1785 }
1786 }
1787}
1788
1789#[allow(clippy::too_many_arguments)]
1794fn run_job_start_upload(
1795 cli: &Cli,
1796 local: &str,
1797 plate: u32,
1798 dest: Option<&str>,
1799 overwrite: bool,
1800 ams_map: Option<&str>,
1801 bed_type: &str,
1802 timelapse: bool,
1803 dry_run: bool,
1804 confirm: bool,
1805 watch: bool,
1806 watch_timeout: u64,
1807 interval: Option<u64>,
1808) -> Result<(), CliError> {
1809 let local_path = std::path::Path::new(local);
1810 let basename = local_path
1811 .file_name()
1812 .and_then(|s| s.to_str())
1813 .ok_or_else(|| CliError::new(exit::VALIDATION, format!("invalid local file: {local:?}")))?;
1814 let is_3mf = basename.to_ascii_lowercase().ends_with(".3mf");
1815 let remote = match dest {
1818 Some(d) => d.to_string(),
1819 None => format!("/{basename}"),
1820 };
1821 if remote.to_ascii_lowercase().ends_with(".3mf") != is_3mf {
1825 return Err(CliError::new(
1826 exit::VALIDATION,
1827 format!(
1828 "--dest {remote:?} must keep {basename:?}'s type (both .3mf, or both raw .gcode)"
1829 ),
1830 ));
1831 }
1832
1833 let parsed_ams: Option<Vec<i32>> = match (is_3mf, ams_map) {
1835 (true, Some(m)) => Some(parse_ams_map(m)?),
1836 _ => None,
1837 };
1838 if let Some(m) = &parsed_ams {
1839 validate_ams_map(m, None)?;
1840 }
1841
1842 let inspection: Option<PlateInspection> = if is_3mf {
1845 let bytes = std::fs::read(local_path)
1846 .map_err(|e| CliError::new(exit::VALIDATION, format!("reading {local}: {e}")))?;
1847 let insp = project::inspect_plate(&bytes, plate)
1848 .map_err(|e| CliError::new(exit::VALIDATION, format!("3mf inspection: {e}")))?;
1849 if let Some(m) = &parsed_ams {
1850 for w in validate_ams_map(m, Some(insp.filament_colors.len()))? {
1851 eprintln!("warning: {w}");
1852 }
1853 }
1854 Some(insp)
1855 } else {
1856 None
1857 };
1858
1859 let params = PrintStartParams {
1861 file: remote.clone(),
1862 plate,
1863 use_ams: parsed_ams.is_some(),
1864 ams_map: parsed_ams.clone().unwrap_or_default(),
1865 bed_type: bed_type.to_string(),
1866 timelapse,
1867 };
1868 let cmd = start::build_command(¶ms, inspection.as_ref());
1869
1870 if dry_run {
1871 let mut plan = start_plan_json(
1873 &cmd,
1874 &remote,
1875 inspection.as_ref(),
1876 None,
1877 parsed_ams.as_deref(),
1878 timelapse,
1879 );
1880 plan["upload"] =
1881 serde_json::json!({ "local": local, "remote": remote, "overwrite": overwrite });
1882 print_json(&plan);
1883 return Ok(());
1884 }
1885 if !confirm {
1886 return Err(CliError::new(
1887 exit::CONFIRM_REQUIRED,
1888 "refusing to upload + start without --confirm (try --dry-run first)",
1889 ));
1890 }
1891 ensure_idle(cli)?;
1892
1893 let ftps = FtpsClient::new(resolve_target(cli)?);
1895 if !overwrite && remote_file_exists(&ftps, &remote) {
1896 return Err(CliError::new(
1897 exit::VALIDATION,
1898 format!("{remote} already exists on the printer (pass --overwrite to replace it)"),
1899 ));
1900 }
1901 let n = ftps.upload(local_path, &remote)?;
1902 eprintln!("uploaded {n} bytes to {remote}");
1903
1904 let client = connect_client(cli, 30)?;
1905 eprintln!("starting print: {remote}");
1906 let outcome = client.send_and_verify(&cmd)?;
1907 if watch && outcome == CommandOutcome::Verified {
1908 eprintln!("print started; watching for completion / anomalies …");
1909 let (model, profile_name) = watch_identity(cli)?;
1910 let watcher = connect_client(cli, watch_timeout)?;
1911 watch_to_terminal(
1912 &watcher,
1913 cli,
1914 model,
1915 profile_name,
1916 true,
1917 interval.map(Duration::from_secs),
1918 false,
1919 )
1920 } else {
1921 report_command_outcome(cli, outcome)
1922 }
1923}
1924
1925fn remote_file_exists(ftps: &FtpsClient, remote: &str) -> bool {
1930 let (dir, name) = match remote.rsplit_once('/') {
1931 Some((d, n)) => (if d.is_empty() { "/" } else { d }, n),
1932 None => ("/", remote),
1933 };
1934 ftps.list(dir)
1935 .map(|names| names.iter().any(|e| e.rsplit('/').next() == Some(name)))
1936 .unwrap_or(false)
1937}
1938
1939fn build_start_command(
1941 file: &str,
1942 plate: u32,
1943 ams_map: Option<&str>,
1944 bed_type: &str,
1945 timelapse: bool,
1946) -> Result<ProtoCommand, CliError> {
1947 let is_3mf = file.to_ascii_lowercase().ends_with(".3mf");
1951 let (use_ams, parsed_map) = match (is_3mf, ams_map) {
1952 (true, Some(map)) => (true, parse_ams_map(map)?),
1953 _ => (false, Vec::new()),
1954 };
1955 let params = PrintStartParams {
1956 file: file.to_string(),
1957 plate,
1958 use_ams,
1959 ams_map: parsed_map,
1960 bed_type: bed_type.to_string(),
1961 timelapse,
1962 };
1963 Ok(start::build_command(¶ms, None))
1964}
1965
1966fn parse_ams_map(map: &str) -> Result<Vec<i32>, CliError> {
1967 map.split(',')
1968 .map(|s| s.trim().parse::<i32>())
1969 .collect::<Result<Vec<_>, _>>()
1970 .map_err(|_| CliError::new(exit::VALIDATION, format!("invalid --ams-map: {map:?}")))
1971}
1972
1973fn validate_ams_map(
1979 mapping: &[i32],
1980 filament_count: Option<usize>,
1981) -> Result<Vec<String>, CliError> {
1982 for (i, &v) in mapping.iter().enumerate() {
1984 if !(-1..=3).contains(&v) {
1985 return Err(CliError::new(
1986 exit::VALIDATION,
1987 format!(
1988 "--ams-map[{i}]={v} is out of range (AMS trays are 0..3, or -1 for the \
1989 external spool)"
1990 ),
1991 ));
1992 }
1993 }
1994 if let Some(n) = filament_count
1995 && mapping.len() != n
1996 {
1997 return Err(CliError::new(
1998 exit::VALIDATION,
1999 format!(
2000 "--ams-map has {} entr{} but the plate has {n} filament(s) — one tray per \
2001 filament, in order",
2002 mapping.len(),
2003 if mapping.len() == 1 { "y" } else { "ies" },
2004 ),
2005 ));
2006 }
2007 let mut warnings = Vec::new();
2008 if mapping.iter().filter(|&&v| v == -1).count() > 1 {
2009 warnings.push(
2010 "more than one filament is mapped to the external spool (-1); only one filament can \
2011 physically feed from it — verify this is intended"
2012 .to_string(),
2013 );
2014 }
2015 Ok(warnings)
2016}
2017
2018fn ams_mapping_preview(colors: &[String], mapping: &[i32]) -> serde_json::Value {
2021 let entries: Vec<serde_json::Value> = mapping
2022 .iter()
2023 .enumerate()
2024 .map(|(i, &tray)| {
2025 let source = if tray == -1 {
2026 "external spool".to_string()
2027 } else {
2028 format!("AMS tray {tray}")
2029 };
2030 serde_json::json!({
2031 "filament": i,
2032 "color": colors.get(i),
2033 "tray": tray,
2034 "source": source,
2035 })
2036 })
2037 .collect();
2038 serde_json::Value::Array(entries)
2039}
2040
2041fn inspect_remote_plate(
2045 cli: &Cli,
2046 on_printer_path: &str,
2047 plate: u32,
2048) -> Result<PlateInspection, CliError> {
2049 let ftps = FtpsClient::new(resolve_target(cli)?);
2050 let dir = tempfile::Builder::new()
2055 .prefix("bambu-inspect-")
2056 .tempdir()
2057 .map_err(|e| CliError::new(exit::GENERAL, format!("creating temp dir: {e}")))?;
2058 let tmp = dir.path().join("inspect.3mf");
2059 ftps.download(on_printer_path, &tmp)?; let bytes = std::fs::read(&tmp)
2061 .map_err(|e| CliError::new(exit::GENERAL, format!("reading downloaded 3mf: {e}")))?;
2062 project::inspect_plate(&bytes, plate)
2063 .map_err(|e| CliError::new(exit::VALIDATION, format!("3mf inspection: {e}")))
2064 }
2066
2067fn start_plan_json(
2071 cmd: &ProtoCommand,
2072 file: &str,
2073 inspection: Option<&PlateInspection>,
2074 inspect_error: Option<&str>,
2075 ams_mapping: Option<&[i32]>,
2076 timelapse_armed: bool,
2077) -> serde_json::Value {
2078 let inspection_json = match (inspection, inspect_error) {
2079 (Some(i), _) => {
2081 let mut warnings: Vec<String> = Vec::new();
2082 if !i.sidecar_matches {
2083 warnings.push(
2084 "the file's own .gcode.md5 sidecar disagrees with the computed md5; \
2085 using the computed value"
2086 .to_string(),
2087 );
2088 }
2089 if timelapse_armed && !i.has_timelapse_blocks {
2092 warnings.push(
2093 "--timelapse is set, but this plate has no per-layer park moves; \
2094 the head won't park (no clean object-only timelapse)"
2095 .to_string(),
2096 );
2097 }
2098 let ams_preview = ams_mapping.map(|m| ams_mapping_preview(&i.filament_colors, m));
2101 if let Some(m) = ams_mapping
2102 && m.len() != i.filament_colors.len()
2103 {
2104 warnings.push(format!(
2105 "--ams-map has {} entries but the plate has {} filament(s)",
2106 m.len(),
2107 i.filament_colors.len()
2108 ));
2109 }
2110 serde_json::json!({
2111 "inspected": true,
2112 "file": file,
2113 "plate": i.plate,
2114 "gcode_md5": i.gcode_md5,
2115 "sidecar_md5": i.sidecar_md5,
2116 "sidecar_matches": i.sidecar_matches,
2117 "bed_type": i.bed_type,
2118 "filament_colors": i.filament_colors,
2119 "has_timelapse_blocks": i.has_timelapse_blocks,
2123 "ams_mapping_preview": ams_preview,
2124 "source": "on-printer file (downloaded for inspection)",
2125 "warnings": warnings,
2126 })
2127 }
2128 (None, Some(err)) => serde_json::json!({
2131 "inspected": false,
2132 "error": err,
2133 }),
2134 (None, None) => serde_json::Value::Null,
2136 };
2137 serde_json::json!({
2138 "command": cmd.to_payload("1"),
2139 "inspection": inspection_json,
2140 })
2141}
2142
2143fn ensure_idle(cli: &Cli) -> Result<(), CliError> {
2145 let state = connect_client(cli, 10)?.fetch_snapshot()?;
2146 match PrinterStatus::from_state(state.get()).state() {
2147 None | Some(GcodeState::Idle) | Some(GcodeState::Finish) | Some(GcodeState::Failed) => {
2148 Ok(())
2149 }
2150 Some(busy) => Err(CliError::new(
2151 exit::PRINTER_BUSY,
2152 format!("printer is busy ({busy:?}); refusing to start a print"),
2153 )),
2154 }
2155}
2156
2157fn run_ams(cli: &Cli, action: &AmsAction) -> Result<(), CliError> {
2158 let control =
2160 |cli: &Cli, cmd: ProtoCommand, confirm: bool, what: &str| -> Result<(), CliError> {
2161 if !confirm {
2162 return Err(CliError::new(
2163 exit::CONFIRM_REQUIRED,
2164 format!("{what} needs --confirm"),
2165 ));
2166 }
2167 let client = connect_client(cli, 15)?;
2168 eprintln!("{what} … (AMS commands are [spec]; the ACK confirms acceptance)");
2169 report_command_outcome(cli, client.send_and_verify(&cmd)?)
2170 };
2171 match action {
2172 AmsAction::Resume { confirm } => control(
2173 cli,
2174 ProtoCommand::AmsControl(AmsControl::Resume),
2175 *confirm,
2176 "ams resume",
2177 ),
2178 AmsAction::Reset { confirm } => control(
2179 cli,
2180 ProtoCommand::AmsControl(AmsControl::Reset),
2181 *confirm,
2182 "ams reset",
2183 ),
2184 AmsAction::Pause { confirm } => control(
2185 cli,
2186 ProtoCommand::AmsControl(AmsControl::Pause),
2187 *confirm,
2188 "ams pause",
2189 ),
2190 AmsAction::Change {
2191 tray,
2192 tar_temp,
2193 curr_temp,
2194 dry_run,
2195 confirm,
2196 } => {
2197 let max = TempLimits::default().max_nozzle as i64;
2200 let curr = curr_temp.unwrap_or(*tar_temp);
2201 for (label, t) in [("--tar-temp", *tar_temp), ("--curr-temp", curr)] {
2202 if t < 0 || t > max {
2203 return Err(CliError::new(
2204 exit::VALIDATION,
2205 format!("{label} {t}°C is out of range (0..={max})"),
2206 ));
2207 }
2208 }
2209 let cmd = ProtoCommand::AmsChangeFilament {
2210 target: *tray,
2211 curr_temp: curr,
2212 tar_temp: *tar_temp,
2213 };
2214 if *dry_run {
2215 print_json(&cmd.to_payload("1"));
2216 return Ok(());
2217 }
2218 if !*confirm {
2219 return Err(CliError::new(
2220 exit::CONFIRM_REQUIRED,
2221 "ams change physically moves filament; needs --confirm (try --dry-run first)",
2222 ));
2223 }
2224 ensure_idle(cli)?;
2226 let client = connect_client(cli, 30)?;
2227 eprintln!(
2228 "changing filament to tray {tray} … [spec, untested on this unit] — \
2229 the ACK confirms acceptance; watch `bambu status` for the physical change"
2230 );
2231 report_command_outcome(cli, client.send_and_verify(&cmd)?)
2232 }
2233 AmsAction::SetFilament {
2234 ams,
2235 tray,
2236 material,
2237 color,
2238 min,
2239 max,
2240 info_idx,
2241 dry_run,
2242 confirm,
2243 } => {
2244 if min > max {
2246 return Err(CliError::new(
2247 exit::VALIDATION,
2248 format!("--min {min} must be <= --max {max}"),
2249 ));
2250 }
2251 let limit = TempLimits::default().max_nozzle as i64;
2252 if *min < 0 || *max > limit {
2253 return Err(CliError::new(
2254 exit::VALIDATION,
2255 format!("nozzle temps must be within 0..={limit}°C"),
2256 ));
2257 }
2258 if color.len() != 8 || !color.chars().all(|c| c.is_ascii_hexdigit()) {
2259 return Err(CliError::new(
2260 exit::VALIDATION,
2261 format!("--color must be 8 hex digits RRGGBBAA (got {color:?})"),
2262 ));
2263 }
2264 let cmd = ProtoCommand::AmsFilamentSetting(Box::new(AmsFilamentSetting {
2265 ams_id: *ams,
2266 tray_id: *tray,
2267 tray_info_idx: info_idx.clone(),
2268 tray_color: color.clone(),
2269 nozzle_temp_min: *min,
2270 nozzle_temp_max: *max,
2271 tray_type: material.clone(),
2272 }));
2273 if *dry_run {
2274 print_json(&cmd.to_payload("1"));
2275 return Ok(());
2276 }
2277 control(cli, cmd, *confirm, "ams set-filament")
2278 }
2279 AmsAction::Settings {
2280 ams,
2281 startup_read,
2282 tray_read,
2283 confirm,
2284 } => control(
2285 cli,
2286 ProtoCommand::AmsUserSetting {
2287 ams_id: *ams,
2288 startup_read: *startup_read,
2289 tray_read: *tray_read,
2290 },
2291 *confirm,
2292 "ams settings",
2293 ),
2294 }
2295}
2296
2297fn run_calibrate(cli: &Cli, args: &CalibrateArgs) -> Result<(), CliError> {
2298 let none_picked = !(args.bed_level || args.vibration || args.motor_noise);
2300 let bed_level = args.bed_level || none_picked;
2301 let vibration = args.vibration || none_picked;
2302 let motor_noise = args.motor_noise || none_picked;
2303 let cmd = ProtoCommand::Calibration {
2304 bed_level,
2305 vibration,
2306 motor_noise,
2307 };
2308 let what = describe_calibration(bed_level, vibration, motor_noise);
2309
2310 if args.dry_run {
2311 if want_json(cli) {
2313 print_json(&serde_json::json!({
2314 "plan": {
2315 "bed_level": bed_level,
2316 "vibration": vibration,
2317 "motor_noise": motor_noise,
2318 "what": what,
2319 },
2320 "payload": cmd.to_payload("1"),
2321 }));
2322 } else {
2323 eprintln!("dry run — would run calibration: {what}");
2324 eprintln!("(nothing sent; re-run with --confirm to start)");
2325 }
2326 return Ok(());
2327 }
2328 if !args.confirm {
2329 return Err(CliError::new(
2330 exit::CONFIRM_REQUIRED,
2331 "calibration moves the hardware; needs --confirm (try --dry-run first)",
2332 ));
2333 }
2334 ensure_idle(cli)?;
2335 let client = connect_client(cli, 20)?;
2336 eprintln!("starting calibration: {what} …");
2337 let outcome = client.send_and_verify(&cmd)?;
2338 if args.watch && outcome == CommandOutcome::Verified {
2341 eprintln!("calibration started; watching until it finishes …");
2342 let (model, profile_name) = watch_identity(cli)?;
2343 let watcher = connect_client(cli, args.watch_timeout)?;
2344 let watch_interval = args.interval.map(Duration::from_secs);
2345 watch_to_terminal(
2346 &watcher,
2347 cli,
2348 model,
2349 profile_name,
2350 false,
2351 watch_interval,
2352 false,
2353 )
2354 } else {
2355 report_command_outcome(cli, outcome)
2356 }
2357}
2358
2359fn describe_calibration(bed_level: bool, vibration: bool, motor_noise: bool) -> String {
2361 let mut parts = Vec::new();
2362 if bed_level {
2363 parts.push("bed level");
2364 }
2365 if vibration {
2366 parts.push("vibration");
2367 }
2368 if motor_noise {
2369 parts.push("motor noise");
2370 }
2371 if parts.is_empty() {
2372 "nothing".to_string()
2373 } else {
2374 parts.join(" + ")
2375 }
2376}
2377
2378fn job_control(cli: &Cli, cmd: ProtoCommand, confirm: bool) -> Result<(), CliError> {
2379 if !confirm {
2380 return Err(CliError::new(
2381 exit::CONFIRM_REQUIRED,
2382 "this control command needs --confirm",
2383 ));
2384 }
2385 let client = connect_client(cli, 15)?;
2386 report_command_outcome(cli, client.send_and_verify(&cmd)?)
2387}
2388
2389fn run_camera(cli: &Cli, action: &CameraAction) -> Result<(), CliError> {
2390 match action {
2391 CameraAction::Snapshot { out, timeout } => {
2392 let camera =
2393 CameraClient::new(resolve_target(cli)?).with_timeout(Duration::from_secs(*timeout));
2394 let jpeg = camera.snapshot()?;
2395 std::fs::write(out, &jpeg).map_err(|e| {
2396 CliError::new(exit::GENERAL, format!("write {}: {e}", out.display()))
2397 })?;
2398 eprintln!("wrote {} bytes", jpeg.len());
2399 if want_json(cli) {
2400 print_json(&serde_json::json!({
2401 "path": out.to_string_lossy(),
2402 "bytes": jpeg.len(),
2403 }));
2404 } else {
2405 println!("{}", out.display());
2407 }
2408 Ok(())
2409 }
2410 }
2411}
2412
2413fn run_timelapse(cli: &Cli, action: &TimelapseAction) -> Result<(), CliError> {
2414 match action {
2415 TimelapseAction::Enable { timeout } => {
2416 timelapse_set(cli, TimelapseControl::Enable, *timeout)
2417 }
2418 TimelapseAction::Disable { timeout } => {
2419 timelapse_set(cli, TimelapseControl::Disable, *timeout)
2420 }
2421 TimelapseAction::List => {
2422 let names = FtpsClient::new(resolve_target(cli)?).list("/timelapse")?;
2423 if want_json(cli) {
2424 print_json(&names);
2425 } else if names.is_empty() {
2426 println!("no timelapse files on the printer");
2427 } else {
2428 for n in &names {
2429 println!("{n}");
2430 }
2431 }
2432 Ok(())
2433 }
2434 TimelapseAction::Get { name, out } => {
2435 let remote = if name.starts_with('/') {
2437 name.clone()
2438 } else {
2439 format!("/timelapse/{name}")
2440 };
2441 let local = match out {
2442 Some(p) => p.clone(),
2443 None => std::path::Path::new(&remote)
2444 .file_name()
2445 .map(std::path::PathBuf::from)
2446 .ok_or_else(|| {
2447 CliError::new(exit::VALIDATION, "cannot derive an output name; pass --out")
2448 })?,
2449 };
2450 let n = FtpsClient::new(resolve_target(cli)?).download(&remote, &local)?;
2451 eprintln!("downloaded {n} bytes to {}", local.display());
2452 if want_json(cli) {
2453 print_json(&serde_json::json!({
2454 "path": local.to_string_lossy(),
2455 "bytes": n,
2456 }));
2457 } else {
2458 println!("{}", local.display());
2459 }
2460 Ok(())
2461 }
2462 TimelapseAction::Capture {
2463 on_layer_cmd,
2464 out_dir,
2465 every,
2466 ext,
2467 interval,
2468 timeout,
2469 wait,
2470 } => run_timelapse_capture(
2471 cli,
2472 on_layer_cmd,
2473 out_dir,
2474 *every,
2475 ext,
2476 interval.map(Duration::from_secs),
2477 *timeout,
2478 *wait,
2479 ),
2480 TimelapseAction::Encode {
2481 input,
2482 out,
2483 fps,
2484 speed,
2485 } => run_encode(input, out.as_deref(), *fps, *speed),
2486 TimelapseAction::Park {
2487 stream_url,
2488 config,
2489 out,
2490 assemble,
2491 out_fps,
2492 serve,
2493 watch_printer,
2494 width,
2495 height,
2496 max_seconds,
2497 } => run_timelapse_park(ParkArgs {
2498 stream_url,
2499 config,
2500 out,
2501 assemble: assemble.as_deref(),
2502 out_fps: *out_fps,
2503 serve: serve.as_deref(),
2504 watch_printer: *watch_printer,
2505 width: *width,
2506 height: *height,
2507 max_seconds: *max_seconds,
2508 cli,
2509 }),
2510 }
2511}
2512
2513struct ParkArgs<'a> {
2515 stream_url: &'a str,
2516 config: &'a std::path::Path,
2517 out: &'a std::path::Path,
2518 assemble: Option<&'a std::path::Path>,
2519 out_fps: u32,
2520 serve: Option<&'a str>,
2521 watch_printer: bool,
2522 width: u32,
2523 height: u32,
2524 max_seconds: Option<u64>,
2525 cli: &'a Cli,
2526}
2527
2528fn run_timelapse_park(args: ParkArgs) -> Result<(), CliError> {
2534 let ParkArgs {
2535 stream_url,
2536 config,
2537 out,
2538 assemble,
2539 out_fps,
2540 serve,
2541 watch_printer,
2542 width,
2543 height,
2544 max_seconds,
2545 cli,
2546 } = args;
2547 let raw = std::fs::read_to_string(config).map_err(|e| {
2549 CliError::new(
2550 exit::VALIDATION,
2551 format!("reading tuning config {}: {e}", config.display()),
2552 )
2553 })?;
2554 let tuning: ParkTuning = serde_json::from_str(&raw).map_err(|e| {
2555 CliError::new(
2556 exit::VALIDATION,
2557 format!(
2558 "invalid tuning config {} (no defaults): {e}",
2559 config.display()
2560 ),
2561 )
2562 })?;
2563 std::fs::create_dir_all(out)
2564 .map_err(|e| CliError::new(exit::GENERAL, format!("creating {}: {e}", out.display())))?;
2565
2566 let cap = ParkCapture {
2567 id: "park".to_string(),
2568 stream_url: stream_url.to_string(),
2569 tuning,
2570 };
2571 let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2575 {
2576 let cancel = cancel.clone();
2577 let _ =
2579 ctrlc::set_handler(move || cancel.store(true, std::sync::atomic::Ordering::Relaxed));
2580 }
2581 if let Some(secs) = max_seconds {
2582 let cancel = cancel.clone();
2583 std::thread::spawn(move || {
2584 std::thread::sleep(Duration::from_secs(secs));
2585 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
2586 });
2587 }
2588 if let Some(base) = serve {
2589 spawn_serve_autostop(base, &cancel)?;
2590 } else if watch_printer {
2591 spawn_printer_autostop(cli, &cancel)?;
2592 }
2593
2594 eprintln!("watching {stream_url} -> {}", out.display());
2595 eprintln!(
2596 " live preview: open {}/latest_park.jpg in an auto-reloading viewer \
2597 (e.g. feh --reload 1 {}/latest_park.jpg)",
2598 out.display(),
2599 out.display()
2600 );
2601 let auto_stop = if serve.is_some() {
2602 "print end (via serve), "
2603 } else if watch_printer {
2604 "print end (via printer), "
2605 } else {
2606 ""
2607 };
2608 eprintln!(
2609 " stops on: {}{}Ctrl-C",
2610 auto_stop,
2611 max_seconds.map_or(String::new(), |s| format!("{s}s, ")),
2612 );
2613
2614 let mut parks = 0u64;
2615 let mut on_park = |ev| match ev {
2616 ParkEvent::Written => {
2617 eprintln!("park #{parks}");
2618 parks += 1;
2619 }
2620 ParkEvent::Replaced => {
2622 eprintln!("park #{} updated (stronger frame)", parks.saturating_sub(1))
2623 }
2624 ParkEvent::Dropped => eprintln!("warning: a park frame was dropped (ring JPEG missing)"),
2625 };
2626 let stats = run_park_camera(
2627 &cap,
2628 out,
2629 width as usize,
2630 height as usize,
2631 &cancel,
2632 &mut on_park,
2633 )
2634 .map_err(|e| CliError::new(exit::GENERAL, e))?;
2635
2636 if stats.frames == 0 {
2637 return Err(CliError::new(
2638 exit::TRANSPORT,
2639 format!("read 0 frames from {stream_url} — check the URL and that ffmpeg can open it"),
2640 ));
2641 }
2642 eprintln!(
2643 "done: {} parks ({} frames, {} replaced, {} dropped) -> {}",
2644 stats.parks,
2645 stats.frames,
2646 stats.replaced,
2647 stats.dropped,
2648 out.display()
2649 );
2650
2651 let assembled = match assemble {
2652 Some(mp4) if stats.parks > 0 => {
2653 assemble_park_mp4(out, mp4, out_fps)?;
2654 eprintln!("assembled {}", mp4.display());
2655 Some(mp4.to_string_lossy().to_string())
2656 }
2657 Some(_) => {
2658 eprintln!("nothing to assemble (no parks captured)");
2659 None
2660 }
2661 None => None,
2662 };
2663
2664 if want_json(cli) {
2665 print_json(&serde_json::json!({
2666 "out": out.to_string_lossy(),
2667 "frames": stats.frames,
2668 "parks": stats.parks,
2669 "replaced": stats.replaced,
2670 "dropped": stats.dropped,
2671 "assembled": assembled,
2672 }));
2673 }
2674 Ok(())
2675}
2676
2677fn assemble_park_mp4(
2679 out_dir: &std::path::Path,
2680 mp4: &std::path::Path,
2681 fps: u32,
2682) -> Result<(), CliError> {
2683 crate::captures::assemble_mp4(out_dir, crate::captures::CaptureKind::Park, mp4, fps).map_err(
2685 |e| {
2686 let code = if e.contains("ffmpeg not found") {
2687 exit::VALIDATION
2688 } else {
2689 exit::GENERAL
2690 };
2691 CliError::new(code, e)
2692 },
2693 )
2694}
2695
2696#[cfg(feature = "server")]
2702fn spawn_serve_autostop(
2703 base: &str,
2704 cancel: &std::sync::Arc<std::sync::atomic::AtomicBool>,
2705) -> Result<(), CliError> {
2706 fetch_serve_status(base)?;
2708 let base = base.to_string();
2709 let cancel = cancel.clone();
2710 std::thread::spawn(move || {
2711 let mut activity = PrintActivitySession::new(true);
2712 while !cancel.load(std::sync::atomic::Ordering::Relaxed) {
2713 if let Ok(status) = fetch_serve_status(&base)
2714 && activity.observe(&status) == ActivityAction::Stop
2715 {
2716 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
2717 return;
2718 }
2719 std::thread::sleep(Duration::from_secs(2));
2720 }
2721 });
2722 Ok(())
2723}
2724
2725#[cfg(not(feature = "server"))]
2726fn spawn_serve_autostop(
2727 _base: &str,
2728 _cancel: &std::sync::Arc<std::sync::atomic::AtomicBool>,
2729) -> Result<(), CliError> {
2730 Err(CliError::new(
2731 exit::VALIDATION,
2732 "--serve needs the `server` feature (not compiled into this build)",
2733 ))
2734}
2735
2736fn spawn_printer_autostop(
2748 cli: &Cli,
2749 cancel: &std::sync::Arc<std::sync::atomic::AtomicBool>,
2750) -> Result<(), CliError> {
2751 let target = resolve_target(cli)?;
2752 let cancel = cancel.clone();
2753 std::thread::spawn(move || {
2754 use std::sync::atomic::Ordering::Relaxed;
2755 let client = LanMqttClient::new(target).with_timeout(Duration::from_secs(120));
2759 let mut activity = PrintActivitySession::new(true);
2760 while !cancel.load(Relaxed) {
2766 let mut on_update = |state: &ReportState| -> WatchStep {
2767 if cancel.load(Relaxed) {
2768 return WatchStep::Stop; }
2770 let st = PrinterStatus::from_state(state.get());
2771 if activity.observe(&st) == ActivityAction::Stop {
2772 cancel.store(true, Relaxed);
2773 WatchStep::Stop
2774 } else {
2775 WatchStep::Continue
2776 }
2777 };
2778 let result = client.monitor(Some(Duration::from_secs(30)), &mut on_update);
2779 if cancel.load(Relaxed) {
2780 break; }
2782 match result {
2783 Err(e) if !matches!(e, ClientError::Timeout(_)) => {
2784 eprintln!("warning: printer auto-stop watch error: {e}; retrying…")
2785 }
2786 _ => eprintln!(
2787 "warning: printer unreachable — print-end auto-stop paused, retrying…"
2788 ),
2789 }
2790 std::thread::sleep(Duration::from_secs(5));
2791 }
2792 });
2793 Ok(())
2794}
2795
2796fn default_mp4_out(input: &std::path::Path) -> std::path::PathBuf {
2799 input.with_extension("mp4")
2800}
2801
2802fn is_mjpeg(input: &std::path::Path) -> bool {
2804 input
2805 .extension()
2806 .and_then(|e| e.to_str())
2807 .is_some_and(|e| e.eq_ignore_ascii_case("mjpeg"))
2808}
2809
2810fn build_ffmpeg_args(
2815 input: &std::path::Path,
2816 out: &std::path::Path,
2817 fps: u32,
2818 speed: u32,
2819) -> Result<Vec<String>, CliError> {
2820 let fps = fps.max(1);
2821 let speed = speed.max(1);
2822 let mut args: Vec<String> = vec!["-y".into()];
2823 if input.is_dir() {
2824 args.extend(["-framerate".into(), fps.to_string()]);
2826 args.extend(["-pattern_type".into(), "glob".into()]);
2827 args.extend(["-i".into(), format!("{}/frame_*.jpg", input.display())]);
2828 if speed > 1 {
2829 args.extend([
2832 "-vf".into(),
2833 format!("framestep={speed},setpts=N/{fps}/TB"),
2834 "-r".into(),
2835 fps.to_string(),
2836 ]);
2837 }
2838 } else if is_mjpeg(input) {
2839 args.extend(["-f".into(), "mpjpeg".into()]);
2842 args.extend(["-i".into(), input.display().to_string()]);
2843 let vf = if speed > 1 {
2844 format!("framestep={speed},setpts=N/{fps}/TB")
2845 } else {
2846 format!("setpts=N/{fps}/TB")
2847 };
2848 args.extend(["-vf".into(), vf, "-r".into(), fps.to_string(), "-an".into()]);
2849 } else {
2850 return Err(CliError::new(
2851 exit::VALIDATION,
2852 format!(
2853 "{}: encode input must be a directory of frame_*.jpg or a .mjpeg file",
2854 input.display()
2855 ),
2856 ));
2857 }
2858 args.extend(
2859 [
2860 "-c:v",
2861 "libx264",
2862 "-pix_fmt",
2863 "yuv420p",
2864 "-movflags",
2865 "+faststart",
2866 ]
2867 .map(String::from),
2868 );
2869 args.push(out.display().to_string());
2870 Ok(args)
2871}
2872
2873fn run_encode(
2875 input: &std::path::Path,
2876 out: Option<&std::path::Path>,
2877 fps: u32,
2878 speed: u32,
2879) -> Result<(), CliError> {
2880 if !input.exists() {
2881 return Err(CliError::new(
2882 exit::VALIDATION,
2883 format!("{}: no such file or directory", input.display()),
2884 ));
2885 }
2886 let out = out
2887 .map(std::path::Path::to_path_buf)
2888 .unwrap_or_else(|| default_mp4_out(input));
2889 let args = build_ffmpeg_args(input, &out, fps, speed)?;
2890
2891 let status = std::process::Command::new("ffmpeg")
2893 .args(&args)
2894 .status()
2895 .map_err(|e| {
2896 if e.kind() == std::io::ErrorKind::NotFound {
2897 CliError::new(
2898 exit::VALIDATION,
2899 "ffmpeg not found on PATH — install ffmpeg to encode mp4",
2900 )
2901 } else {
2902 CliError::new(exit::GENERAL, format!("running ffmpeg: {e}"))
2903 }
2904 })?;
2905 if !status.success() {
2906 return Err(CliError::new(
2907 exit::GENERAL,
2908 format!("ffmpeg exited with {status}"),
2909 ));
2910 }
2911 eprintln!("encoded {}", out.display());
2912 println!("{}", out.display());
2913 Ok(())
2914}
2915
2916fn timelapse_set(cli: &Cli, control: TimelapseControl, timeout_secs: u64) -> Result<(), CliError> {
2917 let client = connect_client(cli, timeout_secs)?;
2918 eprintln!("setting timelapse {} …", control.as_str());
2919 report_command_outcome(
2920 cli,
2921 client.send_and_verify(&ProtoCommand::IpcamTimelapse(control))?,
2922 )
2923}
2924
2925#[allow(clippy::too_many_arguments)]
2933fn run_timelapse_capture(
2934 cli: &Cli,
2935 on_layer_cmd: &[String],
2936 out_dir: &std::path::Path,
2937 every: u64,
2938 ext: &str,
2939 interval: Option<Duration>,
2940 timeout_secs: u64,
2941 wait: bool,
2942) -> Result<(), CliError> {
2943 if every == 0 {
2944 return Err(CliError::new(exit::VALIDATION, "--every must be >= 1"));
2945 }
2946 if ext.is_empty() || ext.len() > 12 || !ext.chars().all(|c| c.is_ascii_alphanumeric()) {
2947 return Err(CliError::new(
2948 exit::VALIDATION,
2949 "--ext must be 1-12 alphanumeric characters (e.g. jpg, png)",
2950 ));
2951 }
2952 std::fs::create_dir_all(out_dir)
2953 .map_err(|e| CliError::new(exit::GENERAL, format!("create {}: {e}", out_dir.display())))?;
2954 let client = connect_client(cli, timeout_secs)?;
2955
2956 if wait {
2957 eprintln!(
2958 "waiting for a print to start, then capturing every {} layer(s) to {} …",
2959 every,
2960 out_dir.display()
2961 );
2962 } else {
2963 eprintln!(
2964 "watching the active print; capturing every {} layer(s) to {} …",
2965 every,
2966 out_dir.display()
2967 );
2968 }
2969
2970 let (tx, rx) = std::sync::mpsc::channel::<(std::path::PathBuf, i64)>();
2974 let worker = {
2975 let argv = on_layer_cmd.to_vec();
2976 let dir = out_dir.to_path_buf();
2977 std::thread::spawn(move || {
2978 let (mut captured, mut failures) = (0u64, 0u64);
2979 for (frame, layer) in rx {
2980 match run_capture_cmd(&argv, &frame, layer, &dir) {
2981 Ok(()) => {
2982 captured += 1;
2983 eprintln!("captured frame (layer {layer}) -> {}", frame.display());
2984 }
2985 Err(e) => {
2986 failures += 1;
2987 eprintln!("capture failed at layer {layer}: {e} (continuing)");
2988 }
2989 }
2990 }
2991 (captured, failures)
2992 })
2993 };
2994
2995 let watch_result = {
3000 let mut session = CaptureSession::new(every, wait);
3001 let mut on_update = |state: &ReportState| -> WatchStep {
3002 let st = PrinterStatus::from_state(state.get());
3003 match session.observe(&st) {
3004 CaptureAction::Capture { frame_no, layer } => {
3005 let frame = out_dir.join(format!("frame_{frame_no:06}_layer_{layer:05}.{ext}"));
3006 let _ = tx.send((frame, layer));
3009 WatchStep::Continue
3010 }
3011 CaptureAction::Continue => WatchStep::Continue,
3012 CaptureAction::Stop => WatchStep::Stop,
3013 }
3014 };
3015 client.watch(interval, &mut on_update)
3016 };
3017 drop(tx);
3020 let (captured, failures) = worker.join().unwrap_or((0, 0));
3021
3022 let ended_by = match &watch_result {
3023 Ok(_) => "terminal",
3024 Err(ClientError::Timeout(_)) => "timeout",
3025 Err(_) => "error",
3026 };
3027 if let Err(e) = watch_result
3029 && !matches!(e, ClientError::Timeout(_))
3030 {
3031 return Err(e.into());
3032 }
3033
3034 eprintln!("done: {captured} frame(s) captured, {failures} failure(s) ({ended_by})");
3035 let suggested = ffmpeg_suggestion(out_dir, ext);
3036 if want_json(cli) {
3037 print_json(&serde_json::json!({
3038 "captured": captured,
3039 "failures": failures,
3040 "out_dir": out_dir.to_string_lossy(),
3041 "ended_by": ended_by,
3042 "suggested_assemble": (captured > 0).then_some(suggested.clone()),
3043 }));
3044 }
3045 if captured == 0 {
3046 eprintln!(
3047 "no frames captured — start this during an active print (the printer \
3048 must be RUNNING and advancing layers), or pass --wait to launch it \
3049 first and have it wait for the print to start."
3050 );
3051 return Ok(());
3052 }
3053 if !want_json(cli) {
3056 println!("to build a video:\n {suggested}");
3057 }
3058 Ok(())
3059}
3060
3061fn ffmpeg_suggestion(out_dir: &std::path::Path, ext: &str) -> String {
3064 let dir = out_dir.display();
3065 format!(
3066 "ffmpeg -framerate 12 -pattern_type glob -i '{dir}/frame_*.{ext}' \
3067 -c:v libx264 -pix_fmt yuv420p {dir}/timelapse.mp4"
3068 )
3069}
3070
3071fn subst_capture_tokens(s: &str, frame: &str, layer: i64, out_dir: &str) -> String {
3075 s.replace("{frame}", frame)
3076 .replace("{layer}", &layer.to_string())
3077 .replace("{outdir}", out_dir)
3078}
3079
3080fn run_capture_cmd(
3082 argv: &[String],
3083 frame: &std::path::Path,
3084 layer: i64,
3085 out_dir: &std::path::Path,
3086) -> Result<(), String> {
3087 let frame = frame.to_string_lossy();
3088 let dir = out_dir.to_string_lossy();
3089 let subst = |s: &str| subst_capture_tokens(s, &frame, layer, &dir);
3090 let prog = subst(&argv[0]);
3091 let args: Vec<String> = argv[1..].iter().map(|a| subst(a)).collect();
3092 let status = std::process::Command::new(&prog)
3093 .args(&args)
3094 .status()
3095 .map_err(|e| format!("spawn {prog:?}: {e}"))?;
3096 if status.success() {
3097 Ok(())
3098 } else {
3099 Err(format!("{prog:?} exited with {status}"))
3100 }
3101}
3102
3103fn resolve_target(cli: &Cli) -> Result<ResolvedTarget, CliError> {
3105 let cfg = Config::load_or_default(&config_path()?)?;
3106 let profile = selected_profile_name(cli, &cfg)?.and_then(|n| cfg.profile(&n).cloned());
3107 let overrides = flag_overrides(cli).over(Overrides::from_env());
3108 Ok(config::resolve(profile.as_ref(), &overrides)?)
3109}
3110
3111fn connect_client(cli: &Cli, timeout_secs: u64) -> Result<LanMqttClient, CliError> {
3114 Ok(LanMqttClient::new(resolve_target(cli)?).with_timeout(Duration::from_secs(timeout_secs)))
3115}
3116
3117fn report_command_outcome(cli: &Cli, outcome: CommandOutcome) -> Result<(), CliError> {
3124 if want_json(cli) {
3125 let v = match &outcome {
3126 CommandOutcome::Verified => serde_json::json!({ "outcome": "verified" }),
3127 CommandOutcome::Rejected { reason } => {
3128 serde_json::json!({ "outcome": "rejected", "reason": reason })
3129 }
3130 CommandOutcome::Unverified { stage } => serde_json::json!({
3131 "outcome": "unverified",
3132 "stage": match stage {
3133 VerifyStage::Ack => "ack",
3134 VerifyStage::Effect => "effect",
3135 },
3136 }),
3137 };
3138 print_json(&v);
3139 }
3140 match outcome {
3141 CommandOutcome::Verified => {
3142 if !want_json(cli) {
3143 eprintln!("verified: the printer confirmed the command took effect");
3144 }
3145 Ok(())
3146 }
3147 CommandOutcome::Rejected { reason } => Err(CliError::new(
3148 exit::DEVICE_REJECTED,
3149 format!("the printer rejected the command: {reason}"),
3150 )),
3151 CommandOutcome::Unverified {
3152 stage: VerifyStage::Ack,
3153 } => Err(CliError::new(
3154 exit::VERIFY_TIMEOUT,
3155 "command published but not acknowledged within the timeout (unverified)",
3156 )),
3157 CommandOutcome::Unverified {
3158 stage: VerifyStage::Effect,
3159 } => Err(CliError::new(
3160 exit::VERIFY_TIMEOUT,
3161 "command was acknowledged but its effect never showed in the report \
3162 (the printer's state didn't change — e.g. a print that won't start \
3163 or a light that won't switch); unverified — check `bambu status`",
3164 )),
3165 }
3166}
3167
3168fn is_watch_terminal(state: GcodeState) -> bool {
3170 matches!(
3171 state,
3172 GcodeState::Finish | GcodeState::Failed | GcodeState::Idle
3173 )
3174}
3175
3176fn selected_profile_name(cli: &Cli, cfg: &Config) -> Result<Option<String>, CliError> {
3182 let name = match cli.printer.clone().or_else(|| cfg.default_printer.clone()) {
3183 Some(n) => n,
3184 None => return Ok(None),
3185 };
3186 if cfg.printers.contains_key(&name) {
3187 Ok(Some(name))
3188 } else {
3189 Err(CliError::from(ConfigError::UnknownProfile(name)))
3190 }
3191}
3192
3193fn want_json(cli: &Cli) -> bool {
3195 cli.json
3199}
3200
3201fn flag_overrides(cli: &Cli) -> Overrides {
3202 Overrides {
3203 ip: cli.ip.clone(),
3204 serial: cli.serial.clone(),
3205 access_code: cli.access_code.clone(),
3206 model: cli.model.clone(),
3207 }
3208}
3209
3210fn print_json<T: Serialize>(value: &T) {
3211 match serde_json::to_string_pretty(value) {
3212 Ok(s) => println!("{s}"),
3213 Err(e) => eprintln!("error: failed to serialize output: {e}"),
3214 }
3215}
3216
3217fn print_status_human(o: &StatusOutput) {
3218 let s = &o.status;
3219 println!(
3220 "printer: {} ({})",
3221 o.printer.as_deref().unwrap_or("-"),
3222 o.model
3223 );
3224 println!("state: {}", s.gcode_state.as_deref().unwrap_or("?"));
3225 if let Some(err) = &s.error {
3227 println!("error: ⚠ {} (print_error {})", err.hex, err.code);
3228 println!(" {}", err.lookup_url);
3229 }
3230 if let (Some(stage), Some(id)) = (s.stage.as_deref(), s.stg_cur)
3233 && !Stage(id).is_no_stage()
3234 {
3235 println!("stage: {stage} ({id})");
3236 }
3237 if let Some(f) = &s.filament {
3238 let name = f.name.as_deref().or(f.material.as_deref()).unwrap_or("?");
3239 let color = f
3240 .color
3241 .as_deref()
3242 .map(|c| format!(" #{c}"))
3243 .unwrap_or_default();
3244 println!("filament: {name} @ {}{color}", f.location);
3245 }
3246 if let (Some(n), Some(b)) = (s.nozzle_temper, s.bed_temper) {
3247 println!("temps: nozzle {n:.1}°C / bed {b:.1}°C");
3248 }
3249 if let Some(tl) = s.timelapse_mode() {
3250 println!("timelapse: {tl}");
3251 }
3252 if let Some(lvl) = s.spd_lvl {
3253 let name = SpeedLevel::from_level(lvl)
3254 .map(|l| l.as_str())
3255 .unwrap_or("?");
3256 println!("speed: {name} ({lvl})");
3257 }
3258 if let Some(p) = s.mc_percent {
3259 let layer = s.layer_num.unwrap_or(0);
3260 let total = s.total_layer_num.unwrap_or(0);
3261 let eta = match s.remaining_time_min.filter(|m| *m > 0) {
3262 Some(m) => format!(", ETA {}", fmt_eta(m)),
3263 None => String::new(),
3264 };
3265 println!("progress: {p}% (layer {layer}/{total}{eta})");
3266 }
3267}
3268
3269fn fmt_eta(min: i64) -> String {
3271 if min >= 60 {
3272 format!("{}h{:02}m", min / 60, min % 60)
3273 } else {
3274 format!("{min}m")
3275 }
3276}
3277
3278#[cfg(test)]
3279mod tests {
3280 use super::{ams_mapping_preview, fmt_eta, subst_capture_tokens, validate_ams_map};
3281
3282 #[cfg(feature = "server")]
3283 #[test]
3284 fn serve_status_url_joins_and_trims_trailing_slash() {
3285 use super::serve_status_url;
3286 assert_eq!(
3287 serve_status_url("http://127.0.0.1:8088"),
3288 "http://127.0.0.1:8088/api/status"
3289 );
3290 assert_eq!(
3292 serve_status_url("http://h:8088/"),
3293 "http://h:8088/api/status"
3294 );
3295 }
3296
3297 #[test]
3298 fn encode_args_for_an_mjpeg_stream() {
3299 use super::build_ffmpeg_args;
3300 use std::path::Path;
3301 let args = build_ffmpeg_args(
3302 Path::new("/r/plain.mjpeg"),
3303 Path::new("/r/plain.mp4"),
3304 30,
3305 8,
3306 )
3307 .unwrap();
3308 let joined = args.join(" ");
3309 assert!(joined.contains("-f mpjpeg"), "{joined}");
3310 assert!(joined.contains("-i /r/plain.mjpeg"));
3311 assert!(joined.contains("framestep=8"), "speed>1 ⇒ framestep");
3312 assert!(joined.contains("setpts=N/30/TB"));
3313 assert!(joined.trim_end().ends_with("/r/plain.mp4"));
3314 }
3315
3316 #[test]
3317 fn encode_args_for_an_image_sequence_dir() {
3318 use super::build_ffmpeg_args;
3319 let dir = std::env::temp_dir().join(format!("bambu-enc-{}", std::process::id()));
3320 std::fs::create_dir_all(&dir).unwrap();
3321 let out = dir.join("x.mp4");
3322 let args = build_ffmpeg_args(&dir, &out, 20, 1).unwrap();
3323 let joined = args.join(" ");
3324 assert!(joined.contains("-framerate 20"), "{joined}");
3325 assert!(joined.contains("-pattern_type glob"));
3326 assert!(joined.contains("frame_*.jpg"));
3327 assert!(!joined.contains("framestep"), "speed=1 ⇒ no framestep");
3328
3329 let fast = build_ffmpeg_args(&dir, &out, 20, 4).unwrap().join(" ");
3332 assert!(fast.contains("framestep=4"), "{fast}");
3333 assert!(
3334 fast.contains("setpts=N/20/TB"),
3335 "stepped frames must be re-timed: {fast}"
3336 );
3337 let _ = std::fs::remove_dir_all(&dir);
3338 }
3339
3340 #[test]
3341 fn encode_rejects_a_non_dir_non_mjpeg_input() {
3342 use super::build_ffmpeg_args;
3343 use std::path::Path;
3344 let e = build_ffmpeg_args(Path::new("/no/such/file.txt"), Path::new("/o.mp4"), 30, 1)
3345 .unwrap_err();
3346 assert_eq!(e.code, super::exit::VALIDATION);
3347 }
3348
3349 #[test]
3350 fn default_mp4_out_swaps_the_suffix() {
3351 use super::default_mp4_out;
3352 use std::path::Path;
3353 assert_eq!(
3354 default_mp4_out(Path::new("/r/plain.mjpeg")),
3355 Path::new("/r/plain.mp4")
3356 );
3357 assert_eq!(
3358 default_mp4_out(Path::new("/r/ext-1")),
3359 Path::new("/r/ext-1.mp4")
3360 );
3361 }
3362
3363 #[test]
3364 fn watch_line_formats_state_progress_and_temps() {
3365 use super::format_watch_line;
3366 use crate::core::status::PrinterStatus;
3367 let st = PrinterStatus::from_state(&serde_json::json!({ "print": {
3368 "gcode_state": "RUNNING", "mc_percent": 42, "layer_num": 10, "total_layer_num": 240,
3369 "nozzle_temper": 215.0, "nozzle_target_temper": 245.0,
3370 "bed_temper": 60.0, "bed_target_temper": 60.0,
3371 }}));
3372 let line = format_watch_line(&st);
3373 for needle in ["RUNNING", "42%", "layer 10/240", "N215/245", "B60/60"] {
3374 assert!(line.contains(needle), "{needle:?} missing from {line:?}");
3375 }
3376 }
3377
3378 #[test]
3379 fn watch_key_rounds_temps_so_subdegree_jitter_is_one_line() {
3380 use super::watch_key;
3381 use crate::core::status::PrinterStatus;
3382 let noz = |n: f64| {
3383 PrinterStatus::from_state(&serde_json::json!({ "print": { "nozzle_temper": n } }))
3384 };
3385 assert!(watch_key(&noz(215.1)) == watch_key(&noz(215.4)));
3387 assert!(watch_key(&noz(215.0)) != watch_key(&noz(216.0)));
3389 }
3390
3391 #[test]
3392 fn eta_formats_minutes_and_hours() {
3393 assert_eq!(fmt_eta(15), "15m");
3394 assert_eq!(fmt_eta(59), "59m");
3395 assert_eq!(fmt_eta(60), "1h00m");
3396 assert_eq!(fmt_eta(95), "1h35m");
3397 }
3398
3399 #[test]
3400 fn ams_map_range_is_always_checked() {
3401 assert!(validate_ams_map(&[0, 3, -1], None).is_ok());
3403 assert!(validate_ams_map(&[0, 4], None).is_err());
3405 assert!(validate_ams_map(&[-2], None).is_err());
3406 }
3407
3408 #[test]
3409 fn ams_map_length_must_match_filament_count_when_known() {
3410 assert!(validate_ams_map(&[0, 1], Some(2)).is_ok());
3412 assert!(validate_ams_map(&[0, 1], Some(3)).is_err());
3414 assert!(validate_ams_map(&[0], Some(2)).is_err());
3416 }
3417
3418 #[test]
3419 fn ams_map_warns_on_multiple_external_spools() {
3420 let warns = validate_ams_map(&[-1, -1], Some(2)).unwrap();
3421 assert!(warns.iter().any(|w| w.contains("external spool")));
3422 assert!(validate_ams_map(&[0, -1], Some(2)).unwrap().is_empty());
3424 }
3425
3426 #[test]
3427 fn ams_preview_pairs_filaments_with_trays() {
3428 let colors = vec!["#F2754E".to_string(), "#0000FF".to_string()];
3429 let v = ams_mapping_preview(&colors, &[2, -1]);
3430 let arr = v.as_array().unwrap();
3431 assert_eq!(arr[0]["color"], "#F2754E");
3432 assert_eq!(arr[0]["tray"], 2);
3433 assert_eq!(arr[0]["source"], "AMS tray 2");
3434 assert_eq!(arr[1]["tray"], -1);
3435 assert_eq!(arr[1]["source"], "external spool");
3436 }
3437
3438 #[test]
3439 fn capture_tokens_substitute_per_argv_element() {
3440 assert_eq!(
3441 subst_capture_tokens("{outdir}/f_{layer}.jpg", "/t/frame.jpg", 42, "/t"),
3442 "/t/f_42.jpg"
3443 );
3444 assert_eq!(
3445 subst_capture_tokens("{frame}", "/t/frame.jpg", 7, "/t"),
3446 "/t/frame.jpg"
3447 );
3448 assert_eq!(subst_capture_tokens("-r", "/f.jpg", 1, "/t"), "-r");
3450 }
3451
3452 #[test]
3453 fn capture_tokens_do_not_interpret_shell_metacharacters() {
3454 let layer_with_meta = subst_capture_tokens("{frame}", "/t/a b;rm -rf $HOME.jpg", 1, "/t");
3458 assert_eq!(layer_with_meta, "/t/a b;rm -rf $HOME.jpg");
3459 }
3460}
3461
3462#[derive(Serialize)]
3463struct StatusOutput {
3464 printer: Option<String>,
3465 model: String,
3466 #[serde(flatten)]
3467 status: PrinterStatus,
3468}
3469
3470#[derive(Serialize)]
3472struct RedactedProfile<'a> {
3473 name: &'a str,
3474 ip: &'a str,
3475 serial: &'a str,
3476 model: &'a str,
3477 mode: &'a str,
3478 access_code: &'static str,
3479}
3480
3481impl<'a> RedactedProfile<'a> {
3482 fn from(name: &'a str, p: &'a Profile) -> Self {
3483 Self {
3484 name,
3485 ip: &p.ip,
3486 serial: &p.serial,
3487 model: &p.model,
3488 mode: &p.mode,
3489 access_code: "<redacted>",
3490 }
3491 }
3492}
3493
3494impl std::fmt::Display for RedactedProfile<'_> {
3495 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3496 write!(
3497 f,
3498 "{}: ip={} serial={} model={} mode={} access_code={}",
3499 self.name, self.ip, self.serial, self.model, self.mode, self.access_code
3500 )
3501 }
3502}