feat(unifi): first-class unifi protocol, snapshot fallback, payload tests
Auto-registered Protect cameras now get protocol='unifi' (new CHECK value) instead of a bogus vzenith/cancela row; the camera form offers UniFi Protect and skips credential requirements for it (webhook-only source). The alarm image falls back to alarm.snapshot when thumbnail is absent, stripping any data-URI prefix. Detection carries the source protocol. 3 unit tests cover the Alarm Manager payload contract.
This commit is contained in:
@@ -88,7 +88,7 @@ export function LocationsActionDialog({ currentRow, open, onOpenChange }: Props)
|
||||
const color = values.color?.trim() || null
|
||||
const crop_percent = values.crop_percent ?? null
|
||||
const password = values.password
|
||||
if (!isEdit && !password) {
|
||||
if (!isEdit && !password && values.protocol !== 'unifi') {
|
||||
form.setError('password', { message: 'Senha é obrigatória.' })
|
||||
return
|
||||
}
|
||||
@@ -192,6 +192,7 @@ export function LocationsActionDialog({ currentRow, open, onOpenChange }: Props)
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value='unifi'>UniFi Protect</SelectItem>
|
||||
<SelectItem value='vzenith'>Vzenith</SelectItem>
|
||||
<SelectItem value='dahua'>Dahua / Intelbras</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const cameraProtocolSchema = z.enum(['vzenith', 'dahua'])
|
||||
export const cameraProtocolSchema = z.enum(['vzenith', 'dahua', 'unifi'])
|
||||
export type CameraProtocol = z.infer<typeof cameraProtocolSchema>
|
||||
|
||||
// Sentido fixo da passagem; null = automático (Vzenith usa o payload, Dahua sem sentido).
|
||||
@@ -35,25 +35,36 @@ export const cameraListSchema = z.array(cameraSchema)
|
||||
|
||||
const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
|
||||
|
||||
export const cameraFormSchema = z.object({
|
||||
ip_address: z.string().trim().min(1, 'IP é obrigatório.'),
|
||||
location_name: z.string().trim().min(1, 'Local é obrigatório.'),
|
||||
color: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(HEX_COLOR, 'Cor deve ser um hex válido (ex: #3B82F6).')
|
||||
.nullable(),
|
||||
crop_percent: z
|
||||
.number()
|
||||
.min(0, 'Deve ser entre 0 e 1.')
|
||||
.max(1, 'Deve ser entre 0 e 1.')
|
||||
.nullable(),
|
||||
protocol: cameraProtocolSchema,
|
||||
username: z.string().trim().min(1, 'Usuário é obrigatório.'),
|
||||
// obrigatória só no create — validado no onSubmit (no edit, vazia = mantém)
|
||||
password: z.string(),
|
||||
fixed_direction: cameraDirectionSchema.nullable(),
|
||||
})
|
||||
export const cameraFormSchema = z
|
||||
.object({
|
||||
ip_address: z.string().trim().min(1, 'IP é obrigatório.'),
|
||||
location_name: z.string().trim().min(1, 'Local é obrigatório.'),
|
||||
color: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(HEX_COLOR, 'Cor deve ser um hex válido (ex: #3B82F6).')
|
||||
.nullable(),
|
||||
crop_percent: z
|
||||
.number()
|
||||
.min(0, 'Deve ser entre 0 e 1.')
|
||||
.max(1, 'Deve ser entre 0 e 1.')
|
||||
.nullable(),
|
||||
protocol: cameraProtocolSchema,
|
||||
// obrigatórios só quando o protocolo fala com a câmera (unifi = webhook, sem credencial);
|
||||
// password obrigatória só no create — validado no onSubmit (no edit, vazia = mantém)
|
||||
username: z.string().trim(),
|
||||
password: z.string(),
|
||||
fixed_direction: cameraDirectionSchema.nullable(),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (values.protocol !== 'unifi' && !values.username) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['username'],
|
||||
message: 'Usuário é obrigatório.',
|
||||
})
|
||||
}
|
||||
})
|
||||
export type CameraForm = z.infer<typeof cameraFormSchema>
|
||||
|
||||
export type CameraCreateInput = {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Câmera UniFi Protect vira protocolo de primeira classe: só empurra webhook
|
||||
-- (Alarm Manager), sem credenciais nem stream. Antes o auto-registro caía em
|
||||
-- 'vzenith' por falta de opção.
|
||||
ALTER TABLE cameras DROP CONSTRAINT cameras_protocol_check;
|
||||
ALTER TABLE cameras ADD CONSTRAINT cameras_protocol_check
|
||||
CHECK (protocol IN ('vzenith', 'dahua', 'unifi'));
|
||||
|
||||
COMMENT ON COLUMN cameras.protocol IS 'Protocolo da câmera: vzenith (binário TCP 30000), dahua (HTTP/CGI Digest) ou unifi (webhook do Protect, sem credenciais)';
|
||||
+79
-11
@@ -1,3 +1,4 @@
|
||||
use crate::models::entities::CameraProtocol;
|
||||
use crate::models::{HealthResponse, MessageResponse, PlateUnifi, PlateWebhook};
|
||||
use crate::repositories::CameraRepository;
|
||||
use crate::services::{ingest, Detection, Outcome};
|
||||
@@ -39,17 +40,9 @@ pub async fn unifi_webhook(State(state): State<AppState>, payload: String) -> im
|
||||
tracing::info!("Plate: {} | Device: {} | Timestamp: {}", trigger.value, trigger.device, trigger.timestamp);
|
||||
}
|
||||
|
||||
// UniFi sends one thumbnail per alarm (if any) — decode it once, share it.
|
||||
let image_bytes: Option<Vec<u8>> = parsed.alarm.thumbnail.as_deref().and_then(|thumbnail| {
|
||||
let base64_data = thumbnail.strip_prefix("data:image/jpeg;base64,").unwrap_or(thumbnail);
|
||||
match STANDARD.decode(base64_data) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to decode base64 thumbnail: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
// UniFi sends one image per alarm — thumbnail, or snapshot when the Alarm
|
||||
// Manager was configured to attach the full snapshot instead. Decode once, share it.
|
||||
let image_bytes: Option<Vec<u8>> = unifi_alarm_image(&parsed.alarm);
|
||||
|
||||
for trigger in &parsed.alarm.triggers {
|
||||
let device = &trigger.device;
|
||||
@@ -65,6 +58,7 @@ pub async fn unifi_webhook(State(state): State<AppState>, payload: String) -> im
|
||||
plate,
|
||||
timestamp: timestamp_utc,
|
||||
camera_ip: device.clone(),
|
||||
protocol: CameraProtocol::Unifi,
|
||||
direction: None,
|
||||
rect: None,
|
||||
image: Box::pin(async move { image }),
|
||||
@@ -80,6 +74,24 @@ pub async fn unifi_webhook(State(state): State<AppState>, payload: String) -> im
|
||||
)
|
||||
}
|
||||
|
||||
/// Extrai a imagem do alarme UniFi: `thumbnail` tem prioridade, `snapshot` é o
|
||||
/// fallback (o Alarm Manager manda um OU outro conforme a configuração da regra).
|
||||
/// Aceita data-URI (`data:image/...;base64,`) ou base64 puro.
|
||||
fn unifi_alarm_image(alarm: &crate::models::plate_unifi::Alarm) -> Option<Vec<u8>> {
|
||||
let raw = alarm.thumbnail.as_deref().or(alarm.snapshot.as_deref())?;
|
||||
let base64_data = match raw.split_once("base64,") {
|
||||
Some((prefix, rest)) if prefix.starts_with("data:") => rest,
|
||||
_ => raw,
|
||||
};
|
||||
match STANDARD.decode(base64_data) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to decode base64 alarm image: {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn plate_webhook(State(state): State<AppState>, Json(payload): Json<PlateWebhook>) -> impl IntoResponse {
|
||||
let plate_result = &payload.alarm_info_plate.result.plate_result;
|
||||
let ip = &payload.alarm_info_plate.ipaddr;
|
||||
@@ -128,6 +140,7 @@ pub async fn plate_webhook(State(state): State<AppState>, Json(payload): Json<Pl
|
||||
plate,
|
||||
timestamp: timestamp_utc,
|
||||
camera_ip: ip.clone(),
|
||||
protocol: CameraProtocol::Vzenith,
|
||||
direction,
|
||||
rect: Some(plate_result.location.rect.clone()),
|
||||
image: Box::pin(async move { image }),
|
||||
@@ -154,3 +167,58 @@ pub async fn plate_webhook(State(state): State<AppState>, Json(payload): Json<Pl
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Payload mínimo válido do Alarm Manager (campos obrigatórios do contrato).
|
||||
fn alarm_json(image_field: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"alarm": {{
|
||||
"conditions": [{{"condition": {{"source": "license_plate", "type": "is"}}}}],
|
||||
"eventLocalLink": "https://protect/x",
|
||||
"eventPath": "/protect/events/x",
|
||||
"name": "LPR rua",
|
||||
"sources": [{{"device": "1C6A1B833434", "type": "include"}}],
|
||||
{image_field}
|
||||
"triggers": [{{
|
||||
"device": "1C6A1B833434",
|
||||
"eventId": "ev1",
|
||||
"group": {{"name": "g"}},
|
||||
"key": "license_plate",
|
||||
"timestamp": 1754500000000,
|
||||
"value": "ABC1D23",
|
||||
"zones": {{"line": [], "loiter": [], "zone": [1]}}
|
||||
}}]
|
||||
}},
|
||||
"timestamp": 1754500000000
|
||||
}}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unifi_payload_parses_and_thumbnail_decodes() {
|
||||
// "GW" em base64, com prefixo data-URI (formato observado em produção).
|
||||
let payload = alarm_json(r#""thumbnail": "data:image/jpeg;base64,R1c=","#);
|
||||
let parsed: PlateUnifi = serde_json::from_str(&payload).expect("payload deve parsear");
|
||||
assert_eq!(parsed.alarm.triggers[0].value, "ABC1D23");
|
||||
assert_eq!(unifi_alarm_image(&parsed.alarm).unwrap(), b"GW");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unifi_snapshot_is_fallback_when_thumbnail_missing() {
|
||||
let payload = alarm_json(r#""snapshot": "R1c=","#);
|
||||
let parsed: PlateUnifi = serde_json::from_str(&payload).expect("payload deve parsear");
|
||||
assert!(parsed.alarm.thumbnail.is_none());
|
||||
assert_eq!(unifi_alarm_image(&parsed.alarm).unwrap(), b"GW");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unifi_alarm_without_image_yields_none() {
|
||||
let payload = alarm_json("");
|
||||
let parsed: PlateUnifi = serde_json::from_str(&payload).expect("payload deve parsear");
|
||||
assert!(unifi_alarm_image(&parsed.alarm).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,10 @@ pub async fn create(
|
||||
return Err(AppError::validation("IP e nome do local são obrigatórios"));
|
||||
}
|
||||
|
||||
if input.username.trim().is_empty() || input.password.is_empty() {
|
||||
// UniFi só empurra webhook — não há credencial de câmera pra exigir.
|
||||
if input.protocol != CameraProtocol::Unifi
|
||||
&& (input.username.trim().is_empty() || input.password.is_empty())
|
||||
{
|
||||
return Err(AppError::validation("Usuário e senha da câmera são obrigatórios"));
|
||||
}
|
||||
|
||||
@@ -158,7 +161,7 @@ pub async fn update(
|
||||
return Err(AppError::validation("Nome do local é obrigatório"));
|
||||
}
|
||||
|
||||
if input.username.trim().is_empty() {
|
||||
if input.protocol != CameraProtocol::Unifi && input.username.trim().is_empty() {
|
||||
return Err(AppError::validation("Usuário da câmera é obrigatório"));
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ pub struct Vehicle {
|
||||
pub enum CameraProtocol {
|
||||
Vzenith,
|
||||
Dahua,
|
||||
/// UniFi Protect: só empurra webhook (Alarm Manager) — sem credenciais,
|
||||
/// sem stream; o "IP" da câmera é o device id do Protect.
|
||||
Unifi,
|
||||
}
|
||||
|
||||
impl CameraProtocol {
|
||||
@@ -71,6 +74,7 @@ impl CameraProtocol {
|
||||
match self {
|
||||
CameraProtocol::Vzenith => "vzenith",
|
||||
CameraProtocol::Dahua => "dahua",
|
||||
CameraProtocol::Unifi => "unifi",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +86,7 @@ impl std::str::FromStr for CameraProtocol {
|
||||
match s {
|
||||
"vzenith" => Ok(CameraProtocol::Vzenith),
|
||||
"dahua" => Ok(CameraProtocol::Dahua),
|
||||
"unifi" => Ok(CameraProtocol::Unifi),
|
||||
_ => Err(format!("Invalid protocol: {}", s)),
|
||||
}
|
||||
}
|
||||
@@ -197,8 +202,10 @@ mod tests {
|
||||
fn camera_protocol_from_str_and_as_str() {
|
||||
assert_eq!("vzenith".parse::<CameraProtocol>().unwrap(), CameraProtocol::Vzenith);
|
||||
assert_eq!("dahua".parse::<CameraProtocol>().unwrap(), CameraProtocol::Dahua);
|
||||
assert_eq!("unifi".parse::<CameraProtocol>().unwrap(), CameraProtocol::Unifi);
|
||||
assert!("intelbras".parse::<CameraProtocol>().is_err());
|
||||
assert_eq!(CameraProtocol::Dahua.as_str(), "dahua");
|
||||
assert_eq!(CameraProtocol::Unifi.as_str(), "unifi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -139,6 +139,7 @@ impl DahuaEventWorker {
|
||||
plate,
|
||||
timestamp: Utc::now(),
|
||||
camera_ip: camera.ip_address.clone(),
|
||||
protocol: CameraProtocol::Dahua,
|
||||
direction: camera.fixed_direction.clone(),
|
||||
rect: None,
|
||||
image: Box::pin(async move {
|
||||
|
||||
@@ -19,7 +19,7 @@ use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
use crate::models::entities::Passage;
|
||||
use crate::models::entities::{CameraProtocol, Passage};
|
||||
use crate::models::plate_webhook::Rect;
|
||||
use crate::repositories::{CameraRepository, PassageRepository, VehicleRepository};
|
||||
use crate::services::{broadcast_passage, process_alerts, ImageService, PlateService};
|
||||
@@ -49,7 +49,10 @@ pub struct Detection {
|
||||
pub plate: Plate,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Camera identifier — its IP address (= `passages.camera_id`).
|
||||
/// For UniFi it's the Protect device id (no IP in the payload).
|
||||
pub camera_ip: String,
|
||||
/// Protocol the source speaks — used to auto-register an unknown camera.
|
||||
pub protocol: CameraProtocol,
|
||||
/// Direction already resolved by the source (`"in"`/`"out"`), or `None`.
|
||||
pub direction: Option<String>,
|
||||
pub rect: Option<Rect>,
|
||||
@@ -70,6 +73,7 @@ pub async fn ingest(state: &AppState, detection: Detection) -> Outcome {
|
||||
plate,
|
||||
timestamp,
|
||||
camera_ip,
|
||||
protocol,
|
||||
direction,
|
||||
rect,
|
||||
image,
|
||||
@@ -99,6 +103,7 @@ pub async fn ingest(state: &AppState, detection: Detection) -> Outcome {
|
||||
plate,
|
||||
timestamp,
|
||||
&camera_ip,
|
||||
protocol,
|
||||
direction.as_deref(),
|
||||
rect.as_ref(),
|
||||
is_whitelisted,
|
||||
|
||||
@@ -11,11 +11,13 @@ impl PlateService {
|
||||
/// Registers a vehicle passage, creating the vehicle and camera if they don't exist
|
||||
///
|
||||
/// Returns the created Passage object
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn register_passage(
|
||||
pool: &Pool<Postgres>,
|
||||
plate: &str,
|
||||
timestamp: DateTime<Utc>,
|
||||
ip_address: &str,
|
||||
protocol: CameraProtocol,
|
||||
direction: Option<&str>,
|
||||
rect: Option<&Rect>,
|
||||
is_whitelisted: bool,
|
||||
@@ -29,9 +31,9 @@ impl PlateService {
|
||||
// Create camera if it doesn't exist (auto-register new cameras)
|
||||
// Check if camera exists first to avoid unnecessary inserts
|
||||
if CameraRepository::get_by_ip(pool, ip_address).await?.is_none() {
|
||||
tracing::info!("Auto-registering new camera: {}", ip_address);
|
||||
tracing::info!("Auto-registering new camera: {} ({})", ip_address, protocol.as_str());
|
||||
// Use ip_address as location_name initially, can be updated later via admin panel
|
||||
CameraRepository::create(pool, ip_address, ip_address, None, 0.07, CameraProtocol::Vzenith, "", "", 0, None, "cancela").await?;
|
||||
CameraRepository::create(pool, ip_address, ip_address, None, 0.07, protocol, "", "", 0, None, "cancela").await?;
|
||||
}
|
||||
|
||||
// Extract rect coordinates
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
mod common;
|
||||
|
||||
use common::TestApp;
|
||||
use gate_watch::models::entities::CameraProtocol;
|
||||
use gate_watch::repositories::{DriverRepository, VehicleRepository};
|
||||
use gate_watch::services::{ingest, Detection, Outcome};
|
||||
use gate_watch::utils::Plate;
|
||||
@@ -14,6 +15,7 @@ fn detection(camera_ip: &str, plate: &str, direction: Option<&str>) -> Detection
|
||||
plate: Plate::parse(plate).expect("valid plate"),
|
||||
timestamp: chrono::Utc::now(),
|
||||
camera_ip: camera_ip.to_string(),
|
||||
protocol: CameraProtocol::Vzenith,
|
||||
direction: direction.map(|s| s.to_string()),
|
||||
rect: None,
|
||||
image: Box::pin(async { Option::<Vec<u8>>::None }),
|
||||
|
||||
@@ -2,6 +2,7 @@ mod common;
|
||||
|
||||
use chrono::Utc;
|
||||
use common::TestDb;
|
||||
use gate_watch::models::entities::CameraProtocol;
|
||||
use gate_watch::models::plate_webhook::Rect;
|
||||
use gate_watch::repositories::{CameraRepository, PassageRepository, VehicleRepository};
|
||||
use gate_watch::services::PlateService;
|
||||
@@ -18,6 +19,7 @@ async fn register_passage_creates_vehicle_and_passage() {
|
||||
plate,
|
||||
timestamp,
|
||||
"192.168.1.10",
|
||||
CameraProtocol::Vzenith,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
@@ -56,6 +58,7 @@ async fn register_passage_creates_camera_if_new() {
|
||||
"XYZ9A88",
|
||||
Utc::now(),
|
||||
ip,
|
||||
CameraProtocol::Vzenith,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
@@ -81,6 +84,7 @@ async fn register_passage_does_not_duplicate_vehicle() {
|
||||
plate,
|
||||
Utc::now(),
|
||||
"192.168.1.20",
|
||||
CameraProtocol::Vzenith,
|
||||
Some("in"),
|
||||
None,
|
||||
false,
|
||||
@@ -93,6 +97,7 @@ async fn register_passage_does_not_duplicate_vehicle() {
|
||||
plate,
|
||||
Utc::now(),
|
||||
"192.168.1.20",
|
||||
CameraProtocol::Vzenith,
|
||||
Some("out"),
|
||||
None,
|
||||
false,
|
||||
@@ -116,6 +121,7 @@ async fn register_passage_stores_direction() {
|
||||
"DIR1A23",
|
||||
Utc::now(),
|
||||
"192.168.1.30",
|
||||
CameraProtocol::Vzenith,
|
||||
Some("in"),
|
||||
None,
|
||||
false,
|
||||
@@ -130,6 +136,7 @@ async fn register_passage_stores_direction() {
|
||||
"DIR2B45",
|
||||
Utc::now(),
|
||||
"192.168.1.30",
|
||||
CameraProtocol::Vzenith,
|
||||
Some("out"),
|
||||
None,
|
||||
false,
|
||||
@@ -168,6 +175,7 @@ async fn register_passage_stores_rect_coordinates() {
|
||||
"RCT1A23",
|
||||
Utc::now(),
|
||||
"192.168.1.40",
|
||||
CameraProtocol::Vzenith,
|
||||
None,
|
||||
Some(&rect),
|
||||
false,
|
||||
@@ -200,6 +208,7 @@ async fn register_passage_with_whitelisted_flag() {
|
||||
"WHL1A23",
|
||||
Utc::now(),
|
||||
"192.168.1.50",
|
||||
CameraProtocol::Vzenith,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
@@ -214,6 +223,7 @@ async fn register_passage_with_whitelisted_flag() {
|
||||
"WHL2B45",
|
||||
Utc::now(),
|
||||
"192.168.1.50",
|
||||
CameraProtocol::Vzenith,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
@@ -248,6 +258,7 @@ async fn register_passage_multiple_passages_same_vehicle() {
|
||||
plate,
|
||||
Utc::now(),
|
||||
"192.168.1.60",
|
||||
CameraProtocol::Vzenith,
|
||||
Some("in"),
|
||||
None,
|
||||
false,
|
||||
@@ -280,6 +291,7 @@ async fn register_passage_different_cameras() {
|
||||
"CAM1A23",
|
||||
Utc::now(),
|
||||
ip_a,
|
||||
CameraProtocol::Vzenith,
|
||||
Some("in"),
|
||||
None,
|
||||
false,
|
||||
@@ -292,6 +304,7 @@ async fn register_passage_different_cameras() {
|
||||
"CAM2B45",
|
||||
Utc::now(),
|
||||
ip_b,
|
||||
CameraProtocol::Vzenith,
|
||||
Some("out"),
|
||||
None,
|
||||
false,
|
||||
|
||||
Reference in New Issue
Block a user