dahua-camera-server 0.3.1

axum HTTP, SSE and WebSocket API for the dahua-camera stack
Documentation
//! Wire types for the JSON API.
//!
//! Kept separate from the domain deliberately. The API is a published contract
//! with the diagnostics UI and the Tauri shell; letting it be whatever the
//! domain structs happen to serialise to means an internal refactor silently
//! breaks a client.

use dahua_camera_rtsp::Camera;
use dahua_camera_core::CameraStatus;
use serde::Serialize;
use std::sync::atomic::Ordering;

/// One camera, as `GET /api/cameras` reports it.
#[derive(Debug, Clone, Serialize)]
pub struct CameraView {
    /// Stable identifier.
    pub id: String,
    /// Display name, falling back to the id.
    pub label: String,
    /// Current lifecycle state.
    pub status: CameraStatus,
    /// Attached consumers.
    pub viewers: usize,
    /// Samples received since startup.
    pub frames: u64,
    /// Random-access points received since startup.
    pub idr_frames: u64,
    /// Encoded bytes received since startup.
    pub bytes: u64,
    /// Session restarts since startup.
    pub reconnects: u64,
    /// Samples dropped by lagging subscribers.
    pub lagged_drops: u64,
    /// Frames pushed through the decode lane.
    pub decoded_frames: u64,
    /// Coded width, once known.
    pub width: Option<u16>,
    /// Coded height, once known.
    pub height: Option<u16>,
}

impl CameraView {
    /// Snapshot a camera's current state.
    ///
    /// Counters are read independently and may be very slightly inconsistent
    /// with each other; they are for humans watching a dashboard, not for
    /// anything that needs a coherent instant.
    pub fn of(camera: &Camera) -> Self {
        let params = camera.params();
        let stats = camera.stats();
        Self {
            id: camera.id().to_owned(),
            label: camera.config().display_name().to_owned(),
            status: camera.status(),
            viewers: camera.viewer_count(),
            frames: stats.frames.load(Ordering::Relaxed),
            idr_frames: stats.idr_frames.load(Ordering::Relaxed),
            bytes: stats.bytes.load(Ordering::Relaxed),
            reconnects: stats.reconnects.load(Ordering::Relaxed),
            lagged_drops: stats.lagged_drops.load(Ordering::Relaxed),
            decoded_frames: stats.decoded_frames.load(Ordering::Relaxed),
            width: params.as_ref().map(|p| p.codec.width),
            height: params.as_ref().map(|p| p.codec.height),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn status_serialises_as_a_tagged_object() {
        let json = serde_json::to_value(CameraStatus::Streaming { width: 704, height: 576 })
            .expect("serialises");
        assert_eq!(json["state"], "streaming");
        assert_eq!(json["width"], 704);
    }

    #[test]
    fn idle_status_carries_no_extra_fields() {
        let json = serde_json::to_value(CameraStatus::Idle).expect("serialises");
        assert_eq!(json["state"], "idle");
        assert_eq!(json.as_object().expect("object").len(), 1);
    }

    #[test]
    fn reconnecting_status_reports_the_delay_and_reason() {
        let json = serde_json::to_value(CameraStatus::Reconnecting {
            after_secs: 4,
            reason: "stream ended".into(),
        })
        .expect("serialises");
        assert_eq!(json["state"], "reconnecting");
        assert_eq!(json["after_secs"], 4);
        assert_eq!(json["reason"], "stream ended");
    }
}