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 }, /// 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, }, } /// 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, #[serde(skip_serializing_if = "Option::is_none")] pub percent: Option, /// 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, #[serde(skip_serializing_if = "Option::is_none")] pub layer: Option, #[serde(skip_serializing_if = "Option::is_none")] pub total_layers: Option, #[serde(skip_serializing_if = "Option::is_none")] pub remaining_minutes: Option, #[serde(skip_serializing_if = "Option::is_none")] pub job_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub nozzle_temp: Option, #[serde(skip_serializing_if = "Option::is_none")] pub nozzle_target: Option, #[serde(skip_serializing_if = "Option::is_none")] pub bed_temp: Option, #[serde(skip_serializing_if = "Option::is_none")] pub bed_target: Option, /// Non-zero means the printer is reporting a fault. #[serde(skip_serializing_if = "Option::is_none")] pub print_error: Option, /// 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, } /// 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", &"") .finish(), } } }