Files
continuum-proxy/src/uplink/protocol.rs
T
octoturge 901d5e9748 Execute start_print, and never await it on the uplink task
The gateway can now run a cloud-dispatched print: download the job file from
the presigned URL in the command, then hand it to the existing
`upload_project_file`, which derives the filament mapping from the archive,
transfers it over FTPS and starts it.

## The download refuses to be a weapon

`src/uplink/download.rs` checks the HTTP status before reading a byte,
because an expired presigned URL answers 403 with an XML error document and
writing *that* to a printer as a project is the worst outcome available. It
rejects a `Content-Length` over 256 MiB before reading, and enforces the cap
while streaming as well, so a server that lies or omits the length cannot
make a small gateway box buffer until the OOM killer arrives. A byte count
that disagrees with `Content-Length` is an error: a `.3mf` is a zip, and a
truncated one loses the central directory the printer needs. HTTPS only, no
redirects, and the URL is stripped from error messages.

`PrinterCommand` no longer derives `Debug` for the same reason - `dispatch`
logs every command, and a presigned URL is a bearer credential until it
expires. It prints as `<redacted>`.

## Why it is spawned

`start_print` is the one command that cannot be answered inline, and the
reason is not latency. Everything else here is an MQTT publish returning in
milliseconds. This downloads tens of megabytes and then transfers them at a
measured 0.2 MiB/s - about four minutes for a 50 MB project - and awaiting
that inside the uplink's `select!` loop stops heartbeats and, far worse,
stops telemetry for the duration.

No telemetry is what makes spawning mandatory rather than tidy. The cloud now
derives a job's state from the telemetry that follows a start, so a gateway
that goes silent for four minutes during every job start withholds exactly
the information the job depends on - while the backend times the command out
after ten seconds and concludes the start failed. The job would be requeued
onto a printer that was busy printing it.

So the `ok` means "accepted, working on it", which is all a start reply was
ever trusted to mean. `runs_for_minutes` is an exhaustive match rather than a
`matches!`, so a new variant has to make that choice instead of defaulting to
the stall.

The gap this leaves is honest and recorded in the code: a failure after the
ack has no `CommandResult` to travel on, because answering later needs an
outbound channel this loop does not have. Until it does, the gateway's log is
where the reason lives and the backend reaps the job.

reqwest 0.12 with `rustls-tls-native-roots`, not 0.13: 0.13 pulls
`webpki-root-certs` under CDLA-Permissive-2.0, which is not on the allow
list, and `aws-lc` brings OpenSSL-licensed C. `cargo deny check licenses`
passes. 54 tests.
2026-10-15 22:53:47 +00:00

204 lines
9.9 KiB
Rust

