mod checks;
mod report;
pub use report::{
Category, CheckResult, CheckStatus, HealthReport, ProfileAssessment, ProfileVerdict,
};
use std::time::Instant;
use tokio::task::JoinSet;
use crate::OnvifSession;
pub struct HealthCheck {
device_url: String,
credentials: Option<(String, String)>,
write_checks: bool,
clock_sync: bool,
}
impl HealthCheck {
pub fn new(device_url: impl Into<String>) -> Self {
Self {
device_url: device_url.into(),
credentials: None,
write_checks: false,
clock_sync: false,
}
}
pub fn with_credentials(
mut self,
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
self.credentials = Some((username.into(), password.into()));
self
}
pub fn with_write_checks(mut self, enabled: bool) -> Self {
self.write_checks = enabled;
self
}
pub fn with_clock_sync(mut self, enabled: bool) -> Self {
self.clock_sync = enabled;
self
}
pub async fn run(self) -> HealthReport {
let started = Instant::now();
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) => {
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()),
];
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}"),
)),
}
}
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: 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"));
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}"
);
}
}