Windows discovery tool / windows (push) Successful in 1m2s
The three were println stubs. Each is now an HTTP client (Moonraker, OctoPrint's REST API, PrusaLink's v1 API) behind a small shared helper: status polled in the background, pause/resume/cancel, G-code lines, fan speed, and upload-and-start of a sliced file. A slicer project (.3mf) is refused for these, and so is an upload while a print is running. Telemetry and the completion watcher now read a vendor-neutral PrinterStatus instead of Bambu's PrintReport, so these printers report progress, temperatures and job state to the cloud. Written from the public API docs and tested against mock servers only; no real Klipper, OctoPrint or PrusaLink machine has been on the bench.
254 lines
10 KiB
Rust
254 lines
10 KiB
Rust
//! 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.status().await.and_then(|s| s.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.
|
|
/// Public so `examples/bambu_print_probe.rs` can run the real blink against a
|
|
/// real printer. Twenty light commands in ten seconds is a rate a printer can
|
|
/// refuse, and a blink reimplemented in the probe to match would not have
|
|
/// told us whether this one works.
|
|
pub 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);
|
|
}
|
|
}
|