Plan de desarrollo — Integración Hanwha → Milestone XProtect con zonas¶
Basado en
investigacion/01-hallazgos.md. Todo lo marcado ✅ está verificado en vivo contra el servidor real y la cámara real, no asumido.
Objetivo¶
Hoy Milestone recibe de las Hanwha un evento plano: "hubo intrusión en CAM-01". No dice en qué zona. El objetivo es que reciba "intrusión en la zona 2 de CAM-01", de forma que se puedan armar reglas y definiciones de alarma distintas por zona.
Decisiones tomadas¶
| Tema | Decisión |
|---|---|
| Arquitectura | Servicio Windows .NET 8 + UI web. Sin plugin MIP (por ahora) |
| Escala objetivo | 100+ cámaras |
| Inyección a Milestone | Analytics Events, TCP 9090 |
| Nombres de zona | Autogenerados, editables en el panel |
| Patrón de tipos de evento | Copiar el de Hikvision: sufijo _N con el índice de zona |
| Cantidad de zonas | 5 zonas y 5 líneas |
| Alcance de detecciones | Sólo Intrusion y Line Crossing (no Entering/Exiting/Loitering/Appearing) |
Índice _N |
Slot lógico 1..5, no el area ID crudo de la cámara |
| Estado del evento | Inicio y fin |
Arquitectura¶
┌──────────────────────────────────────────────────────────────────┐
│ HanwhaZoneBridge (servicio Windows, .NET 8) │
│ │
│ ┌────────────────┐ MIP SDK ┌──────────────────────────┐ │
│ │ VmsInventory │◄──────────────►│ Management Server │ │
│ │ cámaras, │ lee grupos, │ (fuente de verdad) │ │
│ │ grupos, creds │ IPs y claves └──────────────────────────┘ │
│ └───────┬────────┘ │
│ │ │
│ ┌───────▼────────────────────────┐ │
│ │ CameraPoller (1 por cámara) │ SUNAPI long-poll │
│ │ monitordiff + SchemaBased │──────────────► Cámara Hanwha │
│ │ → detecta flancos por zona │ vía VPN │
│ └───────┬────────────────────────┘ │
│ │ ZoneEvent(camera, detection, zoneId, rising/falling) │
│ ┌───────▼────────────────────────┐ │
│ │ EventMapper │ aplica config: ¿habilitado? │
│ │ → nombre de tipo + nombre lindo│ ¿anti-rebote? ¿nombre zona?│
│ └───────┬────────────────────────┘ │
│ ┌───────▼────────────────────────┐ XML AnalyticsEvent │
│ │ AnalyticsEventSink │──────────────► Event Server │
│ └────────────────────────────────┘ TCP 9090 │
│ │
│ ┌────────────────────────────────┐ │
│ │ ASP.NET Core — API + UI web │◄──── navegador :8080 │
│ └────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
Por qué el servicio no guarda credenciales ✅¶
Verificado: el MIP SDK devuelve la contraseña del hardware en claro
(Get-VmsHardwarePassword, igual que el botón del Management Client).
El servicio se autentica una vez contra el Management Server con su cuenta
de servicio y de ahí saca IP + usuario + clave de cada cámara.
Consecuencia práctica a 100+ cámaras: dar de alta una Hanwha en Milestone es todo lo que hay que hacer. El panel la ve aparecer sola. No hay un segundo inventario que mantener sincronizado ni credenciales duplicadas que se desactualicen.
Nomenclatura de los Analytics Event Types¶
Copiando el patrón Hikvision verificado en este mismo sistema
(Intrusion_0..Intrusion_5), pero con la numeración nativa de Hanwha, que
es 1-based, para que coincida con lo que muestra el web de la cámara:
| Evento SUNAPI | Analytics Event Type |
|---|---|
VideoAnalytics.Intrusion.DefinedAreaID.* |
Hanwha Intrusion_1 … _5 |
VideoAnalytics.Passing.LineID.* |
Hanwha Line Crossing_1 … _5 |
10 tipos en total. Nada más.
Alcance deliberadamente acotado a intrusión y cruce de línea. Entering,
Exiting, Loitering y Appearing la cámara los expone igual y el poller los
va a leer, pero no se emiten a Milestone para no llenar el Management
Client de tipos que nadie usa. Si más adelante hacen falta, es sumar tipos: el
motor ya los tiene.
El prefijo Hanwha evita colisión con los Intrusion_0 de Hikvision que ya
existen en este sistema.
El mapping: area ID de la cámara → slot 1..5¶
_N no es el area ID de la cámara. Es un slot lógico. Hace falta porque los
area IDs de la cámara no son contiguos: la TNO-4050T del lab tiene áreas
1, 2 y 9.
Cámara CAM-01 Mapping Milestone
───────────────── ─────── ─────────
DefinedArea 1 (activa) ──────► slot 1 ─────► Hanwha Intrusion_1
DefinedArea 2 (activa) ──────► slot 2 ─────► Hanwha Intrusion_2
DefinedArea 9 (Mode vacío) (ignorada)
Bien: el mapping es por cámara. Cada cámara mapea sus propias áreas a sus propios slots, y los 10 tipos de evento se reutilizan en las 100+ cámaras.
Qué cuenta como zona "disponible"¶
Una zona entra al mapping si cumple las dos condiciones, y ambas ya están verificadas contra la cámara real:
- Aparece en
eventsources.cgi?msubmenu=videoanalysis2&action=viewconModeno vacío. ElArea9del lab tieneModevacío → es un resto de una zona borrada. - Aparece en el stream
eventstatusconSchemaBased=True. Confirmado: el stream reportaDefinedAreaID.1y.2y omite la 9. La cámara ya filtra sola las zonas muertas.
⚠️ El mapping tiene que ser pegajoso, no posicional¶
Es la decisión importante de esta parte, y la forma intuitiva es la incorrecta.
Si el slot se asigna por posición ("la primera área que existe va al slot 1"), el día que alguien borra una zona en la cámara todos los slots siguientes se corren, y las alarmas ya definidas en Milestone pasan a apuntar a la zona física equivocada. Sin ningún error. La alarma "Intrusión Portón Norte" empieza a dispararse por el portón sur.
ANTES alguien borra el área 2 en la cámara
área 1 → slot 1 │
área 2 → slot 2 ▼
área 9 → slot 3 POSICIONAL (mal) PEGAJOSO (bien)
área 1 → slot 1 área 1 → slot 1
área 9 → slot 2 ✗ área 9 → slot 3 ✓
↑ ↑
la regla del slot 2 el slot 2 queda
ahora dispara por libre; la regla
la zona equivocada del 3 sigue bien
Diseño: el par (cámara, areaIdDeLaCámara) → slot se asigna una sola vez y
se persiste. Nunca se recalcula. Si se borra un área, su slot queda libre y se
reusa recién cuando aparece un área nueva. Renombrar o reubicar una zona en la
cámara no afecta el mapping mientras el area ID no cambie.
Casos a cubrir en el panel¶
- Área nueva detectada → toma el primer slot libre, avisa en el panel.
- Área desaparecida → el slot queda huérfano; se marca en el panel para que decidas si liberarlo (y revisar la alarma que lo usaba) o dejarlo reservado.
- Más de 5 áreas activas en una cámara → no entran. El panel lo marca en rojo y te deja elegir cuáles 5 mapear, en vez de perder eventos en silencio.
- El panel muestra siempre las dos numeraciones, para que al armar la regla en Milestone sepas qué es qué:
El nombre lindo de la zona¶
El tipo de evento es genérico y reutilizable por las 100+ cámaras. El nombre
que cargás en el panel ("Portón Norte") viaja en el Description/Message del
evento, así el operador lo ve en el Smart Client, y se usa para sugerir el
nombre de la definición de alarma:
Eventos de fin¶
Ya no es un problema. Con el alcance acotado a intrusión y cruce de línea, inicio + fin son 20 tipos, no 60:
Hanwha Intrusion_1 .. _5 Hanwha Intrusion_1 End .. _5 End
Hanwha Line Crossing_1 .. _5 Hanwha Line Crossing_1 End .. _5 End
Se crean los 20 desde el arranque. Es una lista corta y el fin habilita reglas de "grabar mientras dure la intrusión", que es donde está la mayor parte del valor.
Nota: el cruce de línea es instantáneo por naturaleza — la transición
True→Falsellega unos segundos después y el evento de fin aporta poco. Se emite igual por consistencia, pero probablemente no lo uses en reglas.
Fases¶
✅ Fase 0 — Prueba de extremo a extremo — COMPLETADA¶
Era el único tramo no verificado y ya está cerrado. Resultado:
Evento inyectado en el 9090 → aceptado → asociado a CAM-01 → dispara la
definición de alarma. Prueba de control: se enviaron Hanwha Intrusion_1 y
Hanwha Intrusion_2; ambos aceptados, sólo el _2 generó alarma porque
sólo el _2 tiene definición. Discriminación por zona confirmada.
El contrato real del protocolo (namespace obligatorio, el nombre del tipo va en
<Message> y no en <Type>, el origen se resuelve por IP o FQID y nunca por
nombre de cámara, y el servidor responde 200 OK incluso cuando descarta) está
en investigacion/05-protocolo-analytics-events.md.
Vale la pena leerlo antes de tocar el AnalyticsEventSink: son tres fallas
silenciosas.
También quedó verificado que crear Analytics Event Types y Alarm Definitions por Configuration API funciona, así que la autoconfiguración de la Fase 2 es viable.
Fase 1 — Motor¶
VmsInventory: conexión MIP SDK, listado de grupos/cámaras, filtro por driver Hanwha, lectura de credenciales, refresco periódico.CameraPoller: long-pollmonitordiff&SchemaBased=True&IncludeTimestamp=TrueconHttpClient+ Digest,CancellationToken, reconexión con backoff exponencial, detección de flancos por clave.- Diseño para 100+ cámaras: un
Taskasync por cámara sobre unSocketsHttpHandlercompartido (sin hilo por cámara), límite de concurrencia configurable, y métrica de salud por cámara (última respuesta, errores consecutivos, latencia). AnalyticsEventSink: pool de conexiones TCP al 9090, cola con contrapresión, reintento.
Fase 2 — Configuración y autoconfiguración¶
- Almacén de config propio (SQLite o JSON) con: cámara → zona → habilitada, nombre lindo, anti-rebote.
- Autodescubrimiento de zonas reales por cámara vía
eventsources.cgi?msubmenu=videoanalysis2&action=view, incluyendo elModede cada zona (qué analíticas tiene activas) y la geometría. - Creación automática de los Analytics Event Types que falten en Milestone.
Fase 3 — Panel web¶
Vista de árbol de los grupos de cámaras tal como están en Milestone:
┌─ Integración Hanwha ──────────────────┐
│ Grupo: hanwa │
│ ▸ CAM-01 192.0.2.10 ● online │
│ ☑ Zona 1 [Playa de carga] │
│ ☑Intrusion ☑Entering ☐Loiter │
│ ☑ Zona 2 [Portón Norte] │
│ ☑Intrusion ☑Appearing │
│ ☐ Línea 1 [Cruce perimetral] │
└───────────────────────────────────────┘
- Estado en vivo por cámara (online/offline, último evento).
- Habilitar/deshabilitar envío por cámara, por zona y por tipo de detección.
- Renombrar zonas.
- Visor de eventos en vivo para diagnóstico (ver que la zona llega bien antes de armar la alarma).
- Nice to have: dibujar la geometría de la zona sobre un snapshot de la cámara, ya que tenemos las coordenadas.
Fase 4 — Producción¶
- Instalación como servicio Windows, arranque automático, logging rotativo.
- Cuenta de servicio con rol de administrador en el VMS.
- Documento de puesta en marcha y prueba de carga con cámaras simuladas (no hay 100 Hanwha en el lab: hay que simular).
Riesgos¶
| Riesgo | Mitigación |
|---|---|
| El Event Server rechaza eventos de tipos no registrados | Fase 0 lo valida primero; la app autocrea los tipos |
| 100+ long-polls saturan CPU o la VPN | Async sin hilos dedicados, límite de concurrencia, monitordiff (sólo cambios) |
Firmware Hanwha distinto no soporta SchemaBased |
Detectar por attributes.cgi al conectar y degradar a evento sin zona avisando en el panel |
| Doble notificación: el driver nativo de Milestone ya emite el evento plano | Conviven; documentar y dejar que la regla use el tipo con zona |
Colisión con el HanwhaVisionPluginServer 1.09 instalado |
Prefijo Hanwha propio y revisión de qué emite ese plugin hoy |
| Cambio de IP o credencial de cámara | Se lee de Milestone en cada refresco, se corrige solo |
Pendientes de verificar¶
- [ ] Envío real al 9090 aceptado y asociado a la cámara ← lo próximo
- [ ] Lista blanca de IPs en Analytics Events
- [x] ~~
monitordiffprobado en vivo~~ ✅ verificado, ver abajo - [x] ~~Qué eventos emite hoy el driver Hanwha~~ ✅ sólo 3:
IntrusionStart,Tripwire,Tampering(verinvestigacion/04-...) - [ ] Máximo real de
DefinedAreaen el firmware de la TNO-4050T
Apéndice — monitordiff verificado en vivo ✅¶
Capturados eventos de intrusión reales, con zona, inicio y fin:
23:09:09 ACTIVO Intrusion zona 1
23:09:15 fin Intrusion zona 1
23:09:27 ACTIVO Intrusion zona 2
23:09:30 fin Intrusion zona 2
Detalles que condicionan la implementación del CameraPoller:
-
No es un long-poll que cierra: es un stream persistente.
Content-Type: multipart/x-mixed-replace; boundary=SamsungTechwin,Transfer-Encoding: chunked. Hay que usarHttpCompletionOption.ResponseHeadersReady leer el stream incrementalmente. ConReadAsStringAsyncel cliente espera un cuerpo que no termina nunca y muere por timeout. Es el error que más tiempo cuesta si no se sabe. -
El primer chunk es el estado completo, los siguientes sólo los cambios. El poller debe tomarlo como línea de base y no emitir eventos con él (si no, inunda Milestone con ~50 eventos en cada reconexión).
-
Cuando cambia una zona, la cámara reenvía todas las claves hermanas de ese tipo de detección, hayan cambiado o no:
Sin comparar contra el estado previo se generan eventos de "fin" fantasma. El poller necesita detección de flanco real por clave, no confiar en el diff. -
Keepalive: chunk vacío cada ~60s. Sirve como detector de cámara viva; si no llega en ~90s, reconectar.
-
Timestamp de la cámara en cada chunk (
Timestamp=2026-09-21T01:57:50.293+00:00), que conviene propagar a Milestone en vez de la hora de llegada. -
El stream se cierra solo cada varios minutos → reconexión con backoff y re-toma de línea de base, obligatorio.
Implementación de referencia funcionando:
scripts/eventos_hanwha_zonas.ps1
Los índices de zona no son contiguos — resuelto con el mapping¶
La cámara de lab devolvió Area9 en su configuración, con Mode vacío:
resto de una zona borrada. Confirma que los area IDs no son 1..N contiguos.
Verificación clave: el stream eventstatus con SchemaBased=True reporta
DefinedAreaID.1 y .2 y omite la 9. La cámara ya filtra las zonas sin
modo activo, así que el descubrimiento de "zonas disponibles" es confiable.
Esto es lo que resuelve el mapping pegajoso (cámara, areaId) → slot 1..5
descrito arriba. El límite de 5 pasa a ser "5 zonas activas por cámara",
que es una restricción mucho más razonable que "índices 1 a 5".