From e7c6f2f9ce4f76367120bc20cb35244a0a4cb7fb Mon Sep 17 00:00:00 2001 From: Vinny Marquez Date: Sun, 30 Aug 2026 20:42:00 +1000 Subject: [PATCH] Fix printer send + live telemetry; add hardware-in-the-loop tests Confirmed end-to-end against a real Bambu P2S. send_print: force use_ams:false in the project_file payload. A re-sliced spoolbook .3mf carries no per-filament AMS metadata, so use_ams:true fails at HMS 07FF-8012 "Failed to get AMS mapping table" regardless of ams_mapping. With it false the printer feeds from the threaded filament and the print starts. ams_mapping/ams_mapping2 now always empty. printer_mqtt: - max_packet_size 128KB -> 1MB. A P2S pushall full-status object overflowed 128KB, erroring the eventloop right after ConnAck -> the card flapped "Connected"/"Live" then back to "Not connected / No live job data". - connect / ConnAck / disconnect-with-error / subscribe-fail now log; the reconnect loop was previously silent. - publish_command uses a numeric sequence_id (submission_id()) + empty param, so the P2S actually acts on pause/resume/stop. - connect_and_subscribe_loop + publish_raw made pub for the new tests. printer_telemetry: record_reading no longer .expect()s its queries. It runs on the MQTT eventloop task; a DB error there unwound the whole telemetry+control connection for the printer (pause/resume/stop dead until restart). Split into try_record_reading + a wrapper that logs and drops the one reading. PrinterCard.svelte: drop the inline live feed from the status panel. An streaming the camera is a permanent client and Bambu firmware serves one at a time, so the card starved the printer's own toolhead-camera init mid-print. Camera is popup-only now. reslicing: slice_via_service made pub. tests/printer_live.rs (all #[ignore], local dev only, never CI): - live_connection_comes_up_and_stays_up - ftps_upload_roundtrips - send_a_print_then_cancel_before_it_extrudes - reslice_then_print_then_cancel (unsliced .3mf -> slicer-service -> print) Print tests are triple opt-in (SPOOLBOOK_TEST_ALLOW_REAL_PRINT=1) and cancel in PREPARE before any extrusion. examples/stop_print.rs: standalone MQTT stop command, used as the manual kill switch while developing the print path. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XmMzHSCTn7fkYGn9rWNnPS --- spoolbook-rs/examples/stop_print.rs | 91 +++++ spoolbook-rs/src/printer_mqtt.rs | 38 +- spoolbook-rs/src/printer_telemetry.rs | 32 +- spoolbook-rs/src/reslicing.rs | 4 +- spoolbook-rs/src/send_print.rs | 32 +- spoolbook-rs/tests/printer_live.rs | 343 ++++++++++++++++++ spoolbook-rs/tests/send_print.rs | 11 +- .../components/printers/PrinterCard.svelte | 10 +- 8 files changed, 522 insertions(+), 39 deletions(-) create mode 100644 spoolbook-rs/examples/stop_print.rs create mode 100644 spoolbook-rs/tests/printer_live.rs diff --git a/spoolbook-rs/examples/stop_print.rs b/spoolbook-rs/examples/stop_print.rs new file mode 100644 index 0000000..66e84fe --- /dev/null +++ b/spoolbook-rs/examples/stop_print.rs @@ -0,0 +1,91 @@ +// Emergency / manual cancel: connect, publish the `stop` command, watch gcode_state settle. +// +// cargo run --example stop_print -- +use rumqttc::{AsyncClient, Event, MqttOptions, Packet, QoS, TlsConfiguration, Transport}; +use std::time::Duration; + +#[derive(Debug)] +struct NoCertVerification; + +impl rustls::client::danger::ServerCertVerifier for NoCertVerification { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::CryptoProvider::get_default().map(|p| p.signature_verification_algorithms.supported_schemes()).unwrap_or_default() + } +} + +fn tls_config() -> rustls::ClientConfig { + rustls::ClientConfig::builder().dangerous().with_custom_certificate_verifier(std::sync::Arc::new(NoCertVerification)).with_no_client_auth() +} + +#[tokio::main] +async fn main() { + let args: Vec = std::env::args().collect(); + let [_, ip, access_code, serial] = args.as_slice() else { + eprintln!("usage: stop_print "); + std::process::exit(1); + }; + + let mut mqttoptions = MqttOptions::new(format!("spoolbook-stop-{}", std::process::id()), ip.clone(), 8883); + mqttoptions.set_credentials("bblp", access_code); + mqttoptions.set_keep_alive(Duration::from_secs(30)); + mqttoptions.set_transport(Transport::tls_with_config(TlsConfiguration::Rustls(std::sync::Arc::new(tls_config())))); + mqttoptions.set_max_packet_size(1024 * 1024, 1024 * 1024); + + let (client, mut eventloop) = AsyncClient::new(mqttoptions, 10); + let report_topic = format!("device/{serial}/report"); + let request_topic = format!("device/{serial}/request"); + client.subscribe(&report_topic, QoS::AtMostOnce).await.expect("subscribe"); + + let mut sent = false; + for _ in 0..40 { + match eventloop.poll().await { + Ok(Event::Incoming(Packet::ConnAck(_))) => { + let seq = format!("{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis()); + let payload = format!(r#"{{"print":{{"command":"stop","param":"","sequence_id":"{seq}"}}}}"#); + client.publish(&request_topic, QoS::AtMostOnce, false, payload).await.expect("publish stop"); + println!("STOP sent (seq {seq})"); + sent = true; + } + Ok(Event::Incoming(Packet::Publish(p))) => { + let body = String::from_utf8_lossy(&p.payload); + if let Some(i) = body.find("\"gcode_state\"") { + println!("gcode_state {}", &body[i..(i + 30).min(body.len())]); + } + } + Ok(_) => {} + Err(e) => { + eprintln!("poll error: {e}"); + if sent { + return; + } + } + } + } + println!("done polling"); +} diff --git a/spoolbook-rs/src/printer_mqtt.rs b/spoolbook-rs/src/printer_mqtt.rs index 3e35c07..59c086e 100644 --- a/spoolbook-rs/src/printer_mqtt.rs +++ b/spoolbook-rs/src/printer_mqtt.rs @@ -182,7 +182,10 @@ async fn purge_stale_jobs_loop(pool: SqlitePool) { // polling the eventloop is simultaneously what maintains the connection and what delivers // messages. So unlike the .NET original's connect-then-watchdog-loop shape, this is one loop // that does both; behaviorally equivalent (connected, receiving, reconnecting on failure). -async fn connect_and_subscribe_loop( +// pub rather than private: driven directly by tests/printer_live.rs against a real printer on +// the LAN (no mock -- the bugs this catches are all "the firmware's own wire format / packet +// size", invisible to anything but real hardware). Same testing boundary as handle_message. +pub async fn connect_and_subscribe_loop( printer_id: i64, ip_address: String, access_code: String, @@ -202,23 +205,28 @@ async fn connect_and_subscribe_loop( mqttoptions.set_keep_alive(Duration::from_secs(30)); mqttoptions.set_transport(Transport::tls_with_config(TlsConfiguration::Rustls(Arc::new(tls_config())))); // rumqttc's default incoming limit (10KB) is smaller than this printer's real device/report - // payload (~14KB, confirmed against a real P2S -- the connection ConnAcks and SubAcks fine, - // then errors out the instant the first real status report arrives, since nothing here ever - // subscribed to real hardware's own report size before). 128KB is a comfortable margin. - mqttoptions.set_max_packet_size(128 * 1024, 128 * 1024); + // payload (~14KB delta, confirmed against a real P2S -- the connection ConnAcks and SubAcks + // fine, then errors out the instant the first real status report arrives). The pushall + // *full* object (every AMS tray + the whole HMS array) is bigger again, so 128KB wasn't + // always enough -- an oversized-packet poll error right after ConnAck is exactly the + // "connects, flips to Live, then drops to No live job data" flap. 1MB is well clear. + mqttoptions.set_max_packet_size(1024 * 1024, 1024 * 1024); let (client, mut eventloop) = AsyncClient::new(mqttoptions, 10); let topic = format!("device/{serial_number}/report"); if client.subscribe(&topic, QoS::AtMostOnce).await.is_err() { + eprintln!("[mqtt {printer_id}] subscribe to {topic} failed, retrying in 15s"); tokio::time::sleep(Duration::from_secs(15)).await; continue; } + eprintln!("[mqtt {printer_id}] connecting to {ip_address}:8883"); store.write().await.entry(printer_id).or_default().client = Some(client.clone()); loop { match eventloop.poll().await { Ok(Event::Incoming(Packet::ConnAck(_))) => { + eprintln!("[mqtt {printer_id}] connected, sending pushall"); store.write().await.entry(printer_id).or_default().connected = true; // Bambu's broker only emits a full status object (gcode_state, task_id, ams, // the pause/HMS reason) on request or on its own slow (~minutes) schedule -- @@ -233,11 +241,21 @@ async fn connect_and_subscribe_loop( } Ok(Event::Incoming(Packet::Publish(publish))) => { if let Ok(payload) = std::str::from_utf8(&publish.payload) { + // Set SPOOLBOOK_MQTT_DEBUG=1 to dump every raw report to stderr -- the + // only way to see the printer's own pause/HMS reason, gcode_state, and + // error codes, none of which the parser keeps. Off by default (a real + // report is ~14KB). + if std::env::var("SPOOLBOOK_MQTT_DEBUG").is_ok() { + eprintln!("[mqtt {printer_id}] report: {payload}"); + } handle_message(printer_id, payload, &pool, &store, &mut active_task_id, &camera_registry).await; } } Ok(_) => {} - Err(_) => break, + Err(e) => { + eprintln!("[mqtt {printer_id}] disconnected: {e} -- reconnecting in 15s"); + break; + } } } @@ -312,13 +330,17 @@ async fn snapshot_for_end_job(store: &LiveStatusStore, printer_id: i64) -> (Opti // than opening a new one per action — so a command fails outright (rather than queuing) if that // connection happens to be mid-reconnect. pub async fn publish_command(store: &LiveStatusStore, printer_id: i64, serial_number: &str, command: &str) -> Result<(), String> { - let payload = serde_json::json!({ "print": { "command": command, "sequence_id": uuid_v4() } }).to_string(); + // sequence_id must be a stringified integer, not a uuid: the P2S firmware ignores pause/ + // resume/stop commands whose sequence_id isn't numeric (the start-print "project_file" path + // already uses a numeric one via submission_id() and works). This is what makes the on-screen + // "select Resume to retry" prompt after an HMS actually respond to spoolbook's Resume button. + let payload = serde_json::json!({ "print": { "command": command, "param": "", "sequence_id": crate::send_print::submission_id() } }).to_string(); publish_raw(store, printer_id, serial_number, payload).await } // Shared by publish_command above and send_print.rs's "project_file" start-print command — // both just publish an already-built payload on the same live connection telemetry holds open. -pub(crate) async fn publish_raw(store: &LiveStatusStore, printer_id: i64, serial_number: &str, payload: String) -> Result<(), String> { +pub async fn publish_raw(store: &LiveStatusStore, printer_id: i64, serial_number: &str, payload: String) -> Result<(), String> { let client = store.read().await.get(&printer_id).and_then(|s| s.client.clone()); let Some(client) = client else { return Err("Printer isn't connected — telemetry link is down or still reconnecting.".to_string()); diff --git a/spoolbook-rs/src/printer_telemetry.rs b/spoolbook-rs/src/printer_telemetry.rs index 26df7d4..d12b955 100644 --- a/spoolbook-rs/src/printer_telemetry.rs +++ b/spoolbook-rs/src/printer_telemetry.rs @@ -19,6 +19,10 @@ pub struct PrinterJob { const COLUMNS: &str = "id, printer_id, external_job_id, started_at, ended_at, print_id"; +// Called from the MQTT eventloop task (printer_mqtt::handle_message) for every active-state +// reading. A DB error here must not unwind -- a panic in that spawned task kills the whole +// telemetry+control connection for the printer (pause/resume/stop stop working mid-print) and +// it doesn't come back until a restart. So this logs and drops the one reading instead. pub async fn record_reading( pool: &SqlitePool, printer_id: i64, @@ -26,14 +30,25 @@ pub async fn record_reading( input: &ReadingInput, at: Option<&str>, ) { + if let Err(e) = try_record_reading(pool, printer_id, external_job_id, input, at).await { + eprintln!("[telemetry {printer_id}] dropped a reading for job {external_job_id}: {e}"); + } +} + +async fn try_record_reading( + pool: &SqlitePool, + printer_id: i64, + external_job_id: &str, + input: &ReadingInput, + at: Option<&str>, +) -> Result<(), sqlx::Error> { let existing_job_id = sqlx::query_scalar::<_, i64>( "SELECT id FROM printer_jobs WHERE printer_id = ?1 AND external_job_id = ?2 AND ended_at IS NULL", ) .bind(printer_id) .bind(external_job_id) .fetch_optional(pool) - .await - .expect("query failed"); + .await?; let is_new_job = existing_job_id.is_none(); let job_id = match existing_job_id { @@ -47,8 +62,7 @@ pub async fn record_reading( .bind(external_job_id) .bind(at) .fetch_one(pool) - .await - .expect("insert failed"), + .await?, }; sqlx::query( @@ -66,8 +80,7 @@ pub async fn record_reading( .bind(input.layer_num) .bind(input.total_layer_num) .execute(pool) - .await - .expect("insert failed"); + .await?; // Auto-create-on-send (docs/adr/0017's 2026-08-14 addendum): a brand-new Job attaches // straight to the printer's open (InProgress, not yet attached) Print instead of waiting for @@ -84,18 +97,17 @@ pub async fn record_reading( ) .bind(printer_id) .fetch_optional(pool) - .await - .expect("query failed"); + .await?; if let Some(open_print_id) = open_print_id { sqlx::query("UPDATE printer_jobs SET print_id = ?1 WHERE id = ?2") .bind(open_print_id) .bind(job_id) .execute(pool) - .await - .expect("update failed"); + .await?; } } + Ok(()) } #[derive(Serialize, serde::Deserialize, sqlx::FromRow, Clone)] diff --git a/spoolbook-rs/src/reslicing.rs b/spoolbook-rs/src/reslicing.rs index 97e1edb..27b72df 100644 --- a/spoolbook-rs/src/reslicing.rs +++ b/spoolbook-rs/src/reslicing.rs @@ -81,7 +81,9 @@ async fn slice_and_save(pool: &SqlitePool, patched_path: &std::path::Path, displ result } -async fn slice_via_service(patched_path: &std::path::Path) -> Result, String> { +// pub for tests/printer_live.rs's non-sliced -> slice -> print flow against the real +// slicer-service container. Takes any .3mf on disk; the caller patches settings first (or not). +pub async fn slice_via_service(patched_path: &std::path::Path) -> Result, String> { let base_url = std::env::var("RESLICE_SERVICE_URL").unwrap_or_else(|_| "http://localhost:8100".to_string()); let bytes = std::fs::read(patched_path).map_err(|e| e.to_string())?; let file_name = patched_path.file_name().and_then(|n| n.to_str()).unwrap_or("project.3mf").to_string(); diff --git a/spoolbook-rs/src/send_print.rs b/spoolbook-rs/src/send_print.rs index 85a3ccd..9b612c4 100644 --- a/spoolbook-rs/src/send_print.rs +++ b/spoolbook-rs/src/send_print.rs @@ -48,16 +48,18 @@ pub fn build_project_file_payload( remote_file_name: &str, md5: &str, plate_gcode_file_name: &str, - use_ams: bool, - ams_slot: i64, + // Both kept for the wire contract with the frontend's AMS toggle + tray picker, but neither + // is sent: spoolbook's .3mf is re-sliced by the headless BambuStudio CLI, whose gcode carries + // no per-filament AMS-assignment metadata. With that missing, use_ams:true (with or without + // an ams_mapping) fails at HMS 07FF-8012 "Failed to get AMS mapping table" — confirmed + // against a real P2S: only use_ams:false prints. The printer then just feeds from whatever + // filament is threaded (the AMS tray's PTFE, in practice). Real AMS/multi-material support + // needs a file sliced *for* AMS and is a separate feature. + _use_ams: bool, + _ams_slot: i64, is_p2s: bool, submission_id: &str, ) -> String { - let flat_ams_mapping: Vec = if use_ams { vec![ams_slot] } else { vec![] }; - // Global tray ID = ams_id*4 + slot_id (bambuddy's "regular AMS tray" case — spoolbook only - // targets a single onboard AMS unit, not AMS-HT/external-spool/multi-nozzle setups). - let ams_mapping2: Vec = - flat_ams_mapping.iter().map(|t| json!({ "ams_id": t / 4, "slot_id": t % 4 })).collect(); let subtask_name = std::path::Path::new(remote_file_name).file_stem().and_then(|s| s.to_str()).unwrap_or(remote_file_name); json!({ @@ -80,7 +82,9 @@ pub fn build_project_file_payload( // specifically for P2S/N7. "vibration_cali": !is_p2s, "layer_inspect": false, - "use_ams": use_ams, + // Forced false — see the doc comment on the _use_ams param. A re-sliced spoolbook + // .3mf has no AMS filament data, so use_ams:true fails at HMS 07FF-8012. + "use_ams": false, "cfg": "0", "extrude_cali_flag": 2, "extrude_cali_manual_mode": 0, @@ -93,8 +97,16 @@ pub fn build_project_file_payload( "project_id": submission_id, "subtask_id": submission_id, "task_id": submission_id, - "ams_mapping": flat_ams_mapping, - "ams_mapping2": ams_mapping2, + // Deliberately empty, never a per-tray [tray_id]. spoolbook's .3mf is re-sliced by the + // headless BambuStudio CLI, whose gcode omits the per-filament AMS-assignment metadata + // the printer cross-references an incoming mapping against — supplying one (even a + // correct-looking [ams_id*4+slot_id]) fails at HMS 07FF-8012 "Failed to get AMS + // mapping table" (confirmed against a real P2S: empty prints fine, [1] does not). + // Empty tells the printer to feed from its currently-active AMS tray, matching Bambu + // Handy's own single-colour default. True per-tray selection needs a file sliced *for* + // AMS and isn't supported here yet. + "ams_mapping": [], + "ams_mapping2": [], } }) .to_string() diff --git a/spoolbook-rs/tests/printer_live.rs b/spoolbook-rs/tests/printer_live.rs new file mode 100644 index 0000000..c3ceef2 --- /dev/null +++ b/spoolbook-rs/tests/printer_live.rs @@ -0,0 +1,343 @@ +// Hardware-in-the-loop tests. Ignored by default -- they need a real Bambu printer on the LAN, +// powered on and idle. Run them by hand while developing the printer integration: +// +// SPOOLBOOK_TEST_PRINTER_IP=192.168.1.189 \ +// SPOOLBOOK_TEST_PRINTER_ACCESS_CODE=xxxxxxxx \ +// SPOOLBOOK_TEST_PRINTER_SERIAL=AAAAAAAAAAAAAAA \ +// cargo test --test printer_live -- --ignored --nocapture +// +// Every bug these guard against -- the MQTT max-packet-size flap, the FTPS TLS-session-reuse +// gap, a wire-format the firmware silently rejects -- is invisible to a mock: only the real +// device's own protocol behaviour surfaces it. Not wired into CI (no printer there); this is a +// local dev tool, same as pointing the app at the printer and clicking Print, but repeatable +// and green-or-red. + +use spoolbook_rs::printer_camera; +use spoolbook_rs::printer_mqtt::{self, connect_and_subscribe_loop}; +use spoolbook_rs::send_print::{build_project_file_payload, compute_gcode_md5, sanitize_for_printer_filename, submission_id}; +use sqlx::sqlite::SqlitePoolOptions; +use std::time::{Duration, Instant}; + +struct PrinterEnv { + ip: String, + access_code: String, + serial: String, +} + +/// Returns the printer connection details, or `None` (with a printed skip notice) when the env +/// vars aren't set -- so `cargo test --test printer_live -- --ignored` on a machine with no +/// printer is a visible skip, not a hang or a confusing failure. +fn printer_env() -> Option { + let ip = std::env::var("SPOOLBOOK_TEST_PRINTER_IP").ok()?; + let access_code = std::env::var("SPOOLBOOK_TEST_PRINTER_ACCESS_CODE").ok()?; + let serial = std::env::var("SPOOLBOOK_TEST_PRINTER_SERIAL").ok()?; + Some(PrinterEnv { ip, access_code, serial }) +} + +macro_rules! require_printer { + () => { + match printer_env() { + Some(p) => p, + None => { + eprintln!( + "SKIP: set SPOOLBOOK_TEST_PRINTER_IP / _ACCESS_CODE / _SERIAL to run this against a real printer" + ); + return; + } + } + }; +} + +// Seeds printer id 1 so record_reading's job/reading inserts satisfy their FKs -- the loop +// under test writes real telemetry rows the moment the printer reports an active state. +async fn test_pool() -> sqlx::SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("open in-memory db"); + sqlx::migrate!().run(&pool).await.expect("migrations"); + sqlx::query("INSERT INTO printers (id, name, model) VALUES (1, 'live-test', 'P2S')") + .execute(&pool) + .await + .expect("seed printer"); + pool +} + +// The whole point: connect the way the app does, confirm the link comes up, confirm a full +// status object lands (pushall -> gcode_state parsed -- the "No live job data yet" symptom), and +// confirm the loop is still connected 35s later (an oversized pushall packet used to error the +// eventloop right after ConnAck, dropping straight back to "Not connected"). +#[tokio::test] +#[ignore = "needs a real printer on the LAN"] +async fn live_connection_comes_up_and_stays_up() { + let p = require_printer!(); + let pool = test_pool().await; + let store = printer_mqtt::new_store(); + let registry = printer_camera::new_registry(); + + let handle = tokio::spawn(connect_and_subscribe_loop( + 1, + p.ip.clone(), + p.access_code.clone(), + p.serial.clone(), + pool, + store.clone(), + registry, + )); + + // ConnAck within 15s. + let connected = wait_until(Duration::from_secs(15), || { + let store = store.clone(); + async move { printer_mqtt::snapshot(&store, 1).await.connected } + }) + .await; + assert!(connected, "no MQTT ConnAck within 15s -- check IP / access code / that port 8883 is reachable"); + + // A parsed full status (gcode_state) within 30s -- proves pushall was accepted and its + // response fit the packet-size limit. + let has_status = wait_until(Duration::from_secs(30), || { + let store = store.clone(); + async move { printer_mqtt::snapshot(&store, 1).await.gcode_state.is_some() } + }) + .await; + assert!(has_status, "connected but no full status in 30s -- pushall rejected or its response overflowed max_packet_size"); + + // Still up after a while -- no flap. + tokio::time::sleep(Duration::from_secs(35)).await; + assert!( + printer_mqtt::snapshot(&store, 1).await.connected, + "connection dropped within ~35s -- the eventloop is erroring (packet size, keepalive, or the printer closed it)" + ); + + handle.abort(); +} + +// FTPS upload is its own protocol path (implicit TLS on 990, and this printer's vsftpd enforces +// require_ssl_reuse on the data channel -- two rustls FTP stacks failed exactly there). Uploads +// a tiny throwaway file and deletes nothing; a stray 12-byte file in the printer's root is +// harmless and gets overwritten next run. +#[tokio::test] +#[ignore = "needs a real printer on the LAN"] +async fn ftps_upload_roundtrips() { + let p = require_printer!(); + + let local = std::env::temp_dir().join("spoolbook_ftps_probe.txt"); + std::fs::write(&local, b"spoolbook ok").expect("write probe file"); + + let result = spoolbook_rs::send_print::upload_via_ftps( + &p.ip, + &p.access_code, + local.to_str().unwrap(), + "spoolbook_ftps_probe.txt", + ) + .await; + + std::fs::remove_file(&local).ok(); + assert!(result.is_ok(), "FTPS upload failed: {}", result.unwrap_err()); +} + +// The full send path, end to end, against the real printer: upload the .3mf over FTPS, publish +// the `project_file` command, watch `gcode_state` transition into the prep phase, then cancel +// before a gram of filament moves. Guards every wire-format bug at once -- a rejected `use_ams` +// (HMS 07FF-8012 -> never reaches PREPARE), a bad md5, the FTPS session-reuse gap, and the +// `stop` command's sequence_id (a non-numeric one is silently ignored and the print runs). +// +// P2S `project_file` order: gcode_state IDLE -> PREPARE (heat bed + nozzle, auto bed-level) -> +// RUNNING (extrusion). Cancelling in PREPARE wastes nothing. From cold, PREPARE can take a few +// minutes -- SPOOLBOOK_TEST_PRINT_PREP_TIMEOUT_SECS overrides the default 240s. +// +// Triple opt-in on purpose: it heats the printer every run, and if `stop` ever regresses the +// printer WILL start printing for real. Needs, all set: +// SPOOLBOOK_TEST_PRINTER_IP / _ACCESS_CODE / _SERIAL (as above) +// SPOOLBOOK_TEST_PRINT_3MF -- path to a real sliced .3mf with Metadata/.gcode +// SPOOLBOOK_TEST_PRINT_PLATE -- plate gcode name, default "plate_1.gcode" +// SPOOLBOOK_TEST_ALLOW_REAL_PRINT=1 +#[tokio::test] +#[ignore = "sends a real print to the printer (cancelled before extrusion) -- needs SPOOLBOOK_TEST_ALLOW_REAL_PRINT=1"] +async fn send_a_print_then_cancel_before_it_extrudes() { + let p = require_printer!(); + if !allow_real_print() { + return; + } + let Ok(threemf_path) = std::env::var("SPOOLBOOK_TEST_PRINT_3MF") else { + eprintln!("SKIP: set SPOOLBOOK_TEST_PRINT_3MF to a real sliced .3mf on disk"); + return; + }; + let plate = std::env::var("SPOOLBOOK_TEST_PRINT_PLATE").unwrap_or_else(|_| "plate_1.gcode".to_string()); + + assert!( + compute_gcode_md5(&threemf_path, &plate).is_some(), + "no Metadata/{plate} inside {threemf_path} -- is it a sliced export? use reslice_then_print_then_cancel for an unsliced project" + ); + print_3mf_then_cancel(&p, std::path::Path::new(&threemf_path), &plate, prep_timeout()).await; +} + +// The whole user story in one run: an unsliced project .3mf (a MakerWorld download / saved +// project, no Metadata/*.gcode) -> POST it to the real slicer-service container -> get back a +// sliced .3mf -> send that to the printer -> cancel in prep. Catches a slicer-service output the +// LAN `project_file` protocol can't drive (wrong plate name, missing md5 sidecar, a +// project_settings the P2S rejects), which the pre-sliced test can't see. +// +// Extra to the above, all required: +// SPOOLBOOK_TEST_PROJECT_3MF -- path to an UNSLICED project .3mf +// a slicer-service reachable at RESLICE_SERVICE_URL (default http://localhost:8100), e.g. +// docker run -d -p 8100:8100 --platform linux/amd64 ghcr.io/t2vi/spoolbook-slicer:latest +#[tokio::test] +#[ignore = "slices then sends a real print (cancelled before extrusion) -- needs SPOOLBOOK_TEST_ALLOW_REAL_PRINT=1 + slicer-service"] +async fn reslice_then_print_then_cancel() { + let p = require_printer!(); + if !allow_real_print() { + return; + } + let Ok(project_path) = std::env::var("SPOOLBOOK_TEST_PROJECT_3MF") else { + eprintln!("SKIP: set SPOOLBOOK_TEST_PROJECT_3MF to an unsliced project .3mf on disk"); + return; + }; + if !slicer_service_up().await { + eprintln!("SKIP: no slicer-service at {} -- start the spoolbook-slicer container", reslice_base_url()); + return; + } + + // Precondition: the input really is unsliced (otherwise this isn't testing the slice step). + assert!( + compute_gcode_md5(&project_path, "plate_1.gcode").is_none(), + "{project_path} already contains Metadata/plate_1.gcode -- that's a sliced export, not an unsliced project" + ); + + // Slice it through the real service, exactly as reslicing.rs::slice_via_service does. + let sliced_bytes = spoolbook_rs::reslicing::slice_via_service(std::path::Path::new(&project_path)) + .await + .expect("slicer-service call failed"); + let sliced_path = std::env::temp_dir().join(format!("spoolbook-live-sliced-{}.3mf", std::process::id())); + std::fs::write(&sliced_path, &sliced_bytes).expect("write sliced .3mf"); + + // Postcondition: the slice produced a driveable plate gcode. + let has_gcode = compute_gcode_md5(sliced_path.to_str().unwrap(), "plate_1.gcode").is_some(); + if !has_gcode { + std::fs::remove_file(&sliced_path).ok(); + panic!("slicer-service returned a .3mf with no Metadata/plate_1.gcode -- check its --slice/--export flags"); + } + eprintln!("sliced OK ({} bytes) -- sending to printer", sliced_bytes.len()); + + print_3mf_then_cancel(&p, &sliced_path, "plate_1.gcode", prep_timeout()).await; + std::fs::remove_file(&sliced_path).ok(); +} + +fn allow_real_print() -> bool { + if std::env::var("SPOOLBOOK_TEST_ALLOW_REAL_PRINT").as_deref() == Ok("1") { + return true; + } + eprintln!("SKIP: set SPOOLBOOK_TEST_ALLOW_REAL_PRINT=1 -- this heats the printer and starts a (cancelled) print"); + false +} + +fn prep_timeout() -> Duration { + Duration::from_secs( + std::env::var("SPOOLBOOK_TEST_PRINT_PREP_TIMEOUT_SECS").ok().and_then(|s| s.parse().ok()).unwrap_or(240), + ) +} + +fn reslice_base_url() -> String { + std::env::var("RESLICE_SERVICE_URL").unwrap_or_else(|_| "http://localhost:8100".to_string()) +} + +async fn slicer_service_up() -> bool { + reqwest::Client::new() + .get(reslice_base_url()) + .timeout(Duration::from_secs(3)) + .send() + .await + .is_ok() +} + +// Shared tail of both print tests: stand up the telemetry loop, confirm the printer's idle, +// upload + publish `project_file` (use_ams:false), assert it reaches the prep phase, then `stop` +// and assert it actually cancels. Panics with an actionable message at every failure point. +async fn print_3mf_then_cancel(p: &PrinterEnv, threemf_path: &std::path::Path, plate: &str, prep_timeout: Duration) { + let pool = test_pool().await; + let store = printer_mqtt::new_store(); + let registry = printer_camera::new_registry(); + let handle = tokio::spawn(connect_and_subscribe_loop( + 1, + p.ip.clone(), + p.access_code.clone(), + p.serial.clone(), + pool, + store.clone(), + registry, + )); + + // Link up and get one real status before touching anything. + let ready = wait_until(Duration::from_secs(30), || { + let store = store.clone(); + async move { printer_mqtt::snapshot(&store, 1).await.gcode_state.is_some() } + }) + .await; + assert!(ready, "no telemetry within 30s -- can't safely send a print"); + + // Refuse to hijack a print already in progress. + let state = printer_mqtt::snapshot(&store, 1).await.gcode_state.unwrap_or_default(); + assert!( + matches!(state.as_str(), "IDLE" | "FINISH" | "FAILED"), + "printer is busy (gcode_state={state}) -- aborting so this test never interrupts a real job" + ); + + // Upload + publish, same steps as start_print's handler. + let remote = sanitize_for_printer_filename(threemf_path.file_name().unwrap().to_str().unwrap()); + spoolbook_rs::send_print::upload_via_ftps(&p.ip, &p.access_code, threemf_path.to_str().unwrap(), &remote) + .await + .expect("FTPS upload failed"); + let md5 = compute_gcode_md5(threemf_path.to_str().unwrap(), plate) + .unwrap_or_else(|| panic!("no Metadata/{plate} inside {} -- is it a sliced export?", threemf_path.display())); + let payload = build_project_file_payload(&remote, &md5, plate, false, 0, true, &submission_id()); + printer_mqtt::publish_raw(&store, 1, &p.serial, payload).await.expect("publish project_file failed"); + + // Status must move into the prep/print phase -- this is the assertion that the whole send + // was accepted (a rejected payload leaves gcode_state at IDLE or flips it to FAILED). + let started = wait_until(prep_timeout, || { + let store = store.clone(); + async move { + matches!( + printer_mqtt::snapshot(&store, 1).await.gcode_state.as_deref(), + Some("PREPARE") | Some("RUNNING") | Some("SLICING") + ) + } + }) + .await; + let seen = printer_mqtt::snapshot(&store, 1).await.gcode_state.unwrap_or_default(); + assert!(started, "print never started (gcode_state={seen}) -- payload rejected? check stderr for the HMS code"); + eprintln!("print accepted, gcode_state={seen} -- cancelling now"); + + // Cancel. Must actually take. + printer_mqtt::publish_command(&store, 1, &p.serial, "stop").await.expect("stop command failed to publish"); + let stopped = wait_until(Duration::from_secs(90), || { + let store = store.clone(); + async move { + matches!( + printer_mqtt::snapshot(&store, 1).await.gcode_state.as_deref(), + Some("IDLE") | Some("FAILED") | Some("FINISH") + ) + } + }) + .await; + let end_state = printer_mqtt::snapshot(&store, 1).await.gcode_state.unwrap_or_default(); + handle.abort(); + assert!(stopped, "STOP did not cancel the print within 90s (gcode_state={end_state}) -- printer is now printing for real, go stop it by hand"); +} + +/// Polls `check` every 500ms until it returns true or `timeout` elapses. +async fn wait_until(timeout: Duration, mut check: F) -> bool +where + F: FnMut() -> Fut, + Fut: std::future::Future, +{ + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if check().await { + return true; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + false +} diff --git a/spoolbook-rs/tests/send_print.rs b/spoolbook-rs/tests/send_print.rs index 1731951..627f46e 100644 --- a/spoolbook-rs/tests/send_print.rs +++ b/spoolbook-rs/tests/send_print.rs @@ -56,7 +56,9 @@ fn compute_gcode_md5_returns_none_when_the_plate_entry_is_missing() { } #[test] -fn build_project_file_payload_maps_ams_slot_to_a_regular_tray_and_forces_vibration_cali_off_for_p2s() { +fn build_project_file_payload_never_sends_ams_and_forces_vibration_cali_off_for_p2s() { + // Even with use_ams=true + a slot requested, the payload disables AMS and sends no mapping: + // a re-sliced spoolbook .3mf has no AMS filament data, so use_ams:true fails HMS 07FF-8012. let payload = build_project_file_payload("print.3mf", "abc123", "plate_1.gcode", true, 5, true, "42"); let v: Value = serde_json::from_str(&payload).unwrap(); @@ -67,11 +69,10 @@ fn build_project_file_payload_maps_ams_slot_to_a_regular_tray_and_forces_vibrati assert_eq!(print["url"], "ftp:///print.3mf"); assert_eq!(print["file"], "print.3mf"); assert_eq!(print["md5"], "abc123"); - assert_eq!(print["use_ams"], true); + assert_eq!(print["use_ams"], false); assert_eq!(print["vibration_cali"], false, "P2S doesn't support vibration cali"); - assert_eq!(print["ams_mapping"], serde_json::json!([5])); - // Global tray 5 = ams_id 1, slot_id 1 (5 / 4 = 1, 5 % 4 = 1). - assert_eq!(print["ams_mapping2"], serde_json::json!([{ "ams_id": 1, "slot_id": 1 }])); + assert_eq!(print["ams_mapping"], serde_json::json!([])); + assert_eq!(print["ams_mapping2"], serde_json::json!([])); assert_eq!(print["project_id"], "42"); assert_eq!(print["subtask_id"], "42"); assert_eq!(print["task_id"], "42"); diff --git a/spoolbook-web-svelte/src/lib/components/printers/PrinterCard.svelte b/spoolbook-web-svelte/src/lib/components/printers/PrinterCard.svelte index cd2c390..4ccb532 100644 --- a/spoolbook-web-svelte/src/lib/components/printers/PrinterCard.svelte +++ b/spoolbook-web-svelte/src/lib/components/printers/PrinterCard.svelte @@ -198,16 +198,16 @@ Status
+

{connected ? 'Connected' : 'Not connected'}