Initial boilerplate scaffold for continuum-proxy

This commit is contained in:
2026-08-28 16:25:26 +00:00
commit 2807b2b067
18 changed files with 1002 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
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<f32>,
#[serde(default)]
pub bed_temper: Option<f32>,
#[serde(default)]
pub mc_percent: Option<u8>,
#[serde(default)]
pub gcode_state: Option<String>,
}
#[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::<serde_json::Value>(&publish.payload) {
Ok(value) => {
if let Some(print) = value.get("print") {
if let Ok(report) = serde_json::from_value::<BambuReport>(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(())
}