Files
continuum-proxy/src/completion.rs
T
octoturgeandClaude Opus 5 3d907d0443 Confirm the light and the upload on a real P1P
Four things were shipped unverified because the printer was off when they
were written. Three are now confirmed first-hand (P1P, 2026-10-15), and one
is settled in the opposite direction from what was assumed.

**`set_light` had never once reached a printer**, and `completion::run`
blinks a finished print's chamber light with it. It works - but "accepted" is
not "it changed": `ledctrl` answers `result: "success"` as soon as the
command parses, exactly like the print-control verbs. So `lights_report` is
now modelled on `PrintReport`, which is the only read-back there is, and the
probe polls it rather than printing a reassuring message. Setting the light
on and off reads `[{"node":"chamber_light","mode":"on"}]` / `"off"` back,
about 3s later. The field was observed on v1 even though only the v2 schema
declares it.

**The blink itself works at the rate it ships at.** `completion::celebrate`
is now public so the probe runs the real function instead of a copy: twenty
`ledctrl` commands half a second apart, which a printer is entitled to
refuse. None were refused, it returned after 10.35s, and the light ended
`off` as specified - a blink that leaves the light on would look like a
printer still working.

**The firmware's own `flashing` mode is not the better option.** It is
accepted, and the reply echoes all four parameters back
(`led_on_time`, `led_off_time`, `loop_times`, `interval_time`), so the
printer does parse them. But `lights_report` never reports `flashing` - it
stayed on the previous mode for 12s of polling. A firmware blink is therefore
unobservable: nothing can confirm it happened or tell when it ended, while
the self-driven blink is built from a call that reads back. That is a better
reason to keep `celebrate` than the one in the comment it replaces, which was
that `loop_times` semantics were unconfirmed. Whether the light physically
blinks is still unwitnessed - nobody was watching the machine.

**Multi-MB FTPS upload works, and is slow.** A 4.96 MB project archive went
up with an exact byte count and came back byte-identical (sha256). But it
took 31.3s - about 0.2 MiB/s - so the 62 MB project already on that card
would take six and a half minutes. Anything that uploads a job needs a
timeout budgeted in minutes, not seconds. The claim in `download`'s comment
that these files are "a few hundred KB" is gone; the card disproves it.

Also adds `delete`, because the probe left a 5 MB file on a real SD card and
there was no way to remove it. One explicit path, no globbing: the convenient
version of this deletes something somebody printed from last week. Used to
clean up, and the card is back as it was.

New probe steps: `light on|off|flash`, `blink`, `upload <local> [name]`,
`delete <path>`. 51 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 22:23:41 +00:00

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.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.
/// 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);
}
}