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}