Skip to main content

dahua_camera_server/handlers/
control.rs

1//! Out-of-band camera control, forwarded to the
2//! [`CameraControl`](dahua_camera_core::ports::CameraControl) port.
3
4use crate::AppState;
5use axum::extract::{Path, State};
6use axum::http::{header, StatusCode};
7use axum::response::IntoResponse;
8use axum::Json;
9use serde::{Deserialize, Serialize};
10
11/// Body of `POST /api/cameras/{id}/control`.
12#[derive(Debug, Clone, Deserialize)]
13pub struct ControlRequest {
14    /// One of `day_night`, `ir_mode`, `ptz`.
15    pub action: String,
16    /// Action-specific value, e.g. `Night`, `Off`, `Up`.
17    pub value: String,
18}
19
20/// Response to a control request.
21#[derive(Debug, Clone, Serialize)]
22pub struct ControlResponse {
23    /// `ok` or `error`.
24    pub status: &'static str,
25    /// The camera's own reply, on success.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub output: Option<String>,
28    /// What went wrong, on failure.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub message: Option<String>,
31}
32
33/// `POST /api/cameras/{id}/control` — day/night, IR, or PTZ.
34///
35/// An unknown action is a `400`, but a camera that refuses a valid action
36/// comes back `200` with `status: "error"`: the request was well-formed and
37/// the UI wants to show the camera's complaint rather than treat it as a bug.
38pub async fn control_camera(
39    State(state): State<AppState>,
40    Path(id): Path<String>,
41    Json(request): Json<ControlRequest>,
42) -> Result<Json<ControlResponse>, StatusCode> {
43    let camera = state.get(&id).ok_or(StatusCode::NOT_FOUND)?;
44    let control = camera.control();
45
46    let result = match request.action.as_str() {
47        "day_night" => control.set_day_night(&request.value).await,
48        "ir_mode" => control.set_ir_mode(&request.value).await,
49        "ptz" => control.ptz_move(&request.value).await,
50        _ => return Err(StatusCode::BAD_REQUEST),
51    };
52
53    Ok(Json(match result {
54        Ok(output) => ControlResponse { status: "ok", output: Some(output), message: None },
55        Err(e) => {
56            tracing::warn!(camera = %id, action = %request.action, error = %e, "control failed");
57            ControlResponse { status: "error", output: None, message: Some(e.to_string()) }
58        }
59    }))
60}
61
62/// `GET /api/cameras/{id}/cgi_snapshot` — a still straight from the camera.
63///
64/// Independent of the decode lane, so it works in the passthrough build and
65/// costs this service no CPU at all.
66pub async fn cgi_snapshot(
67    State(state): State<AppState>,
68    Path(id): Path<String>,
69) -> axum::response::Response {
70    let Some(camera) = state.get(&id) else {
71        return (StatusCode::NOT_FOUND, format!("no camera '{id}'")).into_response();
72    };
73
74    match camera.control().snapshot().await {
75        Ok(jpeg) => ([(header::CONTENT_TYPE, "image/jpeg")], jpeg).into_response(),
76        Err(e) => {
77            tracing::warn!(camera = %id, error = %e, "cgi snapshot failed");
78            (StatusCode::BAD_GATEWAY, e.to_string()).into_response()
79        }
80    }
81}