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.
This commit is contained in:
2026-10-15 21:27:24 +00:00
parent 431dd849da
commit c22ccf5d22
8 changed files with 1075 additions and 36 deletions
+249
View File
@@ -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);
}
}