Bound MQTT publishes and send status refreshes unacked
Windows discovery tool / windows (push) Successful in 1m35s

On the first real dispatch the proxy froze about 4.5 minutes in: pushall was
published at QoS 1 from two tasks while holding the fleet lock, and once the
in-flight window filled every publish blocked forever, stalling telemetry,
heartbeats and the uplink. Publishes now time out after 10 s and pushall is
QoS 0.

Also adds an optional ams_slot to start_print so a job can choose its AMS
slot (single-filament projects, same material type only).
This commit is contained in:
2026-10-30 06:56:24 +00:00
parent 47290175aa
commit b1e733a5fe
8 changed files with 127 additions and 18 deletions
+4
View File
@@ -47,6 +47,10 @@ async fn main() -> anyhow::Result<()> {
for f in continuum_proxy::printer::plate_filaments(&bytes, 1)? { for f in continuum_proxy::printer::plate_filaments(&bytes, 1)? {
println!("project filament {}: {} {} #{}", f.index, f.filament_type, f.tray_info_idx, f.color); println!("project filament {}: {} {} #{}", f.index, f.filament_type, f.tray_info_idx, f.color);
} }
if let Some(slot) = std::env::var("AMS_SLOT").ok().and_then(|v| v.parse::<u8>().ok()) {
println!("(with AMS_SLOT={slot} chosen explicitly)");
bambu.set_ams_slot_override(Some(slot));
}
match bambu.filament_source_for(&bytes, 1).await { match bambu.filament_source_for(&bytes, 1).await {
Ok(source) => println!("mapping the production path would use: {source:?}"), Ok(source) => println!("mapping the production path would use: {source:?}"),
Err(err) => println!("the production path would REFUSE: {err}"), Err(err) => println!("the production path would REFUSE: {err}"),
+24 -2
View File
@@ -246,6 +246,8 @@ pub struct BambuPrinter {
/// alongside the protocol version it settled on, since the two are /// alongside the protocol version it settled on, since the two are
/// decided together and always used together - see `bambu_mqtt.rs`. /// decided together and always used together - see `bambu_mqtt.rs`.
connection: Option<(super::bambu_mqtt::BambuMqttSession, BambuProtocolVersion)>, connection: Option<(super::bambu_mqtt::BambuMqttSession, BambuProtocolVersion)>,
/// See `set_ams_slot_override`.
ams_slot_override: Option<u8>,
} }
/// Which plate of a multi-plate project gets printed. /// Which plate of a multi-plate project gets printed.
@@ -491,9 +493,17 @@ impl BambuPrinter {
tls, tls,
preference, preference,
connection: None, connection: None,
ams_slot_override: None,
} }
} }
/// The operator's chosen AMS slot for the next print (0-based across all
/// units), or `None` to map by the project's own filament. Set by the
/// uplink around one `start_print` and cleared after it.
pub fn set_ams_slot_override(&mut self, slot: Option<u8>) {
self.ams_slot_override = slot;
}
pub fn base(&self) -> &PrinterBase { pub fn base(&self) -> &PrinterBase {
&self.base &self.base
} }
@@ -758,7 +768,8 @@ impl BambuPrinter {
.map_err(|err| PrinterError(format!("failed to build pushall request: {err}")))?; .map_err(|err| PrinterError(format!("failed to build pushall request: {err}")))?;
body["push_target"] = serde_json::Value::from(1); body["push_target"] = serde_json::Value::from(1);
session.publish("pushing", &body).await // Unacked: nothing waits on a reply to this, and see `publish_unacked`.
session.publish_unacked("pushing", &body).await
} }
/// Just the FTPS transfer: puts `data` in the printer's `cache/` /// Just the FTPS transfer: puts `data` in the printer's `cache/`
@@ -874,7 +885,18 @@ impl BambuPrinter {
// naming a filament that also happens to be on the spool should // naming a filament that also happens to be on the spool should
// still use the AMS, because that is what it was sliced for. // still use the AMS, because that is what it was sliced for.
if !trays.is_empty() { if !trays.is_empty() {
match super::map_to_trays(&filaments, &trays) { let mapped = match self.ams_slot_override {
Some(slot) => super::map_to_slot(&filaments, &trays, i32::from(slot)),
None => super::map_to_trays(&filaments, &trays),
};
// An explicit slot that cannot be honoured is an error, never a
// fallback to some other spool.
if self.ams_slot_override.is_some() {
if let Err(err) = &mapped {
return Err(PrinterError(format!("{}: {err}", self.base.name)));
}
}
match mapped {
Ok(mapping) => { Ok(mapping) => {
tracing::info!( tracing::info!(
printer = %self.base.name, printer = %self.base.name,
+25 -2
View File
@@ -76,6 +76,9 @@ use super::PrinterError;
type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>; type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// How long a publish may wait for room in the MQTT request queue.
const PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
/// Both rumqttc clients default to a 10KB max packet size in *both* /// Both rumqttc clients default to a 10KB max packet size in *both*
/// directions - fine for a request, nowhere near enough for a reply: /// directions - fine for a request, nowhere near enough for a reply:
/// confirmed live against a real H2C, whose `get_version` reply alone /// confirmed live against a real H2C, whose `get_version` reply alone
@@ -359,18 +362,38 @@ impl BambuMqttSession {
/// `publish` above and `GenericPrinter::publish` (a raw pass-through, /// `publish` above and `GenericPrinter::publish` (a raw pass-through,
/// no envelope opinion at all) are built on. /// no envelope opinion at all) are built on.
pub async fn publish_raw(&self, payload: String) -> Result<(), PrinterError> { pub async fn publish_raw(&self, payload: String) -> Result<(), PrinterError> {
self.publish_with_qos(payload, true).await
}
/// Like `publish`, but QoS 0: for requests that need no acknowledgement
/// (a status refresh). QoS 1 publishes the printer never acknowledges
/// fill rumqttc's in-flight window, after which every publish blocks
/// forever - found on the first real dispatch, where that froze the whole
/// proxy about 4.5 minutes in, because callers hold the fleet lock.
pub async fn publish_unacked(&self, category: &str, body: &Value) -> Result<(), PrinterError> {
self.publish_with_qos(serde_json::json!({ category: body }).to_string(), false).await
}
async fn publish_with_qos(&self, payload: String, acked: bool) -> Result<(), PrinterError> {
let topic = format!("device/{}/request", self.sn); let topic = format!("device/{}/request", self.sn);
// Bounded: a publish waits for room in rumqttc's request queue, and an
// unbounded wait here wedges every caller that holds the fleet lock.
let publishing = async {
match &self.client { match &self.client {
BambuMqttClient::V1(client) => client BambuMqttClient::V1(client) => client
.publish(topic, QoS::AtLeastOnce, false, payload) .publish(topic, if acked { QoS::AtLeastOnce } else { QoS::AtMostOnce }, false, payload)
.await .await
.map_err(|err| PrinterError(format!("publish failed: {err}"))), .map_err(|err| PrinterError(format!("publish failed: {err}"))),
BambuMqttClient::V2(client) => client BambuMqttClient::V2(client) => client
.publish(topic, QoSV5::AtLeastOnce, false, payload) .publish(topic, if acked { QoSV5::AtLeastOnce } else { QoSV5::AtMostOnce }, false, payload)
.await .await
.map_err(|err| PrinterError(format!("publish failed: {err}"))), .map_err(|err| PrinterError(format!("publish failed: {err}"))),
} }
};
tokio::time::timeout(PUBLISH_TIMEOUT, publishing)
.await
.map_err(|_| PrinterError(format!("{} publish timed out after {PUBLISH_TIMEOUT:?} - the MQTT queue is not draining", self.sn)))?
} }
/// The current in-memory `print.push_status` object, if at least one /// The current in-memory `print.push_status` object, if at least one
+1 -1
View File
@@ -40,7 +40,7 @@ mod prusa;
// free of printer types so it is testable without a printer. // free of printer types so it is testable without a printer.
mod project_file; mod project_file;
pub use project_file::{ pub use project_file::{
map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament, map_to_slot, map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament,
ProjectFileError, ProjectFileError,
}; };
+44
View File
@@ -71,6 +71,8 @@ pub enum ProjectFileError {
/// material or none. /// material or none.
#[error("no loaded tray matches {wanted} (filament index {index}); loaded: {loaded}")] #[error("no loaded tray matches {wanted} (filament index {index}); loaded: {loaded}")]
NoMatchingTray { index: usize, wanted: String, loaded: String }, NoMatchingTray { index: usize, wanted: String, loaded: String },
#[error("cannot use AMS slot {0}: {1}")]
BadSlot(i32, String),
} }
/// Normalises a colour to six uppercase hex digits. /// Normalises a colour to six uppercase hex digits.
@@ -271,6 +273,36 @@ pub fn map_to_trays(
} }
/// Match quality, lower is better; `None` means no match. /// Match quality, lower is better; `None` means no match.
/// An explicit choice of slot instead of the colour-based pick: the operator
/// said which spool to use. Deliberately narrow - a single-filament project
/// only (which filament goes where is ambiguous otherwise), onto a slot that
/// is loaded and holds the same *material type*, because the type decides
/// the temperatures. A different colour is allowed: that is the point.
pub fn map_to_slot(
filaments: &[ProjectFilament],
trays: &[AvailableTray],
slot: i32,
) -> Result<Vec<i32>, ProjectFileError> {
let [filament] = filaments else {
return Err(ProjectFileError::BadSlot(
slot,
format!("the project uses {} filaments; a chosen slot is only for single-filament projects", filaments.len()),
));
};
let tray = trays.iter().find(|t| t.slot == slot).ok_or_else(|| {
ProjectFileError::BadSlot(slot, "no filament is loaded in that slot".into())
})?;
if !tray.filament_type.eq_ignore_ascii_case(&filament.filament_type) {
return Err(ProjectFileError::BadSlot(
slot,
format!("it holds {} but the project was sliced for {}", tray.filament_type, filament.filament_type),
));
}
let mut mapping = vec![-1; filament.index + 1];
mapping[filament.index] = slot;
Ok(mapping)
}
fn tier(filament: &ProjectFilament, tray: &AvailableTray) -> Option<u8> { fn tier(filament: &ProjectFilament, tray: &AvailableTray) -> Option<u8> {
let same_product = filament.tray_info_idx.eq_ignore_ascii_case(&tray.tray_info_idx); let same_product = filament.tray_info_idx.eq_ignore_ascii_case(&tray.tray_info_idx);
let same_color = filament.color == tray.color; let same_color = filament.color == tray.color;
@@ -288,6 +320,18 @@ mod tests {
use super::*; use super::*;
use std::io::Write; use std::io::Write;
#[test]
fn a_chosen_slot_overrides_the_colour_pick_but_not_the_material() {
let f = [filament(0, "GFG99", "PETG", "161616")];
let t = [tray(2, "GFG99", "PETG", "161616"), tray(3, "GFG99", "PETG", "2850E0"), tray(1, "GFL99", "PLA", "D3C5A3")];
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![2]);
assert_eq!(map_to_slot(&f, &t, 3).unwrap(), vec![3]);
assert!(map_to_slot(&f, &t, 1).is_err(), "PLA slot for a PETG project");
assert!(map_to_slot(&f, &t, 4).is_err(), "empty slot");
let two = [filament(0, "GFG99", "PETG", "161616"), filament(1, "GFG99", "PETG", "2850E0")];
assert!(map_to_slot(&two, &t, 3).is_err(), "multi-filament project");
}
const REAL: &str = r##"<?xml version="1.0" encoding="UTF-8"?> const REAL: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
<config> <config>
<header> <header>
+12 -3
View File
@@ -652,7 +652,7 @@ async fn dispatch(fleet: &SharedFleet, printer_id: &str, command: PrinterCommand
PrinterCommand::ControlPrint { control } => { PrinterCommand::ControlPrint { control } => {
printer.control_print(control).await.map_err(|err| err.to_string()) printer.control_print(control).await.map_err(|err| err.to_string())
} }
PrinterCommand::StartPrint { job_id, file_name, .. } => { PrinterCommand::StartPrint { job_id, file_name, ams_slot, .. } => {
let bytes = downloaded.ok_or_else(|| "internal error: start_print reached dispatch with no downloaded file".to_string())?; let bytes = downloaded.ok_or_else(|| "internal error: start_print reached dispatch with no downloaded file".to_string())?;
let byte_count = bytes.len(); let byte_count = bytes.len();
info!(%printer_id, %job_id, %file_name, byte_count, "job file downloaded, sending to printer"); info!(%printer_id, %job_id, %file_name, byte_count, "job file downloaded, sending to printer");
@@ -661,10 +661,19 @@ async fn dispatch(fleet: &SharedFleet, printer_id: &str, command: PrinterCommand
// all as one error - so the stage here is "printer", not finer. // all as one error - so the stage here is "printer", not finer.
// The printer's own message says which part (mapping, upload of // The printer's own message says which part (mapping, upload of
// the file, or start) it was. // the file, or start) it was.
printer if let PrinterHandle::Bambu(bambu) = &mut *printer {
bambu.set_ams_slot_override(ams_slot);
} else if ams_slot.is_some() {
return Err("this printer cannot be told which AMS slot to use".into());
}
let result = printer
.upload_project_file(file_name, bytes) .upload_project_file(file_name, bytes)
.await .await
.map_err(|err| format!("printer upload/start failed (download of {byte_count} bytes succeeded): {err}")) .map_err(|err| format!("printer upload/start failed (download of {byte_count} bytes succeeded): {err}"));
if let PrinterHandle::Bambu(bambu) = &mut *printer {
bambu.set_ams_slot_override(None);
}
result
} }
} }
} }
+8 -1
View File
@@ -329,7 +329,14 @@ pub enum PrinterCommand {
/// hides it. `file_name` is what the file is called on the printer; /// hides it. `file_name` is what the file is called on the printer;
/// `.3mf` is treated as a sliced project, anything else as bare gcode. /// `.3mf` is treated as a sliced project, anything else as bare gcode.
/// `job_id` is only for lining gateway logs up against a cloud job. /// `job_id` is only for lining gateway logs up against a cloud job.
StartPrint { job_id: String, file_name: String, url: String }, StartPrint {
job_id: String,
file_name: String,
url: String,
/// Use this AMS slot (0-based, all units) instead of the project's own pick.
#[serde(default)]
ams_slot: Option<u8>,
},
} }
impl PrinterCommand { impl PrinterCommand {
+1 -1
View File
@@ -106,7 +106,7 @@ fn a_vendor_specific_control_verb_is_rejected() {
fn a_start_print_command_arrives_with_its_fields_intact() { fn a_start_print_command_arrives_with_its_fields_intact() {
let raw = r#"{"type":"command","command_id":"c","printer_id":"p1p","command":{"action":"start_print","job_id":"5b0e7c1a-0000-4000-8000-000000000000","file_name":"plate_1.3mf","url":"https://objects.example.com/jobs/abc.3mf?X-Amz-Signature=deadbeef&X-Amz-Expires=300"}}"#; let raw = r#"{"type":"command","command_id":"c","printer_id":"p1p","command":{"action":"start_print","job_id":"5b0e7c1a-0000-4000-8000-000000000000","file_name":"plate_1.3mf","url":"https://objects.example.com/jobs/abc.3mf?X-Amz-Signature=deadbeef&X-Amz-Expires=300"}}"#;
match serde_json::from_str::<ServerMessage>(raw).expect("should parse") { match serde_json::from_str::<ServerMessage>(raw).expect("should parse") {
ServerMessage::Command { command: PrinterCommand::StartPrint { job_id, file_name, url }, .. } => { ServerMessage::Command { command: PrinterCommand::StartPrint { job_id, file_name, url, .. }, .. } => {
assert_eq!(job_id, "5b0e7c1a-0000-4000-8000-000000000000"); assert_eq!(job_id, "5b0e7c1a-0000-4000-8000-000000000000");
assert_eq!(file_name, "plate_1.3mf"); assert_eq!(file_name, "plate_1.3mf");
assert_eq!( assert_eq!(