oxvif 0.9.7

Async Rust client library for the ONVIF IP camera protocol
Documentation
//! Fast, scriptable ONVIF health / conformance check for a target device.
//!
//! A lightweight, readable alternative to the official ONVIF Device Test Tool.
//! Point it at a camera and it runs a curated set of read-only checks
//! concurrently, then reports per-check Pass/Warn/Fail/Skip with timings plus a
//! Profile S/T/G assessment.
//!
//! This module is **additive and feature-gated** (`health`): it builds on
//! [`OnvifSession`] internally but adds no methods to it.
//!
//! ```no_run
//! # async fn run() {
//! use oxvif::health::HealthCheck;
//! let report = HealthCheck::new("http://192.168.1.100/onvif/device_service")
//!     .with_credentials("admin", "password")
//!     .run()
//!     .await;
//! println!("{report}");
//! # }
//! ```

mod checks;
mod report;

pub use report::{
    Category, CheckResult, CheckStatus, HealthReport, ProfileAssessment, ProfileVerdict,
};

use std::time::Instant;

use tokio::task::JoinSet;

use crate::OnvifSession;

/// Builder + runner for a single device health check.
pub struct HealthCheck {
    device_url: String,
    credentials: Option<(String, String)>,
    write_checks: bool,
    clock_sync: bool,
}

impl HealthCheck {
    /// Target a device by its device-service URL.
    pub fn new(device_url: impl Into<String>) -> Self {
        Self {
            device_url: device_url.into(),
            credentials: None,
            write_checks: false,
            clock_sync: false,
        }
    }

    /// Supply credentials for WS-Security / HTTP Digest.
    pub fn with_credentials(
        mut self,
        username: impl Into<String>,
        password: impl Into<String>,
    ) -> Self {
        self.credentials = Some((username.into(), password.into()));
        self
    }

    /// Enable the opt-in, non-destructive write round-trip check (re-applies an
    /// unchanged video encoder configuration to exercise the `Set` path).
    pub fn with_write_checks(mut self, enabled: bool) -> Self {
        self.write_checks = enabled;
        self
    }

    /// Sync the WS-Security timestamp to the device clock before checks
    /// (mirrors [`OnvifSessionBuilder::with_clock_sync`](crate::OnvifSessionBuilder::with_clock_sync)).
    pub fn with_clock_sync(mut self, enabled: bool) -> Self {
        self.clock_sync = enabled;
        self
    }

    /// Run the checks and produce a [`HealthReport`].
    pub async fn run(self) -> HealthReport {
        let started = Instant::now();

        // 1. Connectivity — build the session (one GetCapabilities round-trip).
        let conn_start = Instant::now();
        let mut builder = OnvifSession::builder(&self.device_url);
        if let Some((u, p)) = &self.credentials {
            builder = builder.with_credentials(u.clone(), p.clone());
        }
        if self.clock_sync {
            builder = builder.with_clock_sync();
        }
        let session = match builder.build().await {
            Ok(s) => s,
            Err(e) => {
                // Can't reach / auth the device — report just the failure.
                let conn = CheckResult::fail("connect", Category::Connectivity, e.to_string())
                    .with_elapsed(conn_start.elapsed());
                return HealthReport {
                    target: self.device_url,
                    total_elapsed: started.elapsed(),
                    profiles: assess(std::slice::from_ref(&conn)),
                    checks: vec![conn],
                };
            }
        };
        let mut checks = vec![
            CheckResult::pass("connect", Category::Connectivity, "GetCapabilities ok")
                .with_elapsed(conn_start.elapsed()),
        ];

        // 2. Independent checks, concurrently (session is cheap to clone).
        let mut set: JoinSet<Vec<CheckResult>> = JoinSet::new();
        macro_rules! spawn_check {
            ($f:path) => {{
                let s = session.clone();
                set.spawn(async move { $f(&s).await });
            }};
        }
        spawn_check!(checks::device_info);
        spawn_check!(checks::time);
        spawn_check!(checks::services);
        spawn_check!(checks::media);
        spawn_check!(checks::imaging);
        spawn_check!(checks::ptz);
        spawn_check!(checks::events);
        spawn_check!(checks::network);
        spawn_check!(checks::users);
        if self.write_checks {
            spawn_check!(checks::write_roundtrip);
        }

        while let Some(joined) = set.join_next().await {
            match joined {
                Ok(mut v) => checks.append(&mut v),
                Err(e) => checks.push(CheckResult::fail(
                    "internal",
                    Category::Connectivity,
                    format!("check task panicked: {e}"),
                )),
            }
        }

        // 3. Stable ordering for the report.
        checks.sort_by(|a, b| a.category.cmp(&b.category).then_with(|| a.id.cmp(b.id)));

        let profiles = assess(&checks);
        HealthReport {
            target: self.device_url,
            total_elapsed: started.elapsed(),
            checks,
            profiles,
        }
    }
}

fn check_passed(checks: &[CheckResult], id: &str) -> bool {
    checks
        .iter()
        .any(|c| c.id == id && matches!(c.status, CheckStatus::Pass | CheckStatus::Warn(_)))
}

fn verdict(
    checks: &[CheckResult],
    required: &[&'static str],
) -> (ProfileVerdict, Vec<&'static str>) {
    let missing: Vec<&'static str> = required
        .iter()
        .copied()
        .filter(|id| !check_passed(checks, id))
        .collect();
    let v = if missing.is_empty() {
        ProfileVerdict::Conformant
    } else if missing.len() < required.len() {
        ProfileVerdict::Partial
    } else {
        ProfileVerdict::Unsupported
    };
    (v, missing)
}

fn assess(checks: &[CheckResult]) -> ProfileAssessment {
    ProfileAssessment {
        profile_s: verdict(
            checks,
            &[
                "connect",
                "get_services",
                "get_profiles",
                "get_stream_uri",
                "get_snapshot_uri",
                "get_video_encoder_configurations",
            ],
        ),
        profile_t: verdict(
            checks,
            &[
                "connect",
                "get_profiles",
                "get_stream_uri",
                "get_imaging_settings",
                "get_event_properties",
            ],
        ),
        // Profile G (recording/search/replay) is not exercised by these checks.
        profile_g: verdict(checks, &["recording", "search", "replay"]),
    }
}

#[cfg(all(test, feature = "mock-server"))]
mod tests {
    use super::*;
    use crate::mock::MockServer;

    #[tokio::test]
    async fn healthcheck_against_mock_passes_core() {
        let server = MockServer::start().await.unwrap();
        let report = HealthCheck::new(server.device_url()).run().await;

        assert!(report.ok(), "mock health check had failures:\n{report}");
        assert!(check_passed(&report.checks, "connect"));
        assert!(check_passed(&report.checks, "get_profiles"));
        assert!(check_passed(&report.checks, "get_users"));
        // The mock advertises Media/Imaging/PTZ/Events, so Profile S/T should
        // not come back Unsupported.
        assert_ne!(report.profiles.profile_s.0, ProfileVerdict::Unsupported);
    }

    #[tokio::test]
    async fn healthcheck_write_roundtrip_against_mock() {
        let server = MockServer::start().await.unwrap();
        let report = HealthCheck::new(server.device_url())
            .with_write_checks(true)
            .run()
            .await;
        assert!(
            check_passed(&report.checks, "set_video_encoder_roundtrip"),
            "write round-trip should pass against the mock:\n{report}"
        );
    }
}