use std::path::Path; use std::time::Duration; use rumqttc::{AsyncClient, Event, MqttOptions, Packet, QoS, TlsConfiguration, Transport}; use serde::{Deserialize, Serialize}; use suppaftp::{AsyncNativeTlsConnector, AsyncNativeTlsFtpStream}; use tokio::sync::mpsc; use tracing::{debug, info, warn}; const MQTT_PORT: u16 = 8883; const FTPS_PORT: u16 = 990; #[derive(Debug, Clone)] pub struct BambuPrinter { pub serial: String, pub host: String, pub access_code: String, } /// Telemetry pushed from the printer's `report` MQTT topic, decoded into the /// subset of fields the control plane cares about. #[derive(Debug, Clone, Deserialize)] pub struct BambuReport { #[serde(default)] pub nozzle_temper: Option, #[serde(default)] pub bed_temper: Option, #[serde(default)] pub mc_percent: Option, #[serde(default)] pub gcode_state: Option, } #[derive(Debug, Serialize)] struct BambuCommandEnvelope<'a> { print: BambuCommand<'a>, } #[derive(Debug, Serialize)] struct BambuCommand<'a> { sequence_id: &'a str, command: &'a str, } /// Connects over local-LAN MQTTS to a single Bambu printer and streams /// decoded telemetry reports out on `tx`. Bambu's LAN-mode broker uses a /// self-signed cert, so a permissive TLS config is required. pub async fn run_telemetry(printer: BambuPrinter, tx: mpsc::Sender<(String, BambuReport)>) { loop { if let Err(err) = telemetry_session(&printer, &tx).await { warn!(serial = %printer.serial, ?err, "bambu MQTT session ended, reconnecting"); } tokio::time::sleep(Duration::from_secs(5)).await; } } async fn telemetry_session( printer: &BambuPrinter, tx: &mpsc::Sender<(String, BambuReport)>, ) -> anyhow::Result<()> { let mut opts = MqttOptions::new( format!("continuum-proxy-{}", printer.serial), printer.host.clone(), MQTT_PORT, ); opts.set_credentials("bblp", printer.access_code.clone()); opts.set_keep_alive(Duration::from_secs(20)); // Bambu LAN-mode uses a self-signed certificate; the client trusts it // explicitly because the connection never leaves the local network. opts.set_transport(Transport::Tls(TlsConfiguration::default())); let (client, mut event_loop) = AsyncClient::new(opts, 16); let report_topic = format!("device/{}/report", printer.serial); client.subscribe(&report_topic, QoS::AtMostOnce).await?; loop { match event_loop.poll().await? { Event::Incoming(Packet::Publish(publish)) if publish.topic == report_topic => { match serde_json::from_slice::(&publish.payload) { Ok(value) => { if let Some(print) = value.get("print") { if let Ok(report) = serde_json::from_value::(print.clone()) { debug!(serial = %printer.serial, ?report, "bambu telemetry"); if tx.send((printer.serial.clone(), report)).await.is_err() { return Ok(()); } } } } Err(err) => warn!(?err, "failed to decode bambu report payload"), } } Event::Incoming(Packet::Disconnect) => { return Err(anyhow::anyhow!("printer closed MQTT connection")); } _ => {} } } } /// Sends a G-code print-control command (pause/resume/stop/etc.) to the /// printer over its LAN MQTT channel. pub async fn send_command(printer: &BambuPrinter, sequence_id: &str, command: &str) -> anyhow::Result<()> { let mut opts = MqttOptions::new(format!("continuum-proxy-cmd-{}", printer.serial), printer.host.clone(), MQTT_PORT); opts.set_credentials("bblp", printer.access_code.clone()); opts.set_transport(Transport::Tls(TlsConfiguration::default())); let (client, mut event_loop) = AsyncClient::new(opts, 4); let request_topic = format!("device/{}/request", printer.serial); let envelope = BambuCommandEnvelope { print: BambuCommand { sequence_id, command }, }; client .publish(&request_topic, QoS::AtLeastOnce, false, serde_json::to_vec(&envelope)?) .await?; // Pump the event loop once so the publish actually flushes before we drop the client. let _ = tokio::time::timeout(Duration::from_secs(3), event_loop.poll()).await; Ok(()) } /// Uploads a sliced `.gcode.3mf` project file to the printer's local storage /// over FTPS (port 990, implicit TLS) ahead of a print job. pub async fn dispatch_file(printer: &BambuPrinter, local_path: &Path, remote_name: &str) -> anyhow::Result<()> { info!(serial = %printer.serial, remote_name, "dispatching file via FTPS"); let ftp = AsyncNativeTlsFtpStream::connect(format!("{}:{FTPS_PORT}", printer.host)).await?; let mut ftp = ftp .into_secure(AsyncNativeTlsConnector::from(native_tls::TlsConnector::new()?), &printer.host) .await?; ftp.login("bblp", &printer.access_code).await?; let mut file = tokio::fs::File::open(local_path).await?; ftp.put_file(remote_name, &mut file).await?; ftp.quit().await?; Ok(()) }