Compare commits
2
Commits
b4948076a0
...
0c449c47bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c449c47bc
|
||
|
|
0c8ad9cc3d
|
@@ -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
@@ -18,9 +18,10 @@
|
||||
//! 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
|
||||
//! 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<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.
|
||||
///
|
||||
/// Hand-written YAML rather than a serializer, for the same reason the
|
||||
|
||||
@@ -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<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
|
||||
/// `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<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.
|
||||
///
|
||||
/// Takes one explicit path, because the convenient version of this is
|
||||
|
||||
@@ -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
@@ -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<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.
|
||||
///
|
||||
/// An inherent method rather than a `GenericPrinter` trait method,
|
||||
|
||||
@@ -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<PrinterTelemetry> {
|
||||
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<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
|
||||
/// `String` rather than a typed error because its only destination is a
|
||||
/// `CommandResult` going back to the cloud as text.
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<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
|
||||
@@ -191,6 +201,11 @@ pub struct PrinterTelemetry {
|
||||
/// `crate::camera`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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::<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"}"#);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user