Derive the AMS mapping from the project, and blink on completion
Two changes, both following from the print that paused at 1% because
`use_ams: false` pointed an AMS-sliced project at the external spool.
## upload_project_file no longer guesses
It now reads the `.3mf` it already holds and works out where each filament
should come from. `Metadata/slice_info.config` names every filament's
`tray_info_idx`, type and colour; the AMS report names the same three per
loaded tray; matching them is the whole job. `src/printer/project_file.rs`
does the reading and matching and is free of printer types, so it is
testable without a printer - which is the only way this gets tested at all,
the alternative being to notice a mapping bug by watching a machine print in
the wrong colour.
The mapping is derived *before* the upload: a project that cannot be matched
should not result in a file being moved onto the machine that cannot then be
printed.
It never falls back to the external spool to make an unmatched project
"work". The one case it answers `ExternalSpool` for is a printer reporting no
AMS trays at all. A printer *with* an AMS whose project wants the spool is an
error, not a fallback, because `vt_tray` is not modelled on `PrintReport` -
there is no way to check the spool holds the right material, and claiming a
match without checking is how this went wrong the first time.
Two fixtures carry the real cases: `cube_slice_info.3mf` is the project that
cost the print (deflated on purpose - every other test archive is Stored, so
the decompression path and the flate2 backend behind zip's `deflate-flate2`
would otherwise never run), and the existing `push_status_v1_p1p_idle.json`
supplies the AMS side. The end-to-end test asserts they agree on
`[3,-1,-1,-1,-1]`: both slots hold PETG GFG99 and only the colour tells them
apart, so a matcher stopping at `tray_info_idx` would print the cube in
black.
A malformed `<filament>` element is an error rather than a skip. A skipped
filament leaves a hole at its index, the hole becomes `-1`, and the printer
is told that material is unused - so the plate prints in whatever remains
mapped.
New dependency: `zip` 9.0.3 (MIT) with `deflate-flate2`, plus `flate2` for a
backend, which zip's feature does not select on its own. All six new crates
are MIT, MIT/Apache-2.0, 0BSD/MIT/Apache-2.0, MIT/Zlib/Apache-2.0 or Zlib,
and `cargo deny check licenses` passes. No bzip2, lzma, xz or zstd entered
the graph.
## Lights on completion
`completion::run` is a third daemon task that notices a print finishing and
blinks that printer's chamber light for ten seconds, then leaves it off:
500ms on, 500ms off, twenty commands, ending dark.
Its own task rather than a branch in the uplink's telemetry loop, because
that loop only ticks while the cloud connection is up - and a light that only
works when the internet does is not much of a notification for someone
standing next to the printer.
Three rules, each a false celebration avoided and each tested: only a
*transition* into `FINISH` fires, because `FINISH` persists until the next
print starts; a printer seen for the first time never fires, so restarting
the gateway does not announce prints that finished yesterday; and `FAILED`
never fires, because that is what a Bambu reports for a cancellation,
including every `print.stop` this software sends.
The blink is driven from `On`/`Off` rather than the firmware's `flashing`
mode. `flashing` is in the schema but the meaning of `loop_times` versus
`interval_time` is unconfirmed, so "blink for ten seconds" could not be
predicted; two obvious commands give an exact duration. The light is left off
even when a blink fails part-way, since a machine abandoned with its light on
looks like one still working.
`set_light` is **unverified against hardware** - the P1P was powered off when
this was written, and the doc comment says so. The shape is built from
`schemas/bambulab/{v1,v2}/request/system/ledctrl.schema.json` rather than by
hand, and it goes out as a correlated request so a rejection is visible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
//! Noticing that a print has finished, and saying so at the machine.
|
||||
//!
|
||||
//! Its own task, polling the fleet, rather than a branch inside the uplink's
|
||||
//! telemetry loop. The uplink's loop only ticks while the cloud connection is
|
||||
//! up, and a light that only comes on when the internet is working is not
|
||||
//! much of a notification: the person this is for is standing next to the
|
||||
//! printer, and the printer is on the same LAN as this process. Nothing about
|
||||
//! telling them should route through a datacentre.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::printer::{GenericPrinter, LightState, PrinterHandle, CHAMBER_LIGHT};
|
||||
use crate::uplink::SharedFleet;
|
||||
|
||||
/// How long the lights blink for when a print finishes.
|
||||
const BLINK_FOR: Duration = Duration::from_secs(10);
|
||||
|
||||
/// On for this long, then off for this long. 500ms reads as a deliberate
|
||||
/// blink from across a room; much faster looks like a fault.
|
||||
const BLINK_HALF_CYCLE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How often to look for a print that has just finished.
|
||||
///
|
||||
/// A notification a few seconds late is fine. What matters more is that this
|
||||
/// is frequent enough not to miss a state: `FINISH` persists until the next
|
||||
/// print starts, so there is no race to lose - but the first poll that sees
|
||||
/// it is the one that fires.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Bambu's word for a print that ran to completion.
|
||||
///
|
||||
/// Deliberately only this one. `FAILED` is what a Bambu reports for a print
|
||||
/// that was cancelled or went wrong - including every `print.stop` this
|
||||
/// software sends - and celebrating a cancellation would be worse than
|
||||
/// staying dark.
|
||||
const FINISHED: &str = "FINISH";
|
||||
|
||||
/// Remembers each printer's last known state, so a finish can be told from a
|
||||
/// printer that has simply been sitting in `FINISH` for an hour.
|
||||
#[derive(Default)]
|
||||
pub struct CompletionWatcher {
|
||||
last_state: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl CompletionWatcher {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The printers that have *just* finished a print.
|
||||
///
|
||||
/// Two rules, both of which exist to avoid a false celebration:
|
||||
///
|
||||
/// - It must be a transition. `FINISH` persists until the next print
|
||||
/// starts, so without this every poll for the next hour would fire.
|
||||
/// - A printer seen for the first time never fires, even if it is
|
||||
/// already `FINISH`. Otherwise restarting the gateway blinks every
|
||||
/// printer that happens to be sitting in `FINISH`, announcing prints
|
||||
/// that finished yesterday.
|
||||
pub fn newly_finished<I, S>(&mut self, states: I) -> Vec<String>
|
||||
where
|
||||
I: IntoIterator<Item = (String, S)>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut finished = Vec::new();
|
||||
|
||||
for (printer_id, state) in states {
|
||||
let state = state.as_ref();
|
||||
let previous = self.last_state.insert(printer_id.clone(), state.to_string());
|
||||
|
||||
if state == FINISHED && previous.is_some_and(|prev| prev != FINISHED) {
|
||||
finished.push(printer_id);
|
||||
}
|
||||
}
|
||||
|
||||
finished
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches the fleet and blinks a printer's lights when it finishes a print.
|
||||
pub async fn run(fleet: SharedFleet) {
|
||||
let mut watcher = CompletionWatcher::new();
|
||||
let mut ticker = tokio::time::interval(POLL_INTERVAL);
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
|
||||
let states = {
|
||||
let fleet = fleet.lock().await;
|
||||
// `print_status` is a cached read, but `refresh_status` below
|
||||
// publishes, so this does hold the lock across one MQTT publish
|
||||
// per printer. Same shape as the uplink's own `refresh_fleet`,
|
||||
// and at a handful of printers it is not worth more machinery;
|
||||
// the blink itself is what had to stay out of the lock, because
|
||||
// that one takes ten seconds.
|
||||
let mut states = Vec::new();
|
||||
for printer in fleet.iter() {
|
||||
// v1 printers only push a status when asked. Doing it here as
|
||||
// well as in the uplink means this keeps working with the
|
||||
// cloud connection down, which is the whole point of the
|
||||
// separate task.
|
||||
if let Err(err) = printer.refresh_status().await {
|
||||
warn!(printer = %printer.base().id, %err, "could not request a status refresh");
|
||||
}
|
||||
if let Some(state) = printer.print_status().await.and_then(|r| r.gcode_state) {
|
||||
states.push((printer.base().id.clone(), state));
|
||||
}
|
||||
}
|
||||
states
|
||||
};
|
||||
|
||||
for printer_id in watcher.newly_finished(states) {
|
||||
info!(printer = %printer_id, "print finished; blinking the lights");
|
||||
// Spawned, not awaited: this takes ten seconds, and nothing else
|
||||
// in the fleet should wait for it.
|
||||
tokio::spawn(celebrate(fleet.clone(), printer_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blinks one printer's chamber light for `BLINK_FOR`, then leaves it off.
|
||||
///
|
||||
/// The light is left off whatever happens, including when a blink fails
|
||||
/// part-way: a printer abandoned mid-blink with its light on looks like a
|
||||
/// machine still working.
|
||||
async fn celebrate(fleet: SharedFleet, printer_id: String) {
|
||||
let deadline = tokio::time::Instant::now() + BLINK_FOR;
|
||||
let mut state = LightState::On;
|
||||
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if let Err(err) = set_light(&fleet, &printer_id, state).await {
|
||||
// Stop at the first failure rather than logging twenty times,
|
||||
// but still fall through to the final Off below.
|
||||
warn!(printer = %printer_id, %err, "stopped blinking after a failed light command");
|
||||
break;
|
||||
}
|
||||
|
||||
state = match state {
|
||||
LightState::On => LightState::Off,
|
||||
LightState::Off => LightState::On,
|
||||
};
|
||||
tokio::time::sleep(BLINK_HALF_CYCLE).await;
|
||||
}
|
||||
|
||||
if let Err(err) = set_light(&fleet, &printer_id, LightState::Off).await {
|
||||
warn!(printer = %printer_id, %err, "could not turn the light back off");
|
||||
}
|
||||
}
|
||||
|
||||
/// One light command, holding the fleet lock only for as long as it takes.
|
||||
///
|
||||
/// Locking per command rather than once around the whole blink: the fleet
|
||||
/// mutex is also what telemetry and cloud command dispatch need, and holding
|
||||
/// it for ten seconds would stall both for every printer on the farm.
|
||||
async fn set_light(
|
||||
fleet: &SharedFleet,
|
||||
printer_id: &str,
|
||||
state: LightState,
|
||||
) -> Result<(), String> {
|
||||
let fleet = fleet.lock().await;
|
||||
let printer = fleet
|
||||
.iter()
|
||||
.find(|p| p.base().id == printer_id)
|
||||
.ok_or_else(|| format!("no printer with id '{printer_id}' in this gateway's fleet"))?;
|
||||
|
||||
// Bambu-only for now. A Prusa has no addressable light and Klipper's
|
||||
// depends entirely on the user's own config, so there is nothing
|
||||
// vendor-neutral to put on `GenericPrinter` yet - and inventing a
|
||||
// trait method that three of four implementations refuse is worse than
|
||||
// matching here.
|
||||
match printer {
|
||||
PrinterHandle::Bambu(bambu) => {
|
||||
bambu.set_light(CHAMBER_LIGHT, state).await.map_err(|err| err.to_string())
|
||||
}
|
||||
other => Err(format!(
|
||||
"{} has no light this software knows how to control",
|
||||
other.display_name()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn states(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
pairs.iter().map(|(id, s)| (id.to_string(), s.to_string())).collect()
|
||||
}
|
||||
|
||||
/// The regression that matters most: a gateway restarting while a
|
||||
/// printer sits in FINISH must not announce a print that finished
|
||||
/// yesterday.
|
||||
#[test]
|
||||
fn a_printer_first_seen_already_finished_does_not_fire() {
|
||||
let mut watcher = CompletionWatcher::new();
|
||||
assert!(watcher.newly_finished(states(&[("p1p", "FINISH")])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transition_into_finish_fires_once_and_only_once() {
|
||||
let mut watcher = CompletionWatcher::new();
|
||||
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
|
||||
|
||||
assert_eq!(watcher.newly_finished(states(&[("p1p", "FINISH")])), vec!["p1p"]);
|
||||
// FINISH persists until the next print starts; without the
|
||||
// transition check this would fire every poll for an hour.
|
||||
assert!(watcher.newly_finished(states(&[("p1p", "FINISH")])).is_empty());
|
||||
}
|
||||
|
||||
/// A Bambu reports an operator cancellation as FAILED - including every
|
||||
/// `print.stop` this software sends. Celebrating that would be worse
|
||||
/// than staying dark.
|
||||
#[test]
|
||||
fn a_cancelled_print_never_fires() {
|
||||
let mut watcher = CompletionWatcher::new();
|
||||
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
|
||||
assert!(watcher.newly_finished(states(&[("p1p", "FAILED")])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_print_fires_again() {
|
||||
let mut watcher = CompletionWatcher::new();
|
||||
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
|
||||
watcher.newly_finished(states(&[("p1p", "FINISH")]));
|
||||
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
|
||||
assert_eq!(watcher.newly_finished(states(&[("p1p", "FINISH")])), vec!["p1p"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printers_are_tracked_independently() {
|
||||
let mut watcher = CompletionWatcher::new();
|
||||
watcher.newly_finished(states(&[("p1p", "RUNNING"), ("h2c", "RUNNING")]));
|
||||
|
||||
let finished = watcher.newly_finished(states(&[("p1p", "FINISH"), ("h2c", "RUNNING")]));
|
||||
assert_eq!(finished, vec!["p1p"]);
|
||||
}
|
||||
|
||||
/// Ten seconds of 500ms half-cycles is 20 commands, ending on Off. Worth
|
||||
/// pinning: the ask was "blink for ten seconds then turn the lights off",
|
||||
/// and a half-cycle that does not divide the duration would end mid-blink.
|
||||
#[test]
|
||||
fn the_blink_divides_evenly_into_the_duration() {
|
||||
assert_eq!(BLINK_FOR.as_millis() % BLINK_HALF_CYCLE.as_millis(), 0);
|
||||
assert_eq!(BLINK_FOR.as_millis() / BLINK_HALF_CYCLE.as_millis(), 20);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! compiled once. `go2rtc` is the one exception: the camera-restreaming
|
||||
//! watchdog is the daemon's own concern and nothing else calls it.
|
||||
|
||||
pub mod completion;
|
||||
pub mod config;
|
||||
pub mod fleet;
|
||||
pub mod printer;
|
||||
|
||||
+17
-8
@@ -10,7 +10,7 @@ use tracing_subscriber::EnvFilter;
|
||||
|
||||
use continuum_proxy::config::Config;
|
||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||
use continuum_proxy::{fleet, uplink};
|
||||
use continuum_proxy::{completion, fleet, uplink};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@@ -36,16 +36,25 @@ async fn main() -> anyhow::Result<()> {
|
||||
// see `uplink::SharedFleet` for why that type and what it costs.
|
||||
let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet));
|
||||
|
||||
// Two long-running tasks: the cloud uplink and the go2rtc
|
||||
// camera-restreaming watchdog. Every printer's MQTT session is a third
|
||||
// thing running concurrently, but implicitly - each one has its own
|
||||
// background poll task spawned by `connect()`, kept alive by the fleet
|
||||
// above. More will join these later (printer discovery, the plate
|
||||
// changer). `tokio::select!` stops as soon as either returns; both loop
|
||||
// forever, so in practice this runs until the process is killed.
|
||||
// Three long-running tasks: the cloud uplink, the go2rtc
|
||||
// camera-restreaming watchdog, and the print-completion watcher that
|
||||
// blinks a printer's lights when it finishes. Every printer's MQTT
|
||||
// session is a fourth thing running concurrently, but implicitly - each
|
||||
// one has its own background poll task spawned by `connect()`, kept
|
||||
// alive by the fleet above. More will join these later (printer
|
||||
// discovery, the plate changer).
|
||||
//
|
||||
// The completion watcher is deliberately its own task rather than part
|
||||
// of the uplink: it must keep working with the cloud connection down,
|
||||
// because the person it is for is standing next to the printer. See
|
||||
// `completion::run`.
|
||||
//
|
||||
// `tokio::select!` stops as soon as any returns; all three loop forever,
|
||||
// so in practice this runs until the process is killed.
|
||||
tokio::select! {
|
||||
_ = uplink::run(config.clone(), fleet.clone()) => {}
|
||||
_ = go2rtc::watchdog(config.go2rtc_bin.clone(), config.go2rtc_config.clone()) => {}
|
||||
_ = completion::run(fleet.clone()) => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+274
-28
@@ -226,6 +226,104 @@ pub struct BambuPrinter {
|
||||
connection: Option<(super::bambu_mqtt::BambuMqttSession, BambuProtocolVersion)>,
|
||||
}
|
||||
|
||||
/// Which plate of a multi-plate project gets printed.
|
||||
///
|
||||
/// Always the first, matching `start_uploaded_file`'s hardcoded
|
||||
/// `Metadata/plate_1.gcode`. The two have to agree: deriving the filament
|
||||
/// mapping from plate 2 while printing plate 1 would map confidently onto
|
||||
/// the wrong thing. When one becomes a parameter, so must the other.
|
||||
const FIRST_PLATE: u32 = 1;
|
||||
|
||||
/// How many slots Bambu numbers each AMS unit as, for turning a
|
||||
/// (unit, tray) pair into the single slot number `ams_mapping` wants.
|
||||
///
|
||||
/// 4 even for an AMS HT, which physically has one: the numbering is by unit
|
||||
/// index, not by how many slots the unit happens to have.
|
||||
const SLOTS_PER_AMS: i32 = 4;
|
||||
|
||||
/// External spools, which `ams_mapping` has to be padded for even though it
|
||||
/// cannot address them.
|
||||
///
|
||||
/// 1 is right for every printer this has been used with. A dual-nozzle H2C
|
||||
/// has two, and this should come from the report rather than a constant once
|
||||
/// `vt_tray` is modelled on `PrintReport` - the ams_mapping schema spells
|
||||
/// out the arithmetic.
|
||||
const EXTERNAL_SPOOLS: usize = 1;
|
||||
|
||||
/// Flattens the AMS report into trays with global slot numbers, plus the
|
||||
/// total tray count `ams_mapping` must be padded to.
|
||||
///
|
||||
/// The count includes *empty* AMS slots and the external spool: the padding
|
||||
/// rule is over every tray the printer has, not every tray with filament in
|
||||
/// it. Getting that wrong produces an under-length array, which is one of
|
||||
/// the quiet ways a mapping fails.
|
||||
fn available_trays(ams: Option<&super::AmsState>) -> (Vec<super::AvailableTray>, usize) {
|
||||
let Some(ams) = ams else {
|
||||
return (Vec::new(), EXTERNAL_SPOOLS);
|
||||
};
|
||||
|
||||
let mut trays = Vec::new();
|
||||
let mut total = EXTERNAL_SPOOLS;
|
||||
|
||||
for unit in &ams.ams {
|
||||
let unit_index: i32 = unit.id.parse().unwrap_or(0);
|
||||
total += unit.tray.len();
|
||||
|
||||
for tray in &unit.tray {
|
||||
// Only a loaded tray can be matched against; an empty slot still
|
||||
// counts towards `total` above.
|
||||
let super::AmsTray::Loaded(loaded) = tray else {
|
||||
continue;
|
||||
};
|
||||
let Ok(slot_in_unit) = loaded.id.parse::<i32>() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
trays.push(super::AvailableTray {
|
||||
slot: unit_index * SLOTS_PER_AMS + slot_in_unit,
|
||||
tray_info_idx: loaded.tray_info_idx.clone(),
|
||||
filament_type: loaded.tray_type.clone(),
|
||||
color: super::normalise_color(&loaded.tray_color),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(trays, total)
|
||||
}
|
||||
|
||||
/// What a printer's light should be doing.
|
||||
///
|
||||
/// Only the two steady states. Bambu's `system.ledctrl` also has a
|
||||
/// `flashing` mode taking `led_on_time`/`led_off_time`/`loop_times`/
|
||||
/// `interval_time` (see
|
||||
/// `schemas/bambulab/v1/request/system/ledctrl.schema.json`), and it is
|
||||
/// deliberately not wired up: the meaning of `loop_times` versus
|
||||
/// `interval_time` is not confirmed against hardware, so a caller asking for
|
||||
/// "blink for ten seconds" cannot currently predict what the firmware will
|
||||
/// do with it. Driving the blink from here out of `On` and `Off` gives an
|
||||
/// exact duration out of two commands whose behaviour is obvious. Switch to
|
||||
/// the firmware mode once someone has watched a real printer do it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LightState {
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
impl LightState {
|
||||
/// The wire word. The schema accepts upper and lower case; lower is what
|
||||
/// Bambu Studio sends.
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::On => "on",
|
||||
Self::Off => "off",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The light every Bambu has. `work_light` and `chamber_light2` are in the
|
||||
/// schema's enum but not on every model, so they are not assumed here.
|
||||
pub const CHAMBER_LIGHT: &str = "chamber_light";
|
||||
|
||||
/// Where a project print draws its filament from.
|
||||
///
|
||||
/// This exists because the alternative - hardcoding `use_ams: false` -
|
||||
@@ -540,6 +638,40 @@ impl BambuPrinter {
|
||||
/// was arriving and being silently dropped by a `PrintReport`
|
||||
/// deserialization failure (see `handle_incoming_report`). So there is
|
||||
/// no missing "other trigger" to go looking for.
|
||||
/// Turns one of the printer's lights on or off.
|
||||
///
|
||||
/// **Unverified against hardware.** Every other live-confirmed call in
|
||||
/// this file says so explicitly; this one was written while the only
|
||||
/// available printer was powered off. The command shape comes from
|
||||
/// `schemas/bambulab/{v1,v2}/request/system/ledctrl.schema.json` and is
|
||||
/// built from it rather than by hand, so the field names and types are
|
||||
/// as good as the schema is - but whether the printer accepts
|
||||
/// `chamber_light` and answers, and whether it replies at all, is
|
||||
/// untested. It goes out as a correlated `request` so that a rejection
|
||||
/// is visible rather than lost, the same reason `control_print` does.
|
||||
///
|
||||
/// If this turns out not to reply, `request` will time out after 10s and
|
||||
/// this should become a `publish` - the symptom to look for.
|
||||
pub async fn set_light(&self, node: &str, state: LightState) -> Result<(), PrinterError> {
|
||||
let (session, version) = self
|
||||
.connection
|
||||
.as_ref()
|
||||
.ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
|
||||
|
||||
let mut body = continuum_schema_instance::build_instance_from_file(&get_schema_for_command(
|
||||
*version,
|
||||
"system".into(),
|
||||
"ledctrl".into(),
|
||||
))
|
||||
.map_err(|err| PrinterError(format!("failed to build ledctrl request: {err}")))?;
|
||||
|
||||
body["led_node"] = serde_json::Value::String(node.to_string());
|
||||
body["led_mode"] = serde_json::Value::String(state.as_str().to_string());
|
||||
|
||||
let reply = session.request("system", body).await?;
|
||||
Self::command_outcome(&self.base.name, "ledctrl", &reply)
|
||||
}
|
||||
|
||||
pub async fn pushall(&self) -> Result<(), PrinterError> {
|
||||
let (session, version) =
|
||||
self.connection.as_ref().ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
|
||||
@@ -617,6 +749,73 @@ impl BambuPrinter {
|
||||
self.list_dir(super::bambu_ftps::CACHE_DIR).await
|
||||
}
|
||||
|
||||
/// Works out where a project's filaments should come from, by reading
|
||||
/// the project and comparing it with what the printer actually has
|
||||
/// loaded.
|
||||
///
|
||||
/// This is the thing that `use_ams: false` was standing in for. The
|
||||
/// archive's `Metadata/slice_info.config` names each filament's
|
||||
/// `tray_info_idx`, type and colour; the AMS report names the same three
|
||||
/// things per loaded tray; matching them is the whole job. See
|
||||
/// `project_file::map_to_trays` for the tie-break order, and
|
||||
/// `FilamentSource` for what getting this wrong does to a real print.
|
||||
///
|
||||
/// Requires a `print.push_status` to have arrived, because there is no
|
||||
/// way to guess what is loaded. On v1 that means `pushall` must have
|
||||
/// been called since connecting; `connect()` does.
|
||||
///
|
||||
/// ## What it will not do
|
||||
///
|
||||
/// It never falls back to the external spool to make an unmatched
|
||||
/// project "work". An unmatched filament is an error, because every
|
||||
/// alternative is a guess about what someone meant to print.
|
||||
///
|
||||
/// The one case it does answer `ExternalSpool` for is a printer
|
||||
/// reporting no AMS trays at all, where there is nothing else it could
|
||||
/// mean. Note the asymmetry that leaves: a printer *with* an AMS whose
|
||||
/// project wants the external spool is an error here, not an
|
||||
/// `ExternalSpool`, because `vt_tray` is not modelled on `PrintReport` -
|
||||
/// so there is no way to check that the spool holds the right material,
|
||||
/// and claiming a match without checking is how this went wrong the
|
||||
/// first time. Modelling `vt_tray` is what fixes that.
|
||||
pub async fn filament_source_for(
|
||||
&self,
|
||||
archive: &[u8],
|
||||
plate: u32,
|
||||
) -> Result<FilamentSource, PrinterError> {
|
||||
let filaments = super::plate_filaments(archive, plate)
|
||||
.map_err(|err| PrinterError(format!("{}: {err}", self.base.name)))?;
|
||||
|
||||
let report = self.print_status().await.ok_or_else(|| {
|
||||
PrinterError(format!(
|
||||
"{}: no print.push_status yet, so there is no way to know what filament is loaded",
|
||||
self.base.name
|
||||
))
|
||||
})?;
|
||||
|
||||
let (trays, tray_count) = available_trays(report.ams.as_ref());
|
||||
|
||||
if trays.is_empty() {
|
||||
tracing::info!(
|
||||
printer = %self.base.name,
|
||||
"no AMS trays reported; printing from the external spool"
|
||||
);
|
||||
return Ok(FilamentSource::ExternalSpool);
|
||||
}
|
||||
|
||||
let mapping = super::map_to_trays(&filaments, &trays)
|
||||
.map_err(|err| PrinterError(format!("{}: {err}", self.base.name)))?;
|
||||
|
||||
tracing::info!(
|
||||
printer = %self.base.name,
|
||||
?mapping,
|
||||
tray_count,
|
||||
"mapped the project's filaments onto AMS slots"
|
||||
);
|
||||
|
||||
FilamentSource::ams(mapping, tray_count)
|
||||
}
|
||||
|
||||
/// Reads one file back off the printer's SD card.
|
||||
///
|
||||
/// Read-only, and the counterpart to `upload_file`. Needed because a
|
||||
@@ -686,8 +885,11 @@ impl BambuPrinter {
|
||||
session.request(category, body).await
|
||||
}
|
||||
|
||||
/// Interprets the `result`/`reason` pair a `print` command's reply
|
||||
/// carries, turning a rejection into an `Err` instead of losing it.
|
||||
/// Interprets the `result`/`reason` pair a command's reply carries,
|
||||
/// turning a rejection into an `Err` instead of losing it.
|
||||
///
|
||||
/// Used for `print` and `system` commands alike - both answer in this
|
||||
/// shape.
|
||||
///
|
||||
/// Confirmed live on a P1P (2026-10-11): `print.gcode_file` naming a
|
||||
/// file that doesn't exist replies
|
||||
@@ -704,7 +906,7 @@ impl BambuPrinter {
|
||||
/// (it echoed our `sequence_id` back), and not every firmware/command
|
||||
/// pair is known to include `result` - so this refuses to invent a
|
||||
/// failure, while still not hiding an unrecognised shape.
|
||||
fn print_command_outcome(
|
||||
fn command_outcome(
|
||||
printer: &str,
|
||||
command: &str,
|
||||
reply: &serde_json::Value,
|
||||
@@ -728,7 +930,7 @@ impl BambuPrinter {
|
||||
printer,
|
||||
command,
|
||||
reply = %reply,
|
||||
"a print command's reply carried no 'result' field; treating it as accepted"
|
||||
"a command's reply carried no 'result' field; treating it as accepted"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -812,7 +1014,7 @@ impl BambuPrinter {
|
||||
/// real print. If you are the one who tests it, record the answer here.
|
||||
///
|
||||
/// A rejection is at least no longer silent: both branches go out as
|
||||
/// correlated requests and `print_command_outcome` turns a `fail` into
|
||||
/// correlated requests and `command_outcome` turns a `fail` into
|
||||
/// an `Err`. Before that, asking this printer to print a file it could
|
||||
/// not find returned `Ok(())`.
|
||||
///
|
||||
@@ -871,9 +1073,9 @@ impl BambuPrinter {
|
||||
|
||||
// A correlated `request`, not a fire-and-forget publish. This was
|
||||
// the latter until a P1P was asked to print a file it didn't have
|
||||
// and happily returned `Ok(())` - see `print_command_outcome`.
|
||||
// and happily returned `Ok(())` - see `command_outcome`.
|
||||
let reply = session.request("print", body).await?;
|
||||
Self::print_command_outcome(&self.base.name, command, &reply)
|
||||
Self::command_outcome(&self.base.name, command, &reply)
|
||||
}
|
||||
|
||||
/// The current in-memory `print.push_status` object - `None` until at
|
||||
@@ -922,7 +1124,7 @@ impl GenericPrinter for BambuPrinter {
|
||||
// read `gcode_state` back via `print_status()`. The value of the
|
||||
// reply is the negative case: a rejection is no longer silent.
|
||||
let reply = session.request("print", body).await?;
|
||||
Self::print_command_outcome(&self.base.name, action.as_str(), &reply)
|
||||
Self::command_outcome(&self.base.name, action.as_str(), &reply)
|
||||
}
|
||||
|
||||
|
||||
@@ -1010,31 +1212,25 @@ impl GenericPrinter for BambuPrinter {
|
||||
file_name: String,
|
||||
data: Vec<u8>,
|
||||
) -> Result<(), PrinterError> {
|
||||
// `data` is about to be moved, so take its length while it's here.
|
||||
let byte_count = data.len();
|
||||
|
||||
// Worked out *before* the upload, from the bytes still in hand. If
|
||||
// this project cannot be mapped to what the printer has loaded, the
|
||||
// right outcome is to say so and not move a file onto the machine
|
||||
// that cannot then be printed.
|
||||
let filament = self.filament_source_for(&data, FIRST_PLATE).await?;
|
||||
|
||||
let written = self.upload_file(&file_name, data).await?;
|
||||
|
||||
println!(
|
||||
"[{}] uploaded {file_name} ({written} of {byte_count} bytes) to {}/",
|
||||
self.base.name,
|
||||
super::bambu_ftps::CACHE_DIR
|
||||
tracing::info!(
|
||||
printer = %self.base.name,
|
||||
file = %file_name,
|
||||
written,
|
||||
byte_count,
|
||||
"uploaded a project file to the SD card root"
|
||||
);
|
||||
|
||||
// The external spool, because this trait method has no way to know
|
||||
// any better: `upload_project_file` is vendor-generic and an AMS
|
||||
// mapping is a Bambu concept, so it cannot appear in the signature.
|
||||
//
|
||||
// That makes this the one remaining place that can silently pick the
|
||||
// wrong filament source - see `FilamentSource` for what that does to
|
||||
// a real print. It is not currently reachable: there is no
|
||||
// start-print command on the uplink, so nothing calls this. The fix
|
||||
// is for this method to *derive* the mapping from the `.3mf` it
|
||||
// already holds in `data` - `Metadata/slice_info.config` lists each
|
||||
// plate's filaments with their `tray_info_idx` and colour, which is
|
||||
// exactly what has to be matched against the AMS report. That needs
|
||||
// a zip reader, which is a dependency decision, which is why it is
|
||||
// not done here yet.
|
||||
self.start_uploaded_file(&file_name, &FilamentSource::ExternalSpool).await
|
||||
self.start_uploaded_file(&file_name, &filament).await
|
||||
}
|
||||
|
||||
/// Builds the real JSON payload straight from continuum-schemas'
|
||||
@@ -1116,6 +1312,56 @@ mod tests {
|
||||
assert_eq!(body["ams_mapping"], serde_json::json!(""));
|
||||
}
|
||||
|
||||
/// The AMS half of the mapping, against the real P1P report in
|
||||
/// `tests/fixtures/push_status_v1_p1p_idle.json`: one unit, slots 0 and
|
||||
/// 1 empty, slots 2 and 3 holding PETG in two different colours.
|
||||
#[test]
|
||||
fn real_ams_report_flattens_to_the_loaded_slots() {
|
||||
let raw = include_str!("../../tests/fixtures/push_status_v1_p1p_idle.json");
|
||||
let value: serde_json::Value = serde_json::from_str(raw).expect("fixture is json");
|
||||
let body = value.get("print").unwrap_or(&value);
|
||||
let report: super::super::PrintReport =
|
||||
serde_json::from_value(body.clone()).expect("fixture should parse");
|
||||
|
||||
let (trays, total) = available_trays(report.ams.as_ref());
|
||||
|
||||
assert_eq!(trays.len(), 2, "only slots 2 and 3 hold filament");
|
||||
assert_eq!(trays[0].slot, 2);
|
||||
assert_eq!(trays[1].slot, 3);
|
||||
// The printer reports RGBA; the project writes RGB.
|
||||
assert_eq!(trays[0].color, "161616");
|
||||
assert_eq!(trays[1].color, "2850E0");
|
||||
|
||||
// 4 AMS slots - including the two empty ones - plus one external
|
||||
// spool. The padding rule is over every tray, not every loaded tray,
|
||||
// and an under-length array is one of the quiet failure modes.
|
||||
assert_eq!(total, 5);
|
||||
}
|
||||
|
||||
/// The whole thing, end to end, on the two real captures: the project
|
||||
/// that cost a print and the AMS report of the printer it was sent to.
|
||||
/// This is the case the old `use_ams: false` got wrong.
|
||||
#[test]
|
||||
fn the_real_project_and_the_real_printer_agree_on_slot_three() {
|
||||
let raw = include_str!("../../tests/fixtures/push_status_v1_p1p_idle.json");
|
||||
let value: serde_json::Value = serde_json::from_str(raw).expect("fixture is json");
|
||||
let body = value.get("print").unwrap_or(&value);
|
||||
let report: super::super::PrintReport =
|
||||
serde_json::from_value(body.clone()).expect("fixture should parse");
|
||||
let (trays, total) = available_trays(report.ams.as_ref());
|
||||
|
||||
let project = include_bytes!("../../tests/fixtures/cube_slice_info.3mf");
|
||||
let filaments = super::super::plate_filaments(project, FIRST_PLATE).expect("should parse");
|
||||
|
||||
let mapping = super::super::map_to_trays(&filaments, &trays).expect("should match");
|
||||
let source = FilamentSource::ams(mapping, total).expect("valid");
|
||||
|
||||
let mut sent = serde_json::json!({});
|
||||
source.apply(&mut sent);
|
||||
assert_eq!(sent["use_ams"], serde_json::json!(true));
|
||||
assert_eq!(sent["ams_mapping"], serde_json::json!([3, -1, -1, -1, -1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ams_source_sends_an_array_and_turns_use_ams_on() {
|
||||
let mut body = serde_json::json!({});
|
||||
|
||||
@@ -35,8 +35,17 @@ mod klipper;
|
||||
mod marlin_serial;
|
||||
mod prusa;
|
||||
|
||||
// Reading a `.3mf` well enough to know which filament a plate wants. Kept
|
||||
// free of printer types so it is testable without a printer.
|
||||
mod project_file;
|
||||
pub use project_file::{
|
||||
map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament,
|
||||
ProjectFileError,
|
||||
};
|
||||
|
||||
pub use bambu::{
|
||||
BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource,
|
||||
LightState, CHAMBER_LIGHT,
|
||||
};
|
||||
pub use bambu_commands::{
|
||||
AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest,
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
//! Reading a Bambu `.3mf` project archive well enough to know which
|
||||
//! filament each plate wants.
|
||||
//!
|
||||
//! This exists because `use_ams: false` is not a safe default. Hardcoding it
|
||||
//! overrode the slicer's own choice and pointed an AMS-sliced project at the
|
||||
//! external spool; on a real P1P the print started, heated, reached 1% and
|
||||
//! then the printer paused itself with `print_error: 50364420` and
|
||||
//! `tray_now: 255`. See `FilamentSource` in `bambu.rs`.
|
||||
//!
|
||||
//! Deliberately free of any MQTT or printer types: it takes archive bytes and
|
||||
//! a plain list of loaded trays, and returns an `ams_mapping`. That keeps it
|
||||
//! unit-testable without a printer, which is the only way this gets tested at
|
||||
//! all - the alternative is discovering a mapping bug by watching a machine
|
||||
//! print in the wrong colour.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
/// One filament a plate was sliced for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProjectFilament {
|
||||
/// 0-based. This is the position in `ams_mapping`, so it has to come
|
||||
/// from the filament's own `id` minus one and not from the order the
|
||||
/// entries happen to appear in the XML.
|
||||
pub index: usize,
|
||||
/// Bambu's filament identifier, e.g. `GFG99`. The strongest signal for
|
||||
/// matching, because it names the exact product.
|
||||
pub tray_info_idx: String,
|
||||
/// e.g. `PETG`.
|
||||
pub filament_type: String,
|
||||
/// Six uppercase hex digits, no `#`. Normalised on the way in, because
|
||||
/// the project writes `#2850E0` and the printer reports `2850E0FF` - the
|
||||
/// same colour with a trailing alpha byte.
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
/// A tray the printer actually has filament in, flattened to a single slot
|
||||
/// number.
|
||||
///
|
||||
/// Deliberately not `bambu_commands::LoadedTray`, which is the wire shape:
|
||||
/// that carries an `id` scoped to its own AMS unit, so slot 2 of unit 1 and
|
||||
/// slot 2 of unit 0 are both `"2"`. Flattening to a global slot is the
|
||||
/// caller's job, because only the caller knows how many units there are.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AvailableTray {
|
||||
/// The AMS slot number, as it appears in `ams_mapping`.
|
||||
pub slot: i32,
|
||||
pub tray_info_idx: String,
|
||||
pub filament_type: String,
|
||||
/// Same normalisation as `ProjectFilament::color`.
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProjectFileError {
|
||||
#[error("the project archive could not be opened: {0}")]
|
||||
NotAnArchive(String),
|
||||
#[error("the archive has no {0} - it may not be a Bambu project")]
|
||||
MissingEntry(String),
|
||||
#[error("{0} could not be read: {1}")]
|
||||
UnreadableEntry(String, String),
|
||||
#[error("the archive describes no plate {0}")]
|
||||
NoSuchPlate(u32),
|
||||
#[error("plate {0} lists no filaments")]
|
||||
NoFilaments(u32),
|
||||
/// A `<filament>` element that could not be read. An error rather than a
|
||||
/// skip: see the comment at the parse site.
|
||||
#[error("plate {plate} has an unreadable filament: {detail}")]
|
||||
MalformedFilament { plate: u32, detail: String },
|
||||
/// Deliberately an error and never a silent fallback to the external
|
||||
/// spool: an unmatched filament means the print would draw the wrong
|
||||
/// material or none.
|
||||
#[error("no loaded tray matches {wanted} (filament index {index}); loaded: {loaded}")]
|
||||
NoMatchingTray { index: usize, wanted: String, loaded: String },
|
||||
}
|
||||
|
||||
/// Normalises a colour to six uppercase hex digits.
|
||||
///
|
||||
/// Accepts `#2850E0`, `2850E0`, `2850E0FF` and lowercase, because the
|
||||
/// project archive and the printer's own report disagree on all three
|
||||
/// points.
|
||||
///
|
||||
/// Input with fewer than six hex digits comes back uppercased and stripped
|
||||
/// but otherwise untouched. It will not equal any real colour, so the matcher
|
||||
/// fails to match - the safe outcome - rather than this panicking.
|
||||
pub fn normalise_color(raw: &str) -> String {
|
||||
let stripped = raw.trim().trim_start_matches('#').to_ascii_uppercase();
|
||||
let head: String = stripped.chars().take(6).collect();
|
||||
if head.len() == 6 && head.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
head
|
||||
} else {
|
||||
stripped
|
||||
}
|
||||
}
|
||||
|
||||
const SLICE_INFO: &str = "Metadata/slice_info.config";
|
||||
|
||||
/// Reads the filaments one plate was sliced for out of a `.3mf`.
|
||||
///
|
||||
/// `plate_index` is 1-based, matching the archive's own numbering
|
||||
/// (`Metadata/plate_1.gcode`, `<metadata key="index" value="1"/>`).
|
||||
///
|
||||
/// There is no XML crate in the dependency tree, so this scans the text.
|
||||
/// That is tolerable only because `slice_info.config` is machine-written by
|
||||
/// one program in a stable shape; the scanning is still defensive (tag names
|
||||
/// must be followed by whitespace, attribute names must start at a word
|
||||
/// boundary) so that a neighbouring element cannot be mistaken for the one
|
||||
/// we want. Anything unexpected yields a typed error, never a guess.
|
||||
pub fn plate_filaments(
|
||||
archive: &[u8],
|
||||
plate_index: u32,
|
||||
) -> Result<Vec<ProjectFilament>, ProjectFileError> {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(archive))
|
||||
.map_err(|e| ProjectFileError::NotAnArchive(e.to_string()))?;
|
||||
let mut entry = zip
|
||||
.by_name(SLICE_INFO)
|
||||
.map_err(|_| ProjectFileError::MissingEntry(SLICE_INFO.to_string()))?;
|
||||
let mut xml = String::new();
|
||||
entry
|
||||
.read_to_string(&mut xml)
|
||||
.map_err(|e| ProjectFileError::UnreadableEntry(SLICE_INFO.to_string(), e.to_string()))?;
|
||||
|
||||
let block = plate_blocks(&xml)
|
||||
.into_iter()
|
||||
.find(|block| plate_number(block) == Some(plate_index))
|
||||
.ok_or(ProjectFileError::NoSuchPlate(plate_index))?;
|
||||
|
||||
// Every `<filament>` must parse. A malformed one used to be dropped
|
||||
// here, which is the worst available outcome: a dropped filament leaves
|
||||
// a hole at its index, `map_to_trays` fills the hole with `-1`, and the
|
||||
// printer is told that material is unused - so the plate prints in
|
||||
// whatever remains mapped. Refusing is the only safe answer, because
|
||||
// there is nothing to fall back to that is not a guess about what
|
||||
// someone meant to print.
|
||||
let mut filaments: Vec<ProjectFilament> = tags(block, "filament")
|
||||
.into_iter()
|
||||
.map(|tag| {
|
||||
let missing = |what: &str| ProjectFileError::MalformedFilament {
|
||||
plate: plate_index,
|
||||
detail: format!("{what} in <filament {}>", tag.trim()),
|
||||
};
|
||||
|
||||
// Ids are 1-based, so 0 or a non-number would underflow into a
|
||||
// bogus position.
|
||||
let raw_id = attr(tag, "id").ok_or_else(|| missing("no id"))?;
|
||||
let id: usize = raw_id.parse().map_err(|_| missing("id is not a number"))?;
|
||||
let index = id.checked_sub(1).ok_or_else(|| missing("id is 0, but ids are 1-based"))?;
|
||||
|
||||
Ok(ProjectFilament {
|
||||
index,
|
||||
tray_info_idx: attr(tag, "tray_info_idx")
|
||||
.ok_or_else(|| missing("no tray_info_idx"))?
|
||||
.to_string(),
|
||||
filament_type: attr(tag, "type").ok_or_else(|| missing("no type"))?.to_string(),
|
||||
color: normalise_color(attr(tag, "color").ok_or_else(|| missing("no color"))?),
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, ProjectFileError>>()?;
|
||||
filaments.sort_by_key(|f| f.index);
|
||||
|
||||
if filaments.is_empty() {
|
||||
return Err(ProjectFileError::NoFilaments(plate_index));
|
||||
}
|
||||
Ok(filaments)
|
||||
}
|
||||
|
||||
/// Each `<plate>` element's text, up to the start of the next one.
|
||||
fn plate_blocks(xml: &str) -> Vec<&str> {
|
||||
xml.split("<plate")
|
||||
.skip(1)
|
||||
// `<plate_x` would be a different element; `</plate>` never matches
|
||||
// the split at all because its `<` is followed by `/`.
|
||||
.filter(|rest| rest.starts_with(|c: char| c == '>' || c.is_whitespace()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn plate_number(block: &str) -> Option<u32> {
|
||||
tags(block, "metadata")
|
||||
.into_iter()
|
||||
.find(|tag| attr(tag, "key") == Some("index"))
|
||||
.and_then(|tag| attr(tag, "value")?.parse().ok())
|
||||
}
|
||||
|
||||
/// Every `<name ...>` start tag in `text`, without the angle brackets.
|
||||
/// Requires whitespace after the name so `filament` does not match
|
||||
/// `<filament_settings>`.
|
||||
fn tags<'a>(text: &'a str, name: &str) -> Vec<&'a str> {
|
||||
let open = format!("<{name}");
|
||||
let mut found = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find(&open) {
|
||||
let after = &rest[at + open.len()..];
|
||||
let Some(end) = after.find('>') else { break };
|
||||
if after.starts_with(char::is_whitespace) {
|
||||
found.push(&after[..end]);
|
||||
}
|
||||
rest = &after[end..];
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The value of attribute `name` in a start tag.
|
||||
///
|
||||
/// The name must begin right after whitespace, so asking for `id` cannot
|
||||
/// land inside `identify_id` or `group_id`, and asking for `type` cannot
|
||||
/// land inside `volume_type`. Whitespace before the `=` is tolerated
|
||||
/// (`error_code ="..."` occurs in real files).
|
||||
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
let mut from = 0;
|
||||
while let Some(rel) = tag[from..].find(name) {
|
||||
let at = from + rel;
|
||||
from = at + name.len();
|
||||
let at_boundary = tag[..at].ends_with(char::is_whitespace);
|
||||
let after = tag[from..].trim_start();
|
||||
if at_boundary && let Some(value) = after.strip_prefix('=') {
|
||||
let value = value.trim_start().strip_prefix('"')?;
|
||||
return value.split('"').next();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Builds the `ams_mapping` body for a plate: position = project filament
|
||||
/// index, value = the AMS slot to draw it from.
|
||||
///
|
||||
/// Unused positions are `-1`. The caller pads to the printer's full tray
|
||||
/// count - see `FilamentSource::ams`.
|
||||
///
|
||||
/// Each filament takes the best tray by this order:
|
||||
/// 1. same `tray_info_idx` and same colour - the exact product, as sliced.
|
||||
/// 2. same `tray_info_idx`, different colour - a deliberate "right product,
|
||||
/// wrong colour" allowance.
|
||||
/// 3. same `filament_type` and same colour - "right material", a different
|
||||
/// product.
|
||||
///
|
||||
/// Tiers 2 and 3 are better than refusing a print, but they are not what the
|
||||
/// slicer asked for, so a caller may want to warn. Ties within a tier go to
|
||||
/// the lowest slot so the result never depends on tray ordering. A filament
|
||||
/// matching no tier is an error, never a fallback.
|
||||
pub fn map_to_trays(
|
||||
filaments: &[ProjectFilament],
|
||||
trays: &[AvailableTray],
|
||||
) -> Result<Vec<i32>, ProjectFileError> {
|
||||
let Some(max) = filaments.iter().map(|f| f.index).max() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut mapping = vec![-1; max + 1];
|
||||
|
||||
for filament in filaments {
|
||||
let best = trays
|
||||
.iter()
|
||||
.filter_map(|tray| Some((tier(filament, tray)?, tray.slot)))
|
||||
.min();
|
||||
let Some((_, slot)) = best else {
|
||||
let loaded = trays
|
||||
.iter()
|
||||
.map(|t| format!("slot {}: {} {} #{}", t.slot, t.filament_type, t.tray_info_idx, t.color))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
return Err(ProjectFileError::NoMatchingTray {
|
||||
index: filament.index,
|
||||
wanted: format!(
|
||||
"{} {} #{}",
|
||||
filament.filament_type, filament.tray_info_idx, filament.color
|
||||
),
|
||||
loaded,
|
||||
});
|
||||
};
|
||||
mapping[filament.index] = slot;
|
||||
}
|
||||
Ok(mapping)
|
||||
}
|
||||
|
||||
/// Match quality, lower is better; `None` means no match.
|
||||
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_color = filament.color == tray.color;
|
||||
let same_type = filament.filament_type.eq_ignore_ascii_case(&tray.filament_type);
|
||||
match (same_product, same_color, same_type) {
|
||||
(true, true, _) => Some(1),
|
||||
(true, false, _) => Some(2),
|
||||
(false, true, true) => Some(3),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
const REAL: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<header>
|
||||
<header_item key="X-BBL-Client-Type" value="slicer"/>
|
||||
</header>
|
||||
<plate>
|
||||
<metadata key="index" value="1"/>
|
||||
<metadata key="nozzle_diameters" value="0.4"/>
|
||||
<metadata key="prediction" value="2408"/>
|
||||
<object identify_id="69" name="Bambu Cube" skipped="false" />
|
||||
<filament id="1" tray_info_idx="GFG99" type="PETG" color="#2850E0" used_m="2.57" used_g="7.86" group_id="0" nozzle_diameter="0.40" volume_type="Standard" used_for_object="true" used_for_support="false"/>
|
||||
<warning msg="bed_temperature_too_high_than_filament" level="3" error_code ="1000C001" />
|
||||
<nozzle id="0" extruder_id="1" nozzle_diameter="0.4" volume_type="Standard"/>
|
||||
</plate>
|
||||
</config>
|
||||
"##;
|
||||
|
||||
const TWO_PLATES: &str = r##"<config>
|
||||
<plate>
|
||||
<metadata key="index" value="1"/>
|
||||
<filament id="1" tray_info_idx="GFA00" type="PLA" color="#FF0000" group_id="0"/>
|
||||
</plate>
|
||||
<plate>
|
||||
<metadata key="index" value="2"/>
|
||||
<filament id="2" tray_info_idx="GFB00" type="ABS" color="#00ff00" group_id="1"/>
|
||||
<filament id="1" tray_info_idx="GFG99" type="PETG" color="#0000FFFF" group_id="0"/>
|
||||
</plate>
|
||||
</config>
|
||||
"##;
|
||||
|
||||
fn zip_with(name: &str, body: &str) -> Vec<u8> {
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored);
|
||||
w.start_file(name, opts).unwrap();
|
||||
w.write_all(body.as_bytes()).unwrap();
|
||||
w.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn filament(index: usize, idx: &str, ty: &str, color: &str) -> ProjectFilament {
|
||||
ProjectFilament {
|
||||
index,
|
||||
tray_info_idx: idx.into(),
|
||||
filament_type: ty.into(),
|
||||
color: color.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tray(slot: i32, idx: &str, ty: &str, color: &str) -> AvailableTray {
|
||||
AvailableTray {
|
||||
slot,
|
||||
tray_info_idx: idx.into(),
|
||||
filament_type: ty.into(),
|
||||
color: color.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalises_every_spelling() {
|
||||
assert_eq!(normalise_color("#2850E0"), "2850E0");
|
||||
assert_eq!(normalise_color("2850e0"), "2850E0");
|
||||
assert_eq!(normalise_color("2850E0FF"), "2850E0");
|
||||
assert_eq!(normalise_color("#2850e0ff"), "2850E0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_or_garbage_colour_does_not_panic() {
|
||||
assert_eq!(normalise_color("#abc"), "ABC");
|
||||
assert_eq!(normalise_color(""), "");
|
||||
assert_eq!(normalise_color("zzzzzzzz"), "ZZZZZZZZ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_real_single_filament_plate() {
|
||||
let got = plate_filaments(&zip_with(SLICE_INFO, REAL), 1).unwrap();
|
||||
assert_eq!(got, vec![filament(0, "GFG99", "PETG", "2850E0")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_right_plate_and_converts_ids() {
|
||||
let archive = zip_with(SLICE_INFO, TWO_PLATES);
|
||||
let got = plate_filaments(&archive, 2).unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
filament(0, "GFG99", "PETG", "0000FF"),
|
||||
filament(1, "GFB00", "ABS", "00FF00"),
|
||||
]
|
||||
);
|
||||
let first = plate_filaments(&archive, 1).unwrap();
|
||||
assert_eq!(first, vec![filament(0, "GFA00", "PLA", "FF0000")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_errors() {
|
||||
assert!(matches!(
|
||||
plate_filaments(b"not a zip", 1),
|
||||
Err(ProjectFileError::NotAnArchive(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
plate_filaments(&zip_with("other.txt", "x"), 1),
|
||||
Err(ProjectFileError::MissingEntry(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
plate_filaments(&zip_with(SLICE_INFO, REAL), 7),
|
||||
Err(ProjectFileError::NoSuchPlate(7))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plate_without_filaments_is_an_error() {
|
||||
let xml = r#"<config><plate><metadata key="index" value="1"/></plate></config>"#;
|
||||
assert!(matches!(
|
||||
plate_filaments(&zip_with(SLICE_INFO, xml), 1),
|
||||
Err(ProjectFileError::NoFilaments(1))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_does_not_match_longer_names() {
|
||||
let tag = r#"filament group_id="9" identify_id="8" volume_type="Std" id="1" type="PLA""#;
|
||||
assert_eq!(attr(tag, "id"), Some("1"));
|
||||
assert_eq!(attr(tag, "type"), Some("PLA"));
|
||||
assert_eq!(attr(r#"x error_code ="E1""#, "error_code"), Some("E1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_product_different_colour_picks_the_colour_match() {
|
||||
let filaments = [filament(0, "GFG99", "PETG", "2850E0")];
|
||||
let trays = [
|
||||
tray(2, "GFG99", "PETG", "161616"),
|
||||
tray(3, "GFG99", "PETG", "2850E0"),
|
||||
];
|
||||
assert_eq!(map_to_trays(&filaments, &trays).unwrap(), vec![3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiers_and_tie_break() {
|
||||
// Tier 2 (right product, wrong colour) beats nothing, lowest slot wins.
|
||||
let f = [filament(0, "GFG99", "PETG", "2850E0")];
|
||||
let t = [tray(5, "GFG99", "PETG", "111111"), tray(1, "gfg99", "PETG", "222222")];
|
||||
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![1]);
|
||||
// Tier 3 (right material and colour, different product).
|
||||
let t = [tray(4, "GFA00", "petg", "2850E0")];
|
||||
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![4]);
|
||||
// Tier 1 outranks a lower-numbered tier 2.
|
||||
let t = [tray(0, "GFG99", "PETG", "111111"), tray(6, "GFG99", "PETG", "2850E0")];
|
||||
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_filament_is_an_error() {
|
||||
let f = [filament(0, "GFG99", "PETG", "2850E0")];
|
||||
let t = [tray(1, "GFA00", "PLA", "FF0000")];
|
||||
match map_to_trays(&f, &t) {
|
||||
Err(ProjectFileError::NoMatchingTray { index, wanted, loaded }) => {
|
||||
assert_eq!(index, 0);
|
||||
assert_eq!(wanted, "PETG GFG99 #2850E0");
|
||||
assert_eq!(loaded, "slot 1: PLA GFA00 #FF0000");
|
||||
}
|
||||
other => panic!("expected NoMatchingTray, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaps_become_minus_one() {
|
||||
let f = [filament(0, "GFA00", "PLA", "FF0000"), filament(2, "GFG99", "PETG", "2850E0")];
|
||||
let t = [tray(1, "GFA00", "PLA", "FF0000"), tray(3, "GFG99", "PETG", "2850E0")];
|
||||
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![1, -1, 3]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests against a real project, not just hand-built XML.
|
||||
///
|
||||
/// `tests/fixtures/cube_slice_info.3mf` holds the `slice_info.config` of a
|
||||
/// project read off a P1P over FTPS on 2026-10-11 - the one that exposed the
|
||||
/// `use_ams: false` bug - re-zipped with **deflate**. The compression matters
|
||||
/// as much as the content: every other test here builds a `Stored` archive,
|
||||
/// so without this the decompression path (and the flate2 backend wired up
|
||||
/// behind `zip`'s `deflate-flate2` feature) is never exercised, and a real
|
||||
/// archive is always deflated.
|
||||
#[cfg(test)]
|
||||
mod real_project_tests {
|
||||
use super::*;
|
||||
|
||||
const REAL_PROJECT: &[u8] = include_bytes!("../../tests/fixtures/cube_slice_info.3mf");
|
||||
|
||||
#[test]
|
||||
fn reads_a_deflated_archive_from_a_real_printer() {
|
||||
let filaments = plate_filaments(REAL_PROJECT, 1).expect("the real project should parse");
|
||||
|
||||
assert_eq!(filaments.len(), 1, "the cube is a single-filament plate");
|
||||
assert_eq!(filaments[0].index, 0, "id=\"1\" is 1-based, so index 0");
|
||||
assert_eq!(filaments[0].tray_info_idx, "GFG99");
|
||||
assert_eq!(filaments[0].filament_type, "PETG");
|
||||
// `#2850E0` in the project; the printer calls the same colour
|
||||
// `2850E0FF`.
|
||||
assert_eq!(filaments[0].color, "2850E0");
|
||||
}
|
||||
|
||||
/// End to end on the exact case that cost a print: both AMS slots hold
|
||||
/// the same product (PETG, GFG99) and only the colour distinguishes
|
||||
/// them, so a matcher that stopped at `tray_info_idx` would pick slot 2
|
||||
/// and print the cube in black.
|
||||
#[test]
|
||||
fn the_real_project_maps_to_the_slot_it_was_sliced_for() {
|
||||
let filaments = plate_filaments(REAL_PROJECT, 1).expect("should parse");
|
||||
let trays = vec![
|
||||
AvailableTray {
|
||||
slot: 2,
|
||||
tray_info_idx: "GFG99".into(),
|
||||
filament_type: "PETG".into(),
|
||||
color: "161616".into(),
|
||||
},
|
||||
AvailableTray {
|
||||
slot: 3,
|
||||
tray_info_idx: "GFG99".into(),
|
||||
filament_type: "PETG".into(),
|
||||
color: "2850E0".into(),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(map_to_trays(&filaments, &trays).expect("should match"), vec![3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_real_project_has_no_second_plate() {
|
||||
assert!(matches!(
|
||||
plate_filaments(REAL_PROJECT, 2),
|
||||
Err(ProjectFileError::NoSuchPlate(2))
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user