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:
Alexandre Possebom
2026-08-06 11:03:22 -03:00
parent 0fbc1c09e1
commit 20bbcdd3e0
11 changed files with 158 additions and 37 deletions
@@ -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>
+31 -20
View File
@@ -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óriano 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óriosquando 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
View File
@@ -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());
}
}
+5 -2
View File
@@ -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"));
}
+7
View File
@@ -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]
+1
View File
@@ -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 {
+6 -1
View File
@@ -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,
+4 -2
View File
@@ -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
+2
View File
@@ -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 }),
+13
View File
@@ -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,