Initial boilerplate scaffold for continuum-proxy
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user