use std::process::ExitCode;
use std::time::Duration;
use clap::{Parser, Subcommand};
use serde::Serialize;
use crate::camera::{CameraClient, CameraError};
use crate::client::{
ClientError, CommandOutcome, LanMqttClient, StatusSource, VerifyStage, WatchStep,
};
use crate::config::{self, Config, ConfigError, Overrides, Profile, ResolvedTarget};
use crate::core::capability::{self, ControlAssessment, ControlRefusal};
use crate::core::command::{
AmsControl, AmsFilamentSetting, Command as ProtoCommand, LedNode, SpeedLevel, TimelapseControl,
};
use crate::core::park::ParkTuning;
use crate::core::project::{self, PlateInspection};
use crate::core::report::ReportState;
use crate::core::safety::{self, GcodeVerdict, TempLimits};
use crate::core::stage::Stage;
use crate::core::start::{self, PrintStartParams};
use crate::core::status::{GcodeState, PrinterStatus};
use crate::core::timelapse::{ActivityAction, CaptureAction, CaptureSession, PrintActivitySession};
use crate::core::version::Module;
use crate::ftp::{FtpError, FtpsClient};
use crate::park::{DECODE_H, DECODE_W, ParkCapture, ParkEvent, run_park_camera};
mod exit {
pub const GENERAL: u8 = 1;
pub const VALIDATION: u8 = 3;
pub const CONFIRM_REQUIRED: u8 = 4;
pub const PRINTER_BUSY: u8 = 5;
pub const VERIFY_TIMEOUT: u8 = 6;
pub const TRANSPORT: u8 = 7;
pub const DEVICE_REJECTED: u8 = 8;
}
#[derive(Parser)]
#[command(
name = "bambu",
version,
about = "Monitor and drive Bambu Lab printers over the LAN"
)]
struct Cli {
#[arg(long, global = true)]
printer: Option<String>,
#[arg(long, global = true)]
ip: Option<String>,
#[arg(long, global = true)]
serial: Option<String>,
#[arg(long, global = true)]
access_code: Option<String>,
#[arg(long, global = true)]
model: Option<String>,
#[arg(long, global = true)]
json: bool,
#[cfg(feature = "server")]
#[arg(long, global = true, env = "BAMBU_SERVE_URL", value_name = "URL")]
via_serve: Option<String>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Config {
#[command(subcommand)]
action: ConfigAction,
},
Status {
#[arg(long)]
watch: bool,
#[arg(long)]
interval: Option<u64>,
#[arg(long, default_value_t = 120)]
timeout: u64,
},
Info,
Hms,
Job {
#[command(subcommand)]
action: JobAction,
},
File {
#[command(subcommand)]
action: FileAction,
},
Camera {
#[command(subcommand)]
action: CameraAction,
},
Timelapse {
#[command(subcommand)]
action: TimelapseAction,
},
Light {
#[arg(value_parser = ["on", "off"])]
state: String,
#[arg(long, default_value = "chamber", value_parser = ["chamber", "work"])]
node: String,
#[arg(long, default_value_t = 8)]
timeout: u64,
},
Speed {
#[arg(value_parser = ["silent", "standard", "sport", "ludicrous"])]
level: String,
#[arg(long, default_value_t = 8)]
timeout: u64,
},
Ams {
#[command(subcommand)]
action: AmsAction,
},
Calibrate {
#[command(flatten)]
args: CalibrateArgs,
},
Gcode {
line: String,
#[arg(long)]
confirm: bool,
#[arg(long)]
force: bool,
#[arg(long, default_value_t = 30)]
timeout: u64,
},
Reboot {
#[arg(long)]
confirm: bool,
},
#[cfg(feature = "server")]
#[command(alias = "dashboard")]
Serve {
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(long, default_value_t = 8088)]
port: u16,
#[arg(long, env = "BAMBU_SERVE_PASSWORD")]
password: Option<String>,
#[arg(long)]
fake: bool,
#[arg(long)]
interval: Option<u64>,
#[arg(long, env = "BAMBU_CAMERA_URL", value_delimiter = ',')]
camera_url: Vec<String>,
#[arg(long, value_name = "PATH")]
cameras_config: Option<std::path::PathBuf>,
},
}
#[derive(Subcommand)]
enum ConfigAction {
Add {
#[arg(long)]
ip: String,
#[arg(long)]
serial: String,
#[arg(long)]
access_code: String,
#[arg(long)]
model: String,
#[arg(long)]
set_default: bool,
},
List,
Show,
}
#[derive(Subcommand)]
enum JobAction {
Start {
file: String,
#[arg(long)]
upload: bool,
#[arg(long)]
dest: Option<String>,
#[arg(long)]
overwrite: bool,
#[arg(long, default_value_t = 1)]
plate: u32,
#[arg(long)]
ams_map: Option<String>,
#[arg(long, default_value = "auto")]
bed_type: String,
#[arg(long)]
timelapse: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
confirm: bool,
#[arg(long)]
expect_md5: Option<String>,
#[arg(long)]
expect_plate: Option<u32>,
#[arg(long)]
watch: bool,
#[arg(long, default_value_t = 21600)]
watch_timeout: u64,
#[arg(long)]
interval: Option<u64>,
},
Pause {
#[arg(long)]
confirm: bool,
},
Resume {
#[arg(long)]
confirm: bool,
},
Stop {
#[arg(long)]
confirm: bool,
},
ClearError {
#[arg(long)]
confirm: bool,
},
}
#[derive(clap::Args)]
struct CalibrateArgs {
#[arg(long)]
bed_level: bool,
#[arg(long)]
vibration: bool,
#[arg(long)]
motor_noise: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
confirm: bool,
#[arg(long)]
watch: bool,
#[arg(long, default_value_t = 3600)]
watch_timeout: u64,
#[arg(long)]
interval: Option<u64>,
}
#[derive(Subcommand)]
enum TimelapseAction {
Enable {
#[arg(long, default_value_t = 8)]
timeout: u64,
},
Disable {
#[arg(long, default_value_t = 8)]
timeout: u64,
},
List,
Get {
name: String,
#[arg(long)]
out: Option<std::path::PathBuf>,
},
Capture {
#[arg(long, default_value = "./timelapse")]
out_dir: std::path::PathBuf,
#[arg(long, default_value_t = 1)]
every: u64,
#[arg(long, default_value = "jpg")]
ext: String,
#[arg(long)]
interval: Option<u64>,
#[arg(long, default_value_t = 21600)]
timeout: u64,
#[arg(long)]
wait: bool,
#[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 1.., value_name = "CMD")]
on_layer_cmd: Vec<String>,
},
Park {
stream_url: String,
#[arg(long)]
config: std::path::PathBuf,
#[arg(long, default_value = "./park")]
out: std::path::PathBuf,
#[arg(long)]
assemble: Option<std::path::PathBuf>,
#[arg(long, default_value_t = 12)]
out_fps: u32,
#[arg(long, value_name = "SERVE_URL")]
serve: Option<String>,
#[arg(long, conflicts_with = "serve")]
watch_printer: bool,
#[arg(long, default_value_t = DECODE_W as u32)]
width: u32,
#[arg(long, default_value_t = DECODE_H as u32)]
height: u32,
#[arg(long)]
max_seconds: Option<u64>,
},
Encode {
input: std::path::PathBuf,
#[arg(long)]
out: Option<std::path::PathBuf>,
#[arg(long, default_value_t = 30)]
fps: u32,
#[arg(long, default_value_t = 1)]
speed: u32,
},
}
#[derive(Subcommand)]
enum AmsAction {
Resume {
#[arg(long)]
confirm: bool,
},
Reset {
#[arg(long)]
confirm: bool,
},
Pause {
#[arg(long)]
confirm: bool,
},
Change {
#[arg(long)]
tray: u32,
#[arg(long)]
tar_temp: i64,
#[arg(long)]
curr_temp: Option<i64>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
confirm: bool,
},
SetFilament {
#[arg(long, default_value_t = 0)]
ams: u32,
#[arg(long)]
tray: u32,
#[arg(long = "type")]
material: String,
#[arg(long, default_value = "000000FF")]
color: String,
#[arg(long)]
min: i64,
#[arg(long)]
max: i64,
#[arg(long, default_value = "")]
info_idx: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
confirm: bool,
},
Settings {
#[arg(long, default_value_t = 0)]
ams: u32,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
startup_read: bool,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
tray_read: bool,
#[arg(long)]
confirm: bool,
},
}
#[derive(Subcommand)]
enum CameraAction {
Snapshot {
#[arg(long, default_value = "snapshot.jpg")]
out: std::path::PathBuf,
#[arg(long, default_value_t = 10)]
timeout: u64,
},
}
#[derive(Subcommand)]
enum FileAction {
Ls {
#[arg(default_value = "/")]
dir: String,
},
Upload {
local: std::path::PathBuf,
#[arg(long, default_value = "/")]
dest: String,
},
Download {
remote: String,
#[arg(long)]
out: Option<std::path::PathBuf>,
},
Rm {
remote: String,
#[arg(long)]
confirm: bool,
},
}
#[derive(Debug)]
struct CliError {
code: u8,
message: String,
}
impl CliError {
fn new(code: u8, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
impl From<ConfigError> for CliError {
fn from(e: ConfigError) -> Self {
let code = match e {
ConfigError::MissingField(_) | ConfigError::UnknownProfile(_) => exit::VALIDATION,
_ => exit::GENERAL,
};
CliError::new(code, e.to_string())
}
}
impl From<ClientError> for CliError {
fn from(e: ClientError) -> Self {
let code = match e {
ClientError::Timeout(_) => exit::VERIFY_TIMEOUT,
_ => exit::TRANSPORT,
};
CliError::new(code, e.to_string())
}
}
impl From<FtpError> for CliError {
fn from(e: FtpError) -> Self {
CliError::new(exit::TRANSPORT, e.to_string())
}
}
impl From<CameraError> for CliError {
fn from(e: CameraError) -> Self {
CliError::new(exit::TRANSPORT, e.to_string())
}
}
pub fn run() -> ExitCode {
config::load_dotenv();
#[cfg(feature = "license-notice")]
let cli = {
use notalawyer_clap::{ParseExt, include_notice};
Cli::parse_with_license_notice(include_notice!())
};
#[cfg(not(feature = "license-notice"))]
let cli = Cli::parse();
match dispatch(&cli) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {}", e.message);
ExitCode::from(e.code)
}
}
}
fn dispatch(cli: &Cli) -> Result<(), CliError> {
match &cli.command {
Command::Config { action } => run_config(cli, action),
Command::Status {
watch,
interval,
timeout,
} => run_status(cli, *watch, *interval, *timeout),
Command::Info => run_info(cli),
Command::Hms => run_hms(cli),
Command::Job { action } => run_job(cli, action),
Command::File { action } => run_file(cli, action),
Command::Camera { action } => run_camera(cli, action),
Command::Timelapse { action } => run_timelapse(cli, action),
Command::Ams { action } => run_ams(cli, action),
Command::Light {
state,
node,
timeout,
} => run_light(cli, state == "on", node, *timeout),
Command::Speed { level, timeout } => run_speed(cli, level, *timeout),
Command::Calibrate { args } => run_calibrate(cli, args),
Command::Gcode {
line,
confirm,
force,
timeout,
} => run_gcode(cli, line, *confirm, *force, *timeout),
Command::Reboot { confirm } => run_reboot(cli, *confirm),
#[cfg(feature = "server")]
Command::Serve {
host,
port,
password,
fake,
interval,
camera_url,
cameras_config,
} => run_serve(
cli,
host,
*port,
password.clone(),
*fake,
*interval,
camera_url.clone(),
cameras_config.clone(),
),
}
}
fn config_path() -> Result<std::path::PathBuf, CliError> {
config::default_config_path()
.ok_or_else(|| CliError::new(exit::GENERAL, "cannot determine config path (no HOME)"))
}
fn run_config(cli: &Cli, action: &ConfigAction) -> Result<(), CliError> {
let path = config_path()?;
let mut cfg = Config::load_or_default(&path)?;
match action {
ConfigAction::Add {
ip,
serial,
access_code,
model,
set_default,
} => {
let name = cli.printer.clone().ok_or_else(|| {
CliError::new(exit::VALIDATION, "config add needs --printer <name>")
})?;
let profile = Profile {
ip: ip.clone(),
serial: serial.clone(),
model: model.clone(),
mode: "lan".to_string(),
access_code: access_code.clone(),
};
cfg.printers.insert(name.clone(), profile);
if *set_default || cfg.default_printer.is_none() {
cfg.default_printer = Some(name.clone());
}
cfg.save(&path)?;
eprintln!("saved profile '{name}' to {}", path.display());
Ok(())
}
ConfigAction::List => {
if want_json(cli) {
let names: Vec<&String> = cfg.printers.keys().collect();
print_json(&serde_json::json!({
"default": cfg.default_printer,
"printers": names,
}));
} else if cfg.printers.is_empty() {
eprintln!("no profiles configured");
} else {
for name in cfg.printers.keys() {
let marker = if cfg.default_printer.as_deref() == Some(name) {
" (default)"
} else {
""
};
println!("{name}{marker}");
}
}
Ok(())
}
ConfigAction::Show => {
let name = selected_profile_name(cli, &cfg)?.ok_or_else(|| {
CliError::new(
exit::VALIDATION,
"no printer selected: pass --printer or set a default",
)
})?;
let profile = cfg
.profile(&name)
.ok_or_else(|| CliError::from(ConfigError::UnknownProfile(name.clone())))?;
let view = RedactedProfile::from(&name, profile);
if want_json(cli) {
print_json(&view);
} else {
println!("{view}");
}
Ok(())
}
}
}
fn run_status(
cli: &Cli,
watch: bool,
interval_secs: Option<u64>,
timeout_secs: u64,
) -> Result<(), CliError> {
#[cfg(feature = "server")]
if let Some(base) = cli.via_serve.clone() {
return run_status_via_serve(cli, &base, watch, interval_secs);
}
let cfg = Config::load_or_default(&config_path()?)?;
let profile_name = selected_profile_name(cli, &cfg)?;
let profile = profile_name.as_deref().and_then(|n| cfg.profile(n));
let overrides = flag_overrides(cli).over(Overrides::from_env());
let target = config::resolve(profile, &overrides)?;
let model = target.model.to_string();
if watch {
let client = LanMqttClient::new(target).with_timeout(Duration::from_secs(timeout_secs));
let interval = interval_secs.map(Duration::from_secs);
return watch_to_terminal(&client, cli, model, profile_name, false, interval, true);
}
let state = LanMqttClient::new(target).fetch_snapshot()?;
let status = PrinterStatus::from_state(state.get());
let output = StatusOutput {
printer: profile_name,
model,
status,
};
if want_json(cli) {
print_json(&output);
} else {
print_status_human(&output);
}
Ok(())
}
#[cfg(feature = "server")]
fn run_status_via_serve(
cli: &Cli,
base: &str,
watch: bool,
interval_secs: Option<u64>,
) -> Result<(), CliError> {
if watch {
return watch_via_serve(cli, base, interval_secs);
}
let (printer, model) = serve_display_identity(cli);
let status = fetch_serve_status(base)?;
let output = StatusOutput {
printer,
model,
status,
};
if want_json(cli) {
print_json(&output);
} else {
print_status_human(&output);
}
Ok(())
}
#[cfg(feature = "server")]
fn watch_via_serve(cli: &Cli, base: &str, interval_secs: Option<u64>) -> Result<(), CliError> {
let interval = Duration::from_secs(interval_secs.unwrap_or(2).max(1));
let mut last: Option<WatchKey> = None;
loop {
let status = fetch_serve_status(base)?;
emit_watch_change(&status, &mut last, cli, true);
std::thread::sleep(interval);
}
}
#[cfg(feature = "server")]
fn serve_status_url(base: &str) -> String {
format!("{}/api/status", base.trim_end_matches('/'))
}
#[cfg(feature = "server")]
fn fetch_serve_status(base: &str) -> Result<PrinterStatus, CliError> {
let url = serve_status_url(base);
let resp = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(5))
.build()
.get(&url)
.call()
.map_err(|e| {
CliError::new(
exit::TRANSPORT,
format!("couldn't reach serve at {url}: {e}"),
)
})?;
let body = resp.into_string().map_err(|e| {
CliError::new(
exit::TRANSPORT,
format!("couldn't read response from {url}: {e}"),
)
})?;
serde_json::from_str::<PrinterStatus>(&body).map_err(|e| {
CliError::new(
exit::TRANSPORT,
format!("unexpected status response from {url}: {e}"),
)
})
}
#[cfg(feature = "server")]
fn serve_display_identity(cli: &Cli) -> (Option<String>, String) {
let cfg = config_path()
.ok()
.map(|p| Config::load_or_default(&p))
.and_then(Result::ok);
let printer = cli
.printer
.clone()
.or_else(|| cfg.as_ref().and_then(|c| c.default_printer.clone()));
let profile = printer
.as_deref()
.zip(cfg.as_ref())
.and_then(|(n, c)| c.profile(n));
let overrides = flag_overrides(cli).over(Overrides::from_env());
let model = overrides
.model
.or_else(|| profile.map(|p| p.model.clone()))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown".to_string());
(printer, model)
}
#[derive(Serialize)]
struct HmsView {
code: String,
code_hyphen: String,
severity: u16,
is_lidar: bool,
wiki: String,
}
fn run_hms(cli: &Cli) -> Result<(), CliError> {
let state = connect_client(cli, 10)?.fetch_snapshot()?;
let entries = crate::core::hms::decode_report_hms(state.get());
let views: Vec<HmsView> = entries
.iter()
.map(|e| HmsView {
code: e.code_string(),
code_hyphen: e.code_hyphen(),
severity: e.severity_raw(),
is_lidar: e.is_lidar(),
wiki: e.wiki_url(),
})
.collect();
if want_json(cli) {
print_json(&views);
} else if views.is_empty() {
println!("no active HMS alerts");
} else {
for v in &views {
println!("{} (severity {}) {}", v.code, v.severity, v.wiki);
}
}
Ok(())
}
#[derive(Serialize)]
struct ControlView {
status: &'static str,
expected_ok: bool,
reason: Option<String>,
}
impl ControlView {
fn from(assessment: ControlAssessment) -> Self {
let refusal = |r: ControlRefusal| match r {
ControlRefusal::UnknownModel => "model not in the capability registry",
ControlRefusal::FirmwareNewerThanKnown => "firmware newer than the registry knows",
ControlRefusal::DeveloperModeUnavailable => {
"Developer Mode unavailable on this firmware"
}
ControlRefusal::UnknownControlBoundary => {
"no confirmed control boundary for this model"
}
};
match assessment {
ControlAssessment::Allowed => ControlView {
status: "allowed",
expected_ok: true,
reason: None,
},
ControlAssessment::RequiresDeveloperMode => ControlView {
status: "requires_developer_mode",
expected_ok: true,
reason: Some("control needs LAN-only + Developer Mode enabled".into()),
},
ControlAssessment::NewerFirmwareUntested => ControlView {
status: "newer_firmware_untested",
expected_ok: true,
reason: Some(
"firmware is newer than the tested range; control is very likely fine but \
unverified against this version"
.into(),
),
},
ControlAssessment::Refused(r) => ControlView {
status: "refused",
expected_ok: false,
reason: Some(refusal(r).into()),
},
}
}
}
#[derive(Serialize)]
struct InfoOutput {
printer: Option<String>,
model: String,
firmware: Option<String>,
registry_status: &'static str,
push_mode: Option<&'static str>,
camera_transport: Option<&'static str>,
developer_mode: Option<&'static str>,
control: ControlView,
modules: Vec<Module>,
}
fn run_info(cli: &Cli) -> Result<(), CliError> {
let cfg = Config::load_or_default(&config_path()?)?;
let profile_name = selected_profile_name(cli, &cfg)?;
let profile = profile_name.as_deref().and_then(|n| cfg.profile(n));
let overrides = flag_overrides(cli).over(Overrides::from_env());
let target = config::resolve(profile, &overrides)?;
let model = target.model.clone();
let version = connect_client(cli, 10)?.fetch_version()?;
let registry = capability::default_registry();
let output = match &version.firmware {
Some(fw) => {
let caps = capability::resolve(®istry, &model, fw);
InfoOutput {
printer: profile_name,
model: model.to_string(),
firmware: Some(fw.to_string()),
registry_status: registry_status_str(caps.registry_status),
push_mode: caps.push_mode.map(push_mode_str),
camera_transport: caps.camera_transport.map(camera_transport_str),
developer_mode: caps.developer_mode.map(developer_mode_str),
control: ControlView::from(caps.control_assessment()),
modules: version.modules.clone(),
}
}
None => InfoOutput {
printer: profile_name,
model: model.to_string(),
firmware: None,
registry_status: "unknown_firmware",
push_mode: None,
camera_transport: None,
developer_mode: None,
control: ControlView {
status: "unknown",
expected_ok: false,
reason: Some("could not read the firmware version (no `ota` module)".into()),
},
modules: version.modules.clone(),
},
};
if want_json(cli) {
print_json(&output);
} else {
print_info_human(&output);
}
Ok(())
}
fn registry_status_str(s: capability::RegistryStatus) -> &'static str {
use capability::RegistryStatus::*;
match s {
Supported => "supported",
FirmwareNewerThanKnown => "firmware_newer_than_known",
UnknownModel => "unknown_model",
}
}
fn push_mode_str(m: capability::PushMode) -> &'static str {
match m {
capability::PushMode::Full => "full",
capability::PushMode::DeltaOnly => "delta_only",
}
}
fn camera_transport_str(t: capability::CameraTransport) -> &'static str {
use capability::CameraTransport::*;
match t {
Rtsp322 => "rtsp_322",
JpegTcp6000 => "jpeg_tcp_6000",
None => "none",
}
}
fn developer_mode_str(d: capability::DeveloperMode) -> &'static str {
match d {
capability::DeveloperMode::Available => "available",
capability::DeveloperMode::Unavailable => "unavailable",
}
}
fn print_info_human(o: &InfoOutput) {
println!(
"printer: {} ({})",
o.printer.as_deref().unwrap_or("-"),
o.model
);
println!("firmware: {}", o.firmware.as_deref().unwrap_or("?"));
println!("registry: {}", o.registry_status);
if let Some(p) = o.push_mode {
println!("push: {p}");
}
if let Some(c) = o.camera_transport {
println!("camera: {c}");
}
match &o.control.reason {
Some(r) => println!("control: {} — {r}", o.control.status),
None => println!("control: {}", o.control.status),
}
if !o.modules.is_empty() {
println!("modules:");
for m in &o.modules {
let hw = m.hw_ver.as_deref().unwrap_or("-");
let sw = m.sw_ver.as_deref().unwrap_or("-");
let prod = m
.product_name
.as_deref()
.map(|p| format!(" {p}"))
.unwrap_or_default();
println!(" {:<10} hw {:<9} sw {}{prod}", m.name, hw, sw);
}
}
}
#[derive(PartialEq)]
struct WatchKey {
gcode_state: Option<String>,
stg_cur: Option<i64>,
mc_percent: Option<i64>,
layer_num: Option<i64>,
nozzle: Option<i64>,
bed: Option<i64>,
error: Option<i64>,
}
fn watch_key(st: &PrinterStatus) -> WatchKey {
WatchKey {
gcode_state: st.gcode_state.clone(),
stg_cur: st.stg_cur,
mc_percent: st.mc_percent,
layer_num: st.layer_num,
nozzle: st.nozzle_temper.map(|v| v.round() as i64),
bed: st.bed_temper.map(|v| v.round() as i64),
error: st.error.as_ref().map(|e| e.code),
}
}
fn format_watch_line(st: &PrinterStatus) -> String {
let stage = match (st.stg_cur, st.stage.as_deref()) {
(Some(id), Some(name)) if !Stage(id).is_no_stage() => format!(" [{name}]"),
_ => String::new(),
};
let err = match &st.error {
Some(e) => format!(" ⚠ {}", e.hex),
None => String::new(),
};
let temp = |cur: Option<f64>, tgt: Option<f64>| match cur {
Some(c) => match tgt.filter(|t| *t > 0.0) {
Some(t) => format!("{c:.0}/{t:.0}"),
None => format!("{c:.0}"),
},
None => "-".to_string(),
};
let eta = match st.remaining_time_min.filter(|m| *m > 0) {
Some(m) => format!(" ETA {}", fmt_eta(m)),
None => String::new(),
};
format!(
"{:<8} {:>3}% layer {}/{} N{} B{}{eta}{stage}{err}",
st.gcode_state.as_deref().unwrap_or("?"),
st.mc_percent.unwrap_or(0),
st.layer_num.unwrap_or(0),
st.total_layer_num.unwrap_or(0),
temp(st.nozzle_temper, st.nozzle_target),
temp(st.bed_temper, st.bed_target),
)
}
fn emit_watch_change(st: &PrinterStatus, last: &mut Option<WatchKey>, cli: &Cli, continuous: bool) {
let key = watch_key(st);
if last.as_ref() == Some(&key) {
return;
}
*last = Some(key);
if continuous {
if want_json(cli) {
if let Ok(j) = serde_json::to_string(st) {
println!("{j}");
}
} else {
println!("{}", format_watch_line(st));
}
} else {
eprintln!("{}", format_watch_line(st));
}
}
fn watch_to_terminal(
client: &LanMqttClient,
cli: &Cli,
model: String,
profile_name: Option<String>,
exit_status: bool,
interval: Option<Duration>,
continuous: bool,
) -> Result<(), CliError> {
let mut last: Option<WatchKey> = None;
let mut on_update = |state: &ReportState| -> WatchStep {
let st = PrinterStatus::from_state(state.get());
emit_watch_change(&st, &mut last, cli, continuous);
if continuous {
return WatchStep::Continue;
}
if st.error.is_some() {
return WatchStep::Stop;
}
match st.state() {
Some(s) if is_watch_terminal(s) => WatchStep::Stop,
_ => WatchStep::Continue,
}
};
let result = if continuous {
client.monitor(interval, &mut on_update)
} else {
client.watch(interval, &mut on_update)
};
let final_state = result?;
if continuous {
return Ok(());
}
let status = PrinterStatus::from_state(final_state.get());
let error = status.error.clone();
let failed = status.state() == Some(GcodeState::Failed);
let output = StatusOutput {
printer: profile_name,
model,
status,
};
if want_json(cli) {
print_json(&output);
} else {
print_status_human(&output);
}
if let Some(e) = error {
return Err(CliError::new(
exit::DEVICE_REJECTED,
format!(
"a device error appeared during the job: {} ({})",
e.hex, e.code
),
));
}
if exit_status && failed {
return Err(CliError::new(
exit::GENERAL,
"print ended in a FAILED state",
));
}
Ok(())
}
fn watch_identity(cli: &Cli) -> Result<(String, Option<String>), CliError> {
let cfg = Config::load_or_default(&config_path()?)?;
let profile_name = selected_profile_name(cli, &cfg)?;
let profile = profile_name.as_deref().and_then(|n| cfg.profile(n));
let overrides = flag_overrides(cli).over(Overrides::from_env());
let target = config::resolve(profile, &overrides)?;
Ok((target.model.to_string(), profile_name))
}
fn run_light(cli: &Cli, on: bool, node: &str, timeout_secs: u64) -> Result<(), CliError> {
let node = match node {
"chamber" => LedNode::ChamberLight,
"work" => LedNode::WorkLight,
other => {
return Err(CliError::new(
exit::VALIDATION,
format!("unknown light {other:?}"),
));
}
};
let client = connect_client(cli, timeout_secs)?;
eprintln!(
"setting {} {} …",
node.as_str(),
if on { "on" } else { "off" }
);
report_command_outcome(
cli,
client.send_and_verify(&ProtoCommand::Led { node, on })?,
)
}
fn run_speed(cli: &Cli, level: &str, timeout_secs: u64) -> Result<(), CliError> {
let level = match level {
"silent" => SpeedLevel::Silent,
"standard" => SpeedLevel::Standard,
"sport" => SpeedLevel::Sport,
"ludicrous" => SpeedLevel::Ludicrous,
other => {
return Err(CliError::new(
exit::VALIDATION,
format!("unknown speed {other:?}"),
));
}
};
let client = connect_client(cli, timeout_secs)?;
eprintln!(
"setting print speed to {} (level {}) …",
level.as_str(),
level.level()
);
report_command_outcome(
cli,
client.send_and_verify(&ProtoCommand::PrintSpeed(level))?,
)
}
fn run_reboot(cli: &Cli, confirm: bool) -> Result<(), CliError> {
if !confirm {
return Err(CliError::new(
exit::CONFIRM_REQUIRED,
"refusing to reboot without --confirm (the printer will disconnect and restart)",
));
}
let client = connect_client(cli, 10)?;
eprintln!("sending reboot …");
client.send_fire(&ProtoCommand::Reboot)?;
eprintln!(
"reboot sent — the printer will disconnect and restart (~1–2 min). \
No ACK is expected; it may rejoin DHCP on a different IP."
);
Ok(())
}
#[cfg(feature = "server")]
#[allow(clippy::too_many_arguments)]
fn run_serve(
cli: &Cli,
host: &str,
port: u16,
password: Option<String>,
fake: bool,
interval: Option<u64>,
camera_url: Vec<String>,
cameras_config: Option<std::path::PathBuf>,
) -> Result<(), CliError> {
let target = if fake {
None
} else {
Some(resolve_target(cli)?)
};
let mut external_cameras: Vec<crate::server::ExternalCamera> = Vec::new();
for e in camera_url
.iter()
.map(|e| e.trim())
.filter(|e| !e.is_empty())
{
if let Some(c) = crate::server::ExternalCamera::parse(e, external_cameras.len()) {
external_cameras.push(c);
}
}
if let Some(path) = &cameras_config {
for seed in load_seed_cameras(path)? {
let i = external_cameras.len();
let (park, select) = match &seed.park_tuning {
None => (None, None),
Some(v) => {
let park: ParkTuning = serde_json::from_value(v.clone()).map_err(|e| {
CliError::new(
exit::VALIDATION,
format!("invalid park_tuning in --cameras-config: {e}"),
)
})?;
let select = serde_json::from_value(v.clone()).ok();
(Some(park), select)
}
};
external_cameras.push(
crate::server::ExternalCamera::new(seed.label, seed.url, seed.stream_url, i)
.with_park_tuning(park)
.with_select_tuning(select),
);
}
}
let opts = crate::server::ServeOpts {
host: host.to_string(),
port,
password,
fake,
interval: interval.map(Duration::from_secs),
external_cameras,
};
crate::server::serve(target, opts).map_err(|e| CliError::new(exit::GENERAL, e.to_string()))
}
#[cfg(feature = "server")]
#[derive(serde::Deserialize)]
struct SeedCamera {
#[serde(default)]
label: Option<String>,
url: String,
#[serde(default)]
stream_url: Option<String>,
#[serde(default)]
park_tuning: Option<serde_json::Value>,
}
#[cfg(feature = "server")]
fn load_seed_cameras(path: &std::path::Path) -> Result<Vec<SeedCamera>, CliError> {
let raw = std::fs::read_to_string(path)
.map_err(|e| CliError::new(exit::VALIDATION, format!("reading {}: {e}", path.display())))?;
serde_json::from_str(&raw).map_err(|e| {
CliError::new(
exit::VALIDATION,
format!("invalid --cameras-config {}: {e}", path.display()),
)
})
}
fn run_gcode(
cli: &Cli,
line: &str,
confirm: bool,
force: bool,
timeout_secs: u64,
) -> Result<(), CliError> {
if !confirm {
return Err(CliError::new(
exit::CONFIRM_REQUIRED,
"refusing to send a control command without --confirm",
));
}
if !force && let GcodeVerdict::Block(reason) = safety::check_gcode(line, &TempLimits::default())
{
return Err(CliError::new(
exit::VALIDATION,
format!("refusing unsafe G-code: {reason}"),
));
}
let client = connect_client(cli, timeout_secs)?;
eprintln!("sending gcode_line {line:?} …");
report_command_outcome(
cli,
client.send_and_verify(&ProtoCommand::GcodeLine(line.to_string()))?,
)
}
fn run_file(cli: &Cli, action: &FileAction) -> Result<(), CliError> {
let ftps = FtpsClient::new(resolve_target(cli)?);
match action {
FileAction::Ls { dir } => {
let names = ftps.list(dir)?;
if want_json(cli) {
print_json(&names);
} else {
for name in &names {
println!("{name}");
}
}
Ok(())
}
FileAction::Upload { local, dest } => {
let filename = local
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| CliError::new(exit::VALIDATION, "invalid local file name"))?;
let remote = format!("{}/{filename}", dest.trim_end_matches('/'));
let n = ftps.upload(local, &remote)?;
eprintln!("uploaded {n} bytes to {remote}");
Ok(())
}
FileAction::Download { remote, out } => {
let local = match out {
Some(p) => p.clone(),
None => std::path::Path::new(remote)
.file_name()
.map(std::path::PathBuf::from)
.ok_or_else(|| {
CliError::new(
exit::VALIDATION,
format!("cannot derive an output name from {remote:?}; pass --out"),
)
})?,
};
let n = ftps.download(remote, &local)?;
eprintln!("downloaded {n} bytes to {}", local.display());
if want_json(cli) {
print_json(&serde_json::json!({
"path": local.to_string_lossy(),
"bytes": n,
}));
} else {
println!("{}", local.display());
}
Ok(())
}
FileAction::Rm { remote, confirm } => {
if !*confirm {
return Err(CliError::new(
exit::CONFIRM_REQUIRED,
"refusing to delete a file without --confirm",
));
}
ftps.delete(remote)?;
eprintln!("deleted {remote}");
if want_json(cli) {
print_json(&serde_json::json!({ "deleted": true, "remote": remote }));
}
Ok(())
}
}
}
fn run_job(cli: &Cli, action: &JobAction) -> Result<(), CliError> {
match action {
JobAction::Start {
file,
upload,
dest,
overwrite,
plate,
ams_map,
bed_type,
timelapse,
dry_run,
confirm,
expect_md5,
expect_plate,
watch,
watch_timeout,
interval,
} => {
if *upload {
if expect_md5.is_some() || expect_plate.is_some() {
return Err(CliError::new(
exit::VALIDATION,
"--expect-md5 / --expect-plate don't apply with --upload \
(you're providing the local file; its md5 is used directly)",
));
}
return run_job_start_upload(
cli,
file,
*plate,
dest.as_deref(),
*overwrite,
ams_map.as_deref(),
bed_type,
*timelapse,
*dry_run,
*confirm,
*watch,
*watch_timeout,
*interval,
);
}
let is_3mf = file.to_ascii_lowercase().ends_with(".3mf");
if !is_3mf && (expect_md5.is_some() || expect_plate.is_some()) {
return Err(CliError::new(
exit::VALIDATION,
"--expect-md5 / --expect-plate only apply to .3mf files",
));
}
let cmd = build_start_command(file, *plate, ams_map.as_deref(), bed_type, *timelapse)?;
let ams_mapping: Option<Vec<i32>> = match &cmd {
ProtoCommand::ProjectFile(pf) if pf.use_ams => Some(pf.ams_mapping.clone()),
_ => None,
};
if let Some(m) = &ams_mapping {
validate_ams_map(m, None)?;
}
let has_expect = expect_md5.is_some() || expect_plate.is_some();
let mut inspection: Option<PlateInspection> = None;
let mut inspect_error: Option<String> = None;
if is_3mf && (has_expect || ams_mapping.is_some() || *dry_run) {
let mandatory = has_expect || ams_mapping.is_some();
match inspect_remote_plate(cli, file, *plate) {
Ok(insp) => {
project::verify_expectations(
&insp,
*plate,
expect_md5.as_deref(),
*expect_plate,
)
.map_err(|e| CliError::new(exit::VALIDATION, e.to_string()))?;
if let Some(m) = &ams_mapping {
match validate_ams_map(m, Some(insp.filament_colors.len())) {
Ok(warns) => {
for w in warns {
eprintln!("warning: {w}");
}
}
Err(e) if *dry_run => eprintln!("warning: {}", e.message),
Err(e) => return Err(e),
}
}
inspection = Some(insp);
}
Err(e) if mandatory => return Err(e),
Err(e) => {
eprintln!(
"note: could not inspect the on-printer file ({}); \
showing the payload only",
e.message
);
inspect_error = Some(e.message);
}
}
}
if *dry_run {
print_json(&start_plan_json(
&cmd,
file,
inspection.as_ref(),
inspect_error.as_deref(),
ams_mapping.as_deref(),
*timelapse,
));
return Ok(());
}
if !*confirm {
return Err(CliError::new(
exit::CONFIRM_REQUIRED,
"refusing to start a print without --confirm (try --dry-run first)",
));
}
ensure_idle(cli)?;
let client = connect_client(cli, 30)?;
eprintln!("starting print: {file}");
let outcome = client.send_and_verify(&cmd)?;
if *watch && outcome == CommandOutcome::Verified {
eprintln!("print started; watching for completion / anomalies …");
let (model, profile_name) = watch_identity(cli)?;
let watcher = connect_client(cli, *watch_timeout)?;
let watch_interval = interval.map(Duration::from_secs);
watch_to_terminal(
&watcher,
cli,
model,
profile_name,
true,
watch_interval,
false,
)
} else {
report_command_outcome(cli, outcome)
}
}
JobAction::Pause { confirm } => job_control(cli, ProtoCommand::Pause, *confirm),
JobAction::Resume { confirm } => job_control(cli, ProtoCommand::Resume, *confirm),
JobAction::Stop { confirm } => job_control(cli, ProtoCommand::Stop, *confirm),
JobAction::ClearError { confirm } => {
job_control(cli, ProtoCommand::CleanPrintError, *confirm)
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_job_start_upload(
cli: &Cli,
local: &str,
plate: u32,
dest: Option<&str>,
overwrite: bool,
ams_map: Option<&str>,
bed_type: &str,
timelapse: bool,
dry_run: bool,
confirm: bool,
watch: bool,
watch_timeout: u64,
interval: Option<u64>,
) -> Result<(), CliError> {
let local_path = std::path::Path::new(local);
let basename = local_path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| CliError::new(exit::VALIDATION, format!("invalid local file: {local:?}")))?;
let is_3mf = basename.to_ascii_lowercase().ends_with(".3mf");
let remote = match dest {
Some(d) => d.to_string(),
None => format!("/{basename}"),
};
if remote.to_ascii_lowercase().ends_with(".3mf") != is_3mf {
return Err(CliError::new(
exit::VALIDATION,
format!(
"--dest {remote:?} must keep {basename:?}'s type (both .3mf, or both raw .gcode)"
),
));
}
let parsed_ams: Option<Vec<i32>> = match (is_3mf, ams_map) {
(true, Some(m)) => Some(parse_ams_map(m)?),
_ => None,
};
if let Some(m) = &parsed_ams {
validate_ams_map(m, None)?;
}
let inspection: Option<PlateInspection> = if is_3mf {
let bytes = std::fs::read(local_path)
.map_err(|e| CliError::new(exit::VALIDATION, format!("reading {local}: {e}")))?;
let insp = project::inspect_plate(&bytes, plate)
.map_err(|e| CliError::new(exit::VALIDATION, format!("3mf inspection: {e}")))?;
if let Some(m) = &parsed_ams {
for w in validate_ams_map(m, Some(insp.filament_colors.len()))? {
eprintln!("warning: {w}");
}
}
Some(insp)
} else {
None
};
let params = PrintStartParams {
file: remote.clone(),
plate,
use_ams: parsed_ams.is_some(),
ams_map: parsed_ams.clone().unwrap_or_default(),
bed_type: bed_type.to_string(),
timelapse,
};
let cmd = start::build_command(¶ms, inspection.as_ref());
if dry_run {
let mut plan = start_plan_json(
&cmd,
&remote,
inspection.as_ref(),
None,
parsed_ams.as_deref(),
timelapse,
);
plan["upload"] =
serde_json::json!({ "local": local, "remote": remote, "overwrite": overwrite });
print_json(&plan);
return Ok(());
}
if !confirm {
return Err(CliError::new(
exit::CONFIRM_REQUIRED,
"refusing to upload + start without --confirm (try --dry-run first)",
));
}
ensure_idle(cli)?;
let ftps = FtpsClient::new(resolve_target(cli)?);
if !overwrite && remote_file_exists(&ftps, &remote) {
return Err(CliError::new(
exit::VALIDATION,
format!("{remote} already exists on the printer (pass --overwrite to replace it)"),
));
}
let n = ftps.upload(local_path, &remote)?;
eprintln!("uploaded {n} bytes to {remote}");
let client = connect_client(cli, 30)?;
eprintln!("starting print: {remote}");
let outcome = client.send_and_verify(&cmd)?;
if watch && outcome == CommandOutcome::Verified {
eprintln!("print started; watching for completion / anomalies …");
let (model, profile_name) = watch_identity(cli)?;
let watcher = connect_client(cli, watch_timeout)?;
watch_to_terminal(
&watcher,
cli,
model,
profile_name,
true,
interval.map(Duration::from_secs),
false,
)
} else {
report_command_outcome(cli, outcome)
}
}
fn remote_file_exists(ftps: &FtpsClient, remote: &str) -> bool {
let (dir, name) = match remote.rsplit_once('/') {
Some((d, n)) => (if d.is_empty() { "/" } else { d }, n),
None => ("/", remote),
};
ftps.list(dir)
.map(|names| names.iter().any(|e| e.rsplit('/').next() == Some(name)))
.unwrap_or(false)
}
fn build_start_command(
file: &str,
plate: u32,
ams_map: Option<&str>,
bed_type: &str,
timelapse: bool,
) -> Result<ProtoCommand, CliError> {
let is_3mf = file.to_ascii_lowercase().ends_with(".3mf");
let (use_ams, parsed_map) = match (is_3mf, ams_map) {
(true, Some(map)) => (true, parse_ams_map(map)?),
_ => (false, Vec::new()),
};
let params = PrintStartParams {
file: file.to_string(),
plate,
use_ams,
ams_map: parsed_map,
bed_type: bed_type.to_string(),
timelapse,
};
Ok(start::build_command(¶ms, None))
}
fn parse_ams_map(map: &str) -> Result<Vec<i32>, CliError> {
map.split(',')
.map(|s| s.trim().parse::<i32>())
.collect::<Result<Vec<_>, _>>()
.map_err(|_| CliError::new(exit::VALIDATION, format!("invalid --ams-map: {map:?}")))
}
fn validate_ams_map(
mapping: &[i32],
filament_count: Option<usize>,
) -> Result<Vec<String>, CliError> {
for (i, &v) in mapping.iter().enumerate() {
if !(-1..=3).contains(&v) {
return Err(CliError::new(
exit::VALIDATION,
format!(
"--ams-map[{i}]={v} is out of range (AMS trays are 0..3, or -1 for the \
external spool)"
),
));
}
}
if let Some(n) = filament_count
&& mapping.len() != n
{
return Err(CliError::new(
exit::VALIDATION,
format!(
"--ams-map has {} entr{} but the plate has {n} filament(s) — one tray per \
filament, in order",
mapping.len(),
if mapping.len() == 1 { "y" } else { "ies" },
),
));
}
let mut warnings = Vec::new();
if mapping.iter().filter(|&&v| v == -1).count() > 1 {
warnings.push(
"more than one filament is mapped to the external spool (-1); only one filament can \
physically feed from it — verify this is intended"
.to_string(),
);
}
Ok(warnings)
}
fn ams_mapping_preview(colors: &[String], mapping: &[i32]) -> serde_json::Value {
let entries: Vec<serde_json::Value> = mapping
.iter()
.enumerate()
.map(|(i, &tray)| {
let source = if tray == -1 {
"external spool".to_string()
} else {
format!("AMS tray {tray}")
};
serde_json::json!({
"filament": i,
"color": colors.get(i),
"tray": tray,
"source": source,
})
})
.collect();
serde_json::Value::Array(entries)
}
fn inspect_remote_plate(
cli: &Cli,
on_printer_path: &str,
plate: u32,
) -> Result<PlateInspection, CliError> {
let ftps = FtpsClient::new(resolve_target(cli)?);
let dir = tempfile::Builder::new()
.prefix("bambu-inspect-")
.tempdir()
.map_err(|e| CliError::new(exit::GENERAL, format!("creating temp dir: {e}")))?;
let tmp = dir.path().join("inspect.3mf");
ftps.download(on_printer_path, &tmp)?; let bytes = std::fs::read(&tmp)
.map_err(|e| CliError::new(exit::GENERAL, format!("reading downloaded 3mf: {e}")))?;
project::inspect_plate(&bytes, plate)
.map_err(|e| CliError::new(exit::VALIDATION, format!("3mf inspection: {e}")))
}
fn start_plan_json(
cmd: &ProtoCommand,
file: &str,
inspection: Option<&PlateInspection>,
inspect_error: Option<&str>,
ams_mapping: Option<&[i32]>,
timelapse_armed: bool,
) -> serde_json::Value {
let inspection_json = match (inspection, inspect_error) {
(Some(i), _) => {
let mut warnings: Vec<String> = Vec::new();
if !i.sidecar_matches {
warnings.push(
"the file's own .gcode.md5 sidecar disagrees with the computed md5; \
using the computed value"
.to_string(),
);
}
if timelapse_armed && !i.has_timelapse_blocks {
warnings.push(
"--timelapse is set, but this plate has no per-layer park moves; \
the head won't park (no clean object-only timelapse)"
.to_string(),
);
}
let ams_preview = ams_mapping.map(|m| ams_mapping_preview(&i.filament_colors, m));
if let Some(m) = ams_mapping
&& m.len() != i.filament_colors.len()
{
warnings.push(format!(
"--ams-map has {} entries but the plate has {} filament(s)",
m.len(),
i.filament_colors.len()
));
}
serde_json::json!({
"inspected": true,
"file": file,
"plate": i.plate,
"gcode_md5": i.gcode_md5,
"sidecar_md5": i.sidecar_md5,
"sidecar_matches": i.sidecar_matches,
"bed_type": i.bed_type,
"filament_colors": i.filament_colors,
"has_timelapse_blocks": i.has_timelapse_blocks,
"ams_mapping_preview": ams_preview,
"source": "on-printer file (downloaded for inspection)",
"warnings": warnings,
})
}
(None, Some(err)) => serde_json::json!({
"inspected": false,
"error": err,
}),
(None, None) => serde_json::Value::Null,
};
serde_json::json!({
"command": cmd.to_payload("1"),
"inspection": inspection_json,
})
}
fn ensure_idle(cli: &Cli) -> Result<(), CliError> {
let state = connect_client(cli, 10)?.fetch_snapshot()?;
match PrinterStatus::from_state(state.get()).state() {
None | Some(GcodeState::Idle) | Some(GcodeState::Finish) | Some(GcodeState::Failed) => {
Ok(())
}
Some(busy) => Err(CliError::new(
exit::PRINTER_BUSY,
format!("printer is busy ({busy:?}); refusing to start a print"),
)),
}
}
fn run_ams(cli: &Cli, action: &AmsAction) -> Result<(), CliError> {
let control =
|cli: &Cli, cmd: ProtoCommand, confirm: bool, what: &str| -> Result<(), CliError> {
if !confirm {
return Err(CliError::new(
exit::CONFIRM_REQUIRED,
format!("{what} needs --confirm"),
));
}
let client = connect_client(cli, 15)?;
eprintln!("{what} … (AMS commands are [spec]; the ACK confirms acceptance)");
report_command_outcome(cli, client.send_and_verify(&cmd)?)
};
match action {
AmsAction::Resume { confirm } => control(
cli,
ProtoCommand::AmsControl(AmsControl::Resume),
*confirm,
"ams resume",
),
AmsAction::Reset { confirm } => control(
cli,
ProtoCommand::AmsControl(AmsControl::Reset),
*confirm,
"ams reset",
),
AmsAction::Pause { confirm } => control(
cli,
ProtoCommand::AmsControl(AmsControl::Pause),
*confirm,
"ams pause",
),
AmsAction::Change {
tray,
tar_temp,
curr_temp,
dry_run,
confirm,
} => {
let max = TempLimits::default().max_nozzle as i64;
let curr = curr_temp.unwrap_or(*tar_temp);
for (label, t) in [("--tar-temp", *tar_temp), ("--curr-temp", curr)] {
if t < 0 || t > max {
return Err(CliError::new(
exit::VALIDATION,
format!("{label} {t}°C is out of range (0..={max})"),
));
}
}
let cmd = ProtoCommand::AmsChangeFilament {
target: *tray,
curr_temp: curr,
tar_temp: *tar_temp,
};
if *dry_run {
print_json(&cmd.to_payload("1"));
return Ok(());
}
if !*confirm {
return Err(CliError::new(
exit::CONFIRM_REQUIRED,
"ams change physically moves filament; needs --confirm (try --dry-run first)",
));
}
ensure_idle(cli)?;
let client = connect_client(cli, 30)?;
eprintln!(
"changing filament to tray {tray} … [spec, untested on this unit] — \
the ACK confirms acceptance; watch `bambu status` for the physical change"
);
report_command_outcome(cli, client.send_and_verify(&cmd)?)
}
AmsAction::SetFilament {
ams,
tray,
material,
color,
min,
max,
info_idx,
dry_run,
confirm,
} => {
if min > max {
return Err(CliError::new(
exit::VALIDATION,
format!("--min {min} must be <= --max {max}"),
));
}
let limit = TempLimits::default().max_nozzle as i64;
if *min < 0 || *max > limit {
return Err(CliError::new(
exit::VALIDATION,
format!("nozzle temps must be within 0..={limit}°C"),
));
}
if color.len() != 8 || !color.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(CliError::new(
exit::VALIDATION,
format!("--color must be 8 hex digits RRGGBBAA (got {color:?})"),
));
}
let cmd = ProtoCommand::AmsFilamentSetting(Box::new(AmsFilamentSetting {
ams_id: *ams,
tray_id: *tray,
tray_info_idx: info_idx.clone(),
tray_color: color.clone(),
nozzle_temp_min: *min,
nozzle_temp_max: *max,
tray_type: material.clone(),
}));
if *dry_run {
print_json(&cmd.to_payload("1"));
return Ok(());
}
control(cli, cmd, *confirm, "ams set-filament")
}
AmsAction::Settings {
ams,
startup_read,
tray_read,
confirm,
} => control(
cli,
ProtoCommand::AmsUserSetting {
ams_id: *ams,
startup_read: *startup_read,
tray_read: *tray_read,
},
*confirm,
"ams settings",
),
}
}
fn run_calibrate(cli: &Cli, args: &CalibrateArgs) -> Result<(), CliError> {
let none_picked = !(args.bed_level || args.vibration || args.motor_noise);
let bed_level = args.bed_level || none_picked;
let vibration = args.vibration || none_picked;
let motor_noise = args.motor_noise || none_picked;
let cmd = ProtoCommand::Calibration {
bed_level,
vibration,
motor_noise,
};
let what = describe_calibration(bed_level, vibration, motor_noise);
if args.dry_run {
if want_json(cli) {
print_json(&serde_json::json!({
"plan": {
"bed_level": bed_level,
"vibration": vibration,
"motor_noise": motor_noise,
"what": what,
},
"payload": cmd.to_payload("1"),
}));
} else {
eprintln!("dry run — would run calibration: {what}");
eprintln!("(nothing sent; re-run with --confirm to start)");
}
return Ok(());
}
if !args.confirm {
return Err(CliError::new(
exit::CONFIRM_REQUIRED,
"calibration moves the hardware; needs --confirm (try --dry-run first)",
));
}
ensure_idle(cli)?;
let client = connect_client(cli, 20)?;
eprintln!("starting calibration: {what} …");
let outcome = client.send_and_verify(&cmd)?;
if args.watch && outcome == CommandOutcome::Verified {
eprintln!("calibration started; watching until it finishes …");
let (model, profile_name) = watch_identity(cli)?;
let watcher = connect_client(cli, args.watch_timeout)?;
let watch_interval = args.interval.map(Duration::from_secs);
watch_to_terminal(
&watcher,
cli,
model,
profile_name,
false,
watch_interval,
false,
)
} else {
report_command_outcome(cli, outcome)
}
}
fn describe_calibration(bed_level: bool, vibration: bool, motor_noise: bool) -> String {
let mut parts = Vec::new();
if bed_level {
parts.push("bed level");
}
if vibration {
parts.push("vibration");
}
if motor_noise {
parts.push("motor noise");
}
if parts.is_empty() {
"nothing".to_string()
} else {
parts.join(" + ")
}
}
fn job_control(cli: &Cli, cmd: ProtoCommand, confirm: bool) -> Result<(), CliError> {
if !confirm {
return Err(CliError::new(
exit::CONFIRM_REQUIRED,
"this control command needs --confirm",
));
}
let client = connect_client(cli, 15)?;
report_command_outcome(cli, client.send_and_verify(&cmd)?)
}
fn run_camera(cli: &Cli, action: &CameraAction) -> Result<(), CliError> {
match action {
CameraAction::Snapshot { out, timeout } => {
let camera =
CameraClient::new(resolve_target(cli)?).with_timeout(Duration::from_secs(*timeout));
let jpeg = camera.snapshot()?;
std::fs::write(out, &jpeg).map_err(|e| {
CliError::new(exit::GENERAL, format!("write {}: {e}", out.display()))
})?;
eprintln!("wrote {} bytes", jpeg.len());
if want_json(cli) {
print_json(&serde_json::json!({
"path": out.to_string_lossy(),
"bytes": jpeg.len(),
}));
} else {
println!("{}", out.display());
}
Ok(())
}
}
}
fn run_timelapse(cli: &Cli, action: &TimelapseAction) -> Result<(), CliError> {
match action {
TimelapseAction::Enable { timeout } => {
timelapse_set(cli, TimelapseControl::Enable, *timeout)
}
TimelapseAction::Disable { timeout } => {
timelapse_set(cli, TimelapseControl::Disable, *timeout)
}
TimelapseAction::List => {
let names = FtpsClient::new(resolve_target(cli)?).list("/timelapse")?;
if want_json(cli) {
print_json(&names);
} else if names.is_empty() {
println!("no timelapse files on the printer");
} else {
for n in &names {
println!("{n}");
}
}
Ok(())
}
TimelapseAction::Get { name, out } => {
let remote = if name.starts_with('/') {
name.clone()
} else {
format!("/timelapse/{name}")
};
let local = match out {
Some(p) => p.clone(),
None => std::path::Path::new(&remote)
.file_name()
.map(std::path::PathBuf::from)
.ok_or_else(|| {
CliError::new(exit::VALIDATION, "cannot derive an output name; pass --out")
})?,
};
let n = FtpsClient::new(resolve_target(cli)?).download(&remote, &local)?;
eprintln!("downloaded {n} bytes to {}", local.display());
if want_json(cli) {
print_json(&serde_json::json!({
"path": local.to_string_lossy(),
"bytes": n,
}));
} else {
println!("{}", local.display());
}
Ok(())
}
TimelapseAction::Capture {
on_layer_cmd,
out_dir,
every,
ext,
interval,
timeout,
wait,
} => run_timelapse_capture(
cli,
on_layer_cmd,
out_dir,
*every,
ext,
interval.map(Duration::from_secs),
*timeout,
*wait,
),
TimelapseAction::Encode {
input,
out,
fps,
speed,
} => run_encode(input, out.as_deref(), *fps, *speed),
TimelapseAction::Park {
stream_url,
config,
out,
assemble,
out_fps,
serve,
watch_printer,
width,
height,
max_seconds,
} => run_timelapse_park(ParkArgs {
stream_url,
config,
out,
assemble: assemble.as_deref(),
out_fps: *out_fps,
serve: serve.as_deref(),
watch_printer: *watch_printer,
width: *width,
height: *height,
max_seconds: *max_seconds,
cli,
}),
}
}
struct ParkArgs<'a> {
stream_url: &'a str,
config: &'a std::path::Path,
out: &'a std::path::Path,
assemble: Option<&'a std::path::Path>,
out_fps: u32,
serve: Option<&'a str>,
watch_printer: bool,
width: u32,
height: u32,
max_seconds: Option<u64>,
cli: &'a Cli,
}
fn run_timelapse_park(args: ParkArgs) -> Result<(), CliError> {
let ParkArgs {
stream_url,
config,
out,
assemble,
out_fps,
serve,
watch_printer,
width,
height,
max_seconds,
cli,
} = args;
let raw = std::fs::read_to_string(config).map_err(|e| {
CliError::new(
exit::VALIDATION,
format!("reading tuning config {}: {e}", config.display()),
)
})?;
let tuning: ParkTuning = serde_json::from_str(&raw).map_err(|e| {
CliError::new(
exit::VALIDATION,
format!(
"invalid tuning config {} (no defaults): {e}",
config.display()
),
)
})?;
std::fs::create_dir_all(out)
.map_err(|e| CliError::new(exit::GENERAL, format!("creating {}: {e}", out.display())))?;
let cap = ParkCapture {
id: "park".to_string(),
stream_url: stream_url.to_string(),
tuning,
};
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
{
let cancel = cancel.clone();
let _ =
ctrlc::set_handler(move || cancel.store(true, std::sync::atomic::Ordering::Relaxed));
}
if let Some(secs) = max_seconds {
let cancel = cancel.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(secs));
cancel.store(true, std::sync::atomic::Ordering::Relaxed);
});
}
if let Some(base) = serve {
spawn_serve_autostop(base, &cancel)?;
} else if watch_printer {
spawn_printer_autostop(cli, &cancel)?;
}
eprintln!("watching {stream_url} -> {}", out.display());
eprintln!(
" live preview: open {}/latest_park.jpg in an auto-reloading viewer \
(e.g. feh --reload 1 {}/latest_park.jpg)",
out.display(),
out.display()
);
let auto_stop = if serve.is_some() {
"print end (via serve), "
} else if watch_printer {
"print end (via printer), "
} else {
""
};
eprintln!(
" stops on: {}{}Ctrl-C",
auto_stop,
max_seconds.map_or(String::new(), |s| format!("{s}s, ")),
);
let mut parks = 0u64;
let mut on_park = |ev| match ev {
ParkEvent::Written => {
eprintln!("park #{parks}");
parks += 1;
}
ParkEvent::Replaced => {
eprintln!("park #{} updated (stronger frame)", parks.saturating_sub(1))
}
ParkEvent::Dropped => eprintln!("warning: a park frame was dropped (ring JPEG missing)"),
};
let stats = run_park_camera(
&cap,
out,
width as usize,
height as usize,
&cancel,
&mut on_park,
)
.map_err(|e| CliError::new(exit::GENERAL, e))?;
if stats.frames == 0 {
return Err(CliError::new(
exit::TRANSPORT,
format!("read 0 frames from {stream_url} — check the URL and that ffmpeg can open it"),
));
}
eprintln!(
"done: {} parks ({} frames, {} replaced, {} dropped) -> {}",
stats.parks,
stats.frames,
stats.replaced,
stats.dropped,
out.display()
);
let assembled = match assemble {
Some(mp4) if stats.parks > 0 => {
assemble_park_mp4(out, mp4, out_fps)?;
eprintln!("assembled {}", mp4.display());
Some(mp4.to_string_lossy().to_string())
}
Some(_) => {
eprintln!("nothing to assemble (no parks captured)");
None
}
None => None,
};
if want_json(cli) {
print_json(&serde_json::json!({
"out": out.to_string_lossy(),
"frames": stats.frames,
"parks": stats.parks,
"replaced": stats.replaced,
"dropped": stats.dropped,
"assembled": assembled,
}));
}
Ok(())
}
fn assemble_park_mp4(
out_dir: &std::path::Path,
mp4: &std::path::Path,
fps: u32,
) -> Result<(), CliError> {
crate::captures::assemble_mp4(out_dir, crate::captures::CaptureKind::Park, mp4, fps).map_err(
|e| {
let code = if e.contains("ffmpeg not found") {
exit::VALIDATION
} else {
exit::GENERAL
};
CliError::new(code, e)
},
)
}
#[cfg(feature = "server")]
fn spawn_serve_autostop(
base: &str,
cancel: &std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> Result<(), CliError> {
fetch_serve_status(base)?;
let base = base.to_string();
let cancel = cancel.clone();
std::thread::spawn(move || {
let mut activity = PrintActivitySession::new(true);
while !cancel.load(std::sync::atomic::Ordering::Relaxed) {
if let Ok(status) = fetch_serve_status(&base)
&& activity.observe(&status) == ActivityAction::Stop
{
cancel.store(true, std::sync::atomic::Ordering::Relaxed);
return;
}
std::thread::sleep(Duration::from_secs(2));
}
});
Ok(())
}
#[cfg(not(feature = "server"))]
fn spawn_serve_autostop(
_base: &str,
_cancel: &std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> Result<(), CliError> {
Err(CliError::new(
exit::VALIDATION,
"--serve needs the `server` feature (not compiled into this build)",
))
}
fn spawn_printer_autostop(
cli: &Cli,
cancel: &std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> Result<(), CliError> {
let target = resolve_target(cli)?;
let cancel = cancel.clone();
std::thread::spawn(move || {
use std::sync::atomic::Ordering::Relaxed;
let client = LanMqttClient::new(target).with_timeout(Duration::from_secs(120));
let mut activity = PrintActivitySession::new(true);
while !cancel.load(Relaxed) {
let mut on_update = |state: &ReportState| -> WatchStep {
if cancel.load(Relaxed) {
return WatchStep::Stop; }
let st = PrinterStatus::from_state(state.get());
if activity.observe(&st) == ActivityAction::Stop {
cancel.store(true, Relaxed);
WatchStep::Stop
} else {
WatchStep::Continue
}
};
let result = client.monitor(Some(Duration::from_secs(30)), &mut on_update);
if cancel.load(Relaxed) {
break; }
match result {
Err(e) if !matches!(e, ClientError::Timeout(_)) => {
eprintln!("warning: printer auto-stop watch error: {e}; retrying…")
}
_ => eprintln!(
"warning: printer unreachable — print-end auto-stop paused, retrying…"
),
}
std::thread::sleep(Duration::from_secs(5));
}
});
Ok(())
}
fn default_mp4_out(input: &std::path::Path) -> std::path::PathBuf {
input.with_extension("mp4")
}
fn is_mjpeg(input: &std::path::Path) -> bool {
input
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("mjpeg"))
}
fn build_ffmpeg_args(
input: &std::path::Path,
out: &std::path::Path,
fps: u32,
speed: u32,
) -> Result<Vec<String>, CliError> {
let fps = fps.max(1);
let speed = speed.max(1);
let mut args: Vec<String> = vec!["-y".into()];
if input.is_dir() {
args.extend(["-framerate".into(), fps.to_string()]);
args.extend(["-pattern_type".into(), "glob".into()]);
args.extend(["-i".into(), format!("{}/frame_*.jpg", input.display())]);
if speed > 1 {
args.extend([
"-vf".into(),
format!("framestep={speed},setpts=N/{fps}/TB"),
"-r".into(),
fps.to_string(),
]);
}
} else if is_mjpeg(input) {
args.extend(["-f".into(), "mpjpeg".into()]);
args.extend(["-i".into(), input.display().to_string()]);
let vf = if speed > 1 {
format!("framestep={speed},setpts=N/{fps}/TB")
} else {
format!("setpts=N/{fps}/TB")
};
args.extend(["-vf".into(), vf, "-r".into(), fps.to_string(), "-an".into()]);
} else {
return Err(CliError::new(
exit::VALIDATION,
format!(
"{}: encode input must be a directory of frame_*.jpg or a .mjpeg file",
input.display()
),
));
}
args.extend(
[
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
]
.map(String::from),
);
args.push(out.display().to_string());
Ok(args)
}
fn run_encode(
input: &std::path::Path,
out: Option<&std::path::Path>,
fps: u32,
speed: u32,
) -> Result<(), CliError> {
if !input.exists() {
return Err(CliError::new(
exit::VALIDATION,
format!("{}: no such file or directory", input.display()),
));
}
let out = out
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| default_mp4_out(input));
let args = build_ffmpeg_args(input, &out, fps, speed)?;
let status = std::process::Command::new("ffmpeg")
.args(&args)
.status()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
CliError::new(
exit::VALIDATION,
"ffmpeg not found on PATH — install ffmpeg to encode mp4",
)
} else {
CliError::new(exit::GENERAL, format!("running ffmpeg: {e}"))
}
})?;
if !status.success() {
return Err(CliError::new(
exit::GENERAL,
format!("ffmpeg exited with {status}"),
));
}
eprintln!("encoded {}", out.display());
println!("{}", out.display());
Ok(())
}
fn timelapse_set(cli: &Cli, control: TimelapseControl, timeout_secs: u64) -> Result<(), CliError> {
let client = connect_client(cli, timeout_secs)?;
eprintln!("setting timelapse {} …", control.as_str());
report_command_outcome(
cli,
client.send_and_verify(&ProtoCommand::IpcamTimelapse(control))?,
)
}
#[allow(clippy::too_many_arguments)]
fn run_timelapse_capture(
cli: &Cli,
on_layer_cmd: &[String],
out_dir: &std::path::Path,
every: u64,
ext: &str,
interval: Option<Duration>,
timeout_secs: u64,
wait: bool,
) -> Result<(), CliError> {
if every == 0 {
return Err(CliError::new(exit::VALIDATION, "--every must be >= 1"));
}
if ext.is_empty() || ext.len() > 12 || !ext.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(CliError::new(
exit::VALIDATION,
"--ext must be 1-12 alphanumeric characters (e.g. jpg, png)",
));
}
std::fs::create_dir_all(out_dir)
.map_err(|e| CliError::new(exit::GENERAL, format!("create {}: {e}", out_dir.display())))?;
let client = connect_client(cli, timeout_secs)?;
if wait {
eprintln!(
"waiting for a print to start, then capturing every {} layer(s) to {} …",
every,
out_dir.display()
);
} else {
eprintln!(
"watching the active print; capturing every {} layer(s) to {} …",
every,
out_dir.display()
);
}
let (tx, rx) = std::sync::mpsc::channel::<(std::path::PathBuf, i64)>();
let worker = {
let argv = on_layer_cmd.to_vec();
let dir = out_dir.to_path_buf();
std::thread::spawn(move || {
let (mut captured, mut failures) = (0u64, 0u64);
for (frame, layer) in rx {
match run_capture_cmd(&argv, &frame, layer, &dir) {
Ok(()) => {
captured += 1;
eprintln!("captured frame (layer {layer}) -> {}", frame.display());
}
Err(e) => {
failures += 1;
eprintln!("capture failed at layer {layer}: {e} (continuing)");
}
}
}
(captured, failures)
})
};
let watch_result = {
let mut session = CaptureSession::new(every, wait);
let mut on_update = |state: &ReportState| -> WatchStep {
let st = PrinterStatus::from_state(state.get());
match session.observe(&st) {
CaptureAction::Capture { frame_no, layer } => {
let frame = out_dir.join(format!("frame_{frame_no:06}_layer_{layer:05}.{ext}"));
let _ = tx.send((frame, layer));
WatchStep::Continue
}
CaptureAction::Continue => WatchStep::Continue,
CaptureAction::Stop => WatchStep::Stop,
}
};
client.watch(interval, &mut on_update)
};
drop(tx);
let (captured, failures) = worker.join().unwrap_or((0, 0));
let ended_by = match &watch_result {
Ok(_) => "terminal",
Err(ClientError::Timeout(_)) => "timeout",
Err(_) => "error",
};
if let Err(e) = watch_result
&& !matches!(e, ClientError::Timeout(_))
{
return Err(e.into());
}
eprintln!("done: {captured} frame(s) captured, {failures} failure(s) ({ended_by})");
let suggested = ffmpeg_suggestion(out_dir, ext);
if want_json(cli) {
print_json(&serde_json::json!({
"captured": captured,
"failures": failures,
"out_dir": out_dir.to_string_lossy(),
"ended_by": ended_by,
"suggested_assemble": (captured > 0).then_some(suggested.clone()),
}));
}
if captured == 0 {
eprintln!(
"no frames captured — start this during an active print (the printer \
must be RUNNING and advancing layers), or pass --wait to launch it \
first and have it wait for the print to start."
);
return Ok(());
}
if !want_json(cli) {
println!("to build a video:\n {suggested}");
}
Ok(())
}
fn ffmpeg_suggestion(out_dir: &std::path::Path, ext: &str) -> String {
let dir = out_dir.display();
format!(
"ffmpeg -framerate 12 -pattern_type glob -i '{dir}/frame_*.{ext}' \
-c:v libx264 -pix_fmt yuv420p {dir}/timelapse.mp4"
)
}
fn subst_capture_tokens(s: &str, frame: &str, layer: i64, out_dir: &str) -> String {
s.replace("{frame}", frame)
.replace("{layer}", &layer.to_string())
.replace("{outdir}", out_dir)
}
fn run_capture_cmd(
argv: &[String],
frame: &std::path::Path,
layer: i64,
out_dir: &std::path::Path,
) -> Result<(), String> {
let frame = frame.to_string_lossy();
let dir = out_dir.to_string_lossy();
let subst = |s: &str| subst_capture_tokens(s, &frame, layer, &dir);
let prog = subst(&argv[0]);
let args: Vec<String> = argv[1..].iter().map(|a| subst(a)).collect();
let status = std::process::Command::new(&prog)
.args(&args)
.status()
.map_err(|e| format!("spawn {prog:?}: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("{prog:?} exited with {status}"))
}
}
fn resolve_target(cli: &Cli) -> Result<ResolvedTarget, CliError> {
let cfg = Config::load_or_default(&config_path()?)?;
let profile = selected_profile_name(cli, &cfg)?.and_then(|n| cfg.profile(&n).cloned());
let overrides = flag_overrides(cli).over(Overrides::from_env());
Ok(config::resolve(profile.as_ref(), &overrides)?)
}
fn connect_client(cli: &Cli, timeout_secs: u64) -> Result<LanMqttClient, CliError> {
Ok(LanMqttClient::new(resolve_target(cli)?).with_timeout(Duration::from_secs(timeout_secs)))
}
fn report_command_outcome(cli: &Cli, outcome: CommandOutcome) -> Result<(), CliError> {
if want_json(cli) {
let v = match &outcome {
CommandOutcome::Verified => serde_json::json!({ "outcome": "verified" }),
CommandOutcome::Rejected { reason } => {
serde_json::json!({ "outcome": "rejected", "reason": reason })
}
CommandOutcome::Unverified { stage } => serde_json::json!({
"outcome": "unverified",
"stage": match stage {
VerifyStage::Ack => "ack",
VerifyStage::Effect => "effect",
},
}),
};
print_json(&v);
}
match outcome {
CommandOutcome::Verified => {
if !want_json(cli) {
eprintln!("verified: the printer confirmed the command took effect");
}
Ok(())
}
CommandOutcome::Rejected { reason } => Err(CliError::new(
exit::DEVICE_REJECTED,
format!("the printer rejected the command: {reason}"),
)),
CommandOutcome::Unverified {
stage: VerifyStage::Ack,
} => Err(CliError::new(
exit::VERIFY_TIMEOUT,
"command published but not acknowledged within the timeout (unverified)",
)),
CommandOutcome::Unverified {
stage: VerifyStage::Effect,
} => Err(CliError::new(
exit::VERIFY_TIMEOUT,
"command was acknowledged but its effect never showed in the report \
(the printer's state didn't change — e.g. a print that won't start \
or a light that won't switch); unverified — check `bambu status`",
)),
}
}
fn is_watch_terminal(state: GcodeState) -> bool {
matches!(
state,
GcodeState::Finish | GcodeState::Failed | GcodeState::Idle
)
}
fn selected_profile_name(cli: &Cli, cfg: &Config) -> Result<Option<String>, CliError> {
let name = match cli.printer.clone().or_else(|| cfg.default_printer.clone()) {
Some(n) => n,
None => return Ok(None),
};
if cfg.printers.contains_key(&name) {
Ok(Some(name))
} else {
Err(CliError::from(ConfigError::UnknownProfile(name)))
}
}
fn want_json(cli: &Cli) -> bool {
cli.json
}
fn flag_overrides(cli: &Cli) -> Overrides {
Overrides {
ip: cli.ip.clone(),
serial: cli.serial.clone(),
access_code: cli.access_code.clone(),
model: cli.model.clone(),
}
}
fn print_json<T: Serialize>(value: &T) {
match serde_json::to_string_pretty(value) {
Ok(s) => println!("{s}"),
Err(e) => eprintln!("error: failed to serialize output: {e}"),
}
}
fn print_status_human(o: &StatusOutput) {
let s = &o.status;
println!(
"printer: {} ({})",
o.printer.as_deref().unwrap_or("-"),
o.model
);
println!("state: {}", s.gcode_state.as_deref().unwrap_or("?"));
if let Some(err) = &s.error {
println!("error: ⚠ {} (print_error {})", err.hex, err.code);
println!(" {}", err.lookup_url);
}
if let (Some(stage), Some(id)) = (s.stage.as_deref(), s.stg_cur)
&& !Stage(id).is_no_stage()
{
println!("stage: {stage} ({id})");
}
if let Some(f) = &s.filament {
let name = f.name.as_deref().or(f.material.as_deref()).unwrap_or("?");
let color = f
.color
.as_deref()
.map(|c| format!(" #{c}"))
.unwrap_or_default();
println!("filament: {name} @ {}{color}", f.location);
}
if let (Some(n), Some(b)) = (s.nozzle_temper, s.bed_temper) {
println!("temps: nozzle {n:.1}°C / bed {b:.1}°C");
}
if let Some(tl) = s.timelapse_mode() {
println!("timelapse: {tl}");
}
if let Some(lvl) = s.spd_lvl {
let name = SpeedLevel::from_level(lvl)
.map(|l| l.as_str())
.unwrap_or("?");
println!("speed: {name} ({lvl})");
}
if let Some(p) = s.mc_percent {
let layer = s.layer_num.unwrap_or(0);
let total = s.total_layer_num.unwrap_or(0);
let eta = match s.remaining_time_min.filter(|m| *m > 0) {
Some(m) => format!(", ETA {}", fmt_eta(m)),
None => String::new(),
};
println!("progress: {p}% (layer {layer}/{total}{eta})");
}
}
fn fmt_eta(min: i64) -> String {
if min >= 60 {
format!("{}h{:02}m", min / 60, min % 60)
} else {
format!("{min}m")
}
}
#[cfg(test)]
mod tests {
use super::{ams_mapping_preview, fmt_eta, subst_capture_tokens, validate_ams_map};
#[cfg(feature = "server")]
#[test]
fn serve_status_url_joins_and_trims_trailing_slash() {
use super::serve_status_url;
assert_eq!(
serve_status_url("http://127.0.0.1:8088"),
"http://127.0.0.1:8088/api/status"
);
assert_eq!(
serve_status_url("http://h:8088/"),
"http://h:8088/api/status"
);
}
#[test]
fn encode_args_for_an_mjpeg_stream() {
use super::build_ffmpeg_args;
use std::path::Path;
let args = build_ffmpeg_args(
Path::new("/r/plain.mjpeg"),
Path::new("/r/plain.mp4"),
30,
8,
)
.unwrap();
let joined = args.join(" ");
assert!(joined.contains("-f mpjpeg"), "{joined}");
assert!(joined.contains("-i /r/plain.mjpeg"));
assert!(joined.contains("framestep=8"), "speed>1 ⇒ framestep");
assert!(joined.contains("setpts=N/30/TB"));
assert!(joined.trim_end().ends_with("/r/plain.mp4"));
}
#[test]
fn encode_args_for_an_image_sequence_dir() {
use super::build_ffmpeg_args;
let dir = std::env::temp_dir().join(format!("bambu-enc-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("x.mp4");
let args = build_ffmpeg_args(&dir, &out, 20, 1).unwrap();
let joined = args.join(" ");
assert!(joined.contains("-framerate 20"), "{joined}");
assert!(joined.contains("-pattern_type glob"));
assert!(joined.contains("frame_*.jpg"));
assert!(!joined.contains("framestep"), "speed=1 ⇒ no framestep");
let fast = build_ffmpeg_args(&dir, &out, 20, 4).unwrap().join(" ");
assert!(fast.contains("framestep=4"), "{fast}");
assert!(
fast.contains("setpts=N/20/TB"),
"stepped frames must be re-timed: {fast}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn encode_rejects_a_non_dir_non_mjpeg_input() {
use super::build_ffmpeg_args;
use std::path::Path;
let e = build_ffmpeg_args(Path::new("/no/such/file.txt"), Path::new("/o.mp4"), 30, 1)
.unwrap_err();
assert_eq!(e.code, super::exit::VALIDATION);
}
#[test]
fn default_mp4_out_swaps_the_suffix() {
use super::default_mp4_out;
use std::path::Path;
assert_eq!(
default_mp4_out(Path::new("/r/plain.mjpeg")),
Path::new("/r/plain.mp4")
);
assert_eq!(
default_mp4_out(Path::new("/r/ext-1")),
Path::new("/r/ext-1.mp4")
);
}
#[test]
fn watch_line_formats_state_progress_and_temps() {
use super::format_watch_line;
use crate::core::status::PrinterStatus;
let st = PrinterStatus::from_state(&serde_json::json!({ "print": {
"gcode_state": "RUNNING", "mc_percent": 42, "layer_num": 10, "total_layer_num": 240,
"nozzle_temper": 215.0, "nozzle_target_temper": 245.0,
"bed_temper": 60.0, "bed_target_temper": 60.0,
}}));
let line = format_watch_line(&st);
for needle in ["RUNNING", "42%", "layer 10/240", "N215/245", "B60/60"] {
assert!(line.contains(needle), "{needle:?} missing from {line:?}");
}
}
#[test]
fn watch_key_rounds_temps_so_subdegree_jitter_is_one_line() {
use super::watch_key;
use crate::core::status::PrinterStatus;
let noz = |n: f64| {
PrinterStatus::from_state(&serde_json::json!({ "print": { "nozzle_temper": n } }))
};
assert!(watch_key(&noz(215.1)) == watch_key(&noz(215.4)));
assert!(watch_key(&noz(215.0)) != watch_key(&noz(216.0)));
}
#[test]
fn eta_formats_minutes_and_hours() {
assert_eq!(fmt_eta(15), "15m");
assert_eq!(fmt_eta(59), "59m");
assert_eq!(fmt_eta(60), "1h00m");
assert_eq!(fmt_eta(95), "1h35m");
}
#[test]
fn ams_map_range_is_always_checked() {
assert!(validate_ams_map(&[0, 3, -1], None).is_ok());
assert!(validate_ams_map(&[0, 4], None).is_err());
assert!(validate_ams_map(&[-2], None).is_err());
}
#[test]
fn ams_map_length_must_match_filament_count_when_known() {
assert!(validate_ams_map(&[0, 1], Some(2)).is_ok());
assert!(validate_ams_map(&[0, 1], Some(3)).is_err());
assert!(validate_ams_map(&[0], Some(2)).is_err());
}
#[test]
fn ams_map_warns_on_multiple_external_spools() {
let warns = validate_ams_map(&[-1, -1], Some(2)).unwrap();
assert!(warns.iter().any(|w| w.contains("external spool")));
assert!(validate_ams_map(&[0, -1], Some(2)).unwrap().is_empty());
}
#[test]
fn ams_preview_pairs_filaments_with_trays() {
let colors = vec!["#F2754E".to_string(), "#0000FF".to_string()];
let v = ams_mapping_preview(&colors, &[2, -1]);
let arr = v.as_array().unwrap();
assert_eq!(arr[0]["color"], "#F2754E");
assert_eq!(arr[0]["tray"], 2);
assert_eq!(arr[0]["source"], "AMS tray 2");
assert_eq!(arr[1]["tray"], -1);
assert_eq!(arr[1]["source"], "external spool");
}
#[test]
fn capture_tokens_substitute_per_argv_element() {
assert_eq!(
subst_capture_tokens("{outdir}/f_{layer}.jpg", "/t/frame.jpg", 42, "/t"),
"/t/f_42.jpg"
);
assert_eq!(
subst_capture_tokens("{frame}", "/t/frame.jpg", 7, "/t"),
"/t/frame.jpg"
);
assert_eq!(subst_capture_tokens("-r", "/f.jpg", 1, "/t"), "-r");
}
#[test]
fn capture_tokens_do_not_interpret_shell_metacharacters() {
let layer_with_meta = subst_capture_tokens("{frame}", "/t/a b;rm -rf $HOME.jpg", 1, "/t");
assert_eq!(layer_with_meta, "/t/a b;rm -rf $HOME.jpg");
}
}
#[derive(Serialize)]
struct StatusOutput {
printer: Option<String>,
model: String,
#[serde(flatten)]
status: PrinterStatus,
}
#[derive(Serialize)]
struct RedactedProfile<'a> {
name: &'a str,
ip: &'a str,
serial: &'a str,
model: &'a str,
mode: &'a str,
access_code: &'static str,
}
impl<'a> RedactedProfile<'a> {
fn from(name: &'a str, p: &'a Profile) -> Self {
Self {
name,
ip: &p.ip,
serial: &p.serial,
model: &p.model,
mode: &p.mode,
access_code: "<redacted>",
}
}
}
impl std::fmt::Display for RedactedProfile<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}: ip={} serial={} model={} mode={} access_code={}",
self.name, self.ip, self.serial, self.model, self.mode, self.access_code
)
}
}