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:
2026-10-29 15:55:21 +00:00
parent b4948076a0
commit 0c8ad9cc3d
7 changed files with 293 additions and 4 deletions
+54
View File
@@ -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.