Skip to main content

yscv_cli/
diagnostics.rs

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}