Print control (pause/resume/stop) and print start are now both confirmed
against the P1P on the LAN, and doing that turned up three real defects.
`start_uploaded_file` published fire-and-forget, so the printer's reply was
thrown away. Asking it to print a file it does not have returned `Ok(())`,
and watching `print.push_status` could not tell that apart from a successful
start, since both leave the machine IDLE for a while. The printer does in
fact answer - `{"command":"gcode_file","param":"...","reason":"error
string","result":"fail"}` - and it echoes the `sequence_id` we send, so
`request` correlates it. Both this and `control_print` now go out as
correlated requests and run the reply through `print_command_outcome`.
The upload and the print command disagreed about where files live. `upload`
cwd'd into `cache/` while the printable projects are in the card's root: the
root holds 43 `<name>.gcode.3mf` archives, `cache/` holds the printer's own
extracted `<project>_plate_N.gcode` working copies with `.gcode.bbl`
sidecars. Those extracted copies are not independently printable -
`gcode_file` with `param: "/cache/Cube_plate_1.gcode"` was rejected for a
file the FTPS listing shows is right there - while `project_file` with
`url: "ftp:///Cube.gcode.3mf"` started a print. So the destination is now a
parameter and both halves point at `SD_ROOT`.
What the hardware settled, recorded in the doc comments: the `ftp:///` url
spelling is right, `param: "Metadata/plate_1.gcode"` works for a
single-plate project, `use_ams: false` + `ams_mapping: ""` is accepted, and
a stop during PREPARE/RUNNING moves the printer to FAILED, which is how
Bambu reports an operator cancellation. What it did not settle: all three
control verbs answer `result: "success"` on an *idle* printer, so the reply
means "accepted", not "the state changed" - callers still have to read
`gcode_state` back.
`PrinterCommand::ControlPrint` makes this reachable from the cloud at all;
until now pause/resume/stop existed in the proxy with no way to send them.
Still no start-print variant, now for a different reason: the path around it
is missing, not the hardware proof.
Also: `list_dir` for any directory (how the layout above was found, without
sending a print command to guess), `request_raw` for probing an unconfirmed
command shape, and two examples - `bambu_cache_list` (read-only) and
`bambu_print_probe` (gated steps, `start` needing both IDLE and
--yes-start-a-real-print).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
8.1 KiB
Rust
178 lines
8.1 KiB
Rust
//! The other half of the `/ws/edge/v1` contract test.
|
|
//!
|
|
//! `continuum-backend/tests/edge_protocol.test.ts` checks that what this
|
|
//! gateway sends is what the backend accepts. This checks the reverse: that
|
|
//! every message the backend sends is one this gateway can actually read, and
|
|
//! that what this gateway writes matches the schema it claims to follow.
|
|
//!
|
|
//! Worth having on both sides because the two ends had drifted into entirely
|
|
//! different protocols - a handshake the gateway never sent, telemetry shaped
|
|
//! one printer per message instead of a batch, printer ids a gateway has no
|
|
//! way to know - and nothing failed anywhere. Both test files are hand-kept
|
|
//! mirrors; the canonical shape is
|
|
//! `continuum-schemas/schemas/continuum/v1/edge/`.
|
|
|
|
use continuum_proxy::printer::PrintControl;
|
|
use continuum_proxy::uplink::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage};
|
|
|
|
/// Verbatim from the backend's `src/routes/ws/edge.ts`. If the backend
|
|
/// changes one of these, this test is where it should break.
|
|
const BACKEND_MESSAGES: &[&str] = &[
|
|
r#"{"type":"hello_ack","session_id":"3f2b1c5e-0000-4000-8000-000000000000"}"#,
|
|
r#"{"type":"hello_error","message":"invalid edge token"}"#,
|
|
r#"{"type":"heartbeat_ack","seq":7}"#,
|
|
r#"{"type":"command","command_id":"9a7d2f10-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"set_fan_speed","percent":40}}"#,
|
|
r#"{"type":"command","command_id":"9a7d2f11-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"send_gcode_line","line":"M106 S0"}}"#,
|
|
r#"{"type":"command","command_id":"9a7d2f12-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"pause"}}"#,
|
|
r#"{"type":"command","command_id":"9a7d2f13-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"resume"}}"#,
|
|
r#"{"type":"command","command_id":"9a7d2f14-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"stop"}}"#,
|
|
];
|
|
|
|
#[test]
|
|
fn every_backend_message_deserializes() {
|
|
for raw in BACKEND_MESSAGES {
|
|
let parsed: Result<ServerMessage, _> = serde_json::from_str(raw);
|
|
assert!(parsed.is_ok(), "backend sends this and the gateway cannot read it:\n {raw}\n {:?}", parsed.err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_command_arrives_with_its_payload_intact() {
|
|
let raw = r#"{"type":"command","command_id":"cmd-1","printer_id":"p1p","command":{"action":"set_fan_speed","percent":40}}"#;
|
|
match serde_json::from_str::<ServerMessage>(raw).expect("should parse") {
|
|
ServerMessage::Command { command_id, printer_id, command } => {
|
|
assert_eq!(command_id, "cmd-1");
|
|
// The gateway's own id from printers.toml, not a cloud UUID - the
|
|
// backend translates on the way out.
|
|
assert_eq!(printer_id, "p1p");
|
|
match command {
|
|
PrinterCommand::SetFanSpeed { percent } => assert_eq!(percent, 40),
|
|
other => panic!("wrong command variant: {other:?}"),
|
|
}
|
|
}
|
|
other => panic!("wrong message variant: {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Each control verb has to arrive as the right `PrintControl`, not merely
|
|
/// parse. A silent mis-mapping here would send a stop where a pause was
|
|
/// asked for, which on a real machine is the difference between a print that
|
|
/// can be resumed and one that is over.
|
|
#[test]
|
|
fn each_print_control_verb_maps_to_its_own_action() {
|
|
for (verb, expected) in [
|
|
("pause", PrintControl::Pause),
|
|
("resume", PrintControl::Resume),
|
|
("stop", PrintControl::Stop),
|
|
] {
|
|
let raw = format!(
|
|
r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"control_print","control":"{verb}"}}}}"#
|
|
);
|
|
match serde_json::from_str::<ServerMessage>(&raw).expect("should parse") {
|
|
ServerMessage::Command { command: PrinterCommand::ControlPrint { control }, .. } => {
|
|
assert_eq!(control, expected, "'{verb}' mapped to the wrong action");
|
|
// And the wire word the vendor layer sends has to match the
|
|
// wire word that arrived - `as_str` is what builds the Bambu
|
|
// command name.
|
|
assert_eq!(control.as_str(), verb);
|
|
}
|
|
other => panic!("wrong variant for '{verb}': {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// "cancel" is the trap: it is Moonraker's word, and the Klipper
|
|
/// implementation behind `control_print` really does send a cancel - but the
|
|
/// wire word is "stop". A verb only one vendor recognises must be rejected
|
|
/// here rather than reaching a gateway that cannot map it.
|
|
#[test]
|
|
fn a_vendor_specific_control_verb_is_rejected() {
|
|
for verb in ["cancel", "abort", "Pause", ""] {
|
|
let raw = format!(
|
|
r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"control_print","control":"{verb}"}}}}"#
|
|
);
|
|
assert!(
|
|
serde_json::from_str::<ServerMessage>(&raw).is_err(),
|
|
"'{verb}' parsed as a print control verb and should not have"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The variant that is deliberately absent - still, but no longer for the
|
|
/// original reason. The print-start path *has* now been confirmed on a real
|
|
/// P1P (2026-10-11); what is missing is the path around it, since nothing
|
|
/// uploads a sliced file to a gateway and nothing decides which printer runs
|
|
/// which job. Delete this test when there is something to start.
|
|
#[test]
|
|
fn there_is_no_start_print_command() {
|
|
let raw = r#"{"type":"command","command_id":"x","printer_id":"p1p","command":{"action":"start_print","file_name":"plate_1.3mf"}}"#;
|
|
assert!(
|
|
serde_json::from_str::<ServerMessage>(raw).is_err(),
|
|
"a start-print command parsed - is there a job path to drive it yet?"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_messages_are_rejected_rather_than_silently_accepted() {
|
|
// The old protocol. A gateway that accepted this would ack a handshake
|
|
// that never happened.
|
|
assert!(serde_json::from_str::<ServerMessage>(r#"{"type":"auth_ok","farmId":"f"}"#).is_err());
|
|
}
|
|
|
|
/// The bug this test exists for: serde emits `"job_name": null` for an empty
|
|
/// `Option` unless told not to, and the backend's validator reads a missing
|
|
/// key and an explicit null as different things. An idle printer has no job
|
|
/// name, so every batch containing one was rejected whole.
|
|
#[test]
|
|
fn empty_telemetry_fields_are_omitted_not_null() {
|
|
let msg = ClientMessage::Telemetry {
|
|
printers: vec![PrinterTelemetry {
|
|
printer_id: "p1p".into(),
|
|
name: "P1P".into(),
|
|
state: Some("IDLE".into()),
|
|
percent: Some(0),
|
|
layer: Some(0),
|
|
total_layers: Some(0),
|
|
remaining_minutes: Some(0),
|
|
job_name: None,
|
|
nozzle_temp: Some(23.375),
|
|
nozzle_target: Some(0.0),
|
|
bed_temp: Some(22.40625),
|
|
bed_target: Some(0.0),
|
|
print_error: Some(0),
|
|
active_alerts: Some(0),
|
|
}],
|
|
};
|
|
|
|
let json = serde_json::to_string(&msg).expect("should serialize");
|
|
assert!(!json.contains("null"), "telemetry should omit empty fields, got:\n {json}");
|
|
assert!(!json.contains("job_name"), "an absent job name should not appear at all");
|
|
// The fields that *do* have values must still be there - an assertion on
|
|
// the absence of "null" alone would pass on an empty object.
|
|
assert!(json.contains(r#""state":"IDLE""#));
|
|
assert!(json.contains(r#""bed_temp":22.40625"#));
|
|
}
|
|
|
|
#[test]
|
|
fn a_successful_command_result_omits_the_error() {
|
|
let json = serde_json::to_string(&ClientMessage::CommandResult {
|
|
command_id: "cmd-1".into(),
|
|
ok: true,
|
|
error: None,
|
|
})
|
|
.expect("should serialize");
|
|
assert_eq!(json, r#"{"type":"command_result","command_id":"cmd-1","ok":true}"#);
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_command_result_carries_the_reason() {
|
|
let json = serde_json::to_string(&ClientMessage::CommandResult {
|
|
command_id: "cmd-1".into(),
|
|
ok: false,
|
|
error: Some("no printer with id 'nope' in this gateway's fleet".into()),
|
|
})
|
|
.expect("should serialize");
|
|
assert!(json.contains(r#""ok":false"#));
|
|
assert!(json.contains("no printer with id 'nope'"));
|
|
}
|