Skip to main content

bambu_rs/
cli.rs

1//! The `bambu` command-line interface (behind the `cli` feature).
2//!
3//! Thin layer over the library: parse args, resolve a connection target, call
4//! the client, format output. Agent contract: human-readable output by default,
5//! machine-readable JSON to stdout with `--json` (no TTY auto-detection — output
6//! format depends only on the flag); a semantic exit-code scheme; the access
7//! code is never printed.
8
9use 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
36/// Exit codes (a subset of the documented scheme).
37mod 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    /// Printer profile to use (defaults to the configured default).
55    #[arg(long, global = true)]
56    printer: Option<String>,
57    /// Override the printer IP address.
58    #[arg(long, global = true)]
59    ip: Option<String>,
60    /// Override the serial number.
61    #[arg(long, global = true)]
62    serial: Option<String>,
63    /// Override the LAN access code.
64    #[arg(long, global = true)]
65    access_code: Option<String>,
66    /// Override the model (e.g. a1mini).
67    #[arg(long, global = true)]
68    model: Option<String>,
69    /// Emit machine-readable JSON (default output is human-readable).
70    #[arg(long, global = true)]
71    json: bool,
72    /// Read through a running `bambu serve`'s HTTP API instead of opening a
73    /// direct MQTT connection — lower latency, since serve already holds a live
74    /// delta-merged snapshot (a cold connect+pushall costs seconds). Only the
75    /// open reads honor it (`status`, `status --watch`); writes still go direct.
76    /// e.g. `http://127.0.0.1:8088`. (Needs no access code — reads are open.)
77    #[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    /// Manage saved printer profiles.
87    Config {
88        #[command(subcommand)]
89        action: ConfigAction,
90    },
91    /// Print a status snapshot; with --watch, monitor continuously.
92    Status {
93        /// Continuously monitor: print live updates and do NOT stop at job
94        /// completion (runs until --timeout or Ctrl-C). To watch a print *to
95        /// completion*, use `job start --watch`.
96        #[arg(long)]
97        watch: bool,
98        /// With --watch, poll every N seconds (sends `pushall`) for a higher
99        /// data rate, like Bambu Studio. Default: passive (printer's ~2s push).
100        #[arg(long)]
101        interval: Option<u64>,
102        /// With --watch, give up only after NO report for this many seconds
103        /// (resets while the printer responds; drops auto-reconnect). Default 2m.
104        #[arg(long, default_value_t = 120)]
105        timeout: u64,
106    },
107    /// Show the printer's firmware/module inventory and resolved capabilities.
108    Info,
109    /// Decode the active HMS (Health Management System) alerts.
110    Hms,
111    /// Start, pause, resume, stop, or dismiss the error of a print job.
112    Job {
113        #[command(subcommand)]
114        action: JobAction,
115    },
116    /// Transfer files to/from the printer over FTPS.
117    File {
118        #[command(subcommand)]
119        action: FileAction,
120    },
121    /// Camera operations (A1/P1 chamber-image stream).
122    Camera {
123        #[command(subcommand)]
124        action: CameraAction,
125    },
126    /// Timelapse: toggle printer-side recording, fetch videos, or drive an
127    /// external camera from the print's own layer events.
128    Timelapse {
129        #[command(subcommand)]
130        action: TimelapseAction,
131    },
132    /// Turn a light on or off (control test; low-risk).
133    Light {
134        /// "on" or "off".
135        #[arg(value_parser = ["on", "off"])]
136        state: String,
137        /// Which light: chamber (default) or work. `work` is [spec] — not every
138        /// model has one (this A1 mini only reports `chamber_light`).
139        #[arg(long, default_value = "chamber", value_parser = ["chamber", "work"])]
140        node: String,
141        /// Watch the report for this many seconds after sending.
142        #[arg(long, default_value_t = 8)]
143        timeout: u64,
144    },
145    /// Set the print-speed profile (can be sent mid-print; reversible).
146    Speed {
147        /// Speed level.
148        #[arg(value_parser = ["silent", "standard", "sport", "ludicrous"])]
149        level: String,
150        /// Watch the report for this many seconds to confirm spd_lvl changed.
151        #[arg(long, default_value_t = 8)]
152        timeout: u64,
153    },
154    /// AMS operations (control, filament change, tray settings). [spec] —
155    /// derived from OpenBambuAPI, not yet confirmed on this unit's AMS Lite.
156    Ams {
157        #[command(subcommand)]
158        action: AmsAction,
159    },
160    /// Run printer calibration. With no routine flags it runs them ALL (the default);
161    /// pass any of --bed-level/--vibration/--motor-noise to run just those.
162    Calibrate {
163        #[command(flatten)]
164        args: CalibrateArgs,
165    },
166    /// Send a raw G-code line and watch the report (control; needs --confirm).
167    Gcode {
168        /// The G-code line, e.g. "G28" (home all axes).
169        line: String,
170        /// Required to actually send a control command.
171        #[arg(long)]
172        confirm: bool,
173        /// Override the static safety check (over-limit temps, cold extrusion).
174        #[arg(long)]
175        force: bool,
176        /// Watch the report for this many seconds after sending.
177        #[arg(long, default_value_t = 30)]
178        timeout: u64,
179    },
180    /// Reboot the printer (disruptive; needs --confirm). The printer drops the
181    /// connection and restarts (~1–2 min) and may rejoin DHCP on a new IP.
182    Reboot {
183        /// Required — the printer will disconnect and restart.
184        #[arg(long)]
185        confirm: bool,
186    },
187    /// Serve the monitoring + control HTTP API (and the web dashboard SPA when
188    /// built with the `dashboard` feature).
189    #[cfg(feature = "server")]
190    #[command(alias = "dashboard")]
191    Serve {
192        /// Bind host. Default 127.0.0.1; a non-loopback host serves over the
193        /// network (without --password, control is open — a warning is printed).
194        #[arg(long, default_value = "127.0.0.1")]
195        host: String,
196        /// Bind port.
197        #[arg(long, default_value_t = 8088)]
198        port: u16,
199        /// Password gating control (write) requests. Reads are always open; if
200        /// omitted, control is open too. May also be set via $BAMBU_SERVE_PASSWORD.
201        #[arg(long, env = "BAMBU_SERVE_PASSWORD")]
202        password: Option<String>,
203        /// Serve deterministic fake data (no printer needed; for demos/E2E).
204        #[arg(long)]
205        fake: bool,
206        /// Poll the printer every N seconds for live updates (default: passive).
207        #[arg(long)]
208        interval: Option<u64>,
209        /// External IP-camera snapshot URL(s) the dashboard proxies (single JPEG
210        /// per GET, e.g. an ATOM Cam `http://HOST/cgi-bin/get_jpeg.cgi`). Repeat the
211        /// flag for multiple cameras, optionally labelling each as `label=url`. The
212        /// dashboard shows them as tabs and can add/remove more at runtime. May also
213        /// be set via $BAMBU_CAMERA_URL (comma-separated).
214        #[arg(long, env = "BAMBU_CAMERA_URL", value_delimiter = ',')]
215        camera_url: Vec<String>,
216        /// Seed external cameras from a JSON file — a list of
217        /// `{ "label"?, "url", "stream_url"?, "park_tuning"? }`, the same shape as
218        /// `/api/camera/config`. Unlike `--camera-url` this can carry a stream URL and
219        /// per-camera park tuning (so a camera is live-park-capable from launch). Seeded
220        /// after any `--camera-url`; all remain editable at runtime.
221        #[arg(long, value_name = "PATH")]
222        cameras_config: Option<std::path::PathBuf>,
223    },
224}
225
226#[derive(Subcommand)]
227enum ConfigAction {
228    /// Add or update a profile (named by --printer).
229    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        /// Make this the default profile.
239        #[arg(long)]
240        set_default: bool,
241    },
242    /// List saved profiles.
243    List,
244    /// Show a profile (access code redacted).
245    Show,
246}
247
248#[derive(Subcommand)]
249enum JobAction {
250    /// Start a print of a file already on the printer (.gcode or .gcode.3mf).
251    /// With --upload, FILE is instead a LOCAL path that's uploaded first.
252    Start {
253        /// On-printer path (e.g. /foo.gcode.3mf), or — with --upload — a LOCAL
254        /// file to upload then print.
255        file: String,
256        /// Upload FILE (a local path) to the printer, then print it. The remote
257        /// path defaults to /<basename> (override with --dest).
258        #[arg(long)]
259        upload: bool,
260        /// With --upload, the on-printer destination path (default /<basename>).
261        #[arg(long)]
262        dest: Option<String>,
263        /// With --upload, replace the destination if it already exists (default:
264        /// refuse, to avoid clobbering a file mid-print).
265        #[arg(long)]
266        overwrite: bool,
267        /// Plate number (for .3mf project files).
268        #[arg(long, default_value_t = 1)]
269        plate: u32,
270        /// Use the AMS with this mapping: comma-separated tray indices per
271        /// filament, -1 = external spool (e.g. "0,-1").
272        #[arg(long)]
273        ams_map: Option<String>,
274        /// Build-plate type.
275        #[arg(long, default_value = "auto")]
276        bed_type: String,
277        /// Record a printer-side timelapse for this print (sets the
278        /// project_file `timelapse` flag). Needs a working built-in camera.
279        #[arg(long)]
280        timelapse: bool,
281        /// Show the resolved command JSON without sending it (safe).
282        #[arg(long)]
283        dry_run: bool,
284        /// Required to actually start a print.
285        #[arg(long)]
286        confirm: bool,
287        /// Guard: refuse unless the on-printer file's plate-gcode md5 matches
288        /// this (case-insensitive). Get it from `--dry-run`. (.3mf only.)
289        #[arg(long)]
290        expect_md5: Option<String>,
291        /// Guard: refuse unless --plate equals this. (.3mf only.)
292        #[arg(long)]
293        expect_plate: Option<u32>,
294        /// After starting, watch the job to completion and detect anomalies
295        /// (a device error or a FAILED state exits non-zero).
296        #[arg(long)]
297        watch: bool,
298        /// With --watch, give up watching after this many seconds (default 6h).
299        #[arg(long, default_value_t = 21600)]
300        watch_timeout: u64,
301        /// With --watch, poll every N seconds (sends `pushall`) for a higher
302        /// data rate, like Bambu Studio. Default: passive.
303        #[arg(long)]
304        interval: Option<u64>,
305    },
306    /// Pause the current print (needs --confirm).
307    Pause {
308        #[arg(long)]
309        confirm: bool,
310    },
311    /// Resume a paused print (needs --confirm).
312    Resume {
313        #[arg(long)]
314        confirm: bool,
315    },
316    /// Stop (cancel) the current print — irreversible (needs --confirm).
317    Stop {
318        #[arg(long)]
319        confirm: bool,
320    },
321    /// Dismiss a print error (`clean_print_error`) — the way Bambu Studio clears
322    /// an error popup so the printer can leave FAILED without a reboot. Narrow:
323    /// it only acknowledges the error, it does not stop/resume/clear the job or
324    /// the bed. Needs --confirm.
325    ClearError {
326        #[arg(long)]
327        confirm: bool,
328    },
329}
330
331/// `calibrate` flags: which routines to run, plus the shared run/verify knobs. Mirrors the
332/// dashboard's picker — selecting none runs them all (the common "full calibration").
333#[derive(clap::Args)]
334struct CalibrateArgs {
335    /// Auto-level the heated bed.
336    #[arg(long)]
337    bed_level: bool,
338    /// Vibration / resonance compensation.
339    #[arg(long)]
340    vibration: bool,
341    /// Motor-noise (current) calibration.
342    #[arg(long)]
343    motor_noise: bool,
344    /// Show what would run, without sending it (safe).
345    #[arg(long)]
346    dry_run: bool,
347    /// Required to actually run calibration (it moves the hardware).
348    #[arg(long)]
349    confirm: bool,
350    /// After starting, watch the printer report until calibration finishes.
351    #[arg(long)]
352    watch: bool,
353    /// With --watch, give up watching after this many seconds (default 1h).
354    #[arg(long, default_value_t = 3600)]
355    watch_timeout: u64,
356    /// With --watch, poll every N seconds (sends `pushall`) for a higher data
357    /// rate. Default: passive (wait for the printer's own pushes).
358    #[arg(long)]
359    interval: Option<u64>,
360}
361
362#[derive(Subcommand)]
363enum TimelapseAction {
364    /// Enable printer-side timelapse recording (camera.ipcam_timelapse).
365    Enable {
366        /// Watch the report for this many seconds to confirm the setting.
367        #[arg(long, default_value_t = 8)]
368        timeout: u64,
369    },
370    /// Disable printer-side timelapse recording (camera.ipcam_timelapse).
371    Disable {
372        #[arg(long, default_value_t = 8)]
373        timeout: u64,
374    },
375    /// List recorded timelapse files on the printer (FTPS /timelapse).
376    List,
377    /// Download a recorded timelapse file from the printer.
378    Get {
379        /// File name under /timelapse (or a full on-printer path).
380        name: String,
381        /// Local output path (default: the file's basename in the CWD).
382        #[arg(long)]
383        out: Option<std::path::PathBuf>,
384    },
385    /// Drive an EXTERNAL camera: watch the active print and run a capture
386    /// command on each new layer (works even with no/!broken built-in camera).
387    ///
388    /// The capture command goes after `--` and runs as argv (no shell), so its
389    /// own flags are fine. Tokens {frame} (the numbered output path), {layer} and
390    /// {outdir} are substituted. E.g. an ATOM Cam / IP camera:
391    ///   bambu timelapse capture --out-dir ./tl -- \
392    ///     curl -s -m 15 -o {frame} http://$ATOMCAM_HOST/cgi-bin/get_jpeg.cgi
393    Capture {
394        /// Directory for captured frames (created if missing).
395        #[arg(long, default_value = "./timelapse")]
396        out_dir: std::path::PathBuf,
397        /// Capture every Nth layer (1 = every layer).
398        #[arg(long, default_value_t = 1)]
399        every: u64,
400        /// Frame file extension used for {frame} paths.
401        #[arg(long, default_value = "jpg")]
402        ext: String,
403        /// Poll the printer every N seconds (sends `pushall`) for a higher layer
404        /// detection rate. Default: passive (printer's ~2s push).
405        #[arg(long)]
406        interval: Option<u64>,
407        /// Give up watching after this many seconds (default 6h).
408        #[arg(long, default_value_t = 21600)]
409        timeout: u64,
410        /// Wait for a print to start instead of requiring one already running:
411        /// sit through idle/finished states (and a stale error from the last
412        /// print) and begin capturing once the print becomes active. Lets you
413        /// launch this BEFORE starting the print. Bounded by --timeout.
414        #[arg(long)]
415        wait: bool,
416        /// The capture command (after `--`), as argv: program then args, with
417        /// {frame}/{layer}/{outdir} tokens. Run directly, never via a shell.
418        #[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 1.., value_name = "CMD")]
419        on_layer_cmd: Vec<String>,
420    },
421    /// Live "parked frame per layer" preview from a camera's MJPEG /stream — the
422    /// in-process miner, no MQTT/printer connection needed (it reads the camera, not
423    /// the printer). Updates <out>/latest_park.jpg each layer in near-real-time (point an
424    /// auto-reloading viewer at it, e.g. `feh --reload 1 <out>/latest_park.jpg`) and
425    /// accumulates park_*.jpg + parks.jsonl. Ctrl-C stops cleanly. Needs ffmpeg.
426    ///
427    ///   bambu timelapse park http://<host>/stream --config tuning.json --out ./live \
428    ///     --serve http://<serve-host>:8088 --assemble timelapse.mp4
429    Park {
430        /// The camera's MJPEG stream URL, e.g. http://<host>/stream.
431        stream_url: String,
432        /// Per-camera detection tuning JSON (copy the skill's tuning.example.json and
433        /// calibrate). There are deliberately NO defaults — a missing knob is an error,
434        /// because the right values depend on where the camera and printer sit.
435        #[arg(long)]
436        config: std::path::PathBuf,
437        /// Output dir for latest_park.jpg + park_*.jpg + parks.jsonl (created if missing).
438        #[arg(long, default_value = "./park")]
439        out: std::path::PathBuf,
440        /// On stop, assemble the accumulated park_*.jpg into this mp4 (needs ffmpeg).
441        #[arg(long)]
442        assemble: Option<std::path::PathBuf>,
443        /// Playback frame rate for --assemble.
444        #[arg(long, default_value_t = 12)]
445        out_fps: u32,
446        /// Auto-stop when the print ends: poll a running `bambu serve`'s /api/status for
447        /// the print lifecycle (no MQTT from here, so it never conflicts with serve or the
448        /// printer's connection). Without it the run ends on --max-seconds / Ctrl-C.
449        #[arg(long, value_name = "SERVE_URL")]
450        serve: Option<String>,
451        /// Auto-stop when the print ends by watching the printer DIRECTLY over MQTT (uses
452        /// the selected profile / BAMBU_* env). Alternative to --serve when no serve is
453        /// running — the A1 accepts a second MQTT connection, so it won't disrupt one.
454        #[arg(long, conflicts_with = "serve")]
455        watch_printer: bool,
456        /// Detection decode width (tiny grayscale; rarely changed).
457        #[arg(long, default_value_t = DECODE_W as u32)]
458        width: u32,
459        /// Detection decode height.
460        #[arg(long, default_value_t = DECODE_H as u32)]
461        height: u32,
462        /// Stop cleanly after N seconds (default: run until --serve's print-end / Ctrl-C).
463        #[arg(long)]
464        max_seconds: Option<u64>,
465    },
466    /// Encode a captured timelapse to mp4 with ffmpeg (must be on PATH). INPUT is
467    /// either a directory of frame_*.jpg (the smooth per-layer or plain sampled
468    /// frames → an image-sequence timelapse) or a .mjpeg stream file (the plain
469    /// stream recording → real video). Output defaults to the input + `.mp4`.
470    Encode {
471        /// A directory of frame_*.jpg, or a .mjpeg stream file.
472        input: std::path::PathBuf,
473        /// Output mp4 path (default: the input path with a .mp4 suffix).
474        #[arg(long)]
475        out: Option<std::path::PathBuf>,
476        /// Playback frame rate (frames/sec).
477        #[arg(long, default_value_t = 30)]
478        fps: u32,
479        /// Keep only every Nth frame to speed it up (1 = keep all).
480        #[arg(long, default_value_t = 1)]
481        speed: u32,
482    },
483}
484
485#[derive(Subcommand)]
486enum AmsAction {
487    /// Resume the AMS after a pause/error (ams_control resume).
488    Resume {
489        #[arg(long)]
490        confirm: bool,
491    },
492    /// Reset the AMS state (ams_control reset).
493    Reset {
494        #[arg(long)]
495        confirm: bool,
496    },
497    /// Pause the AMS (ams_control pause).
498    Pause {
499        #[arg(long)]
500        confirm: bool,
501    },
502    /// Change the loaded filament via the AMS — physically moves filament.
503    Change {
504        /// Target tray id.
505        #[arg(long)]
506        tray: u32,
507        /// New nozzle temperature (°C) for the target filament.
508        #[arg(long)]
509        tar_temp: i64,
510        /// Current nozzle temperature (°C); defaults to the new temp.
511        #[arg(long)]
512        curr_temp: Option<i64>,
513        #[arg(long)]
514        dry_run: bool,
515        #[arg(long)]
516        confirm: bool,
517    },
518    /// Set a tray's filament profile (material/colour/temps).
519    SetFilament {
520        #[arg(long, default_value_t = 0)]
521        ams: u32,
522        #[arg(long)]
523        tray: u32,
524        /// Material, e.g. PLA, PETG.
525        #[arg(long = "type")]
526        material: String,
527        /// Colour as hex RRGGBBAA (alpha usually FF).
528        #[arg(long, default_value = "000000FF")]
529        color: String,
530        /// Min/max nozzle temperature (°C).
531        #[arg(long)]
532        min: i64,
533        #[arg(long)]
534        max: i64,
535        /// Filament profile id (e.g. GFA00); optional.
536        #[arg(long, default_value = "")]
537        info_idx: String,
538        #[arg(long)]
539        dry_run: bool,
540        #[arg(long)]
541        confirm: bool,
542    },
543    /// Set AMS RFID-read options (ams_user_setting).
544    Settings {
545        #[arg(long, default_value_t = 0)]
546        ams: u32,
547        /// Read RFID on startup.
548        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
549        startup_read: bool,
550        /// Read RFID on tray insertion.
551        #[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    /// Grab one JPEG frame and write it to a file.
561    Snapshot {
562        /// Output file path.
563        #[arg(long, default_value = "snapshot.jpg")]
564        out: std::path::PathBuf,
565        /// Give up after this many seconds.
566        #[arg(long, default_value_t = 10)]
567        timeout: u64,
568    },
569}
570
571#[derive(Subcommand)]
572enum FileAction {
573    /// List file names in a directory on the printer.
574    Ls {
575        #[arg(default_value = "/")]
576        dir: String,
577    },
578    /// Upload a local file to the printer.
579    Upload {
580        /// Local file to upload.
581        local: std::path::PathBuf,
582        /// Destination directory on the printer (root by default — the A1 mini
583        /// prints from `/`; a file under `/cache` fails the print with 0x0500C010).
584        #[arg(long, default_value = "/")]
585        dest: String,
586    },
587    /// Download a file from the printer (e.g. a timelapse video).
588    Download {
589        /// On-printer path, e.g. /timelapse/video.mp4.
590        remote: String,
591        /// Local output path (default: the remote file's basename in the CWD).
592        #[arg(long)]
593        out: Option<std::path::PathBuf>,
594    },
595    /// Delete a file on the printer — irreversible (needs --confirm).
596    Rm {
597        /// On-printer path to delete.
598        remote: String,
599        #[arg(long)]
600        confirm: bool,
601    },
602}
603
604/// A CLI error carrying the exit code to return.
605#[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
652/// Entry point. Parses args, dispatches, and maps errors to exit codes.
653pub fn run() -> ExitCode {
654    // Pull BAMBU_* from a local .env (without overriding real env vars) so an
655    // interactive user need not export them every time.
656    config::load_dotenv();
657    // With the `license-notice` feature (release builds), add a `--license-notice`
658    // flag that prints the embedded third-party notices; otherwise a plain parse.
659    #[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    // `--via-serve`: read the snapshot off a running serve's HTTP API instead of
809    // a direct MQTT connect. Branch BEFORE config::resolve — the serve path needs
810    // no ip/serial/access_code, only the (optional) display identity.
811    #[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        // Continuous monitor: live updates that do NOT stop at job completion
824        // (runs until --timeout or Ctrl-C). Output goes to stdout.
825        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/// `bambu status --via-serve <url>`: fetch the snapshot from a running serve and
846/// render it through the same `StatusOutput` paths as the MQTT version, so the
847/// output is byte-for-byte the same shape. Reads are open, so no credentials are
848/// touched — only the display identity (printer name + model) is resolved, and
849/// even that is best-effort (it may not match the serve's actual target).
850#[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/// `status --watch --via-serve`: poll the serve's snapshot on an interval and
876/// print a line on every change (same renderer as the MQTT monitor). Continuous —
877/// runs until Ctrl-C; a failed poll is a hard transport error (exit 7) rather than
878/// silently repainting a stale snapshot. Default cadence 2s (serve's own update
879/// rate); `--interval` overrides.
880#[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/// Join a serve base URL with the status path. Trailing slashes on the base are
892/// trimmed so `http://h:8088` and `http://h:8088/` both work.
893#[cfg(feature = "server")]
894fn serve_status_url(base: &str) -> String {
895    format!("{}/api/status", base.trim_end_matches('/'))
896}
897
898/// GET the serve's `/api/status` and deserialize it into [`PrinterStatus`]. A
899/// short timeout keeps this on the "instant read" path; any failure is a
900/// transport error (exit 7) — never a silent fall-through to direct MQTT.
901#[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    // ureq is built without its `json` feature (no `into_json`), so read the body
916    // and parse with serde_json directly.
917    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/// Resolve the printer name + model for display only (no credentials). This must
932/// NEVER block the serve read: `--via-serve` exists precisely so a caller without
933/// local config can read an open API, so a missing/corrupt config, no `HOME`, or a
934/// stale `default_printer` all degrade to best-effort (`None` / `"unknown"`)
935/// rather than erroring. The label may not match the serve's actual target — that
936/// is accepted, since the serve is the source of truth here.
937#[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    // The label is just what the caller asked for (or the configured default) — we
944    // do NOT require it to exist as a profile, so a stale name can't block the read.
945    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/// One decoded HMS alert, for output.
963#[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/// Agent-facing view of the control assessment (degrade-not-wall).
999#[derive(Serialize)]
1000struct ControlView {
1001    /// `allowed` | `requires_developer_mode` | `newer_firmware_untested` | `refused`.
1002    status: &'static str,
1003    /// Whether control is *expected* to work (true for the first three).
1004    expected_ok: bool,
1005    /// Human-readable reason, present for warnings/refusals.
1006    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/// Output of `bambu info`: identity + firmware + resolved capabilities.
1051#[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    // Resolve capabilities only when the firmware is known; without it we can
1075    // still report descriptive facts via a model-only lookup is not possible
1076    // (resolve needs a firmware), so we fall back to reporting "unknown firmware".
1077    let registry = capability::default_registry();
1078    let output = match &version.firmware {
1079        Some(fw) => {
1080            let caps = capability::resolve(&registry, &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/// The report fields whose change triggers a new `watch` progress line.
1184/// Temperatures are rounded to whole °C so heating/cooling is visible (each 1 °C
1185/// step prints a line) without spamming on sub-degree jitter.
1186#[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
1197/// The change-trigger fields for `st`. Temperatures round to whole °C so each 1 °C
1198/// step prints (visible heating/cooling) without sub-degree spam.
1199fn 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
1211/// One human progress line: `STATE  pct%  layer a/b  N…/… B…/…  ETA…  [stage]  ⚠err`.
1212fn 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    // Nozzle/bed as current°→target° (target omitted when off/unset).
1222    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
1244/// Print a progress line for `st` IFF it changed from `last` (and update `last`).
1245/// Shared by the MQTT monitor and the serve-polling watch. A continuous monitor's
1246/// lines ARE its output → stdout (NDJSON under `--json`); a watch-to-completion
1247/// keeps stdout for its final snapshot, so its progress goes to stderr.
1248fn 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
1267/// Watch the printer to a terminal state, **or until a device error appears**,
1268/// printing a progress line (to stderr) on every change. Used by `watch` and by
1269/// `job start --watch`. A `print_error` mid-job is treated as an anomaly: stop,
1270/// surface it, and exit non-zero regardless of `exit_status`. `exit_status`
1271/// additionally makes a FAILED end-state exit non-zero (gh-run-watch style).
1272fn 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        // A continuous monitor never stops on its own (runs until timeout / Ctrl-C).
1286        if continuous {
1287            return WatchStep::Continue;
1288        }
1289        // A device fault is an anomaly worth stopping for, even mid-RUNNING.
1290        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    // status --watch monitors (reconnects, stall timeout); job start --watch
1300    // watches a job to completion (fail-fast).
1301    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    // The monitor's per-change lines were the output; it ends only via its
1308    // stall window (or Ctrl-C) — nothing more to print, no exit codes.
1309    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
1344/// Resolve `(model string, profile name)` for status/watch output headers,
1345/// using the same precedence as a connection.
1346fn 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    // Reboot tears down the connection, so there is no ACK — fire-and-forget.
1413    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    // Live mode needs a connection target; fake mode doesn't touch the printer.
1434    let target = if fake {
1435        None
1436    } else {
1437        Some(resolve_target(cli)?)
1438    };
1439    // Parse each `--camera-url` entry (`label=url` or a bare `url`), then any from
1440    // `--cameras-config` (which can also carry a stream URL + park tuning). The running
1441    // index gives stable sequential auto-labels (external 1, external 2, …) across both.
1442    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            // Parse the one tuning object two ways: ParkTuning strictly (a partial tuning is
1456            // a loud validation error, as before), SelectTuning best-effort (its select knobs
1457            // may be absent in a park-only config → no clean smooth assemble for this camera).
1458            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/// One entry of a `--cameras-config` JSON file — the same shape as `/api/camera/config`,
1490/// plus an optional `park_tuning` (validated by serde: no baked defaults).
1491#[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    /// Raw tuning object; parsed below into ParkTuning (strict) AND SelectTuning
1500    /// (best-effort — its extra select knobs may be absent in a park-only config).
1501    #[serde(default)]
1502    park_tuning: Option<serde_json::Value>,
1503}
1504
1505/// Read `--cameras-config`: a JSON array of [`SeedCamera`]. A read/parse failure (incl. a
1506/// partial park_tuning) is a clean validation error rather than a silent skip.
1507#[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    // Static safety guard: block recognised-dangerous lines (over-limit temps,
1533    // cold extrusion) unless explicitly overridden with --force.
1534    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                // The file path is the result (never the file's bytes).
1595                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            // --upload (FILE is a local path → upload then start) is a distinct
1636            // enough flow to live on its own; the shared core::start builder keeps
1637            // the command logic from duplicating.
1638            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            // The expect-guards are 3mf-only (raw .gcode has no plate/md5 metadata):
1664            // reject them on a .gcode rather than silently ignore.
1665            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            // The AMS mapping (if any), for validation + dry-run preview.
1674            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            // Tray-range is cheap and needs no inspection — fail fast on EVERY
1679            // path (even a plain confirm or an unreachable printer). The
1680            // filament-count match needs the 3mf, so it runs below once inspected.
1681            if let Some(m) = &ams_mapping {
1682                validate_ams_map(m, None)?;
1683            }
1684
1685            // When an expect-guard is given, inspecting the on-printer file is
1686            // MANDATORY (the caller asked us to verify) and a mismatch is fatal.
1687            // A bare --dry-run inspects BEST-EFFORT (enrich the plan if the
1688            // printer is reachable, else show the payload alone). A plain start
1689            // doesn't inspect at all — that path stays fast and unchanged.
1690            let has_expect = expect_md5.is_some() || expect_plate.is_some();
1691            let mut inspection: Option<PlateInspection> = None;
1692            // For a best-effort dry-run, remember why inspection failed so the
1693            // plan can say so explicitly (never a silent/ambiguous null).
1694            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                        // Filament-count check now that we know the plate's
1707                        // filaments. On a real start a mismatch is fatal (exit 3);
1708                        // on --dry-run it's downgraded to a warning so the plan
1709                        // (which also flags it) still prints for the agent to fix.
1710                        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                    // Inspection is mandatory when an expect-guard or an AMS
1724                    // mapping needs the filament count; only best-effort for a
1725                    // bare dry-run.
1726                    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                // Real plan: the resolved payload + what the on-printer file holds.
1740                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            // Only keep watching if the print actually started; otherwise the
1761            // verdict (rejected/unverified) is the result.
1762            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/// `bambu job start --upload <local>`: FTPS-upload a local file, then start the
1790/// print from its on-printer path. The command — including the plate-gcode md5,
1791/// read from the LOCAL bytes so the printer verifies what we just sent — is built
1792/// by the shared `core::start` builder (the same one the serve uses).
1793#[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    // Default to the printer root: the A1 mini prints from `/`; an uploaded file
1816    // under `/cache` fails the print start with 0x0500C010 (verified on-device).
1817    let remote = match dest {
1818        Some(d) => d.to_string(),
1819        None => format!("/{basename}"),
1820    };
1821    // The command type (project_file vs gcode_file) is derived from the REMOTE
1822    // path, but inspection/md5 come from the LOCAL file — a --dest that flips the
1823    // .3mf-ness would start the wrong command type for the bytes we uploaded.
1824    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    // Parse + range-check the AMS map up front (no I/O — fail fast).
1834    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    // Inspect the LOCAL bytes (the file we're about to upload) for the md5 the
1843    // printer will verify, plus the filament count for the AMS-map check.
1844    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    // Build the wire command for the REMOTE path, stamping in the local md5.
1860    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(&params, inspection.as_ref());
1869
1870    if dry_run {
1871        // Plan: the resolved command + what would be uploaded where (nothing is sent).
1872        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    // Upload (guarding an accidental clobber), then start from the remote path.
1894    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
1925/// Best-effort "does `remote` already exist?" (list its parent dir, match the
1926/// basename). A listing failure (dir absent, transport blip) is treated as "no":
1927/// a flaky stat shouldn't block an upload, and the upload itself surfaces real
1928/// transport errors.
1929fn 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
1939/// Build the start command, choosing project_file (.3mf) or gcode_file (.gcode).
1940fn 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    // AMS mapping only applies to a .3mf; parse it (the one CLI-fallible bit) and
1948    // hand the resolved params to the shared core builder. md5 is left unset here
1949    // (we have no inspection at this point — `job start --upload` supplies one).
1950    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(&params, 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
1973/// Validate a parsed `--ams-map`. Tray range is **always** checked (needs only
1974/// the mapping); the filament-count match is checked only when `filament_count`
1975/// is known (we have to inspect the on-printer 3mf for that). A wrong mapping is
1976/// the AMS footgun the plan calls out — refuse (exit 3) rather than mis-print.
1977/// Returns warnings (non-fatal advisories) for the caller to surface.
1978fn validate_ams_map(
1979    mapping: &[i32],
1980    filament_count: Option<usize>,
1981) -> Result<Vec<String>, CliError> {
1982    // Range: A1 AMS Lite has trays 0..=3; -1 = external spool.
1983    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
2018/// Build the dry-run `ams_mapping_preview`: one entry per plate filament, pairing
2019/// its colour (the device-confirmed count source) with the tray it's mapped to.
2020fn 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
2041/// Download the on-printer `.3mf` to a temp file and inspect the given plate.
2042/// The temp file is always removed (success or error). A download failure maps
2043/// to exit 7 (transport), a parse/missing-plate to exit 3 (validation).
2044fn 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    // Download into a freshly-created, randomly-named temp DIR (O_EXCL): an
2051    // attacker can't pre-create/symlink a path they can't predict, and the dir
2052    // (with the downloaded file and its `.part`) is RAII-removed on every exit
2053    // path — normal, `?`-error, or panic. Avoids the classic /tmp symlink/TOCTOU.
2054    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)?; // FtpError -> exit 7
2060    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    // `dir` drops here (or at any `?` above) -> the temp dir is removed.
2065}
2066
2067/// Build the `--dry-run` plan: the exact command payload plus what the
2068/// on-printer file actually contains (so an agent can read the md5/plate and
2069/// pass them back as `--expect-md5`/`--expect-plate`).
2070fn 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        // Inspected the on-printer file successfully.
2080        (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            // Arming timelapse on a plate WITHOUT the per-layer park blocks won't park the
2090            // head — no clean/object-only timelapse. Call it out before --confirm.
2091            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            // Pair each filament with the tray it'll draw from, so the mapping can
2099            // be eyeballed before --confirm (the plan's mandatory AMS preview).
2100            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                // Whether the sliced gcode injects the per-layer timelapse park (the
2120                // precondition for a clean/object-only timelapse — it still only runs if
2121                // timelapse is armed at print start with --timelapse).
2122                "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        // Best-effort inspection was attempted but failed — say so explicitly,
2129        // so an agent reading stdout never mistakes "couldn't check" for "fine".
2130        (None, Some(err)) => serde_json::json!({
2131            "inspected": false,
2132            "error": err,
2133        }),
2134        // No inspection applies (raw .gcode has no plate/md5 metadata).
2135        (None, None) => serde_json::Value::Null,
2136    };
2137    serde_json::json!({
2138        "command": cmd.to_payload("1"),
2139        "inspection": inspection_json,
2140    })
2141}
2142
2143/// Refuse to start a print unless the printer is idle (a key safety guard).
2144fn 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    // Helper: a plain control command gated on --confirm (ACK-verified).
2159    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            // Guard the nozzle temps the same way the raw-gcode guard does, so an
2198            // AMS change can't command an unsafe temperature.
2199            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            // A filament change is a physical operation — only when idle.
2225            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            // Validate the user input before building the command.
2245            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    // No routine flag → run them all (the default), matching the dashboard picker.
2299    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        // Human-readable by default; JSON only with --json (matches the contract).
2312        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    // With --watch, follow the report to completion (like `job start --watch`);
2339    // otherwise the accept/verify verdict is the result.
2340    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
2359/// A human label for the calibration steps that are enabled.
2360fn 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                // The file path is the result (never inline image bytes).
2406                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            // Accept either a bare file name or a full on-printer path.
2436            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
2513/// Inputs for [`run_timelapse_park`] — bundled to keep the call readable as options grow.
2514struct 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
2528/// `bambu timelapse park`: drive the live park miner over a camera's MJPEG stream. No
2529/// printer/MQTT connection — the park signal comes from the camera, so this just needs
2530/// the stream URL + a calibrated tuning. Blocks until the stream ends, `--max-seconds`
2531/// elapses, or the process is interrupted; reports each park to stderr and a final
2532/// summary (JSON with `--json`).
2533fn 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    // No baked defaults: a missing knob is a hard error, not a silent stale value.
2548    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    // Every stop source — Ctrl-C, --max-seconds, and --serve's print-end — converges on
2572    // this one flag, which run_park_camera's watchdog turns into an ffmpeg kill + clean
2573    // unwind (final summary + --assemble).
2574    let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2575    {
2576        let cancel = cancel.clone();
2577        // A second Ctrl-C is left to the OS default (hard kill) in case cleanup hangs.
2578        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        // A replace refines the last park (stronger frame, same layer) — not a new one.
2621        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
2677/// Assemble the accumulated `park_%06d.jpg` sequence in `out_dir` into `mp4` at `fps`.
2678fn assemble_park_mp4(
2679    out_dir: &std::path::Path,
2680    mp4: &std::path::Path,
2681    fps: u32,
2682) -> Result<(), CliError> {
2683    // Shared with the serve's download endpoint — one ffmpeg assembly in the library.
2684    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/// Spawn the `--serve` auto-stop poller: read a running serve's `/api/status` on an
2697/// interval, feed it through the pure print-lifecycle state machine, and flip `cancel`
2698/// once the print ends (after having been active). No MQTT from here — serve owns the
2699/// single printer connection — so it never conflicts. Transient poll failures are skipped
2700/// (the run still stops on Ctrl-C / --max-seconds).
2701#[cfg(feature = "server")]
2702fn spawn_serve_autostop(
2703    base: &str,
2704    cancel: &std::sync::Arc<std::sync::atomic::AtomicBool>,
2705) -> Result<(), CliError> {
2706    // Fail fast if serve isn't reachable, rather than silently never auto-stopping.
2707    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
2736/// Spawn the `--watch-printer` auto-stop poller: open a DIRECT MQTT connection to the
2737/// configured printer (profile / BAMBU_* env) and watch its print lifecycle through the
2738/// pure [`PrintActivitySession`], flipping `cancel` once the print ends. The A1 accepts a
2739/// second MQTT connection (device-verified), so this runs fine alongside a `bambu serve`.
2740///
2741/// Uses [`monitor`](LanMqttClient::monitor) (auto-reconnecting; the timeout is a *stall*
2742/// window reset on every report) with a periodic pushall, so it watches INDEFINITELY
2743/// across a long print — or while armed before it starts — and only gives up if the
2744/// printer is unreachable for the whole stall window. The target is resolved up front
2745/// (fail-fast on a missing config); a later disappearance is a non-fatal warning, since
2746/// --max-seconds / Ctrl-C still stop the run.
2747fn 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        // Stall window: monitor only returns after no report for this long. The 30s
2756        // pushall keeps reports flowing (idle or printing), so it watches indefinitely
2757        // while the printer responds, auto-reconnecting through transient drops.
2758        let client = LanMqttClient::new(target).with_timeout(Duration::from_secs(120));
2759        let mut activity = PrintActivitySession::new(true);
2760        // Retry loop: monitor returns when WE stop it (print end / cancel) or after a
2761        // sustained outage (a stall returns Ok, a hard error returns Err). If we didn't
2762        // stop it, auto-stop is momentarily unarmed — say so (never exit silently) and
2763        // re-enter, so it RESUMES once the printer responds again. --max-seconds / Ctrl-C
2764        // stay as backstops throughout.
2765        while !cancel.load(Relaxed) {
2766            let mut on_update = |state: &ReportState| -> WatchStep {
2767                if cancel.load(Relaxed) {
2768                    return WatchStep::Stop; // another stop source fired
2769                }
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; // we (or another stop source) ended the run
2781            }
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
2796/// Default mp4 path for an `encode` input: the input path with a `.mp4` suffix
2797/// (a `frames/` dir → `frames.mp4`; `plain.mjpeg` → `plain.mp4`).
2798fn default_mp4_out(input: &std::path::Path) -> std::path::PathBuf {
2799    input.with_extension("mp4")
2800}
2801
2802/// Whether `input` is a `.mjpeg` stream file (vs an image-sequence directory).
2803fn 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
2810/// Build the ffmpeg argument vector to encode `input` → `out`. A directory is an
2811/// image sequence (`frame_*.jpg`, the smooth/sampled frames); a `.mjpeg` file is a
2812/// multipart stream (the plain recording). Pure, so the command shape is tested
2813/// without running ffmpeg.
2814fn 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        // Image sequence: the input framerate sets the timelapse speed.
2825        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            // framestep drops frames; setpts re-times the survivors to `fps` so it
2830            // actually plays faster (not the original spacing with gaps).
2831            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        // Multipart MJPEG stream: re-time the frames to `fps` (keeping every
2840        // `speed`-th to fast-forward).
2841        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
2873/// `bambu timelapse encode`: run ffmpeg (if present) to turn a recording into mp4.
2874fn 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    // ffmpeg is an optional, runtime dependency — fail clearly if it's missing.
2892    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/// Drive an external camera: watch the active print and run a capture command on
2926/// each new layer. This is the workaround for a missing/broken built-in camera —
2927/// the printer's own `layer_num` is the trigger; the user supplies any capture
2928/// tool. Capture runs as argv (no shell) with `{frame}`/`{layer}`/`{outdir}`
2929/// substituted; a failed grab is logged and skipped so it never aborts the watch.
2930// A CLI handler fanning out one flag per parameter — grouping them into a struct
2931// would add indirection without making the call site (a single match arm) clearer.
2932#[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    // Run captures on a dedicated worker thread fed by a channel, so a slow
2971    // capture command never blocks the MQTT event loop (which would miss layer
2972    // updates and risk tripping the keepalive). The watch callback only enqueues.
2973    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    // The pure `CaptureSession` (core) decides per status snapshot whether to
2996    // grab a frame or stop; here we only turn a `Capture` into a queued frame.
2997    // Scope the callback so its borrow of `tx` ends before we drop `tx` (which
2998    // signals the worker to finish and lets us join it for the final counts).
2999    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                    // Enqueue; the worker captures. send fails only if the worker
3007                    // died, which we surface via the join below.
3008                    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    // Close the channel and drain the worker (runs any queued captures), then
3018    // read the tallies it accumulated.
3019    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    // A hard transport error (not a stall) is still a failure to report.
3028    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    // Frames are written; stitching is left to the user (avoids a second
3054    // command-with-flags arg, and ffmpeg invocations vary). Print the suggestion.
3055    if !want_json(cli) {
3056        println!("to build a video:\n  {suggested}");
3057    }
3058    Ok(())
3059}
3060
3061/// A suggested `ffmpeg` line to stitch the frames (glob handles the layer suffix
3062/// in frame names; sequential `frame_NNNNNN` keeps them ordered).
3063fn 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
3071/// Substitute the capture-command tokens in one argv element. Pure so the
3072/// (security-relevant) substitution is unit-testable; values land in distinct
3073/// argv elements and are never re-parsed by a shell.
3074fn 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
3080/// Run one capture command (argv, no shell), substituting frame/layer/outdir.
3081fn 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
3103/// Resolve a connection target from the selected profile + overrides.
3104fn 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
3111/// Resolve the target and build a client with the given timeout (shared setup
3112/// for control commands).
3113fn 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
3117/// Map a control command's verification outcome to output + an exit code.
3118///
3119/// Under `--json` the outcome is emitted to stdout as a stable object for every
3120/// variant (so an agent gets a machine-readable verdict on writes, not just
3121/// reads); the exit code is unchanged. Without `--json` the verdict is the exit
3122/// code plus a human line (stderr).
3123fn 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
3168/// A print is "done" for watching once it finishes, fails, or returns to idle.
3169fn is_watch_terminal(state: GcodeState) -> bool {
3170    matches!(
3171        state,
3172        GcodeState::Finish | GcodeState::Failed | GcodeState::Idle
3173    )
3174}
3175
3176/// Resolve which profile to use: explicit `--printer`, else the configured
3177/// default. Returns `None` when neither is set (the caller then relies on
3178/// flag/env overrides). A name that IS set but is not in the config is an error
3179/// — we never silently fall back to a different target (e.g. on a `--printer`
3180/// typo with `BAMBU_*` in the environment).
3181fn 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
3193/// JSON output is the default when stdout is not a TTY, or when `--json` is set.
3194fn want_json(cli: &Cli) -> bool {
3195    // Output is human-readable by default and JSON only with an explicit
3196    // `--json` — no TTY auto-detection (that magic surprised users piping into
3197    // e.g. `watch`). Agents/scripts pass `--json`; matches `gh`'s convention.
3198    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    // A device-level fault (print_error) is the most important thing to see.
3226    if let Some(err) = &s.error {
3227        println!("error:   ⚠ {} (print_error {})", err.hex, err.code);
3228        println!("         {}", err.lookup_url);
3229    }
3230    // Show the current activity only when it's a real special stage; the
3231    // no-stage markers (0 / 255) just echo idle-or-printing.
3232    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
3269/// Format a remaining-time estimate in minutes as `15m` or `1h35m`.
3270fn 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        // a trailing slash on the base must not double up
3291        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        // speed>1 must actually speed up: framestep AND a PTS reset (not just drop
3330        // frames at the original spacing).
3331        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        // sub-degree jitter folds to the same key (no redundant line)
3386        assert!(watch_key(&noz(215.1)) == watch_key(&noz(215.4)));
3387        // a full-degree step is a new key (prints, so heating stays visible)
3388        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        // -1..=3 are fine (count unknown).
3402        assert!(validate_ams_map(&[0, 3, -1], None).is_ok());
3403        // Out of range -> error even without a filament count.
3404        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        // 2 filaments, 2 entries -> ok.
3411        assert!(validate_ams_map(&[0, 1], Some(2)).is_ok());
3412        // 2 entries but 3 filaments -> error.
3413        assert!(validate_ams_map(&[0, 1], Some(3)).is_err());
3414        // 1 entry, 2 filaments -> error (the classic footgun).
3415        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        // A single -1 is fine, no warning.
3423        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        // No tokens -> unchanged.
3449        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        // The substituted value lands verbatim in a single argv element (no
3455        // shell parses it), so metacharacters are inert — documents that
3456        // `bambu timelapse capture` runs argv directly, not via a shell.
3457        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/// A profile view with the access code redacted, for `config show`.
3471#[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}