dahua-camera-server 0.3.1

axum HTTP, SSE and WebSocket API for the dahua-camera stack
Documentation
//! Out-of-band camera control, forwarded to the
//! [`CameraControl`](dahua_camera_core::ports::CameraControl) port.

use crate::AppState;
use axum::extract::{Path, State};
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};

/// Body of `POST /api/cameras/{id}/control`.
#[derive(Debug, Clone, Deserialize)]
pub struct ControlRequest {
    /// One of `day_night`, `ir_mode`, `ptz`.
    pub action: String,
    /// Action-specific value, e.g. `Night`, `Off`, `Up`.
    pub value: String,
}

/// Response to a control request.
#[derive(Debug, Clone, Serialize)]
pub struct ControlResponse {
    /// `ok` or `error`.
    pub status: &'static str,
    /// The camera's own reply, on success.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub output: Option<String>,
    /// What went wrong, on failure.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}

/// `POST /api/cameras/{id}/control` — day/night, IR, or PTZ.
///
/// An unknown action is a `400`, but a camera that refuses a valid action
/// comes back `200` with `status: "error"`: the request was well-formed and
/// the UI wants to show the camera's complaint rather than treat it as a bug.
pub async fn control_camera(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(request): Json<ControlRequest>,
) -> Result<Json<ControlResponse>, StatusCode> {
    let camera = state.get(&id).ok_or(StatusCode::NOT_FOUND)?;
    let control = camera.control();

    let result = match request.action.as_str() {
        "day_night" => control.set_day_night(&request.value).await,
        "ir_mode" => control.set_ir_mode(&request.value).await,
        "ptz" => control.ptz_move(&request.value).await,
        _ => return Err(StatusCode::BAD_REQUEST),
    };

    Ok(Json(match result {
        Ok(output) => ControlResponse { status: "ok", output: Some(output), message: None },
        Err(e) => {
            tracing::warn!(camera = %id, action = %request.action, error = %e, "control failed");
            ControlResponse { status: "error", output: None, message: Some(e.to_string()) }
        }
    }))
}

/// `GET /api/cameras/{id}/cgi_snapshot` — a still straight from the camera.
///
/// Independent of the decode lane, so it works in the passthrough build and
/// costs this service no CPU at all.
pub async fn cgi_snapshot(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> axum::response::Response {
    let Some(camera) = state.get(&id) else {
        return (StatusCode::NOT_FOUND, format!("no camera '{id}'")).into_response();
    };

    match camera.control().snapshot().await {
        Ok(jpeg) => ([(header::CONTENT_TYPE, "image/jpeg")], jpeg).into_response(),
        Err(e) => {
            tracing::warn!(camera = %id, error = %e, "cgi snapshot failed");
            (StatusCode::BAD_GATEWAY, e.to_string()).into_response()
        }
    }
}