//! 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()); } }