1use std::fs;
2use std::time::Duration;
3
4use serde_json::{Value, json};
5use yscv_video::{
6 CameraConfig, CameraFrameSource, VideoError, filter_camera_devices, list_camera_devices,
7 resolve_camera_device,
8};
9
10use crate::config::{CliConfig, CliError};
11use crate::error::AppError;
12use crate::util::{duration_to_ms, ensure_parent_dir};
13
14#[derive(Debug, Clone, Copy)]
15struct TimingMetrics {
16 target_fps: f64,
17 wall_fps: f64,
18 sensor_fps: f64,
19 wall_drift_pct: f64,
20 sensor_drift_pct: f64,
21 mean_gap_us: f64,
22 min_gap_us: u64,
23 max_gap_us: u64,
24 dropped_frames: u64,
25}
26
27#[derive(Debug, Clone, Copy)]
28struct FrameSample {
29 index: u64,
30 timestamp_us: u64,
31 width: usize,
32 height: usize,
33}
34
35pub fn run_camera_diagnostics(cli: &CliConfig) -> Result<(), AppError> {
36 println!("yscv-cli camera diagnostics: starting");
37 println!(
38 "requested capture config: device={} {}x{}@{}fps",
39 cli.device_index, cli.width, cli.height, cli.fps
40 );
41
42 let requested = json!({
43 "device_index": cli.device_index,
44 "width": cli.width,
45 "height": cli.height,
46 "fps": cli.fps,
47 "diagnose_frames": cli.diagnose_frames,
48 "device_name_query": cli.device_name_query,
49 });
50
51 let devices = match list_camera_devices() {
52 Ok(devices) => devices,
53 Err(VideoError::CameraBackendDisabled) => {
54 println!("camera support is disabled; rebuild with `--features native-camera`");
55 let report = json!({
56 "tool": "yscv-cli",
57 "mode": "diagnostics",
58 "status": "backend_disabled",
59 "requested": requested,
60 "discovered_devices": [],
61 });
62 maybe_write_report(cli, &report)?;
63 println!("yscv-cli camera diagnostics: completed");
64 return Ok(());
65 }
66 Err(err) => return Err(err.into()),
67 };
68
69 let discovered_devices = devices
70 .iter()
71 .map(|device| {
72 json!({
73 "index": device.index,
74 "label": device.label,
75 })
76 })
77 .collect::<Vec<_>>();
78
79 if devices.is_empty() {
80 println!("diagnostics: no camera devices discovered");
81 let report = json!({
82 "tool": "yscv-cli",
83 "mode": "diagnostics",
84 "status": "no_devices",
85 "requested": requested,
86 "discovered_devices": discovered_devices,
87 });
88 maybe_write_report(cli, &report)?;
89 println!("yscv-cli camera diagnostics: completed");
90 return Ok(());
91 }
92
93 println!("diagnostics: discovered {} device(s)", devices.len());
94 for device in &devices {
95 println!(" {}: {}", device.index, device.label);
96 }
97
98 let selected = if let Some(query) = cli.device_name_query.as_deref() {
99 let selected = resolve_camera_device(query)?;
100 println!(
101 "diagnostics: selected via query `{}` -> {}: {}",
102 query, selected.index, selected.label
103 );
104 selected
105 } else if let Some(found) = devices
106 .iter()
107 .find(|device| device.index == cli.device_index)
108 {
109 found.clone()
110 } else {
111 return Err(CliError::Message(format!(
112 "diagnostics: requested device index {} was not found in discovery list",
113 cli.device_index
114 ))
115 .into());
116 };
117
118 let mut source = CameraFrameSource::open(CameraConfig {
119 device_index: selected.index,
120 width: cli.width,
121 height: cli.height,
122 fps: cli.fps,
123 })?;
124
125 println!(
126 "diagnostics: capturing up to {} frame(s) for timing analysis",
127 cli.diagnose_frames
128 );
129 let mut frames = Vec::with_capacity(cli.diagnose_frames);
130 let capture_started = std::time::Instant::now();
131 for _ in 0..cli.diagnose_frames {
132 match source.next_rgb8_frame()? {
133 Some(frame) => frames.push(FrameSample {
134 index: frame.index(),
135 timestamp_us: frame.timestamp_us(),
136 width: frame.width(),
137 height: frame.height(),
138 }),
139 None => break,
140 }
141 }
142 let capture_elapsed = capture_started.elapsed();
143
144 let first_frame = frames.first().map(|first| {
145 json!({
146 "index": first.index,
147 "timestamp_us": first.timestamp_us,
148 "shape": [first.height, first.width, 3],
149 })
150 });
151
152 let timing = compute_timing_metrics(&frames, capture_elapsed, cli.fps);
153 if frames.is_empty() {
154 println!("diagnostics: capture opened but produced no frames");
155 } else if let Some(first) = frames.first() {
156 println!(
157 "diagnostics: capture ok first_frame_index={} ts_us={} shape=[{}, {}, {}] collected_frames={} wall_ms={:.3}",
158 first.index,
159 first.timestamp_us,
160 first.height,
161 first.width,
162 3,
163 frames.len(),
164 duration_to_ms(capture_elapsed),
165 );
166
167 if let Some(metrics) = timing {
168 println!(
169 "diagnostics: timing target_fps={:.2} wall_fps={:.2} sensor_fps={:.2} wall_drift={:+.1}% sensor_drift={:+.1}%",
170 metrics.target_fps,
171 metrics.wall_fps,
172 metrics.sensor_fps,
173 metrics.wall_drift_pct,
174 metrics.sensor_drift_pct
175 );
176 println!(
177 "diagnostics: frame_interval_us mean={:.1} min={} max={} dropped_frames={}",
178 metrics.mean_gap_us, metrics.min_gap_us, metrics.max_gap_us, metrics.dropped_frames
179 );
180
181 if metrics.dropped_frames > 0 {
182 println!(
183 "diagnostics: warning dropped frame indices observed (count={})",
184 metrics.dropped_frames
185 );
186 }
187 if metrics.wall_drift_pct.abs() > 25.0 || metrics.sensor_drift_pct.abs() > 25.0 {
188 println!(
189 "diagnostics: warning fps drift exceeds 25%; check camera backend/device load"
190 );
191 }
192 } else {
193 println!("diagnostics: collected fewer than 2 frames; timing analysis skipped");
194 }
195 }
196
197 let timing_json = timing.map(|metrics| {
198 json!({
199 "target_fps": metrics.target_fps,
200 "wall_fps": metrics.wall_fps,
201 "sensor_fps": metrics.sensor_fps,
202 "wall_drift_pct": metrics.wall_drift_pct,
203 "sensor_drift_pct": metrics.sensor_drift_pct,
204 "mean_gap_us": metrics.mean_gap_us,
205 "min_gap_us": metrics.min_gap_us,
206 "max_gap_us": metrics.max_gap_us,
207 "dropped_frames": metrics.dropped_frames,
208 "drift_warning": metrics.wall_drift_pct.abs() > 25.0 || metrics.sensor_drift_pct.abs() > 25.0,
209 "dropped_frames_warning": metrics.dropped_frames > 0,
210 })
211 });
212
213 let report = json!({
214 "tool": "yscv-cli",
215 "mode": "diagnostics",
216 "status": if frames.is_empty() { "no_frames" } else { "ok" },
217 "requested": requested,
218 "discovered_devices": discovered_devices,
219 "selected_device": {
220 "index": selected.index,
221 "label": selected.label,
222 },
223 "capture": {
224 "requested_frames": cli.diagnose_frames,
225 "collected_frames": frames.len(),
226 "wall_ms": duration_to_ms(capture_elapsed),
227 "first_frame": first_frame,
228 "timing": timing_json,
229 },
230 });
231 maybe_write_report(cli, &report)?;
232
233 println!("yscv-cli camera diagnostics: completed");
234 Ok(())
235}
236
237pub fn print_camera_devices(query: Option<&str>) -> Result<(), AppError> {
238 let devices = match list_camera_devices() {
239 Ok(devices) => devices,
240 Err(VideoError::CameraBackendDisabled) => {
241 println!("camera support is disabled; rebuild with `--features native-camera`");
242 return Ok(());
243 }
244 Err(err) => return Err(err.into()),
245 };
246
247 let devices = if let Some(query) = query {
248 filter_camera_devices(&devices, query)?
249 } else {
250 devices
251 };
252
253 if devices.is_empty() {
254 if let Some(query) = query {
255 println!("no camera devices matched query `{query}`");
256 } else {
257 println!("no camera devices were found");
258 }
259 return Ok(());
260 }
261
262 if let Some(query) = query {
263 println!("available camera devices matching `{query}`:");
264 } else {
265 println!("available camera devices:");
266 }
267 for device in devices {
268 println!(" {}: {}", device.index, device.label);
269 }
270 Ok(())
271}
272
273fn compute_timing_metrics(
274 frames: &[FrameSample],
275 capture_elapsed: Duration,
276 requested_fps: u32,
277) -> Option<TimingMetrics> {
278 if frames.len() < 2 {
279 return None;
280 }
281
282 let mut gap_sum_us = 0u128;
283 let mut min_gap_us = u64::MAX;
284 let mut max_gap_us = 0u64;
285 let mut dropped_frames = 0u64;
286
287 for pair in frames.windows(2) {
288 let prev = &pair[0];
289 let next = &pair[1];
290
291 let ts_gap_us = next.timestamp_us.saturating_sub(prev.timestamp_us);
292 gap_sum_us += u128::from(ts_gap_us);
293 min_gap_us = min_gap_us.min(ts_gap_us);
294 max_gap_us = max_gap_us.max(ts_gap_us);
295
296 let index_gap = next.index.saturating_sub(prev.index);
297 if index_gap > 1 {
298 dropped_frames += index_gap - 1;
299 }
300 }
301
302 let interval_count = (frames.len() - 1) as f64;
303 let mean_gap_us = gap_sum_us as f64 / interval_count;
304 let sensor_fps = if mean_gap_us > 0.0 {
305 1_000_000.0 / mean_gap_us
306 } else {
307 0.0
308 };
309 let wall_fps = if capture_elapsed.as_secs_f64() > 0.0 {
310 frames.len() as f64 / capture_elapsed.as_secs_f64()
311 } else {
312 0.0
313 };
314 let target_fps = f64::from(requested_fps);
315 let wall_drift_pct = ((wall_fps - target_fps) / target_fps) * 100.0;
316 let sensor_drift_pct = ((sensor_fps - target_fps) / target_fps) * 100.0;
317
318 Some(TimingMetrics {
319 target_fps,
320 wall_fps,
321 sensor_fps,
322 wall_drift_pct,
323 sensor_drift_pct,
324 mean_gap_us,
325 min_gap_us,
326 max_gap_us,
327 dropped_frames,
328 })
329}
330
331fn maybe_write_report(cli: &CliConfig, report: &Value) -> Result<(), AppError> {
332 if let Some(path) = cli.diagnose_report_path.as_deref() {
333 ensure_parent_dir(path)?;
334 let body = serde_json::to_vec_pretty(report)?;
335 fs::write(path, body)?;
336 println!("diagnostics: report saved to {}", path.display());
337 }
338 Ok(())
339}