Skip to main content

bambu_rs/server/
camera.rs

1//! The built-in (printer chamber) camera seam for the server — distinct from the
2//! external IP-camera proxy, which is just a URL on [`super::AppState`]. Live mode
3//! grabs a JPEG over TCP:6000 (see [`crate::camera`]); fake / no-target mode has
4//! no built-in camera. Abstracted as a trait so the server stays testable without
5//! a real printer.
6
7use std::io::Read;
8use std::sync::Arc;
9use std::time::Duration;
10
11use crate::camera::CameraClient;
12use crate::config::ResolvedTarget;
13
14/// Per-request timeout for a built-in-camera grab. Shorter than the CLI's default
15/// because a stalled grab shouldn't tie up an HTTP request (or a blocking-pool
16/// thread) for long: the A1 camera is often off, in which case the connect
17/// succeeds but no frame arrives, so the grab only ends on this timeout. The
18/// dashboard pairs this with a poll back-off so a dead camera isn't hammered.
19const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(4);
20
21/// Source of built-in-camera frames.
22pub trait CameraSource: Send + Sync {
23    /// Whether a built-in camera could be reached at all (live mode with a
24    /// target). The A1 camera is intermittently off, so `true` does **not**
25    /// guarantee [`snapshot`](CameraSource::snapshot) will return a frame.
26    fn configured(&self) -> bool;
27    /// Grab a single JPEG frame, or an error message. Messages never include the
28    /// access code (the underlying [`crate::camera::CameraError`] is careful too).
29    fn snapshot(&self) -> Result<Vec<u8>, String>;
30}
31
32/// Live built-in camera: a one-shot TCP:6000 grab against the printer.
33pub struct LiveCamera {
34    target: ResolvedTarget,
35}
36
37impl LiveCamera {
38    pub fn new(target: ResolvedTarget) -> Self {
39        Self { target }
40    }
41}
42
43impl CameraSource for LiveCamera {
44    fn configured(&self) -> bool {
45        true
46    }
47    fn snapshot(&self) -> Result<Vec<u8>, String> {
48        CameraClient::new(self.target.clone())
49            .with_timeout(SNAPSHOT_TIMEOUT)
50            .snapshot()
51            .map_err(|e| e.to_string())
52    }
53}
54
55/// No built-in camera — fake mode, or `bambu serve` without a printer target.
56pub struct NoCamera;
57
58impl CameraSource for NoCamera {
59    fn configured(&self) -> bool {
60        false
61    }
62    fn snapshot(&self) -> Result<Vec<u8>, String> {
63        Err("no built-in camera".to_string())
64    }
65}
66
67/// One configured **external** IP camera: a human label plus the snapshot URL the
68/// server proxies. The URL is an internal LAN address kept server-side — it's only
69/// exposed on the gated config endpoint, never on the open camera listing.
70// No `Eq`: `park_tuning` carries f64 knobs (ParkTuning is `PartialEq` only).
71#[derive(Clone, Debug, PartialEq)]
72pub struct ExternalCamera {
73    pub label: String,
74    pub url: String,
75    /// Optional live MJPEG stream URL (e.g. an MJPEG `/stream` endpoint). When
76    /// present the server can reverse-proxy a continuous multipart stream instead
77    /// of polling `url` for single JPEGs. `None` = snapshot-only.
78    pub stream_url: Option<String>,
79    /// Optional per-camera live-park detection tuning. Present (with a `stream_url`)
80    /// makes the camera eligible for the `park` timelapse slot; the values are
81    /// camera-specific (framing), so there are no shared defaults. `None` = no live
82    /// park preview for this camera.
83    pub park_tuning: Option<crate::core::park::ParkTuning>,
84    /// Optional per-camera burst-SELECTION tuning (the `select_smooth` knobs:
85    /// min_outlier/min_left_density/min_confidence/select_candidate_frac + left_frac). When
86    /// present, the serve assembles a smooth recording into a CLEAN one-frame-per-layer
87    /// timelapse (pick the parked frame per burst); absent → the raw all-frames assemble.
88    pub select_tuning: Option<crate::core::park::SelectTuning>,
89}
90
91impl ExternalCamera {
92    /// Build from an optional label + snapshot URL + optional stream URL, filling
93    /// a blank label with a stable `external N` (1-based `index`). A blank stream
94    /// URL normalises to `None`. `park_tuning` starts `None`; set it with
95    /// [`with_park_tuning`](Self::with_park_tuning).
96    pub fn new(
97        label: Option<String>,
98        url: String,
99        stream_url: Option<String>,
100        index: usize,
101    ) -> Self {
102        let label = label
103            .map(|l| l.trim().to_string())
104            .filter(|l| !l.is_empty())
105            .unwrap_or_else(|| format!("external {}", index + 1));
106        let stream_url = stream_url
107            .map(|s| s.trim().to_string())
108            .filter(|s| !s.is_empty());
109        Self {
110            label,
111            url: url.trim().to_string(),
112            stream_url,
113            park_tuning: None,
114            select_tuning: None,
115        }
116    }
117
118    /// Attach (or clear) the per-camera live-park tuning. Chained after [`new`](Self::new).
119    pub fn with_park_tuning(mut self, tuning: Option<crate::core::park::ParkTuning>) -> Self {
120        self.park_tuning = tuning;
121        self
122    }
123
124    /// Attach (or clear) the per-camera smooth-selection tuning. Chained after [`new`](Self::new).
125    pub fn with_select_tuning(mut self, tuning: Option<crate::core::park::SelectTuning>) -> Self {
126        self.select_tuning = tuning;
127        self
128    }
129
130    /// Parse a CLI/env entry: `label=url`, or a bare `url` (auto-labelled). The
131    /// leading `http(s)://` is detected so an `=` inside a URL query isn't taken
132    /// as a label separator.
133    pub fn parse(entry: &str, index: usize) -> Option<Self> {
134        let entry = entry.trim();
135        if entry.is_empty() {
136            return None;
137        }
138        let (label, url) = if entry.starts_with("http://") || entry.starts_with("https://") {
139            (None, entry.to_string())
140        } else if let Some((l, u)) = entry.split_once('=') {
141            (Some(l.to_string()), u.to_string())
142        } else {
143            (None, entry.to_string())
144        };
145        Some(Self::new(label, url, None, index))
146    }
147}
148
149/// Connect timeout for opening a camera's MJPEG stream — bounds the handshake;
150/// the body itself is meant to be endless (a per-read timeout ends a stall).
151const STREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
152
153/// An opened MJPEG stream: the upstream `content-type` (which carries the
154/// multipart boundary) plus a blocking reader over the long-lived body.
155pub struct OpenedCameraStream {
156    pub content_type: String,
157    pub reader: Box<dyn Read + Send + 'static>,
158}
159
160/// Opens a camera's MJPEG stream on demand. A closure, not a URL, so a recorder
161/// can reconnect (re-open) without knowing the source and tests can inject a fake
162/// that yields canned readers.
163pub type StreamOpen = Arc<dyn Fn() -> Result<OpenedCameraStream, String> + Send + Sync>;
164
165/// Open `url`'s MJPEG stream (blocking): connect and return the content-type plus
166/// a reader over the endless multipart body. A connect timeout bounds the
167/// handshake and a per-read timeout ends a stalled stream, but there is
168/// deliberately no overall timeout — the stream is meant to be endless. Shared by
169/// the HTTP reverse-proxy and the plain-timelapse stream recorder.
170pub fn open_mjpeg_stream(url: &str) -> Result<OpenedCameraStream, String> {
171    let agent = ureq::AgentBuilder::new()
172        .timeout_connect(STREAM_CONNECT_TIMEOUT)
173        .timeout_read(Duration::from_secs(30))
174        .redirects(0)
175        .build();
176    let resp = agent.get(url).call().map_err(|e| e.to_string())?;
177    let content_type = resp
178        .header("content-type")
179        .map(str::to_string)
180        .unwrap_or_else(|| "multipart/x-mixed-replace".to_string());
181    Ok(OpenedCameraStream {
182        content_type,
183        reader: resp.into_reader(),
184    })
185}
186
187/// A [`StreamOpen`] that re-opens `url` on each call.
188pub fn url_stream_opener(url: String) -> StreamOpen {
189    Arc::new(move || open_mjpeg_stream(&url))
190}