From 7d4cf41999a106b8ee4a18d048d73d346b2b256c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Thu, 15 Oct 2026 22:23:41 +0000 Subject: [PATCH] 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 [name]`, `delete `. 51 tests still pass. --- examples/bambu_print_probe.rs | 219 +++++++++++++++++++++++++++- src/completion.rs | 6 +- src/printer/bambu.rs | 25 ++++ src/printer/bambu_commands/mod.rs | 5 +- src/printer/bambu_commands/print.rs | 33 ++++- src/printer/bambu_ftps.rs | 44 +++++- src/printer/mod.rs | 3 +- 7 files changed, 324 insertions(+), 11 deletions(-) diff --git a/examples/bambu_print_probe.rs b/examples/bambu_print_probe.rs index 1303b44..dc68838 100644 --- a/examples/bambu_print_probe.rs +++ b/examples/bambu_print_probe.rs @@ -16,6 +16,25 @@ //! at all? A clean rejection naming the file means the //! shape is right and only the target was wrong. A //! parse error, or silence, means the shape is wrong. +//! - `upload [name]` +//! **inert.** FTPS-transfers a local file onto the card +//! and stops - nothing prints until told to. Reports +//! the byte count the printer accepted and the rate. +//! - `delete ` removes one named file from the card. +//! - `blink` runs `completion::celebrate`, the real ten-second +//! finished-print blink, and then checks the light +//! ended off. Twenty light commands in ten seconds is +//! a rate the printer may refuse, which is the thing +//! this answers. +//! - `light on|off|flash` +//! **no physical risk** beyond a light changing. `on` +//! and `off` go through `BambuPrinter::set_light`, the +//! same call `completion::run` blinks with, so this +//! confirms the shipped path rather than a hand-built +//! body. `flash` instead sends the schema's `flashing` +//! mode raw, to settle what `loop_times` and +//! `interval_time` actually do - the self-driven blink +//! exists because that was unconfirmed. //! - `start ` sends `print.gcode_file`/`print.project_file` for a //! file in `cache/`. Gated on `gcode_state: IDLE` and //! on the explicit flag. @@ -34,12 +53,20 @@ use std::time::Duration; use continuum_proxy::fleet; -use continuum_proxy::printer::{FilamentSource, GenericPrinter, PrintControl, PrinterHandle}; +use continuum_proxy::printer::{ + FilamentSource, GenericPrinter, LightState, PrintControl, PrinterHandle, CHAMBER_LIGHT, +}; const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); const STATUS_TIMEOUT: Duration = Duration::from_secs(15); /// How long to watch the report stream after sending something. const WATCH: Duration = Duration::from_secs(20); +/// How long to wait for a light change to show up in `lights_report`. A v1 +/// printer only reports when asked, so this polls `pushall` rather than +/// waiting on an organic push that is not coming. +const LIGHT_READBACK: Duration = Duration::from_secs(12); +/// Only for the message - `completion.rs` owns the real value. +const BLINK_FOR_DISPLAY: &str = "10s"; /// A file name that cannot exist in `cache/`, for `probe-missing`. const MISSING_FILE: &str = "continuum-probe-no-such-file.gcode"; @@ -65,11 +92,18 @@ async fn main() -> anyhow::Result<()> { let (Some(target), Some(step)) = (args.get(1).cloned(), args.get(2).cloned()) else { anyhow::bail!( "usage: cargo run --example bambu_print_probe -- [arg]\n\ - steps: status | probe-missing | start | pause | resume | stop" + steps: status | probe-missing | light on|off|flash | start \ + | pause | resume | stop" ); }; let confirmed = args.iter().any(|a| a == "--yes-start-a-real-print"); + // Handled before the fleet below is borrowed, because the real blink + // wants the whole fleet behind a lock rather than one printer. + if step == "blink" { + return blink_probe(&target).await; + } + let mut printers = fleet::load(std::path::Path::new("printers.toml"))?; let printer = printers .iter_mut() @@ -190,6 +224,60 @@ async fn main() -> anyhow::Result<()> { } } + // `light on|off|flash`. The first two deliberately go through + // `set_light` rather than a raw body: that method had never once + // reached a printer, and `completion::run` blinks a finished print's + // chamber light with it, so the thing worth testing is it and not a + // body written here to match. + "light" => { + let mode = args.get(3).map(String::as_str).unwrap_or("on"); + match mode { + "on" | "off" => { + let state = if mode == "on" { LightState::On } else { LightState::Off }; + println!("\n set_light({CHAMBER_LIGHT}, {mode}):"); + match bambu.set_light(CHAMBER_LIGHT, state).await { + Ok(()) => println!(" accepted"), + Err(err) => println!(" FAILED: {err}"), + } + // "Accepted" is not "it changed" - a ledctrl reply says + // success as soon as the command parses. `lights_report` + // is the only read-back there is, so poll until the + // printer says the light is what we asked for. + report_light(bambu, mode).await; + } + // Raw, because there is no typed call for it yet - that is + // the point of the probe. Deliberately short: 3 cycles of + // 500ms on / 500ms off, matching what `completion::run` + // drives by hand, so the two are comparable by eye. + "flash" => { + let body = serde_json::json!({ + "command": "ledctrl", + "led_node": CHAMBER_LIGHT, + "led_mode": "flashing", + "led_on_time": 500, + "led_off_time": 500, + "loop_times": 3, + "interval_time": 1000, + }); + println!("\n sending system.ledctrl flashing (3x 500/500, interval 1000):"); + match bambu.request_raw("system", body).await { + Ok(reply) => println!(" reply: {reply}"), + Err(err) => println!(" no usable reply: {err}"), + } + println!( + " watch the printer: 3 blinks means loop_times is cycles; \n\ + anything else means the field means something other than that" + ); + // And read it back, which says whether `flashing` is a + // firmware *mode* the printer holds and reports, or a + // one-shot that leaves the reported mode untouched. + report_light(bambu, "flashing").await; + } + other => anyhow::bail!("`light` takes on, off or flash - not '{other}'"), + } + return Ok(()); + } + // `shape ` sends one `print` command verbatim as a correlated // request and prints the reply. For settling an unconfirmed command // shape against the hardware - see `BambuPrinter::request_raw`. @@ -206,6 +294,53 @@ async fn main() -> anyhow::Result<()> { } } + // `upload [name]` puts a file on the card and stops there. + // Inert: a file on the SD card does nothing until something is told + // to print it. The only proven transfer before this was 102 bytes, + // and real projects on the card run to 62 MB - a size at which + // buffering, timeouts and the FTPS data connection all start to + // matter in ways a 102-byte write cannot show. + "upload" => { + let Some(local) = args.get(3) else { + anyhow::bail!("`upload` needs a local file path"); + }; + let data = std::fs::read(local)?; + let name = args + .get(4) + .cloned() + .unwrap_or_else(|| format!("continuum-probe-{}", file_stem(local))); + println!("\n uploading {} bytes as {name} ...", data.len()); + let started = std::time::Instant::now(); + match bambu.upload_file(&name, data.clone()).await { + Ok(written) => { + let elapsed = started.elapsed(); + println!(" printer accepted {written} bytes in {elapsed:?}"); + if written == data.len() as u64 { + let rate = data.len() as f64 / elapsed.as_secs_f64() / 1_048_576.0; + println!(" byte count matches exactly ({rate:.1} MiB/s)"); + } else { + println!(" MISMATCH: sent {}, printer took {written}", data.len()); + } + } + Err(err) => println!(" upload FAILED: {err}"), + } + return Ok(()); + } + + // `delete ` removes one file. Named in full on the command + // line every time - there is no pattern form on purpose. + "delete" => { + let Some(path) = args.get(3) else { + anyhow::bail!("`delete` needs the exact path of a file on the card"); + }; + println!("\n deleting {path} ..."); + match bambu.delete_file(path).await { + Ok(()) => println!(" gone"), + Err(err) => println!(" delete FAILED: {err}"), + } + return Ok(()); + } + // `fetch ` copies one file off the card. Read-only. "fetch" => { let (Some(path), Some(dest)) = (args.get(3), args.get(4)) else { @@ -227,6 +362,86 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// The file name without its directory, for naming an upload after its +/// source. Not `Path::file_stem`, which would strip only the last extension +/// and turn `tray.stl.gcode.3mf` into something that is no longer a `.3mf`. +fn file_stem(path: &str) -> String { + path.rsplit('/').next().unwrap_or(path).to_string() +} + +/// Runs `completion::celebrate` - the actual ten-second blink a finished print +/// triggers - against a real printer. The question it answers is a rate one: +/// `celebrate` sends twenty `ledctrl` commands half a second apart, and a +/// printer is entitled to refuse that. It stops at the first failure, so if +/// the rate is too high this prints one warning and ends dark. +async fn blink_probe(target: &str) -> anyhow::Result<()> { + use std::sync::Arc; + use tokio::sync::Mutex; + + let mut printers = fleet::load(std::path::Path::new("printers.toml"))?; + { + let printer = printers + .iter_mut() + .find(|p| p.base().id == target) + .ok_or_else(|| anyhow::anyhow!("no printer with id '{target}' in printers.toml"))?; + let PrinterHandle::Bambu(bambu) = printer else { + anyhow::bail!("'{target}' is not a Bambu printer"); + }; + println!("=== {} / step: blink ===", bambu.display_name()); + match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await { + Ok(Ok(())) => {} + Ok(Err(err)) => anyhow::bail!("connect failed: {err}"), + Err(_) => anyhow::bail!("connect timed out - printer off or unreachable?"), + } + } + + println!("\n running completion::celebrate for {BLINK_FOR_DISPLAY} - watch the printer"); + let fleet: continuum_proxy::uplink::SharedFleet = Arc::new(Mutex::new(printers)); + let started = std::time::Instant::now(); + continuum_proxy::completion::celebrate(fleet.clone(), target.to_string()).await; + println!(" returned after {:?}", started.elapsed()); + + // It is specified to end dark, which is the part worth checking: a blink + // that leaves the light on looks like a printer that is still busy. + let fleet = fleet.lock().await; + if let Some(PrinterHandle::Bambu(bambu)) = fleet.iter().find(|p| p.base().id == target) { + report_light(bambu, "off").await; + } + Ok(()) +} + +/// Polls `lights_report` until the chamber light reads `want`, printing every +/// change with the time it took. Gives up rather than hanging: a light that +/// never reports what was asked for is the result, not a reason to wait. +async fn report_light(bambu: &continuum_proxy::printer::BambuPrinter, want: &str) { + let started = std::time::Instant::now(); + let mut last = String::new(); + while started.elapsed() < LIGHT_READBACK { + let mode = bambu + .print_status() + .await + .and_then(|r| r.lights_report) + .and_then(|lights| { + lights + .into_iter() + .find(|l| l.node.as_deref() == Some(CHAMBER_LIGHT)) + .and_then(|l| l.mode) + }); + let mode = mode.unwrap_or_else(|| "".into()); + if mode != last { + println!(" lights_report after {:?}: {CHAMBER_LIGHT} = {mode}", started.elapsed()); + last = mode.clone(); + } + if mode == want { + println!(" confirmed: the printer reports '{want}', not just that it accepted it"); + return; + } + bambu.pushall().await.ok(); + tokio::time::sleep(Duration::from_millis(1500)).await; + } + println!(" gave up after {LIGHT_READBACK:?}: last reported '{last}', asked for '{want}'"); +} + async fn wait_for_state(bambu: &continuum_proxy::printer::BambuPrinter) -> anyhow::Result { let deadline = tokio::time::Instant::now() + STATUS_TIMEOUT; loop { diff --git a/src/completion.rs b/src/completion.rs index 0e645ec..6a7df76 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -126,7 +126,11 @@ pub async fn run(fleet: SharedFleet) { /// 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) { +/// 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; diff --git a/src/printer/bambu.rs b/src/printer/bambu.rs index a9c4519..cf08bb8 100644 --- a/src/printer/bambu.rs +++ b/src/printer/bambu.rs @@ -933,6 +933,31 @@ impl BambuPrinter { .map_err(|err| PrinterError(format!("{}: FTPS download failed: {err}", self.base.name))) } + /// Removes one file from the printer's SD card. + /// + /// Takes one explicit path, because the convenient version of this is + /// also the dangerous one - see `bambu_ftps::delete`. Nothing calls it + /// automatically yet: a gateway that uploads a project per job needs to + /// clear them eventually, but *which* file is safe to remove is a + /// question about the job queue, not about FTPS. + pub async fn delete_file(&self, path: &str) -> Result<(), PrinterError> { + let connector = self + .tls + .build_connector() + .map_err(|err| PrinterError(format!("{}: TLS setup failed: {err}", self.base.name)))?; + + let host = self.base.host.clone(); + let access_code = self.access_code.clone(); + let path = path.to_string(); + + tokio::task::spawn_blocking(move || { + super::bambu_ftps::delete(&host, &access_code, connector, &path) + }) + .await + .map_err(|err| PrinterError(format!("{}: FTPS delete thread failed: {err}", self.base.name)))? + .map_err(|err| PrinterError(format!("{}: FTPS delete failed: {err}", self.base.name))) + } + /// `list_cache` for any directory on the printer's SD card. /// /// Read-only, and the way to answer "what is the path to this file?" diff --git a/src/printer/bambu_commands/mod.rs b/src/printer/bambu_commands/mod.rs index c285ab3..c832b99 100644 --- a/src/printer/bambu_commands/mod.rs +++ b/src/printer/bambu_commands/mod.rs @@ -26,7 +26,10 @@ mod print; mod pushall; pub use get_version::{GetVersionRequest, GetVersionResponse, ModuleInfo}; -pub use print::{AmsState, AmsTray, AmsUnit, EmptyTray, LoadedTray, PrintReport, VtTray, XcamSettings}; +pub use print::{ + AmsState, AmsTray, AmsUnit, EmptyTray, LightReport, LoadedTray, PrintReport, VtTray, + XcamSettings, +}; pub use pushall::PushallRequest; /// Fields every Bambu command/response shares. This is the "inheritance" diff --git a/src/printer/bambu_commands/print.rs b/src/printer/bambu_commands/print.rs index 8c7eaee..11be487 100644 --- a/src/printer/bambu_commands/print.rs +++ b/src/printer/bambu_commands/print.rs @@ -20,9 +20,8 @@ pub struct PrintReport { /// actual, reliable signal for which kind a given message is - /// confirmed against a real printer, unlike trying to infer it from /// request/reply timing (see `BambuMqttSession`'s `PrintStatusCache`, - /// which used to try that and got it wrong twice). Not in - /// `push_status.schema.json` yet - add it there too when you're back - /// in continuum-schemas. + /// which used to try that and got it wrong twice). Now declared in + /// `push_status.schema.json` too. pub msg: Option, pub ams: Option, pub ams_exist_bits: Option, @@ -112,6 +111,18 @@ pub struct PrintReport { /// is the precedent, and making that one required broke every report from /// the other generation. pub vt_tray: Option, + /// What each light is actually doing. This is the read-back for + /// `set_light`, and the only one there is: a `ledctrl` reply says + /// `result: "success"` as soon as the command parses, which on a Bambu + /// means "accepted" and not "the light changed" (the same trap as the + /// print-control verbs). Confirmed on the P1P (2026-10-15) by setting + /// the chamber light on and off and reading + /// `[{"node":"chamber_light","mode":"on"}]` / `"off"` back here - which + /// is the first time `set_light` was shown to do anything at all. + /// + /// Observed on v1 despite only the v2 schema declaring it; the v1 + /// schema was simply missing it. + pub lights_report: Option>, // There are still ~60 more top-level fields in a real v2 payload (see // `tests/fixtures/push_status_v2_h2c_printing.json` for the full set): @@ -181,9 +192,25 @@ impl PrintReport { self.stg_cur = newer.stg_cur.or(self.stg_cur.take()); self.stg = newer.stg.or(self.stg.take()); self.vt_tray = newer.vt_tray.or(self.vt_tray.take()); + self.lights_report = newer.lights_report.or(self.lights_report.take()); } } +/// One light's state, as the printer reports it rather than as we asked for +/// it. `mode` is the same vocabulary `ledctrl` accepts (`"on"`, `"off"`, +/// `"flashing"`), and `node` the same light name - so a round-trip is +/// directly comparable, which is the point of having it. +/// +/// Both fields optional on principle rather than from evidence: every entry +/// seen on the P1P carried both, but one light in a list is not a sample of +/// every light on every model, and a missing `node` must not cost us the +/// whole report. +#[derive(serde::Deserialize, Debug, Clone)] +pub struct LightReport { + pub node: Option, + pub mode: Option, +} + /// Bambu's own naming has the outer container and the inner array both /// called `ams` - `print.ams.ams`, not a typo here, just what the printer /// actually sends. diff --git a/src/printer/bambu_ftps.rs b/src/printer/bambu_ftps.rs index 4de31e3..1070785 100644 --- a/src/printer/bambu_ftps.rs +++ b/src/printer/bambu_ftps.rs @@ -142,9 +142,15 @@ pub fn download( ftp.transfer_type(FileType::Binary)?; // `retr_as_buffer` rather than `retr_as_stream`: suppaftp 12's - // `TransferStream` finalizes itself on drop, and the files being read - // here are project archives of a few hundred KB, so there is nothing to - // gain from streaming them. + // `TransferStream` finalizes itself on drop, and a project archive is + // read whole anyway - `plate_filaments` needs the central directory at + // its end before it can read anything. + // + // Confirmed fine at 4.96 MB on the P1P (2026-10-15, byte-identical + // round trip). The qualifier here used to be "a few hundred KB", which + // the card itself disproves - the largest project on it is 62 MB, and + // that one would be held in memory twice during a round trip. Streaming + // is the answer if that ever matters; it has not yet. let bytes = ftp.retr_as_buffer(path)?.into_inner(); let _ = ftp.quit(); @@ -152,6 +158,38 @@ pub fn download( Ok(bytes) } +/// Blocking: removes one file from the card. +/// +/// The counterpart to `upload`, and the only reason it did not exist before +/// is that nothing had uploaded anything worth removing. It is needed now: +/// the P1P's card already holds 45 project archives and 47 extracted +/// `cache/` entries, so a gateway that uploads a project per job and never +/// removes one is a gateway that eventually fills a customer's SD card and +/// starts failing prints for a reason nobody will connect to this code. +/// +/// Deliberately takes one explicit path and does no globbing or recursion: +/// the obvious convenience here ("clear the old ones") is also the obvious +/// way to delete a file somebody printed from last week. +pub fn delete( + host: &str, + access_code: &str, + connector: native_tls::TlsConnector, + path: &str, +) -> anyhow::Result<()> { + let mut ftp = NativeTlsFtpStream::connect_secure_implicit( + (host, FTPS_PORT), + NativeTlsConnector::from(connector), + host, + )?; + + ftp.login(FTPS_USER, access_code)?; + ftp.rm(path)?; + + let _ = ftp.quit(); + + Ok(()) +} + /// Blocking: lists `cache/` on the printer. /// /// Read-only, and the counterpart to `upload`. The previous version of this diff --git a/src/printer/mod.rs b/src/printer/mod.rs index eafdd3c..15d4408 100644 --- a/src/printer/mod.rs +++ b/src/printer/mod.rs @@ -49,7 +49,8 @@ pub use bambu::{ }; pub use bambu_commands::{ AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest, - GetVersionResponse, LoadedTray, ModuleInfo, PrintReport, PushallRequest, VtTray, XcamSettings, + GetVersionResponse, LightReport, LoadedTray, ModuleInfo, PrintReport, PushallRequest, VtTray, + XcamSettings, }; pub use klipper::KlipperPrinter; pub use prusa::{PrusaLinkPrinter, PrusaSerialPrinter};