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 { /// The handshake, carrying no credential. /// /// The edge token goes in an `Authorization: Bearer` header on the /// upgrade request instead - see `connect_and_serve`. It used to be a /// `token` field right here, which worked but meant the backend had an /// open socket and a parsed message from a peer that had proved nothing. /// /// `farm_id` stays, even though the token already carries the farm: the /// backend checks the two against each other rather than trusting this /// one, so sending it is how a configuration mismatch gets caught /// instead of silently working against the wrong farm. Hello { gateway_id: String, farm_id: String, /// Where a browser on this LAN can reach the gateway's camera /// streams, if the operator has configured one. See /// `Config::stream_base_url`. /// /// Reported in the handshake rather than per-printer in telemetry /// because it is a property of the gateway, and repeating it in /// every snapshot of every printer would be the same string sent /// hundreds of times an hour. #[serde(skip_serializing_if = "Option::is_none")] stream_base_url: Option, }, 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, }, /// go2rtc's answer SDP for a `WebrtcOffer`, passed through verbatim. WebrtcAnswer { session_id: String, sdp: String }, /// No answer was obtainable: no such camera here, go2rtc not running, or /// it refused the offer. /// /// Its own variant rather than an empty answer, because the cloud is /// waiting on this and a viewer otherwise watches a video element that /// never loads with nothing anywhere saying why. WebrtcError { session_id: String, message: String }, /// Printers found on this gateway's own network and USB ports that are /// *not* in its fleet file, so the console can offer them instead of /// making an operator walk to a machine and read its IP address off the /// screen. /// /// A whole scan result that replaces the last one, not a set of /// additions: a printer that has gone is reported by its absence. An /// empty list is therefore meaningful - it says the gateway looked and /// found nothing, which is different from never having sent one of these /// at all, and a gateway with discovery off (the default) never does. DiscoveredPrinters { printers: Vec, scanned_at: String, }, } /// One candidate, carrying no credential - and it is worth being precise /// about why not, because this is the field a reader looks for. /// /// It is not omitted for tidiness. No probe can learn one: a Bambu access /// code is displayed on the printer's own screen and a PrusaLink API key is /// in its web UI, and every identity endpoint that would reveal either needs /// the credential first. So discovery shortens the operator's job to typing /// one secret, rather than a secret plus an address plus a serial number - /// and the backend's schema refuses a candidate that carries a credential, /// so this staying empty is enforced at the far end too. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct DiscoveredPrinter { /// The `vendor` string this would need in a fleet file. pub vendor: &'static str, /// An IP or hostname, or a device path like `/dev/ttyACM0` for a serial /// candidate. Named `address` rather than `host` because half of them /// are not hosts. pub address: String, #[serde(skip_serializing_if = "Option::is_none")] pub serial: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub detail: Option, /// A vendor this could equally be; the operator settles it. Today only /// `prusa_link` against `octoprint`, which nothing observable separates. #[serde(skip_serializing_if = "Option::is_none")] pub ambiguous_with: Option<&'static str>, pub source: &'static str, /// Whether this build can actually drive the vendor. False is still /// worth reporting - it answers "what is on this network?" - but the /// console has to say so, or an entry looks finished when adding it /// would produce a printer that connects and does nothing. pub driver_ready: bool, } /// 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, /// The go2rtc stream name for this printer's camera, if it has one that /// can actually be played. Combined with the farm's `stream_base_url` /// this is everything a browser needs. /// /// Absent is the common case, not the exception: only the X1/H2 class /// publishes an RTSP URL. A P1 or A1 reports that it *has* a camera /// without giving anything to connect to, and this stays `None` for /// those - a UI should offer a player only when this is present. See /// `crate::camera`. #[serde(skip_serializing_if = "Option::is_none")] pub camera_stream: 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 }, /// Relay a browser's WebRTC offer to this gateway's go2rtc and answer /// with the SDP it returns. **Signalling only - no video on this /// socket.** /// /// Media goes browser-to-go2rtc directly over ICE, which is the whole /// reason only the signalling is relayed: on a workshop LAN the video /// never leaves the building, and it costs the cloud no bandwidth at /// all. go2rtc's answer carries a server-reflexive candidate alongside /// its host ones, so even a remote viewer can often reach the gateway /// without a relay. /// /// The browser cannot do this part itself. go2rtc sends no CORS headers /// and checks its password before CORS, so the unauthenticated preflight /// a browser always sends is refused - measured, not assumed. WebrtcOffer { session_id: String, camera_stream: String, sdp: String }, } /// 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(), } } }