use serde::{Deserialize, Serialize};
use crate::printer::PrintControl;
/// What this gateway sends up to `continuum-backend`'s `/ws/edge/v1` route.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
Hello { gateway_id: String, farm_id: String, token: String },
Heartbeat { seq: u64 },
/// One snapshot per printer, on a timer. Sent as a batch rather than a
/// message per printer: a farm has one uplink and many printers, and the
/// cloud wants a coherent picture of the farm at an instant more than it
/// wants the lowest possible latency per machine.
Telemetry { printers: Vec<PrinterTelemetry> },
/// The reply to a `ServerMessage::Command`. Correlated by `command_id`,
/// which the *cloud* generates - unlike Bambu's `sequence_id`, we're
/// both ends of this protocol, so the side that needs the correlation
/// gets to choose it.
CommandResult {
command_id: String,
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
}
/// A flattened snapshot of one printer, in the terms an operator console
/// actually displays - deliberately not Bambu's `print.push_status` shape.
///
/// Two reasons for the translation rather than forwarding the raw report:
/// this has to describe a Prusa or a Klipper printer too, and Bambu's wire
/// format has traps that should not reach the rest of the system (fan
/// speeds are 0-15 gears, temperatures arrive as either integers or floats,
/// and v2 reuses one `sequence_id` forever - see `PrintReport`).
///
/// Every field is optional because an unreachable or just-connected printer
/// genuinely has nothing to report, and "no data yet" is different from
/// "zero".
///
/// Absent fields are *omitted* rather than serialized as `null`, which is
/// what `skip_serializing_if` below is for. Caught by the contract test
/// against real captures: serde's default is to emit `"job_name": null`, and
/// the backend's validator reads a missing key and an explicit null as
/// different things, so an idle printer's entire telemetry batch was being
/// rejected. The backend now accepts either, but this end sends only the one
/// the schema describes.
#[derive(Debug, Clone, Serialize)]
pub struct PrinterTelemetry {
/// Our own id from printers.toml, not the vendor serial - this is what
/// the cloud's `printers` table keys on.
pub printer_id: String,
pub name: String,
/// `"IDLE"`, `"RUNNING"`, `"PAUSE"`, `"FINISH"`, `"FAILED"` - passed
/// through as the printer's own string rather than narrowed to an enum,
/// so an unrecognised state reaches the cloud as itself instead of being
/// dropped on the floor here.
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub percent: Option<i32>,
/// What the printer is doing when it is not laying down plastic - bed
/// levelling, nozzle cleaning, extrusion calibration. Bambu's `stg_cur`,
/// passed through as the printer's own number rather than decoded,
/// because the code-to-name table is not something we have. 0 means
/// printing.
///
/// Here because progress without it is ambiguous in a way that reads as
/// a fault. Confirmed on a P1P (2026-10-11): `percent` reached 14% while
/// `layer` stayed 0 for minutes, the printer walking `stg_cur` 54 -> 14
/// -> 13 -> 0 before starting layer 1.
#[serde(skip_serializing_if = "Option::is_none")]
pub stage: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_layers: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remaining_minutes: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nozzle_temp: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nozzle_target: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bed_temp: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bed_target: Option<f32>,
/// Non-zero means the printer is reporting a fault.
#[serde(skip_serializing_if = "Option::is_none")]
pub print_error: Option<i32>,
/// How many Health-Management-System entries the printer currently has
/// active. Just the count for now - decoding the `{attr, code}` pairs
/// into messages needs Bambu's HMS tables, which we don't have yet.
#[serde(skip_serializing_if = "Option::is_none")]
pub active_alerts: Option<usize>,
}
/// What `continuum-backend` sends back over the same connection.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
HelloAck { session_id: String },
/// The handshake was rejected - a bad, expired or wrong-farm token. The
/// backend closes the connection right after sending this.
///
/// Worth a variant of its own rather than letting it fall into the
/// unparseable-message branch: retrying a rejected token every few seconds
/// forever looks exactly like a network problem in the logs otherwise, and
/// it is the one uplink failure a human has to go fix by hand.
HelloError { message: String },
HeartbeatAck { seq: u64 },
/// Do something to one printer. `printer_id` is our own id from
/// printers.toml.
Command { command_id: String, printer_id: String, command: PrinterCommand },
}
/// The closed set of things the cloud is allowed to ask a printer to do.
///
/// Deliberately small. `StartPrint` is the newest and the only one that
/// reaches outside the gateway (it fetches a URL), so it is the one with
/// bounds on it - see `download.rs`.
#[derive(Clone, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum PrinterCommand {
/// 0-100. Each vendor converts to whatever its own mechanism wants (for
/// Bambu, an `M106` PWM value over MQTT).
SetFanSpeed { percent: u8 },
/// A single raw gcode line. The most powerful variant by far, and the
/// one to be most careful about ever exposing to a UI.
SendGcodeLine { line: String },
/// Pause, resume or stop whatever the printer is currently running.
///
/// One variant carrying an action rather than three variants, matching
/// `PrintControl` and `GenericPrinter::control_print` - the alternative
/// was three near-identical arms here and three near-identical methods
/// across four vendor implementations.
///
/// `Stop` is a cancel and is not undoable. Confirmed live on a P1P: a
/// stop during `PREPARE`/`RUNNING` moves the printer to `FAILED`, which
/// is how Bambu reports an operator-cancelled print.
ControlPrint { control: PrintControl },
/// Download a job's file, put it on the printer and start it.
///
/// A successful result means the gateway *accepted* the job, not that
/// anything is printing: a real run on a P1P was accepted, uploaded
/// byte-perfect, reached RUNNING, then paused itself on a filament
/// fault. Job state must follow the telemetry, never this reply.
///
/// There is no AMS or filament field on purpose. The mapping is read out
/// of the archive's own `Metadata/slice_info.config` by
/// `upload_project_file`, because only the archive knows what each plate
/// was sliced for - guessing it is what heated a P1P to 250C and paused
/// it at 1%.
///
/// `url` is a presigned, expiring GET, minted at dispatch time (a queued
/// job may wait days). It is a URL and not the bytes so the file stays
/// off the JSON control channel and object-store credentials stay out of
/// every gateway. It is a bearer credential, which is why `Debug` below
/// hides it. `file_name` is what the file is called on the printer;
/// `.3mf` is treated as a sliced project, anything else as bare gcode.
/// `job_id` is only for lining gateway logs up against a cloud job.
StartPrint { job_id: String, file_name: String, url: String },
}
impl PrinterCommand {
/// Whether this command takes long enough that it must not be awaited on
/// the uplink's own task.
///
/// The threshold is not arbitrary: everything but `StartPrint` is a single
/// MQTT publish, while `StartPrint` downloads a project and then transfers
/// it to a printer at a measured 0.2 MiB/s - minutes, during which the
/// uplink would send no telemetry at all. See `spawn_long_command`.
pub fn runs_for_minutes(&self) -> bool {
// Written as an exhaustive match rather than `matches!`, so a new
// variant has to make this decision rather than defaulting to
// "answer it inline" and quietly reintroducing the stall.
match self {
Self::StartPrint { .. } => true,
Self::SetFanSpeed { .. } | Self::SendGcodeLine { .. } | Self::ControlPrint { .. } => false,
}
}
}
/// Hand-written so the presigned `url` never reaches a log line: `dispatch`
/// logs every command with `{:?}`, and the query string of that URL is
/// enough to download the job file until it expires.
impl std::fmt::Debug for PrinterCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SetFanSpeed { percent } => f.debug_struct("SetFanSpeed").field("percent", percent).finish(),
Self::SendGcodeLine { line } => f.debug_struct("SendGcodeLine").field("line", line).finish(),
Self::ControlPrint { control } => f.debug_struct("ControlPrint").field("control", control).finish(),
Self::StartPrint { job_id, file_name, .. } => f
.debug_struct("StartPrint")
.field("job_id", job_id)
.field("file_name", file_name)
.field("url", &"<redacted>")
.finish(),
}
}
}