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.
This commit is contained in:
@@ -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<R: Read>(stream: &mut R) -> anyhow::Result<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<u8> {
|
||||
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::<Vec<_>>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user