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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user