From 0c8ad9cc3d668d487f99df5e1ad01a3b0f44057e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Thu, 29 Oct 2026 15:55:21 +0000 Subject: [PATCH] Serve P1/A1 camera frames over the uplink A P1 or A1 has no RTSP endpoint; its camera is a TLS stream on port 6000. bambu_camera logs in and reads one JPEG frame, and the uplink answers a camera_snapshot_request with it. Telemetry advertises camera_snapshot for printers that report a camera block but no RTSP URL. --- src/camera.rs | 15 +++- src/printer/bambu.rs | 37 ++++++++++ src/printer/bambu_camera.rs | 134 ++++++++++++++++++++++++++++++++++++ src/printer/mod.rs | 12 +++- src/uplink/mod.rs | 54 +++++++++++++++ src/uplink/protocol.rs | 18 +++++ tests/uplink_protocol.rs | 27 ++++++++ 7 files changed, 293 insertions(+), 4 deletions(-) create mode 100644 src/printer/bambu_camera.rs diff --git a/src/camera.rs b/src/camera.rs index 6b63f73..e39b6c0 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -18,9 +18,10 @@ //! Only the X1/H2 class publishes an `rtsp_url`. Confirmed against two real //! machines: an H2C reports `rtsps://:322/streaming/live/1`, and a P1P //! reports an `ipcam` block with no URL in it at all. A P1/A1 camera is -//! reachable only over Bambu's proprietary port-6000 JPEG stream, which this -//! crate does not speak, so those printers get no stream here and say so -//! rather than advertising one that will not play. +//! reachable only over Bambu's proprietary port-6000 JPEG stream. That is +//! not restreamable here (go2rtc cannot make WebRTC of MJPEG without ffmpeg), +//! so those printers get no go2rtc stream; they are served single frames by +//! `printer::bambu_camera` instead and advertise `camera_snapshot`. //! //! That is not just an inference from the missing field - it is confirmed at //! the network layer (2026-10-15). The H2C accepts TCP on 322; the P1P @@ -131,6 +132,14 @@ pub fn source_for(printer: &PrinterHandle, report_url: Option<&str>) -> Option) -> bool { + matches!(printer, PrinterHandle::Bambu(_)) && has_ipcam && report_url.is_none_or(str::is_empty) +} + /// Renders a go2rtc configuration. /// /// Hand-written YAML rather than a serializer, for the same reason the diff --git a/src/printer/bambu.rs b/src/printer/bambu.rs index 7afc039..c27ccea 100644 --- a/src/printer/bambu.rs +++ b/src/printer/bambu.rs @@ -207,6 +207,28 @@ pub enum BambuProtocolPreference { } // endregion: BambuMQTTProtocol +/// One pending camera read for a P1/A1; see `bambu_camera`. Holds the access +/// code, so it has no `Debug`. +pub struct SnapshotJob { + name: String, + host: String, + access_code: String, + connector: native_tls::TlsConnector, +} + +impl SnapshotJob { + /// One JPEG frame from the printer's port-6000 camera. + pub async fn run(self) -> Result, PrinterError> { + let name = self.name; + tokio::task::spawn_blocking(move || { + super::bambu_camera::snapshot(&self.host, &self.access_code, self.connector, std::time::Duration::from_secs(8)) + }) + .await + .map_err(|err| PrinterError(format!("{name}: camera thread failed: {err}")))? + .map_err(|err| PrinterError(format!("{name}: camera: {err}"))) + } +} + /// A Bambu printer. Only one struct now, not two: the old /// `BambuGenericPrinter` (shared fields/behavior) + `BambuV1Printer`/ /// `BambuV2Printer` (thin per-version wrappers) split existed to let two @@ -934,6 +956,21 @@ impl BambuPrinter { .map_err(|err| PrinterError(format!("{}: FTPS download failed: {err}", self.base.name))) } + /// Everything needed to fetch one camera frame, owned, so the caller can + /// drop the fleet lock before the network round trip. + pub fn snapshot_job(&self) -> Result { + let connector = self + .tls + .build_connector() + .map_err(|err| PrinterError(format!("{}: TLS setup failed: {err}", self.base.name)))?; + Ok(SnapshotJob { + name: self.base.name.clone(), + host: self.base.host.clone(), + access_code: self.access_code.clone(), + connector, + }) + } + /// Removes one file from the printer's SD card. /// /// Takes one explicit path, because the convenient version of this is diff --git a/src/printer/bambu_camera.rs b/src/printer/bambu_camera.rs new file mode 100644 index 0000000..bbee6e5 --- /dev/null +++ b/src/printer/bambu_camera.rs @@ -0,0 +1,134 @@ +//! The camera on a Bambu P1 or A1, which is not RTSP. +//! +//! Those models publish no `rtsp_url` and refuse port 322 (confirmed on a +//! real P1P, 2026-10-15). What they have instead is a proprietary stream on +//! TCP 6000: TLS, a single 80-byte login packet, then a continuous run of +//! JPEG frames, each behind a 16-byte header. +//! +//! This reads **one frame** and hangs up. A browser cannot play that stream +//! (it is MJPEG over a custom framing, and go2rtc cannot turn it into WebRTC +//! without ffmpeg, which a gateway does not have), so the console shows a +//! refreshing picture instead and a one-shot read is all it needs. +//! +//! Same blocking-TLS shape as `bambu_ftps`: the caller hands in the printer's +//! own `native_tls` connector, so every trust decision stays in +//! `BambuTls::build_connector`. + +use std::io::{Read, Write}; +use std::net::ToSocketAddrs; +use std::time::Duration; + +pub const PORT: u16 = 6000; + +/// Same username as MQTT and FTPS. +const USER: &str = "bblp"; + +/// A frame this large is a corrupt header, not a picture. Real frames from a +/// P1P are tens of kilobytes. +const MAX_FRAME: usize = 4 * 1024 * 1024; + +/// The 80-byte login: four little-endian u32s (0x40, 0x3000, 0, 0), then the +/// username and the access code, each zero-padded to 32 bytes. +fn auth_packet(access_code: &str) -> [u8; 80] { + let mut packet = [0u8; 80]; + packet[0..4].copy_from_slice(&0x40u32.to_le_bytes()); + packet[4..8].copy_from_slice(&0x3000u32.to_le_bytes()); + let user = USER.as_bytes(); + packet[16..16 + user.len()].copy_from_slice(user); + // An access code is 8 characters; truncating anything longer keeps the + // packet shape rather than overrunning into nothing. + let code = access_code.as_bytes(); + let n = code.len().min(32); + packet[48..48 + n].copy_from_slice(&code[..n]); + packet +} + +/// Reads one frame: a 16-byte header whose first u32 is the payload length, +/// then that many bytes, which must be a JPEG. +fn read_frame(stream: &mut R) -> anyhow::Result> { + let mut header = [0u8; 16]; + stream + .read_exact(&mut header) + .map_err(|err| anyhow::anyhow!("the camera sent no frame (wrong access code, or the stream is in use?): {err}"))?; + let len = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize; + if !(4..=MAX_FRAME).contains(&len) { + anyhow::bail!("the camera announced a {len}-byte frame, which is not a picture"); + } + let mut jpeg = vec![0u8; len]; + stream.read_exact(&mut jpeg)?; + if jpeg[..2] != [0xFF, 0xD8] || jpeg[len - 2..] != [0xFF, 0xD9] { + anyhow::bail!("the camera's frame is not a complete JPEG"); + } + Ok(jpeg) +} + +/// Connects, logs in and returns the first frame. Blocking; call it from +/// `spawn_blocking`. +pub fn snapshot( + host: &str, + access_code: &str, + connector: native_tls::TlsConnector, + timeout: Duration, +) -> anyhow::Result> { + let addr = (host, PORT) + .to_socket_addrs()? + .next() + .ok_or_else(|| anyhow::anyhow!("{host} did not resolve"))?; + let tcp = std::net::TcpStream::connect_timeout(&addr, timeout) + .map_err(|err| anyhow::anyhow!("{host}:{PORT} did not accept a connection: {err}"))?; + tcp.set_read_timeout(Some(timeout))?; + tcp.set_write_timeout(Some(timeout))?; + let mut tls = connector + .connect(host, tcp) + .map_err(|err| anyhow::anyhow!("TLS handshake with the camera failed: {err}"))?; + tls.write_all(&auth_packet(access_code))?; + tls.flush()?; + read_frame(&mut tls) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(payload: &[u8]) -> Vec { + let mut out = (payload.len() as u32).to_le_bytes().to_vec(); + out.extend_from_slice(&[0; 12]); + out.extend_from_slice(payload); + out + } + + #[test] + fn the_login_packet_has_the_documented_layout() { + let p = auth_packet("12345678"); + assert_eq!(&p[0..8], &[0x40, 0, 0, 0, 0, 0x30, 0, 0]); + assert_eq!(&p[8..16], &[0; 8]); + assert_eq!(&p[16..20], b"bblp"); + assert!(p[20..48].iter().all(|b| *b == 0)); + assert_eq!(&p[48..56], b"12345678"); + assert!(p[56..].iter().all(|b| *b == 0)); + } + + #[test] + fn a_whole_jpeg_frame_is_returned() { + let jpeg = [0xFF, 0xD8, 1, 2, 3, 0xFF, 0xD9]; + let mut input = frame(&jpeg).into_iter().chain([9, 9, 9]).collect::>(); + assert_eq!(read_frame(&mut input.as_slice()).unwrap(), jpeg); + input.clear(); + } + + #[test] + fn nonsense_is_refused_not_returned() { + // not a JPEG + assert!(read_frame(&mut frame(&[1, 2, 3, 4]).as_slice()).is_err()); + // length beyond the cap + let mut huge = (u32::MAX).to_le_bytes().to_vec(); + huge.extend_from_slice(&[0; 12]); + assert!(read_frame(&mut huge.as_slice()).is_err()); + // truncated + let mut cut = frame(&[0xFF, 0xD8, 0, 0xFF, 0xD9]); + cut.truncate(cut.len() - 2); + assert!(read_frame(&mut cut.as_slice()).is_err()); + // nothing at all (what a wrong access code looks like) + assert!(read_frame(&mut [].as_slice()).is_err()); + } +} diff --git a/src/printer/mod.rs b/src/printer/mod.rs index 1790922..d0d03b8 100644 --- a/src/printer/mod.rs +++ b/src/printer/mod.rs @@ -28,6 +28,7 @@ //! stubs; that's the next piece to build past this module. mod bambu; +mod bambu_camera; mod bambu_commands; mod bambu_ftps; mod bambu_mqtt; @@ -43,7 +44,7 @@ pub use project_file::{ ProjectFileError, }; -pub use bambu::{ +pub use bambu::{SnapshotJob, BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource, LightState, CHAMBER_LIGHT, }; @@ -194,6 +195,15 @@ pub enum PrinterHandle { } impl PrinterHandle { + /// A pending camera-frame read, for printers whose camera is a JPEG + /// stream rather than RTSP (Bambu P1/A1). + pub fn snapshot_job(&self) -> Result { + match self { + PrinterHandle::Bambu(p) => p.snapshot_job(), + _ => Err(PrinterError("this printer has no snapshot camera".into())), + } + } + /// The latest status this process has for the printer, if it has one. /// /// An inherent method rather than a `GenericPrinter` trait method, diff --git a/src/uplink/mod.rs b/src/uplink/mod.rs index 37ed3f0..29372e1 100644 --- a/src/uplink/mod.rs +++ b/src/uplink/mod.rs @@ -316,6 +316,12 @@ async fn serve( outbound.clone(), ); } + // Spawned: a connect, a TLS handshake and a frame + // from the printer take seconds, and awaiting that + // here would stall heartbeats and telemetry. + Ok(ServerMessage::CameraSnapshotRequest { request_id, printer_id }) => { + spawn_snapshot(fleet.clone(), request_id, printer_id, outbound.clone()); + } // Spawned: connecting to the new printer can take // many seconds, and awaiting it here would stall // heartbeats and telemetry for the whole farm. @@ -401,6 +407,12 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec { report.ipcam.as_ref().and_then(|c| c.rtsp_url.as_deref()), ) .map(|source| source.stream_name), + camera_snapshot: crate::camera::wants_snapshot( + printer, + report.ipcam.is_some(), + report.ipcam.as_ref().and_then(|c| c.rtsp_url.as_deref()), + ) + .then_some(true), }); } @@ -555,6 +567,48 @@ fn spawn_webrtc_negotiation( }); } +/// Reads one camera frame off this connection's task and reports it. +/// +/// The fleet lock is held only to copy out what the read needs (the access +/// code never leaves this process in the message), never across the network. +fn spawn_snapshot( + fleet: SharedFleet, + request_id: String, + printer_id: String, + outbound: tokio::sync::mpsc::Sender, +) { + tokio::spawn(async move { + let job = { + let fleet = fleet.lock().await; + match fleet.iter().find(|p| p.base().id == printer_id) { + Some(printer) => printer.snapshot_job().map_err(|err| err.to_string()), + None => Err(format!("no printer '{printer_id}' in this gateway's fleet")), + } + }; + let result = match job { + Ok(job) => job.run().await.map_err(|err| err.to_string()), + Err(err) => Err(err), + }; + let reply = match result { + Ok(jpeg) => { + use base64::Engine; + ClientMessage::CameraSnapshot { + request_id, + jpeg_base64: Some(base64::engine::general_purpose::STANDARD.encode(jpeg)), + error: None, + } + } + Err(err) => { + warn!(%printer_id, %err, "could not read a camera frame"); + ClientMessage::CameraSnapshot { request_id, jpeg_base64: None, error: Some(err) } + } + }; + if outbound.send(reply).await.is_err() { + warn!(%printer_id, "uplink is shutting down; the viewer gets no picture"); + } + }); +} + /// Finds the printer by our own id and runs the command. The error is a /// `String` rather than a typed error because its only destination is a /// `CommandResult` going back to the cloud as text. diff --git a/src/uplink/protocol.rs b/src/uplink/protocol.rs index 3c609bb..90bfba7 100644 --- a/src/uplink/protocol.rs +++ b/src/uplink/protocol.rs @@ -56,6 +56,16 @@ pub enum ClientMessage { /// 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 }, + /// The answer to a `CameraSnapshotRequest`: one JPEG frame, base64, or + /// the reason there is none. Always sent, success or failure - the cloud + /// is holding a viewer's request open for it. + CameraSnapshot { + request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + jpeg_base64: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + }, /// 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 @@ -191,6 +201,11 @@ pub struct PrinterTelemetry { /// `crate::camera`. #[serde(skip_serializing_if = "Option::is_none")] pub camera_stream: Option, + /// True when the camera can only be read as single JPEG frames (a P1/A1) + /// and this gateway will answer a `CameraSnapshotRequest` for it. See + /// `crate::camera::wants_snapshot`. + #[serde(skip_serializing_if = "Option::is_none")] + pub camera_snapshot: Option, } /// What `continuum-backend` sends back over the same connection. @@ -225,6 +240,9 @@ pub enum ServerMessage { /// 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 }, + /// Read one JPEG frame from a P1/A1-style camera and answer with a + /// `CameraSnapshot`. `printer_id` is our own id. + CameraSnapshotRequest { request_id: String, printer_id: String }, /// Add a printer to this gateway. Carries the printer's credential, sent /// once by the console and kept only here - see `provision`. Answered /// with a `CommandResult` carrying the same id. diff --git a/tests/uplink_protocol.rs b/tests/uplink_protocol.rs index 02d5705..823cbcc 100644 --- a/tests/uplink_protocol.rs +++ b/tests/uplink_protocol.rs @@ -188,6 +188,7 @@ fn empty_telemetry_fields_are_omitted_not_null() { // The common case, and the third field that must be omitted: a // P1P reports that it has a camera without giving a URL for it. camera_stream: None, + camera_snapshot: None, }], }; @@ -226,6 +227,7 @@ fn a_zero_stage_is_sent_rather_than_treated_as_absent() { print_error: Some(0), active_alerts: Some(0), camera_stream: Some("p1p".into()), + camera_snapshot: None, }], }; @@ -299,3 +301,28 @@ fn a_hello_carries_no_credential() { assert!(!json.contains("token"), "the hello must carry no token, got:\n {json}"); assert_eq!(json, r#"{"type":"hello","gateway_id":"gw-1","farm_id":"farm-1"}"#); } + +#[test] +fn a_snapshot_request_is_readable_and_its_answer_matches_the_schema() { + let request = r#"{"type":"camera_snapshot_request","request_id":"r1","printer_id":"p1p"}"#; + assert!(matches!( + serde_json::from_str::(request), + Ok(ServerMessage::CameraSnapshotRequest { .. }) + )); + + let ok = serde_json::to_string(&ClientMessage::CameraSnapshot { + request_id: "r1".into(), + jpeg_base64: Some("/9j/".into()), + error: None, + }) + .unwrap(); + assert_eq!(ok, r#"{"type":"camera_snapshot","request_id":"r1","jpeg_base64":"/9j/"}"#); + + let failed = serde_json::to_string(&ClientMessage::CameraSnapshot { + request_id: "r1".into(), + jpeg_base64: None, + error: Some("no frame".into()), + }) + .unwrap(); + assert_eq!(failed, r#"{"type":"camera_snapshot","request_id":"r1","error":"no frame"}"#); +}