Add upload_project_file/send_gcode_line, real Marlin serial, Bambu protocol auto-detect
- GenericPrinter grows upload_project_file, send_gcode_line, and a generic upload_gcode<P> driver that streams a whole gcode file line by line through any vendor's send_gcode_line. - PrusaSerialPrinter's connect/send_gcode_line are real (not stubs): a held-open MarlinSerialConnection (src/printer/marlin_serial.rs) writes each line and waits for Marlin's "ok" before sending the next. Config changed from com_port: i16 to port: String + baud_rate: u32 to hold a real device path. - Bambu: BambuGenericPrinter + BambuV1Printer/BambuV2Printer collapsed into one BambuPrinter, since which MQTT/BambuMQTT version (v3.1.1/v1 vs v5/v2) a printer speaks is discovered at connect() time (or forced), not something config should pick a type for - the same physical printer can change after a firmware update. printers.toml gets a new "bambu" vendor (auto-detect, the default) alongside bambu_v1/bambu_v2 (force a specific one). - Bambu's gcode_line request is now built for real via continuum-schema-instance against continuum-schemas' JSON Schema, picking the v1 or v2 schema directory based on the detected protocol version, instead of a hand-written serde struct. - Added rumqttc as a dependency (not wired up yet - the real MQTT v5-then-v3.1.1 fallback connect and the publish/receive plumbing are still stubs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MWQVtBGxHYLkVw2kBupd5g
This commit is contained in:
+14
-7
@@ -34,6 +34,18 @@ name = "bambu_commands_demo"
|
|||||||
path = "examples/bambu_commands_demo.rs"
|
path = "examples/bambu_commands_demo.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
# Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/
|
||||||
|
# Moonraker, FTPS for gcode transfer) go here once you're ready to build
|
||||||
|
# past the stubs in src/printer/ - see that module's doc comment.
|
||||||
|
continuum-rust-types = { path = "../continuum-schemas/packages/rust-types" }
|
||||||
|
# FOR NON-LOCAL DEV
|
||||||
|
# continuum-rust-types = { git = "https://git.octoturge.com/Continuum/continuum-schemas.git" }
|
||||||
|
|
||||||
|
# Builds a JSON instance straight from the schema files in
|
||||||
|
# continuum-schemas/schemas/ - used for Bambu's raw MQTT JSON messages
|
||||||
|
# instead of hand-written serde structs (see bambu.rs).
|
||||||
|
continuum-schema-instance = { path = "../continuum-common/packages/rust-schema-instance" }
|
||||||
|
|
||||||
tokio = { version = "1.40", features = ["full"] }
|
tokio = { version = "1.40", features = ["full"] }
|
||||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
@@ -47,10 +59,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
|
tokio-serial = { version = "5.5.0", default-features = false }
|
||||||
# Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/
|
rumqttc = "0.25.1"
|
||||||
# Moonraker, FTPS for gcode transfer) go here once you're ready to build
|
|
||||||
# past the stubs in src/printer/ - see that module's doc comment.
|
|
||||||
continuum-rust-types = { path = "../continuum-schemas/packages/rust-types" }
|
|
||||||
# FOR NON-LOCAL DEV
|
|
||||||
# continuum-rust-types = { git = "https://git.octoturge.com/Continuum/continuum-schemas.git" }
|
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
for printer in &mut printers {
|
for printer in &mut printers {
|
||||||
match printer {
|
match printer {
|
||||||
PrinterHandle::BambuV1(bambu) => report(&bambu.ensure_trusted(cert_dir)),
|
PrinterHandle::Bambu(bambu) => report(&bambu.ensure_trusted(cert_dir)),
|
||||||
PrinterHandle::BambuV2(bambu) => report(&bambu.ensure_trusted(cert_dir)),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
let pem = printers
|
let pem = printers
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|printer| match printer {
|
.find_map(|printer| match printer {
|
||||||
PrinterHandle::BambuV1(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()),
|
PrinterHandle::Bambu(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()),
|
||||||
PrinterHandle::BambuV2(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.ok_or_else(|| anyhow::anyhow!("no Bambu printer with id '{printer_id}' in printers.toml"))??;
|
.ok_or_else(|| anyhow::anyhow!("no Bambu printer with id '{printer_id}' in printers.toml"))??;
|
||||||
|
|||||||
@@ -4,36 +4,40 @@
|
|||||||
//! - `connect()` is called the same way regardless of vendor (polymorphism),
|
//! - `connect()` is called the same way regardless of vendor (polymorphism),
|
||||||
//! but each variant's `impl GenericPrinter` runs completely different
|
//! but each variant's `impl GenericPrinter` runs completely different
|
||||||
//! code (override).
|
//! code (override).
|
||||||
//! - `dispatch_gcode()` only exists on the two Bambu structs (extension) -
|
//! - `dispatch_gcode()` only exists on the Bambu struct (extension) - you
|
||||||
//! you have to `match` the enum back down to the concrete type to reach
|
//! have to `match` the enum back down to the concrete type to reach it,
|
||||||
//! it, which is the trade-off for not having implicit downcasting.
|
//! which is the trade-off for not having implicit downcasting.
|
||||||
|
|
||||||
use continuum_proxy::printer::{
|
use continuum_proxy::printer::{
|
||||||
BambuTls, BambuV1Printer, BambuV2Printer, GenericPrinter, KlipperPrinter, PrinterHandle, PrusaLinkPrinter,
|
BambuPrinter, BambuProtocolPreference, BambuTls, GenericPrinter, KlipperPrinter, PrinterHandle,
|
||||||
PrusaSerialPrinter,
|
PrusaLinkPrinter, PrusaSerialPrinter,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let mut fleet: Vec<PrinterHandle> = vec![
|
let mut fleet: Vec<PrinterHandle> = vec![
|
||||||
PrinterHandle::BambuV1(BambuV1Printer::new(
|
// Forced: a known-old printer.
|
||||||
|
PrinterHandle::Bambu(BambuPrinter::new(
|
||||||
"p1",
|
"p1",
|
||||||
"P1S",
|
"P1S",
|
||||||
"192.168.1.49",
|
"192.168.1.49",
|
||||||
"87654321",
|
"87654321",
|
||||||
"01P00A000000000",
|
"01P00A000000000",
|
||||||
BambuTls::BundledCa,
|
BambuTls::BundledCa,
|
||||||
|
BambuProtocolPreference::ForceV1,
|
||||||
)),
|
)),
|
||||||
PrinterHandle::BambuV2(BambuV2Printer::new(
|
// Auto: the recommended default - see bambu.rs's doc comment.
|
||||||
|
PrinterHandle::Bambu(BambuPrinter::new(
|
||||||
"p2",
|
"p2",
|
||||||
"X1 Carbon",
|
"X1 Carbon",
|
||||||
"192.168.1.50",
|
"192.168.1.50",
|
||||||
"12345678",
|
"12345678",
|
||||||
"00M00A000000000",
|
"00M00A000000000",
|
||||||
BambuTls::BundledCa,
|
BambuTls::BundledCa,
|
||||||
|
BambuProtocolPreference::Auto,
|
||||||
)),
|
)),
|
||||||
PrinterHandle::PrusaLink(PrusaLinkPrinter::new("p3", "MK4", "192.168.1.51", "prusa-api-key")),
|
PrinterHandle::PrusaLink(PrusaLinkPrinter::new("p3", "MK4", "192.168.1.51", "prusa-api-key")),
|
||||||
PrinterHandle::PrusaSerial(PrusaSerialPrinter::new("p4", "MK3S+", 0)),
|
PrinterHandle::PrusaSerial(PrusaSerialPrinter::new("p4", "MK3S+", "/dev/ttyACM0", 115_200)),
|
||||||
PrinterHandle::Klipper(KlipperPrinter::new("p5", "Voron 2.4", "192.168.1.52")),
|
PrinterHandle::Klipper(KlipperPrinter::new("p5", "Voron 2.4", "192.168.1.52")),
|
||||||
// No host configured - this one will hit the Err path.
|
// No host configured - this one will hit the Err path.
|
||||||
PrinterHandle::Klipper(KlipperPrinter::new("p6", "Broken Voron", "")),
|
PrinterHandle::Klipper(KlipperPrinter::new("p6", "Broken Voron", "")),
|
||||||
@@ -50,15 +54,11 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vendor-only extension: reachable only after matching the concrete
|
// Vendor-only extension: reachable only after matching the concrete
|
||||||
// variant back out of the enum. BambuV1Printer and BambuV2Printer are
|
// variant back out of the enum - one arm now that Bambu is a single
|
||||||
// separate types now (each *has* a BambuGenericPrinter rather than
|
// type regardless of which protocol version `connect()` settled on.
|
||||||
// being the same struct), so - unlike before the split - this needs
|
|
||||||
// two arms instead of one `A(x) | B(x)` pattern: an or-pattern requires
|
|
||||||
// every alternative to bind the same type, and these no longer do.
|
|
||||||
for printer in &fleet {
|
for printer in &fleet {
|
||||||
match printer {
|
match printer {
|
||||||
PrinterHandle::BambuV1(bambu) => bambu.dispatch_gcode("part.gcode.3mf"),
|
PrinterHandle::Bambu(bambu) => bambu.dispatch_gcode("part.gcode.3mf"),
|
||||||
PrinterHandle::BambuV2(bambu) => bambu.dispatch_gcode("part.gcode.3mf"),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,9 @@ use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
|||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let printers = fleet::load(std::path::Path::new("printers.toml"))?;
|
let printers = fleet::load(std::path::Path::new("printers.toml"))?;
|
||||||
|
|
||||||
// BambuV1Printer and BambuV2Printer are separate types (each *has* a
|
|
||||||
// BambuGenericPrinter rather than being the same struct), so this
|
|
||||||
// needs one arm per variant rather than a single `A(x) | B(x)` pattern.
|
|
||||||
for printer in &printers {
|
for printer in &printers {
|
||||||
match printer {
|
match printer {
|
||||||
PrinterHandle::BambuV1(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()),
|
PrinterHandle::Bambu(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()),
|
||||||
PrinterHandle::BambuV2(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -5,7 +5,9 @@
|
|||||||
[[printer]]
|
[[printer]]
|
||||||
id = "p1p"
|
id = "p1p"
|
||||||
name = "P1P"
|
name = "P1P"
|
||||||
vendor = "bambu_v1" # bambu_v1 | bambu_v2 | prusa_link | prusa_serial | klipper
|
# Forced instead of "bambu" here since this one's a known-old printer -
|
||||||
|
# see fleet.rs's PrinterEntry doc comment for the full vendor list.
|
||||||
|
vendor = "bambu_v1"
|
||||||
host = "192.168.1.50"
|
host = "192.168.1.50"
|
||||||
access_code = "REPLACE_WITH_ACCESS_CODE"
|
access_code = "REPLACE_WITH_ACCESS_CODE"
|
||||||
sn = "REPLACE_WITH_SERIAL_NUMBER" # printer screen > Settings > Device (used for MQTT topics later)
|
sn = "REPLACE_WITH_SERIAL_NUMBER" # printer screen > Settings > Device (used for MQTT topics later)
|
||||||
@@ -18,7 +20,10 @@ ca_cert_path = "./certs/p1p.pem"
|
|||||||
[[printer]]
|
[[printer]]
|
||||||
id = "h2c"
|
id = "h2c"
|
||||||
name = "H2C"
|
name = "H2C"
|
||||||
vendor = "bambu_v2"
|
# "bambu" auto-detects MQTT v5-vs-v3.1.1 (BambuMQTT v2-vs-v1) on connect -
|
||||||
|
# the recommended default, since a firmware update can change which one a
|
||||||
|
# given printer speaks without you knowing ahead of time.
|
||||||
|
vendor = "bambu"
|
||||||
host = "192.168.1.51"
|
host = "192.168.1.51"
|
||||||
access_code = "REPLACE_WITH_ACCESS_CODE"
|
access_code = "REPLACE_WITH_ACCESS_CODE"
|
||||||
sn = "REPLACE_WITH_SERIAL_NUMBER"
|
sn = "REPLACE_WITH_SERIAL_NUMBER"
|
||||||
@@ -37,3 +42,10 @@ sn = "REPLACE_WITH_SERIAL_NUMBER"
|
|||||||
# vendor = "prusa_link"
|
# vendor = "prusa_link"
|
||||||
# host = "192.168.1.53"
|
# host = "192.168.1.53"
|
||||||
# api_key = "REPLACE_WITH_API_KEY"
|
# api_key = "REPLACE_WITH_API_KEY"
|
||||||
|
|
||||||
|
# [[printer]]
|
||||||
|
# id = "mk3s"
|
||||||
|
# name = "MK3S+"
|
||||||
|
# vendor = "prusa_serial"
|
||||||
|
# port = "/dev/ttyACM0"
|
||||||
|
# baud_rate = 115200 # optional, defaults to 115200
|
||||||
|
|||||||
+30
-19
@@ -7,7 +7,10 @@ use std::path::Path;
|
|||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::printer::{BambuTls, BambuV1Printer, BambuV2Printer, KlipperPrinter, PrinterHandle, PrusaLinkPrinter, PrusaSerialPrinter};
|
use crate::printer::{
|
||||||
|
BambuPrinter, BambuProtocolPreference, BambuTls, KlipperPrinter, PrinterHandle, PrusaLinkPrinter,
|
||||||
|
PrusaSerialPrinter,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct FleetFile {
|
struct FleetFile {
|
||||||
@@ -19,7 +22,10 @@ struct FleetFile {
|
|||||||
struct PrinterEntry {
|
struct PrinterEntry {
|
||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
/// One of: "bambu_v1", "bambu_v2", "prusa_link", "prusa_serial", "klipper".
|
/// One of: "bambu" (auto-detects MQTT v5-vs-v3.1.1/BambuMQTT v2-vs-v1 -
|
||||||
|
/// the default, since a firmware update can move the same physical
|
||||||
|
/// printer between them), "bambu_v1"/"bambu_v2" (force a specific one),
|
||||||
|
/// "prusa_link", "prusa_serial", "klipper".
|
||||||
vendor: String,
|
vendor: String,
|
||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
access_code: Option<String>,
|
access_code: Option<String>,
|
||||||
@@ -27,7 +33,10 @@ struct PrinterEntry {
|
|||||||
/// by serial (`device/{sn}/report`), not by our own `id`.
|
/// by serial (`device/{sn}/report`), not by our own `id`.
|
||||||
sn: Option<String>,
|
sn: Option<String>,
|
||||||
api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
com_port: Option<i16>,
|
/// Serial device path, e.g. `/dev/ttyACM0` on Linux.
|
||||||
|
port: Option<String>,
|
||||||
|
#[serde(default = "default_baud_rate")]
|
||||||
|
baud_rate: u32,
|
||||||
/// Path to a printer-specific CA certificate. Leave unset to use the
|
/// Path to a printer-specific CA certificate. Leave unset to use the
|
||||||
/// bundled Bambu CA - only needed for a printer that doesn't chain to
|
/// bundled Bambu CA - only needed for a printer that doesn't chain to
|
||||||
/// it (P1P, at least; see certs/README.md).
|
/// it (P1P, at least; see certs/README.md).
|
||||||
@@ -40,6 +49,11 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marlin's usual default on most current Prusa firmware.
|
||||||
|
fn default_baud_rate() -> u32 {
|
||||||
|
115_200
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads and parses a printers file into ready-to-use `PrinterHandle`s.
|
/// Reads and parses a printers file into ready-to-use `PrinterHandle`s.
|
||||||
pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
|
pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
|
||||||
let text = std::fs::read_to_string(path).map_err(|err| {
|
let text = std::fs::read_to_string(path).map_err(|err| {
|
||||||
@@ -54,28 +68,17 @@ pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
|
|||||||
|
|
||||||
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
|
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
|
||||||
match entry.vendor.as_str() {
|
match entry.vendor.as_str() {
|
||||||
"bambu_v1" => {
|
"bambu" => build_bambu(entry, BambuProtocolPreference::Auto),
|
||||||
let host = require_field(&entry.id, "host", entry.host)?;
|
"bambu_v1" => build_bambu(entry, BambuProtocolPreference::ForceV1),
|
||||||
let access_code = require_field(&entry.id, "access_code", entry.access_code)?;
|
"bambu_v2" => build_bambu(entry, BambuProtocolPreference::ForceV2),
|
||||||
let sn = require_field(&entry.id, "sn", entry.sn)?;
|
|
||||||
let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?;
|
|
||||||
Ok(PrinterHandle::BambuV1(BambuV1Printer::new(&entry.id, &entry.name, &host, &access_code, &sn, tls)))
|
|
||||||
}
|
|
||||||
"bambu_v2" => {
|
|
||||||
let host = require_field(&entry.id, "host", entry.host)?;
|
|
||||||
let access_code = require_field(&entry.id, "access_code", entry.access_code)?;
|
|
||||||
let sn = require_field(&entry.id, "sn", entry.sn)?;
|
|
||||||
let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?;
|
|
||||||
Ok(PrinterHandle::BambuV2(BambuV2Printer::new(&entry.id, &entry.name, &host, &access_code, &sn, tls)))
|
|
||||||
}
|
|
||||||
"prusa_link" => {
|
"prusa_link" => {
|
||||||
let host = require_field(&entry.id, "host", entry.host)?;
|
let host = require_field(&entry.id, "host", entry.host)?;
|
||||||
let api_key = require_field(&entry.id, "api_key", entry.api_key)?;
|
let api_key = require_field(&entry.id, "api_key", entry.api_key)?;
|
||||||
Ok(PrinterHandle::PrusaLink(PrusaLinkPrinter::new(&entry.id, &entry.name, &host, &api_key)))
|
Ok(PrinterHandle::PrusaLink(PrusaLinkPrinter::new(&entry.id, &entry.name, &host, &api_key)))
|
||||||
}
|
}
|
||||||
"prusa_serial" => {
|
"prusa_serial" => {
|
||||||
let com_port = entry.com_port.ok_or_else(|| anyhow::anyhow!("{}: prusa_serial needs com_port", entry.id))?;
|
let port = require_field(&entry.id, "port", entry.port)?;
|
||||||
Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, com_port)))
|
Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, &port, entry.baud_rate)))
|
||||||
}
|
}
|
||||||
"klipper" => {
|
"klipper" => {
|
||||||
let host = require_field(&entry.id, "host", entry.host)?;
|
let host = require_field(&entry.id, "host", entry.host)?;
|
||||||
@@ -85,6 +88,14 @@ fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_bambu(entry: PrinterEntry, preference: BambuProtocolPreference) -> anyhow::Result<PrinterHandle> {
|
||||||
|
let host = require_field(&entry.id, "host", entry.host)?;
|
||||||
|
let access_code = require_field(&entry.id, "access_code", entry.access_code)?;
|
||||||
|
let sn = require_field(&entry.id, "sn", entry.sn)?;
|
||||||
|
let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?;
|
||||||
|
Ok(PrinterHandle::Bambu(BambuPrinter::new(&entry.id, &entry.name, &host, &access_code, &sn, tls, preference)))
|
||||||
|
}
|
||||||
|
|
||||||
fn require_field(printer_id: &str, field: &str, value: Option<String>) -> anyhow::Result<String> {
|
fn require_field(printer_id: &str, field: &str, value: Option<String>) -> anyhow::Result<String> {
|
||||||
value.ok_or_else(|| anyhow::anyhow!("{printer_id}: missing required field '{field}'"))
|
value.ok_or_else(|| anyhow::anyhow!("{printer_id}: missing required field '{field}'"))
|
||||||
}
|
}
|
||||||
|
|||||||
+206
-122
@@ -1,9 +1,29 @@
|
|||||||
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::{GenericPrinter, PrinterBase, PrinterError};
|
use super::{GenericPrinter, PrinterBase, PrinterError};
|
||||||
|
|
||||||
|
// region: Certificates
|
||||||
|
|
||||||
|
/// The canonical shape for every Bambu MQTT message lives in
|
||||||
|
/// continuum-schemas, not here - see `send_gcode_line` below, which builds
|
||||||
|
/// an instance of it directly via `continuum-schema-instance` instead of a
|
||||||
|
/// hand-written serde struct. `v1`/`v2` underneath this directory are
|
||||||
|
/// BambuMQTT's two message schemas, not to be confused with the MQTT
|
||||||
|
/// *transport* version (v3.1.1 vs v5) below - they happen to move
|
||||||
|
/// together on real Bambu firmware, but they're two different things.
|
||||||
|
const BAMBU_SCHEMA_DIR: &str = concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../continuum-schemas/schemas/bambulab"
|
||||||
|
);
|
||||||
|
|
||||||
|
fn get_schema_for_command(version: BambuProtocolVersion, root: String, command: String) -> PathBuf {
|
||||||
|
Path::new(BAMBU_SCHEMA_DIR)
|
||||||
|
.join(version.schema_dir())
|
||||||
|
.join(format!("request/{root}/{command}.schema.json"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Bambu's shared LAN-mode root CA ("BBL CA2 RSA") - see certs/README.md
|
/// Bambu's shared LAN-mode root CA ("BBL CA2 RSA") - see certs/README.md
|
||||||
/// for how this was obtained and verified. `include_bytes!` embeds it into
|
/// for how this was obtained and verified. `include_bytes!` embeds it into
|
||||||
/// the compiled binary at build time, so there's no file to ship alongside
|
/// the compiled binary at build time, so there's no file to ship alongside
|
||||||
@@ -77,7 +97,10 @@ impl BambuTls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_addr(host: &str) -> anyhow::Result<SocketAddr> {
|
fn resolve_addr(host: &str) -> anyhow::Result<SocketAddr> {
|
||||||
(host, 8883).to_socket_addrs()?.next().ok_or_else(|| anyhow::anyhow!("could not resolve {host}"))
|
(host, 8883)
|
||||||
|
.to_socket_addrs()?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("could not resolve {host}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal DER -> PEM encoder (base64, 64-char lines, standard
|
/// Minimal DER -> PEM encoder (base64, 64-char lines, standard
|
||||||
@@ -102,28 +125,88 @@ fn pem_to_der(pem: &[u8]) -> anyhow::Result<Vec<u8>> {
|
|||||||
Ok(native_tls::Certificate::from_pem(pem)?.to_der()?)
|
Ok(native_tls::Certificate::from_pem(pem)?.to_der()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fields and behavior every Bambu printer shares, regardless of protocol
|
// endregion: Certificates
|
||||||
/// generation - the layer between `GenericPrinter` and the version-specific
|
|
||||||
/// `BambuV1Printer`/`BambuV2Printer` leaves below. Those two *have* one of
|
// region: BambuMQTTProtocol
|
||||||
/// these (composition) rather than a deeper inheritance chain; because
|
/// Which BambuMQTT message schema (and, on real firmware, which MQTT
|
||||||
/// Rust's field/method privacy is scoped to the *module* (this whole file),
|
/// transport version) a connection ended up using. This is an *outcome*
|
||||||
/// not the struct, `BambuV1Printer`/`BambuV2Printer` can freely use
|
/// `connect()` settles on, not something you can assume from the printer
|
||||||
/// `BambuGenericPrinter`'s private fields even though they're separate
|
/// model - a firmware update can move the same physical X1C from v1 to
|
||||||
/// types.
|
/// v2. Every later message this printer sends is built against
|
||||||
pub struct BambuGenericPrinter {
|
/// `schemas/bambulab/{v1,v2}/` depending on which one this is.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BambuProtocolVersion {
|
||||||
|
/// MQTT v3.1.1, BambuMQTT v1 - older firmware (P1P/P1S, original X1 launch firmware).
|
||||||
|
V1,
|
||||||
|
/// MQTT v5, BambuMQTT v2 - current firmware.
|
||||||
|
V2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BambuProtocolVersion {
|
||||||
|
fn schema_dir(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
BambuProtocolVersion::V1 => "v1",
|
||||||
|
BambuProtocolVersion::V2 => "v2",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How to pick the protocol generation for a given printer - config
|
||||||
|
/// *input*, as opposed to `BambuProtocolVersion` (the outcome `connect()`
|
||||||
|
/// settles on). `Auto` is the default for exactly the reason
|
||||||
|
/// `BambuProtocolVersion` isn't derivable from config alone: you can't
|
||||||
|
/// reliably know which one a specific printer speaks today without
|
||||||
|
/// asking it. `ForceV1`/`ForceV2` stay available as an override - useful
|
||||||
|
/// if auto-detection ever misbehaves, or to test one path deliberately.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum BambuProtocolPreference {
|
||||||
|
/// Try MQTT v5 first; if the broker's CONNACK rejects that protocol
|
||||||
|
/// version (older firmware only understands v3.1.1), fall back and
|
||||||
|
/// reconnect with v3.1.1 instead.
|
||||||
|
Auto,
|
||||||
|
ForceV1,
|
||||||
|
ForceV2,
|
||||||
|
}
|
||||||
|
// endregion: BambuMQTTProtocol
|
||||||
|
|
||||||
|
/// A Bambu printer. Only one struct now, not two: the old
|
||||||
|
/// `BambuGenericPrinter` (shared fields/behavior) + `BambuV1Printer`/
|
||||||
|
/// `BambuV2Printer` (thin per-version wrappers) split existed to let two
|
||||||
|
/// *separate types* share a base - but since which protocol version a
|
||||||
|
/// printer uses is now something `connect()` discovers at runtime rather
|
||||||
|
/// than something config picks between two types for, there's only ever
|
||||||
|
/// one type to begin with, so the extra layer stopped earning its keep.
|
||||||
|
pub struct BambuPrinter {
|
||||||
base: PrinterBase,
|
base: PrinterBase,
|
||||||
pub access_code: String,
|
pub access_code: String,
|
||||||
pub sn: String,
|
pub sn: String,
|
||||||
tls: BambuTls,
|
tls: BambuTls,
|
||||||
|
preference: BambuProtocolPreference,
|
||||||
|
/// `None` until `connect()` succeeds.
|
||||||
|
version: Option<BambuProtocolVersion>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BambuGenericPrinter {
|
impl BambuPrinter {
|
||||||
pub fn new(id: &str, name: &str, host: &str, access_code: &str, sn: &str, tls: BambuTls) -> Self {
|
pub fn new(
|
||||||
|
id: &str,
|
||||||
|
name: &str,
|
||||||
|
host: &str,
|
||||||
|
access_code: &str,
|
||||||
|
sn: &str,
|
||||||
|
tls: BambuTls,
|
||||||
|
preference: BambuProtocolPreference,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
|
base: PrinterBase {
|
||||||
|
id: id.into(),
|
||||||
|
name: name.into(),
|
||||||
|
host: host.into(),
|
||||||
|
},
|
||||||
access_code: access_code.into(),
|
access_code: access_code.into(),
|
||||||
sn: sn.into(),
|
sn: sn.into(),
|
||||||
tls,
|
tls,
|
||||||
|
preference,
|
||||||
|
version: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,13 +214,7 @@ impl BambuGenericPrinter {
|
|||||||
&self.base
|
&self.base
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Not part of the `GenericPrinter` trait - Prusa/Klipper have no
|
/* A real (blocking, one-shot) TLS handshake to port 8883 - no MQTT
|
||||||
/// equivalent, since FTPS gcode transfer is a Bambu-specific thing.
|
|
||||||
pub fn dispatch_gcode(&self, file_name: &str) {
|
|
||||||
println!("[{}] uploading {file_name} over FTPS", self.base.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A real (blocking, one-shot) TLS handshake to port 8883 - no MQTT
|
|
||||||
/// protocol, just "does the certificate verify" against this printer's
|
/// protocol, just "does the certificate verify" against this printer's
|
||||||
/// configured trust mode.
|
/// configured trust mode.
|
||||||
///
|
///
|
||||||
@@ -155,13 +232,15 @@ impl BambuGenericPrinter {
|
|||||||
/// the very next connection). So instead: connect with verification
|
/// the very next connection). So instead: connect with verification
|
||||||
/// off, then compare the certificate actually presented against the
|
/// off, then compare the certificate actually presented against the
|
||||||
/// exact bytes pinned, byte for byte. A mismatch is a hard failure -
|
/// exact bytes pinned, byte for byte. A mismatch is a hard failure -
|
||||||
/// no PKI judgment call, just "is this the same certificate as before."
|
/// no PKI judgment call, just "is this the same certificate as before." */
|
||||||
pub fn test_tls_handshake(&self) -> anyhow::Result<()> {
|
pub fn test_tls_handshake(&self) -> anyhow::Result<()> {
|
||||||
let addr = resolve_addr(&self.base.host)?;
|
let addr = resolve_addr(&self.base.host)?;
|
||||||
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?;
|
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?;
|
||||||
|
|
||||||
if let BambuTls::Custom(pinned_pem) = &self.tls {
|
if let BambuTls::Custom(pinned_pem) = &self.tls {
|
||||||
let tls_stream = BambuTls::Insecure.build_connector()?.connect(&self.base.host, stream)?;
|
let tls_stream = BambuTls::Insecure
|
||||||
|
.build_connector()?
|
||||||
|
.connect(&self.base.host, stream)?;
|
||||||
let presented = tls_stream
|
let presented = tls_stream
|
||||||
.peer_certificate()?
|
.peer_certificate()?
|
||||||
.ok_or_else(|| anyhow::anyhow!("{} presented no certificate", self.base.name))?
|
.ok_or_else(|| anyhow::anyhow!("{} presented no certificate", self.base.name))?
|
||||||
@@ -178,11 +257,13 @@ impl BambuGenericPrinter {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.tls.build_connector()?.connect(&self.base.host, stream)?;
|
self.tls
|
||||||
|
.build_connector()?
|
||||||
|
.connect(&self.base.host, stream)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "Trust on first connect": connects once with certificate
|
/* "Trust on first connect": connects once with certificate
|
||||||
/// verification disabled, captures whatever certificate the printer
|
/// verification disabled, captures whatever certificate the printer
|
||||||
/// presents, and returns it as PEM bytes.
|
/// presents, and returns it as PEM bytes.
|
||||||
///
|
///
|
||||||
@@ -191,11 +272,13 @@ impl BambuGenericPrinter {
|
|||||||
/// trusted forever after. That's why it's a method you call once, by
|
/// trusted forever after. That's why it's a method you call once, by
|
||||||
/// hand (see `examples/fetch_bambu_cert.rs`), rather than something
|
/// hand (see `examples/fetch_bambu_cert.rs`), rather than something
|
||||||
/// `connect()` falls back to silently. Save the result to a file and
|
/// `connect()` falls back to silently. Save the result to a file and
|
||||||
/// point that printer's `ca_cert_path` in `printers.toml` at it.
|
/// point that printer's `ca_cert_path` in `printers.toml` at it. */
|
||||||
pub fn fetch_certificate(&self) -> anyhow::Result<Vec<u8>> {
|
pub fn fetch_certificate(&self) -> anyhow::Result<Vec<u8>> {
|
||||||
let addr = resolve_addr(&self.base.host)?;
|
let addr = resolve_addr(&self.base.host)?;
|
||||||
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?;
|
let stream = TcpStream::connect_timeout(&addr, Duration::from_secs(5))?;
|
||||||
let tls_stream = BambuTls::Insecure.build_connector()?.connect(&self.base.host, stream)?;
|
let tls_stream = BambuTls::Insecure
|
||||||
|
.build_connector()?
|
||||||
|
.connect(&self.base.host, stream)?;
|
||||||
|
|
||||||
let cert = tls_stream
|
let cert = tls_stream
|
||||||
.peer_certificate()?
|
.peer_certificate()?
|
||||||
@@ -204,7 +287,7 @@ impl BambuGenericPrinter {
|
|||||||
Ok(der_to_pem(&cert.to_der()?))
|
Ok(der_to_pem(&cert.to_der()?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Makes sure this printer has a *working* trust configuration,
|
/* Makes sure this printer has a *working* trust configuration,
|
||||||
/// auto-pinning a certificate via trust-on-first-connect if needed -
|
/// auto-pinning a certificate via trust-on-first-connect if needed -
|
||||||
/// the "smooth, no manual steps" version of the fetch_bambu_cert
|
/// the "smooth, no manual steps" version of the fetch_bambu_cert
|
||||||
/// workflow, built the way SSH handles host keys:
|
/// workflow, built the way SSH handles host keys:
|
||||||
@@ -230,7 +313,7 @@ impl BambuGenericPrinter {
|
|||||||
/// in printers.toml) never auto-pins over it - a verification
|
/// in printers.toml) never auto-pins over it - a verification
|
||||||
/// failure there is a real error, not something to paper over,
|
/// failure there is a real error, not something to paper over,
|
||||||
/// since that's exactly the signal pinning exists to give you (the
|
/// since that's exactly the signal pinning exists to give you (the
|
||||||
/// cert rotated, or something worse).
|
/// cert rotated, or something worse). */
|
||||||
pub fn ensure_trusted(&mut self, cert_dir: &Path) -> anyhow::Result<()> {
|
pub fn ensure_trusted(&mut self, cert_dir: &Path) -> anyhow::Result<()> {
|
||||||
let pinned_path = cert_dir.join(format!("{}.pem", self.sn));
|
let pinned_path = cert_dir.join(format!("{}.pem", self.sn));
|
||||||
|
|
||||||
@@ -241,7 +324,10 @@ impl BambuGenericPrinter {
|
|||||||
match self.test_tls_handshake() {
|
match self.test_tls_handshake() {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(err) if matches!(self.tls, BambuTls::BundledCa) => {
|
Err(err) if matches!(self.tls, BambuTls::BundledCa) => {
|
||||||
eprintln!("{}: bundled CA didn't verify ({err}); auto-pinning on first connect", self.base.name);
|
eprintln!(
|
||||||
|
"{}: bundled CA didn't verify ({err}); auto-pinning on first connect",
|
||||||
|
self.base.name
|
||||||
|
);
|
||||||
let pem = self.fetch_certificate()?;
|
let pem = self.fetch_certificate()?;
|
||||||
std::fs::create_dir_all(cert_dir)?;
|
std::fs::create_dir_all(cert_dir)?;
|
||||||
std::fs::write(&pinned_path, &pem)?;
|
std::fs::write(&pinned_path, &pem)?;
|
||||||
@@ -251,113 +337,111 @@ impl BambuGenericPrinter {
|
|||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared by both `BambuV1Printer` and `BambuV2Printer` - same MQTT
|
impl GenericPrinter for BambuPrinter {
|
||||||
/// gcode-injection mechanism regardless of protocol generation. Real
|
fn base(&self) -> &PrinterBase {
|
||||||
/// version would publish
|
&self.base
|
||||||
/// `{"print":{"command":"gcode_line","param":"M106 P1 S<pwm>"}}` to
|
|
||||||
/// `device/{sn}/request` over the MQTTS session.
|
|
||||||
async fn set_fan_speed_impl(&mut self, percent: u8) -> Result<(), PrinterError> {
|
|
||||||
let pwm = super::percent_to_pwm(percent);
|
|
||||||
println!("[{}] sending gcode: M106 P1 S{pwm}", self.base.name);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared "connect" logic both V1 and V2 delegate to - the real
|
/// Settles `self.version` - forced directly if `preference` says so,
|
||||||
/// version would open the MQTTS session here. Still a stub, but no
|
/// otherwise (the real version) attempting an MQTT v5 CONNECT first
|
||||||
/// longer duplicated across two structs.
|
/// and falling back to v3.1.1 if the broker's CONNACK rejects that
|
||||||
async fn connect_impl(&mut self) -> Result<(), PrinterError> {
|
/// protocol version. No real MQTT client exists yet (still a stub, no
|
||||||
|
/// different from before), so `Auto` always "detects" v2 for now -
|
||||||
|
/// the fallback attempt itself is what's left to build.
|
||||||
|
async fn connect(&mut self) -> Result<(), PrinterError> {
|
||||||
let mode = match &self.tls {
|
let mode = match &self.tls {
|
||||||
BambuTls::BundledCa => "bundled Bambu CA",
|
BambuTls::BundledCa => "bundled Bambu CA",
|
||||||
BambuTls::Custom(_) => "custom CA cert",
|
BambuTls::Custom(_) => "custom CA cert",
|
||||||
BambuTls::Insecure => "no certificate verification",
|
BambuTls::Insecure => "no certificate verification",
|
||||||
};
|
};
|
||||||
println!("[{}] connecting over MQTTS:8883 ({mode})", self.base.name);
|
|
||||||
|
let version = match self.preference {
|
||||||
|
BambuProtocolPreference::ForceV1 => BambuProtocolVersion::V1,
|
||||||
|
BambuProtocolPreference::ForceV2 => BambuProtocolVersion::V2,
|
||||||
|
BambuProtocolPreference::Auto => {
|
||||||
|
println!(
|
||||||
|
"[{}] auto-detecting protocol: trying MQTT v5 first",
|
||||||
|
self.base.name
|
||||||
|
);
|
||||||
|
BambuProtocolVersion::V2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"[{}] connecting over MQTTS:8883 ({mode}, {version:?})",
|
||||||
|
self.base.name
|
||||||
|
);
|
||||||
|
self.version = Some(version);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Older Bambu MQTT report schema (P1P, P1S, original X1 firmware).
|
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
/// No V1-only fields yet - add them here as you learn what differs.
|
// TODO: add publishing via MQTT
|
||||||
pub struct BambuV1Printer {
|
println!(
|
||||||
generic: BambuGenericPrinter,
|
"[{}] publishing to device/{}/request: {payload}",
|
||||||
}
|
self.base.name, self.sn
|
||||||
|
);
|
||||||
impl BambuV1Printer {
|
Ok(())
|
||||||
pub fn new(id: &str, name: &str, host: &str, access_code: &str, sn: &str, tls: BambuTls) -> Self {
|
|
||||||
Self { generic: BambuGenericPrinter::new(id, name, host, access_code, sn, tls) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn dispatch_gcode(&self, file_name: &str) {
|
async fn receive(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
self.generic.dispatch_gcode(file_name)
|
// TODO: add MQTT response handling
|
||||||
}
|
Ok(())
|
||||||
|
|
||||||
pub fn test_tls_handshake(&self) -> anyhow::Result<()> {
|
|
||||||
self.generic.test_tls_handshake()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fetch_certificate(&self) -> anyhow::Result<Vec<u8>> {
|
|
||||||
self.generic.fetch_certificate()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ensure_trusted(&mut self, cert_dir: &Path) -> anyhow::Result<()> {
|
|
||||||
self.generic.ensure_trusted(cert_dir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GenericPrinter for BambuV1Printer {
|
|
||||||
fn base(&self) -> &PrinterBase {
|
|
||||||
self.generic.base()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
|
||||||
self.generic.connect_impl().await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same MQTT gcode-injection mechanism regardless of protocol
|
||||||
|
/// generation - just routed through `send_gcode_line` below instead of
|
||||||
|
/// its own separate stub, since that's genuinely the same operation.
|
||||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
||||||
self.generic.set_fan_speed_impl(percent).await
|
let pwm = super::percent_to_pwm(percent);
|
||||||
}
|
self.send_gcode_line(format!("M106 P1 S{pwm}")).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current-generation Bambu MQTT report schema (X1 Carbon/X1E, H2 series).
|
/// Real version FTPS-`PUT`s `data` to the printer's own `/cache/`
|
||||||
/// No V2-only fields yet (e.g. AMS slot state) - add them here as you build
|
/// directory (that's storage the printer's firmware owns, same idea
|
||||||
/// out real report parsing.
|
/// as PrusaLink's SD card), then publishes a `project_file` MQTT
|
||||||
pub struct BambuV2Printer {
|
/// command referencing it by name so the printer actually starts
|
||||||
generic: BambuGenericPrinter,
|
/// printing it - two separate steps, both stubbed here as one line
|
||||||
}
|
/// for now.
|
||||||
|
async fn upload_project_file(
|
||||||
impl BambuV2Printer {
|
&mut self,
|
||||||
pub fn new(id: &str, name: &str, host: &str, access_code: &str, sn: &str, tls: BambuTls) -> Self {
|
file_name: String,
|
||||||
Self { generic: BambuGenericPrinter::new(id, name, host, access_code, sn, tls) }
|
data: Vec<u8>,
|
||||||
}
|
) -> Result<(), PrinterError> {
|
||||||
|
println!(
|
||||||
pub fn dispatch_gcode(&self, file_name: &str) {
|
"[{}] FTPS upload {file_name} ({} bytes) to /cache/",
|
||||||
self.generic.dispatch_gcode(file_name)
|
self.base.name,
|
||||||
}
|
data.len()
|
||||||
|
);
|
||||||
pub fn test_tls_handshake(&self) -> anyhow::Result<()> {
|
Ok(())
|
||||||
self.generic.test_tls_handshake()
|
}
|
||||||
}
|
|
||||||
|
/// Builds the real JSON payload straight from continuum-schemas'
|
||||||
pub fn fetch_certificate(&self) -> anyhow::Result<Vec<u8>> {
|
/// `schemas/bambulab/{v1,v2}/request/print/gcode_line.schema.json` -
|
||||||
self.generic.fetch_certificate()
|
/// which one depends on `self.version`, settled by `connect()` above -
|
||||||
}
|
/// instead of a hand-written serde struct: `sequence_id` and the
|
||||||
|
/// `command` const get filled in by the schema itself, `param` is the
|
||||||
pub fn ensure_trusted(&mut self, cert_dir: &Path) -> anyhow::Result<()> {
|
/// one field we actually set. Only the actual MQTT publish to
|
||||||
self.generic.ensure_trusted(cert_dir)
|
/// `device/{sn}/request` is still a stub, same as `connect` above.
|
||||||
}
|
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||||
}
|
let version = self
|
||||||
|
.version
|
||||||
impl GenericPrinter for BambuV2Printer {
|
.ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
|
||||||
fn base(&self) -> &PrinterBase {
|
|
||||||
self.generic.base()
|
let mut print =
|
||||||
}
|
continuum_schema_instance::build_instance_from_file(
|
||||||
|
&get_schema_for_command(
|
||||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
version,
|
||||||
self.generic.connect_impl().await
|
"print".into(),
|
||||||
}
|
"gcode_line".into()
|
||||||
|
)
|
||||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
)
|
||||||
self.generic.set_fan_speed_impl(percent).await
|
.map_err(|err| PrinterError(format!("failed to build gcode_line request: {err}")))?;
|
||||||
|
print["param"] = serde_json::Value::String(gcode_line);
|
||||||
|
|
||||||
|
let payload = serde_json::json!({ "print": print }).to_string();
|
||||||
|
self.publish(payload).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ pub struct CommandEnvelope {
|
|||||||
/// category can have more than one possible request shape - if that ever
|
/// category can have more than one possible request shape - if that ever
|
||||||
/// happens here, that variant's payload becomes its own small
|
/// happens here, that variant's payload becomes its own small
|
||||||
/// `#[serde(tag = "command")]` enum instead of a single struct.
|
/// `#[serde(tag = "command")]` enum instead of a single struct.
|
||||||
|
///
|
||||||
|
/// The "print" category isn't a variant here at all: unlike `info`/
|
||||||
|
/// `pushing`, it has many possible request shapes (`gcode_line`, `pause`,
|
||||||
|
/// `resume`, ...) in continuum-schemas' own `oneOf`, so
|
||||||
|
/// `bambu.rs`'s `send_gcode_line_impl` builds that one directly via
|
||||||
|
/// `continuum-schema-instance` instead of adding it here.
|
||||||
#[derive(serde::Serialize, Debug)]
|
#[derive(serde::Serialize, Debug)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum BambuRequest {
|
pub enum BambuRequest {
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ impl GenericPrinter for KlipperPrinter {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
|
// TODO: add publishing via HTTP
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
|
// TODO: add HTTP response handling
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
/// OVERRIDE: Moonraker's own **hypothetical** native API, not gcode injection - no
|
/// OVERRIDE: Moonraker's own **hypothetical** native API, not gcode injection - no
|
||||||
/// percent-to-PWM conversion needed since it already takes a
|
/// percent-to-PWM conversion needed since it already takes a
|
||||||
/// percentage directly. Real version:
|
/// percentage directly. Real version:
|
||||||
@@ -37,4 +46,20 @@ impl GenericPrinter for KlipperPrinter {
|
|||||||
println!("[{}] Moonraker: SET_FAN_SPEED SPEED={:.2}", self.base.name, percent as f32 / 100.0);
|
println!("[{}] Moonraker: SET_FAN_SPEED SPEED={:.2}", self.base.name, percent as f32 / 100.0);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OVERRIDE: real version is a multipart `POST /server/files/upload`
|
||||||
|
/// with `data` as the file part - Moonraker stores it on the printer's
|
||||||
|
/// virtual SD card, same upload-then-start model as Bambu/PrusaLink.
|
||||||
|
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
||||||
|
println!("[{}] POST /server/files/upload {file_name} ({} bytes)", self.base.name, data.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OVERRIDE: Moonraker's gcode-script endpoint, one line per call - same
|
||||||
|
/// idea as `set_fan_speed`'s `SET_FAN_SPEED` call above, just with an
|
||||||
|
/// arbitrary line instead of a hardcoded one.
|
||||||
|
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||||
|
println!("[{}] POST /printer/gcode/script?script={gcode_line}", self.base.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//! A held-open serial connection to Marlin/RepRap-family firmware - usable
|
||||||
|
//! by any printer connected over a raw serial port, since there's no
|
||||||
|
//! printer-side storage to bulk-upload to the way there is for Bambu/
|
||||||
|
//! PrusaLink/Klipper (see `prusa.rs`'s `PrusaSerialPrinter`).
|
||||||
|
//!
|
||||||
|
//! Marlin only ever buffers a handful of commands at once, so lines have
|
||||||
|
//! to be paced by its own "ok" acknowledgment - send faster than it acks
|
||||||
|
//! and you overrun that buffer, you don't just queue up harmlessly. That
|
||||||
|
//! write-a-line/wait-for-"ok" pattern is the one new idea here. Splitting
|
||||||
|
//! the connection into a read half and a write half so both can be used at
|
||||||
|
//! once isn't new - `uplink/mod.rs` already does the same thing
|
||||||
|
//! (`ws_stream.split()`) for the WebSocket to `continuum-backend`.
|
||||||
|
//!
|
||||||
|
//! The connection has to stay open across many `send_line` calls, not
|
||||||
|
//! reopen per line: opening/closing per line would be wasteful, and on
|
||||||
|
//! Arduino-based boards toggling the serial line's DTR signal (which
|
||||||
|
//! opening the port does) resets the printer.
|
||||||
|
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf};
|
||||||
|
use tokio_serial::{SerialPortBuilderExt, SerialStream};
|
||||||
|
|
||||||
|
use super::PrinterError;
|
||||||
|
|
||||||
|
pub struct MarlinSerialConnection {
|
||||||
|
write_half: WriteHalf<SerialStream>,
|
||||||
|
lines_in: Lines<BufReader<ReadHalf<SerialStream>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarlinSerialConnection {
|
||||||
|
/// Opens `port` (a serial device path - e.g. `/dev/ttyACM0` on Linux) at
|
||||||
|
/// `baud_rate`.
|
||||||
|
pub async fn open(port: &str, baud_rate: u32) -> Result<Self, PrinterError> {
|
||||||
|
let stream = tokio_serial::new(port, baud_rate)
|
||||||
|
.open_native_async()
|
||||||
|
.map_err(|err| PrinterError(format!("failed to open {port}: {err}")))?;
|
||||||
|
|
||||||
|
// Same shape as `ws_stream.split()` in uplink/mod.rs: one
|
||||||
|
// connection, used for writing gcode out and reading
|
||||||
|
// acknowledgments back at the same time.
|
||||||
|
let (read_half, write_half) = tokio::io::split(stream);
|
||||||
|
let lines_in = BufReader::new(read_half).lines();
|
||||||
|
|
||||||
|
Ok(Self { write_half, lines_in })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends one gcode line and waits for Marlin's "ok" before returning.
|
||||||
|
pub async fn send_line(&mut self, line: &str) -> Result<(), PrinterError> {
|
||||||
|
self.write_half
|
||||||
|
.write_all(format!("{line}\n").as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|err| PrinterError(format!("write failed: {err}")))?;
|
||||||
|
|
||||||
|
// Marlin also sends unsolicited lines between commands
|
||||||
|
// (temperature reports, "echo:" messages) - keep reading until the
|
||||||
|
// real "ok" shows up instead of treating the first response as it.
|
||||||
|
loop {
|
||||||
|
let response = self
|
||||||
|
.lines_in
|
||||||
|
.next_line()
|
||||||
|
.await
|
||||||
|
.map_err(|err| PrinterError(format!("read failed: {err}")))?
|
||||||
|
.ok_or_else(|| PrinterError("serial connection closed".into()))?;
|
||||||
|
|
||||||
|
if response.starts_with("ok") {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
-15
@@ -2,12 +2,10 @@
|
|||||||
//! `GenericPrinter -> BambuV1/BambuV2/PrusaLink/PrusaSerial/Klipper`-style
|
//! `GenericPrinter -> BambuV1/BambuV2/PrusaLink/PrusaSerial/Klipper`-style
|
||||||
//! inheritance, since Rust structs can't `extend` each other.
|
//! inheritance, since Rust structs can't `extend` each other.
|
||||||
//!
|
//!
|
||||||
//! Bambu goes one level deeper than the others: `bambu.rs` has
|
//! Bambu is a single `BambuPrinter` type, not a per-version split: which
|
||||||
//! `BambuGenericPrinter` (fields + behavior every Bambu printer shares -
|
//! MQTT/BambuMQTT protocol generation (v1 vs v2) a given printer uses is
|
||||||
//! access code, CA trust, the TLS handshake test/fetch) which
|
//! something `connect()` discovers (or is told to force), not something
|
||||||
//! `BambuV1Printer`/`BambuV2Printer` each *have* one of, mirroring the
|
//! config picks a *type* for - see `bambu.rs`'s doc comment.
|
||||||
//! `GenericPrinter -> BambuGenericPrinter -> BambuV1/V2` chain this project
|
|
||||||
//! actually wanted from the start - see that file's doc comment.
|
|
||||||
//!
|
//!
|
||||||
//! Three pieces, each doing one job an OOP base class would normally do:
|
//! Three pieces, each doing one job an OOP base class would normally do:
|
||||||
//!
|
//!
|
||||||
@@ -31,9 +29,10 @@
|
|||||||
mod bambu;
|
mod bambu;
|
||||||
mod bambu_commands;
|
mod bambu_commands;
|
||||||
mod klipper;
|
mod klipper;
|
||||||
|
mod marlin_serial;
|
||||||
mod prusa;
|
mod prusa;
|
||||||
|
|
||||||
pub use bambu::{BambuGenericPrinter, BambuTls, BambuV1Printer, BambuV2Printer};
|
pub use bambu::{BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls};
|
||||||
pub use bambu_commands::{
|
pub use bambu_commands::{
|
||||||
AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest,
|
AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest,
|
||||||
GetVersionResponse, LoadedTray, PrintReport, PushallRequest, XcamSettings,
|
GetVersionResponse, LoadedTray, PrintReport, PushallRequest, XcamSettings,
|
||||||
@@ -72,12 +71,34 @@ pub trait GenericPrinter {
|
|||||||
/// into the multi-threaded daemon - safe to ignore for now.
|
/// into the multi-threaded daemon - safe to ignore for now.
|
||||||
async fn connect(&mut self) -> Result<(), PrinterError>;
|
async fn connect(&mut self) -> Result<(), PrinterError>;
|
||||||
|
|
||||||
|
|
||||||
|
async fn publish(&mut self, payload: String) -> Result<(), PrinterError>;
|
||||||
|
async fn receive(&mut self, payload: String) -> Result<(), PrinterError>;
|
||||||
|
|
||||||
/// REQUIRED, no default body: `percent` is 0-100, but how it actually
|
/// REQUIRED, no default body: `percent` is 0-100, but how it actually
|
||||||
/// gets sent - an MQTT gcode-injection command, a REST call, raw bytes
|
/// gets sent - an MQTT gcode-injection command, a REST call, raw bytes
|
||||||
/// on a serial port - is different for every vendor, same as
|
/// on a serial port - is different for every vendor, same as
|
||||||
/// `connect()`.
|
/// `connect()`.
|
||||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError>;
|
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError>;
|
||||||
|
|
||||||
|
/// REQUIRED, no default body: takes the *whole file* already in memory
|
||||||
|
/// (`data`), not a path - whatever called this already had to download
|
||||||
|
/// it from the S3 URL the cloud handed over, so there'd be nothing on
|
||||||
|
/// disk to point a path at anyway. Each vendor does something quite
|
||||||
|
/// different with `data`: Bambu/PrusaLink/Klipper each do one bulk
|
||||||
|
/// transfer to storage the printer's own firmware owns; PrusaSerial
|
||||||
|
/// has no such storage at all - see that impl's doc comment.
|
||||||
|
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError>;
|
||||||
|
|
||||||
|
/// REQUIRED, no default body: sends exactly *one* gcode line, using
|
||||||
|
/// whatever single-command mechanism this vendor has (an MQTT
|
||||||
|
/// `gcode_line` publish for Bambu, an HTTP command-injection endpoint
|
||||||
|
/// for PrusaLink/Klipper, a raw write over the port for PrusaSerial).
|
||||||
|
/// `upload_gcode` below is the generic driver that calls this once per
|
||||||
|
/// line for a whole file - this method is where each vendor's actual
|
||||||
|
/// difference lives, same division of labor as every other method here.
|
||||||
|
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError>;
|
||||||
|
|
||||||
/// OPTIONAL: a default body every printer gets for free unless it
|
/// OPTIONAL: a default body every printer gets for free unless it
|
||||||
/// writes its own. None of ours do below, so all three vendors share
|
/// writes its own. None of ours do below, so all three vendors share
|
||||||
/// this exact implementation - the trait-method equivalent of
|
/// this exact implementation - the trait-method equivalent of
|
||||||
@@ -100,8 +121,7 @@ fn percent_to_pwm(percent: u8) -> u8 {
|
|||||||
|
|
||||||
/// The closed set of printer kinds this fleet can talk to.
|
/// The closed set of printer kinds this fleet can talk to.
|
||||||
pub enum PrinterHandle {
|
pub enum PrinterHandle {
|
||||||
BambuV1(BambuV1Printer),
|
Bambu(BambuPrinter),
|
||||||
BambuV2(BambuV2Printer),
|
|
||||||
PrusaLink(PrusaLinkPrinter),
|
PrusaLink(PrusaLinkPrinter),
|
||||||
PrusaSerial(PrusaSerialPrinter),
|
PrusaSerial(PrusaSerialPrinter),
|
||||||
Klipper(KlipperPrinter),
|
Klipper(KlipperPrinter),
|
||||||
@@ -114,8 +134,7 @@ pub enum PrinterHandle {
|
|||||||
impl GenericPrinter for PrinterHandle {
|
impl GenericPrinter for PrinterHandle {
|
||||||
fn base(&self) -> &PrinterBase {
|
fn base(&self) -> &PrinterBase {
|
||||||
match self {
|
match self {
|
||||||
PrinterHandle::BambuV1(p) => p.base(),
|
PrinterHandle::Bambu(p) => p.base(),
|
||||||
PrinterHandle::BambuV2(p) => p.base(),
|
|
||||||
PrinterHandle::PrusaLink(p) => p.base(),
|
PrinterHandle::PrusaLink(p) => p.base(),
|
||||||
PrinterHandle::PrusaSerial(p) => p.base(),
|
PrinterHandle::PrusaSerial(p) => p.base(),
|
||||||
PrinterHandle::Klipper(p) => p.base(),
|
PrinterHandle::Klipper(p) => p.base(),
|
||||||
@@ -124,21 +143,82 @@ impl GenericPrinter for PrinterHandle {
|
|||||||
|
|
||||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
async fn connect(&mut self) -> Result<(), PrinterError> {
|
||||||
match self {
|
match self {
|
||||||
PrinterHandle::BambuV1(p) => p.connect().await,
|
PrinterHandle::Bambu(p) => p.connect().await,
|
||||||
PrinterHandle::BambuV2(p) => p.connect().await,
|
|
||||||
PrinterHandle::PrusaLink(p) => p.connect().await,
|
PrinterHandle::PrusaLink(p) => p.connect().await,
|
||||||
PrinterHandle::PrusaSerial(p) => p.connect().await,
|
PrinterHandle::PrusaSerial(p) => p.connect().await,
|
||||||
PrinterHandle::Klipper(p) => p.connect().await,
|
PrinterHandle::Klipper(p) => p.connect().await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
|
match self {
|
||||||
|
PrinterHandle::Bambu(p) => p.publish(payload).await,
|
||||||
|
PrinterHandle::PrusaLink(p) => p.publish(payload).await,
|
||||||
|
PrinterHandle::PrusaSerial(p) => p.publish(payload).await,
|
||||||
|
PrinterHandle::Klipper(p) => p.publish(payload).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
|
match self {
|
||||||
|
PrinterHandle::Bambu(p) => p.receive(payload).await,
|
||||||
|
PrinterHandle::PrusaLink(p) => p.receive(payload).await,
|
||||||
|
PrinterHandle::PrusaSerial(p) => p.receive(payload).await,
|
||||||
|
PrinterHandle::Klipper(p) => p.receive(payload).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
||||||
match self {
|
match self {
|
||||||
PrinterHandle::BambuV1(p) => p.set_fan_speed(percent).await,
|
PrinterHandle::Bambu(p) => p.set_fan_speed(percent).await,
|
||||||
PrinterHandle::BambuV2(p) => p.set_fan_speed(percent).await,
|
|
||||||
PrinterHandle::PrusaLink(p) => p.set_fan_speed(percent).await,
|
PrinterHandle::PrusaLink(p) => p.set_fan_speed(percent).await,
|
||||||
PrinterHandle::PrusaSerial(p) => p.set_fan_speed(percent).await,
|
PrinterHandle::PrusaSerial(p) => p.set_fan_speed(percent).await,
|
||||||
PrinterHandle::Klipper(p) => p.set_fan_speed(percent).await,
|
PrinterHandle::Klipper(p) => p.set_fan_speed(percent).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
||||||
|
match self {
|
||||||
|
PrinterHandle::Bambu(p) => p.upload_project_file(file_name, data).await,
|
||||||
|
PrinterHandle::PrusaLink(p) => p.upload_project_file(file_name, data).await,
|
||||||
|
PrinterHandle::PrusaSerial(p) => p.upload_project_file(file_name, data).await,
|
||||||
|
PrinterHandle::Klipper(p) => p.upload_project_file(file_name, data).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||||
|
match self {
|
||||||
|
PrinterHandle::Bambu(p) => p.send_gcode_line(gcode_line).await,
|
||||||
|
PrinterHandle::PrusaLink(p) => p.send_gcode_line(gcode_line).await,
|
||||||
|
PrinterHandle::PrusaSerial(p) => p.send_gcode_line(gcode_line).await,
|
||||||
|
PrinterHandle::Klipper(p) => p.send_gcode_line(gcode_line).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits `gcode` into lines and sends each one through `printer`'s own
|
||||||
|
/// `send_gcode_line` - works for *any* vendor, not just serial ones, since
|
||||||
|
/// Bambu/PrusaLink/Klipper each have their own single-line injection
|
||||||
|
/// mechanism too (see each `send_gcode_line` impl). They just don't use
|
||||||
|
/// this for `upload_project_file` - they have real file storage to bulk-
|
||||||
|
/// upload to instead, which is a different, faster path than sending a
|
||||||
|
/// whole file one line at a time.
|
||||||
|
///
|
||||||
|
/// `P: GenericPrinter` makes this a genuine Rust generic: one function
|
||||||
|
/// body, compiled separately for whichever concrete printer type you call
|
||||||
|
/// it with - like a TS function typed `<P extends GenericPrinter>`, except
|
||||||
|
/// Rust generates real, separate machine code per type instead of erasing
|
||||||
|
/// the type at compile time. Call it with any of the five printer structs,
|
||||||
|
/// or with `&mut PrinterHandle` itself (it implements `GenericPrinter` too,
|
||||||
|
/// via the delegation above).
|
||||||
|
pub async fn upload_gcode<P: GenericPrinter>(printer: &mut P, gcode: &str) -> Result<(), PrinterError> {
|
||||||
|
for line in gcode.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
// Blank lines and full-line comments have nothing to send.
|
||||||
|
if line.is_empty() || line.starts_with(';') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
printer.send_gcode_line(line.to_string()).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+82
-14
@@ -29,6 +29,16 @@ impl GenericPrinter for PrusaLinkPrinter {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
|
// TODO: add publishing via HTTP
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
|
// TODO: add HTTP response handling
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// OVERRIDE: gcode via PrusaLink's command-injection endpoint - same
|
/// OVERRIDE: gcode via PrusaLink's command-injection endpoint - same
|
||||||
/// underlying M106 as Bambu, reusing the shared percent->PWM
|
/// underlying M106 as Bambu, reusing the shared percent->PWM
|
||||||
/// conversion, but sent over HTTP instead of MQTT.
|
/// conversion, but sent over HTTP instead of MQTT.
|
||||||
@@ -37,6 +47,22 @@ impl GenericPrinter for PrusaLinkPrinter {
|
|||||||
println!("[{}] POST /api/printer/command {{\"commands\":[\"M106 S{pwm}\"]}}", self.base.name);
|
println!("[{}] POST /api/printer/command {{\"commands\":[\"M106 S{pwm}\"]}}", self.base.name);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OVERRIDE: real version is `PUT /api/v1/files/local/{file_name}` with
|
||||||
|
/// `data` as the request body - PrusaLink stores it on the printer's own
|
||||||
|
/// SD card, same "upload to storage the firmware owns" model as Bambu.
|
||||||
|
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
||||||
|
println!("[{}] PUT /api/v1/files/local/{file_name} ({} bytes)", self.base.name, data.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OVERRIDE: PrusaLink's command-injection endpoint, one line per call -
|
||||||
|
/// same endpoint `set_fan_speed` above already uses for M106, just with
|
||||||
|
/// an arbitrary line instead of a hardcoded one.
|
||||||
|
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||||
|
println!("[{}] POST /api/printer/command {{\"commands\":[\"{gcode_line}\"]}}", self.base.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -44,14 +70,22 @@ impl GenericPrinter for PrusaLinkPrinter {
|
|||||||
|
|
||||||
pub struct PrusaSerialPrinter {
|
pub struct PrusaSerialPrinter {
|
||||||
base: PrinterBase,
|
base: PrinterBase,
|
||||||
pub com_port: i16,
|
pub port: String,
|
||||||
|
pub baud_rate: u32,
|
||||||
|
/// `None` until `connect()` succeeds - a real serial connection, not a
|
||||||
|
/// stub, so there's something to hold onto: unlike Bambu/PrusaLink/
|
||||||
|
/// Klipper's stubbed sessions, this one has to survive across many
|
||||||
|
/// `send_gcode_line` calls (see `marlin_serial.rs` for why).
|
||||||
|
connection: Option<super::marlin_serial::MarlinSerialConnection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrusaSerialPrinter {
|
impl PrusaSerialPrinter {
|
||||||
pub fn new(id: &str, name: &str, com_port: i16) -> Self {
|
pub fn new(id: &str, name: &str, port: &str, baud_rate: u32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base: PrinterBase { id: id.into(), name: name.into(), host: "127.0.0.1".to_string() },
|
base: PrinterBase { id: id.into(), name: name.into(), host: "127.0.0.1".to_string() },
|
||||||
com_port,
|
port: port.into(),
|
||||||
|
baud_rate,
|
||||||
|
connection: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,21 +95,55 @@ impl GenericPrinter for PrusaSerialPrinter {
|
|||||||
&self.base
|
&self.base
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OVERRIDE: a fourth, again-different body - no network host at all,
|
/// OVERRIDE, and a real one (not a stub, unlike the other four vendors'
|
||||||
/// just a local serial port (real version: open the port, e.g. via the
|
/// `connect`): opens the port and keeps it, since serial has no
|
||||||
/// `serialport`/`tokio-serial` crate, and speak Prusa's G-code-over-
|
/// separate "session" concept to stub out - the open connection *is*
|
||||||
/// serial protocol).
|
/// the whole thing.
|
||||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
async fn connect(&mut self) -> Result<(), PrinterError> {
|
||||||
println!("[{}] connecting over serial (COM{})", self.base.name, self.com_port);
|
println!("[{}] connecting over serial ({}, {} baud)", self.base.name, self.port, self.baud_rate);
|
||||||
|
self.connection = Some(super::marlin_serial::MarlinSerialConnection::open(&self.port, self.baud_rate).await?);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OVERRIDE: same gcode as PrusaLink (still M106, still the shared
|
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
/// conversion), but written straight to the serial port instead of
|
// TODO: add publishing via serial
|
||||||
/// sent over HTTP - a third transport for the same underlying command.
|
|
||||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
|
||||||
let pwm = super::percent_to_pwm(percent);
|
|
||||||
println!("[{}] writing to COM{}: M106 S{pwm}", self.base.name, self.com_port);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn receive(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
|
// TODO: add Serial response handling
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
/// OVERRIDE: same gcode as PrusaLink (still M106, still the shared
|
||||||
|
/// conversion), but sent through `send_gcode_line` below instead of a
|
||||||
|
/// separate transport - serial only has the one way to talk to the
|
||||||
|
/// printer at all.
|
||||||
|
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
||||||
|
let pwm = super::percent_to_pwm(percent);
|
||||||
|
self.send_gcode_line(format!("M106 S{pwm}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OVERRIDE, but not just "a fourth transport" like the others above -
|
||||||
|
/// there's no printer-side storage to upload `data` to over a raw
|
||||||
|
/// serial connection at all, so this decodes it as gcode text and
|
||||||
|
/// streams it through `send_gcode_line` one line at a time via the
|
||||||
|
/// generic `upload_gcode` driver, instead of one bulk write like
|
||||||
|
/// Bambu/PrusaLink/Klipper.
|
||||||
|
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
||||||
|
let gcode = String::from_utf8(data)
|
||||||
|
.map_err(|_| PrinterError(format!("{file_name} is not valid UTF-8 gcode")))?;
|
||||||
|
println!("[{}] streaming {file_name} over {}", self.base.name, self.port);
|
||||||
|
super::upload_gcode(self, &gcode).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OVERRIDE, and the real implementation, not a stub: writes `gcode_line`
|
||||||
|
/// to the already-open port and waits for Marlin's "ok" - see
|
||||||
|
/// `marlin_serial.rs` for why that wait matters.
|
||||||
|
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||||
|
let connection = self
|
||||||
|
.connection
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
|
||||||
|
connection.send_line(&gcode_line).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user