Skip to main content

heldar_kernel/routes/
onvif.rs

1//! ONVIF (Profile S MVP) API: network discovery, a per-camera device probe, and PTZ control.
2//!
3//! Discovery + probe + every PTZ command are managed by manager+ (they touch devices / change
4//! state); reading a camera's stored ONVIF profile and its PTZ presets is open to any authenticated
5//! principal. All mutating calls are written to the immutable audit log. Out of scope for this MVP:
6//! ONVIF events, Profile G (recording/replay), Profile T, imaging, and absolute/relative PTZ moves.
7
8use axum::extract::{Path, State};
9use axum::routing::{get, post};
10use axum::{Json, Router};
11use serde::Deserialize;
12use serde_json::{json, Value};
13
14use crate::auth::{self, Principal};
15use crate::error::AppResult;
16use crate::models::{CameraOnvif, PtzPreset};
17use crate::routes::cameras::load_camera;
18use crate::services::onvif;
19use crate::state::AppState;
20
21pub fn router() -> Router<AppState> {
22    Router::new()
23        .route("/api/v1/onvif/discover", post(discover))
24        .route("/api/v1/cameras/{id}/onvif", get(get_onvif))
25        .route("/api/v1/cameras/{id}/onvif/probe", post(probe))
26        .route("/api/v1/cameras/{id}/ptz/presets", get(list_presets))
27        .route(
28            "/api/v1/cameras/{id}/ptz/presets/refresh",
29            post(refresh_presets),
30        )
31        .route("/api/v1/cameras/{id}/ptz/continuous", post(continuous_move))
32        .route("/api/v1/cameras/{id}/ptz/stop", post(ptz_stop))
33        .route("/api/v1/cameras/{id}/ptz/goto_preset", post(goto_preset))
34}
35
36// ---- Discovery ----
37
38async fn discover(State(st): State<AppState>, principal: Principal) -> AppResult<Json<Value>> {
39    principal.require(principal.can_manage_registry(), "run ONVIF discovery")?;
40    let devices = onvif::discover(&st.cfg).await?;
41    auth::audit(
42        &st.pool,
43        &principal,
44        "onvif_discover",
45        "onvif",
46        "discovery",
47        json!({ "found": devices.len() }),
48    )
49    .await;
50    Ok(Json(json!({
51        "found": devices.len(),
52        "devices": devices,
53    })))
54}
55
56// ---- Per-camera device profile ----
57
58async fn get_onvif(
59    State(st): State<AppState>,
60    Path(id): Path<String>,
61    principal: Principal,
62) -> AppResult<Json<CameraOnvif>> {
63    principal.require(principal.can_view(), "view ONVIF profile")?;
64    let _ = load_camera(&st.pool, &id).await?;
65    Ok(Json(onvif::load_onvif(&st.pool, &id).await?))
66}
67
68#[derive(Debug, Default, Deserialize)]
69struct ProbeRequest {
70    /// Optional explicit ONVIF device service URL (e.g. `http://host/onvif/device_service`). When
71    /// omitted, the URL is taken from a prior probe or derived from the camera's address.
72    device_url: Option<String>,
73}
74
75async fn probe(
76    State(st): State<AppState>,
77    Path(id): Path<String>,
78    principal: Principal,
79    body: Option<Json<ProbeRequest>>,
80) -> AppResult<Json<CameraOnvif>> {
81    principal.require(principal.can_manage_registry(), "probe ONVIF devices")?;
82    let _ = load_camera(&st.pool, &id).await?;
83    let device_url = body.and_then(|Json(b)| b.device_url);
84    let onvif = onvif::probe(&st, &id, device_url).await?;
85    auth::audit(
86        &st.pool,
87        &principal,
88        "onvif_probe",
89        "camera",
90        &id,
91        json!({
92            "manufacturer": onvif.manufacturer,
93            "model": onvif.model,
94            "ptz_enabled": onvif.ptz_enabled,
95        }),
96    )
97    .await;
98    Ok(Json(onvif))
99}
100
101// ---- PTZ presets ----
102
103async fn list_presets(
104    State(st): State<AppState>,
105    Path(id): Path<String>,
106    principal: Principal,
107) -> AppResult<Json<Vec<PtzPreset>>> {
108    principal.require(principal.can_view(), "view PTZ presets")?;
109    let _ = load_camera(&st.pool, &id).await?;
110    let rows = sqlx::query_as::<_, PtzPreset>(
111        "SELECT * FROM camera_ptz_presets WHERE camera_id = ? ORDER BY token ASC",
112    )
113    .bind(&id)
114    .fetch_all(&st.pool)
115    .await?;
116    Ok(Json(rows))
117}
118
119async fn refresh_presets(
120    State(st): State<AppState>,
121    Path(id): Path<String>,
122    principal: Principal,
123) -> AppResult<Json<Vec<PtzPreset>>> {
124    principal.require(principal.can_manage_registry(), "refresh PTZ presets")?;
125    let _ = load_camera(&st.pool, &id).await?;
126    let presets = onvif::get_presets(&st, &id).await?;
127    auth::audit(
128        &st.pool,
129        &principal,
130        "ptz_refresh_presets",
131        "camera",
132        &id,
133        json!({ "count": presets.len() }),
134    )
135    .await;
136    Ok(Json(presets))
137}
138
139// ---- PTZ movement ----
140
141#[derive(Debug, Deserialize)]
142struct ContinuousMoveRequest {
143    #[serde(default)]
144    pan: f64,
145    #[serde(default)]
146    tilt: f64,
147    #[serde(default)]
148    zoom: f64,
149}
150
151async fn continuous_move(
152    State(st): State<AppState>,
153    Path(id): Path<String>,
154    principal: Principal,
155    Json(body): Json<ContinuousMoveRequest>,
156) -> AppResult<Json<Value>> {
157    principal.require(principal.can_manage_registry(), "control PTZ")?;
158    let _ = load_camera(&st.pool, &id).await?;
159    onvif::continuous_move(&st, &id, body.pan, body.tilt, body.zoom).await?;
160    auth::audit(
161        &st.pool,
162        &principal,
163        "ptz_continuous_move",
164        "camera",
165        &id,
166        json!({ "pan": body.pan, "tilt": body.tilt, "zoom": body.zoom }),
167    )
168    .await;
169    Ok(Json(json!({ "ok": true })))
170}
171
172async fn ptz_stop(
173    State(st): State<AppState>,
174    Path(id): Path<String>,
175    principal: Principal,
176) -> AppResult<Json<Value>> {
177    principal.require(principal.can_manage_registry(), "control PTZ")?;
178    let _ = load_camera(&st.pool, &id).await?;
179    onvif::stop(&st, &id).await?;
180    auth::audit(&st.pool, &principal, "ptz_stop", "camera", &id, json!({})).await;
181    Ok(Json(json!({ "ok": true })))
182}
183
184#[derive(Debug, Deserialize)]
185struct GotoPresetRequest {
186    token: String,
187}
188
189async fn goto_preset(
190    State(st): State<AppState>,
191    Path(id): Path<String>,
192    principal: Principal,
193    Json(body): Json<GotoPresetRequest>,
194) -> AppResult<Json<Value>> {
195    principal.require(principal.can_manage_registry(), "control PTZ")?;
196    let _ = load_camera(&st.pool, &id).await?;
197    onvif::goto_preset(&st, &id, &body.token).await?;
198    auth::audit(
199        &st.pool,
200        &principal,
201        "ptz_goto_preset",
202        "camera",
203        &id,
204        json!({ "token": body.token }),
205    )
206    .await;
207    Ok(Json(json!({ "ok": true })))
208}