The gateway could already find printers; only a human reading a terminal could see the result. So adding a printer in the web console still began with walking to a machine to read its IP address off the screen. Now a background task scans on a timer and sends a `discovered_printers` frame. It cannot finish the job - the credential is on that screen and no probe can read it - but it removes the address, the serial and the guesswork about which vendor the thing is. OFF by default, which is the load-bearing part of this commit. A /24 sweep is 254 TLS handshakes and up to three HTTP requests per host, and nothing watching can tell that from a port scan; on a corporate network, being indistinguishable from a port scan is itself the problem. A gateway that did this by default would scan networks its operator never meant to point it at, and the first they would hear of it is from their own security team. So: - `discovery.enabled` is false unless set, per gateway, in that gateway's own config. The cloud cannot switch it on. A setting whose whole purpose is to bound what this process does to the local network has to be decided where that network is understood, which is not in a data centre. - `subnets` is explicit, never derived from this host's interfaces, so a second NIC, a VPN or a container bridge cannot widen the sweep. Anything wider than a /24 is still refused, now at config-import time rather than when the timer first fires. - A flag that is neither `true` nor `false` is an error, not a shrug. The two readings of a typo are not equally safe. - Naming a subnet without setting `enabled` scans nothing - the safe reading of an incomplete instruction - and `enabled` with nowhere to look is refused as the mistake it is. - The env var can switch it *off* over a file that enables it, which is the direction that has to work: a Proxy Box's config is signed in the cloud, so an operator who finds their gateway probing cannot just edit it. What a scheduled scan will not do, unlike the example CLI: send `M115`. Opening a serial port toggles DTR, which reboots an Arduino-based board and ruins a print in progress. The example can do it behind `--yes-reset-the-printer` because a human is standing there; a timer has nobody to ask, so it does not get the option and reads sysfs instead. Two things that took more care than expected: `Scan::looked()`. An empty report means "this gateway looked and its network has no unconfigured printers", so sending one after every probe failed would state something untrue about the farm. A partial failure is still evidence, though - withholding a good sweep because a container can never bind SSDP would suppress it forever - so the test is "did anything succeed", not "did everything". `fleet::configured()` reads the fleet file at parse level rather than through `load`, because the entry it most needs to recognise is the one `load` refuses: a printer discovery suggested, pasted in, with the access code not yet typed in. Re-offering that printer as a fresh find is noise at the one moment the operator is mid-way through adding it. Verified against the real LAN: a sweep found both printers by TLS certificate with correct serials and no credentials, and the frame it produced validates against the backend's EdgeClientMessageSchema. The `wire` subcommand on the example prints exactly that frame, and the capture is pinned as a backend fixture. SSDP remains untestable here - this container is a router hop from the printers and multicast does not cross one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
315 lines
16 KiB
Rust
315 lines
16 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 {
|
|
/// 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<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>,
|
|
},
|
|
/// 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<DiscoveredPrinter>,
|
|
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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub model: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub name: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub detail: Option<String>,
|
|
/// 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<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>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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", &"<redacted>")
|
|
.finish(),
|
|
}
|
|
}
|
|
}
|