Compare commits

...
2 Commits
Author SHA1 Message Date
octoturge 0c449c47bc Add a read-only dry run of a print's filament mapping
Windows discovery tool / windows (push) Successful in 1m4s
bambu_mapping_check downloads one project from the card and prints the AMS
mapping the production path would send, starting nothing.
2026-10-29 16:20:03 +00:00
octoturge 0c8ad9cc3d 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.
2026-10-29 15:55:21 +00:00
8 changed files with 348 additions and 4 deletions
+55
View File
@@ -0,0 +1,55 @@
//! Run with: cargo run --example bambu_mapping_check -- <printer-id> <file-on-card>
//!
//! **Read-only dry run of what a print would do.** Connects, reads the
//! printer's state and loaded filament, downloads one project from the SD
//! card root over FTPS, and prints the AMS mapping the production path would
//! send - without sending it. Starts nothing, uploads nothing, deletes
//! nothing. An optional third argument saves the downloaded copy locally.
use std::time::Duration;
use continuum_proxy::fleet;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let (Some(target), Some(file)) = (args.next(), args.next()) else {
anyhow::bail!("usage: cargo run --example bambu_mapping_check -- <printer-id> <file-on-card> [save-to]");
};
let save_to = args.next();
let mut printers = fleet::load(std::path::Path::new("printers.toml"))?;
let printer = printers
.iter_mut()
.find(|p| p.base().id == target)
.ok_or_else(|| anyhow::anyhow!("no printer with id '{target}' in printers.toml"))?;
let PrinterHandle::Bambu(bambu) = printer else {
anyhow::bail!("'{target}' is not a Bambu printer");
};
tokio::time::timeout(Duration::from_secs(20), bambu.connect())
.await
.map_err(|_| anyhow::anyhow!("connect timed out"))??;
bambu.pushall().await.ok();
tokio::time::sleep(Duration::from_secs(4)).await;
let report = bambu.print_status().await.ok_or_else(|| anyhow::anyhow!("no status yet"))?;
println!("gcode_state: {:?}", report.gcode_state);
println!("{:#?}", report.ams);
let bytes = bambu.download_file(&format!("/{file}")).await?;
println!("downloaded {} bytes", bytes.len());
if let Some(path) = save_to {
std::fs::write(&path, &bytes)?;
println!("saved a copy to {path}");
}
for f in continuum_proxy::printer::plate_filaments(&bytes, 1)? {
println!("project filament {}: {} {} #{}", f.index, f.filament_type, f.tray_info_idx, f.color);
}
match bambu.filament_source_for(&bytes, 1).await {
Ok(source) => println!("mapping the production path would use: {source:?}"),
Err(err) => println!("the production path would REFUSE: {err}"),
}
Ok(())
}
+12 -3
View File
@@ -18,9 +18,10 @@
//! Only the X1/H2 class publishes an `rtsp_url`. Confirmed against two real //! Only the X1/H2 class publishes an `rtsp_url`. Confirmed against two real
//! machines: an H2C reports `rtsps://<ip>:322/streaming/live/1`, and a P1P //! machines: an H2C reports `rtsps://<ip>:322/streaming/live/1`, and a P1P
//! reports an `ipcam` block with no URL in it at all. A P1/A1 camera is //! 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 //! reachable only over Bambu's proprietary port-6000 JPEG stream. That is
//! crate does not speak, so those printers get no stream here and say so //! not restreamable here (go2rtc cannot make WebRTC of MJPEG without ffmpeg),
//! rather than advertising one that will not play. //! 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 //! 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 //! 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<C
}) })
} }
/// Whether this printer's camera is the P1/A1 kind: it reports a camera
/// block but no RTSP URL, so the only way to see it is single JPEG frames
/// from port 6000 (`printer::bambu_camera`). The one rule for "snapshot
/// camera", used by telemetry, so what is advertised is what is served.
pub fn wants_snapshot(printer: &PrinterHandle, has_ipcam: bool, report_url: Option<&str>) -> bool {
matches!(printer, PrinterHandle::Bambu(_)) && has_ipcam && report_url.is_none_or(str::is_empty)
}
/// Renders a go2rtc configuration. /// Renders a go2rtc configuration.
/// ///
/// Hand-written YAML rather than a serializer, for the same reason the /// Hand-written YAML rather than a serializer, for the same reason the
+37
View File
@@ -207,6 +207,28 @@ pub enum BambuProtocolPreference {
} }
// endregion: BambuMQTTProtocol // 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<Vec<u8>, 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 /// A Bambu printer. Only one struct now, not two: the old
/// `BambuGenericPrinter` (shared fields/behavior) + `BambuV1Printer`/ /// `BambuGenericPrinter` (shared fields/behavior) + `BambuV1Printer`/
/// `BambuV2Printer` (thin per-version wrappers) split existed to let two /// `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))) .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<SnapshotJob, PrinterError> {
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. /// Removes one file from the printer's SD card.
/// ///
/// Takes one explicit path, because the convenient version of this is /// Takes one explicit path, because the convenient version of this is
+134
View File
@@ -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());
}
}
+11 -1
View File
@@ -28,6 +28,7 @@
//! stubs; that's the next piece to build past this module. //! stubs; that's the next piece to build past this module.
mod bambu; mod bambu;
mod bambu_camera;
mod bambu_commands; mod bambu_commands;
mod bambu_ftps; mod bambu_ftps;
mod bambu_mqtt; mod bambu_mqtt;
@@ -43,7 +44,7 @@ pub use project_file::{
ProjectFileError, ProjectFileError,
}; };
pub use bambu::{ pub use bambu::{SnapshotJob,
BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource, BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource,
LightState, CHAMBER_LIGHT, LightState, CHAMBER_LIGHT,
}; };
@@ -194,6 +195,15 @@ pub enum PrinterHandle {
} }
impl 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<bambu::SnapshotJob, PrinterError> {
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. /// The latest status this process has for the printer, if it has one.
/// ///
/// An inherent method rather than a `GenericPrinter` trait method, /// An inherent method rather than a `GenericPrinter` trait method,
+54
View File
@@ -316,6 +316,12 @@ async fn serve(
outbound.clone(), 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 // Spawned: connecting to the new printer can take
// many seconds, and awaiting it here would stall // many seconds, and awaiting it here would stall
// heartbeats and telemetry for the whole farm. // heartbeats and telemetry for the whole farm.
@@ -401,6 +407,12 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
report.ipcam.as_ref().and_then(|c| c.rtsp_url.as_deref()), report.ipcam.as_ref().and_then(|c| c.rtsp_url.as_deref()),
) )
.map(|source| source.stream_name), .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<ClientMessage>,
) {
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 /// 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 /// `String` rather than a typed error because its only destination is a
/// `CommandResult` going back to the cloud as text. /// `CommandResult` going back to the cloud as text.
+18
View File
@@ -56,6 +56,16 @@ pub enum ClientMessage {
/// waiting on this and a viewer otherwise watches a video element that /// waiting on this and a viewer otherwise watches a video element that
/// never loads with nothing anywhere saying why. /// never loads with nothing anywhere saying why.
WebrtcError { session_id: String, message: String }, 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
},
/// Printers found on this gateway's own network and USB ports that are /// 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 /// *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 /// making an operator walk to a machine and read its IP address off the
@@ -191,6 +201,11 @@ pub struct PrinterTelemetry {
/// `crate::camera`. /// `crate::camera`.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub camera_stream: Option<String>, pub camera_stream: Option<String>,
/// 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<bool>,
} }
/// What `continuum-backend` sends back over the same connection. /// 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 /// and checks its password before CORS, so the unauthenticated preflight
/// a browser always sends is refused - measured, not assumed. /// a browser always sends is refused - measured, not assumed.
WebrtcOffer { session_id: String, camera_stream: String, sdp: String }, 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 /// Add a printer to this gateway. Carries the printer's credential, sent
/// once by the console and kept only here - see `provision`. Answered /// once by the console and kept only here - see `provision`. Answered
/// with a `CommandResult` carrying the same id. /// with a `CommandResult` carrying the same id.
+27
View File
@@ -188,6 +188,7 @@ fn empty_telemetry_fields_are_omitted_not_null() {
// The common case, and the third field that must be omitted: a // 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. // P1P reports that it has a camera without giving a URL for it.
camera_stream: None, 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), print_error: Some(0),
active_alerts: Some(0), active_alerts: Some(0),
camera_stream: Some("p1p".into()), 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!(!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"}"#); 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::<ServerMessage>(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"}"#);
}