Saltar a contenido

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:

  1. Aparece en eventsources.cgi?msubmenu=videoanalysis2&action=view con Mode no vacío. El Area9 del lab tiene Mode vacío → es un resto de una zona borrada.
  2. Aparece en el stream eventstatus con SchemaBased=True. Confirmado: el stream reporta DefinedAreaID.1 y .2 y 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é:
    slot 2  ←  área 9 de la cámara  ·  "Portón Norte"
    

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:

Alarm Definition
  Trigger : Hanwha Intrusion_2
  Source  : CAM-01
  Nombre  : "Intrusión — Portón Norte"

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→False llega 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:

2026-09-21 02:58:22 >> Intrusion - Porton Norte (CAM-01 zona 2) >> tipo=Hanwha Intrusion_2

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-poll monitordiff&SchemaBased=True&IncludeTimestamp=True con HttpClient + Digest, CancellationToken, reconexión con backoff exponencial, detección de flancos por clave.
  • Diseño para 100+ cámaras: un Task async por cámara sobre un SocketsHttpHandler compartido (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 el Mode de 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] ~~monitordiff probado en vivo~~ ✅ verificado, ver abajo
  • [x] ~~Qué eventos emite hoy el driver Hanwha~~ ✅ sólo 3: IntrusionStart, Tripwire, Tampering (ver investigacion/04-...)
  • [ ] Máximo real de DefinedArea en 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:

  1. No es un long-poll que cierra: es un stream persistente. Content-Type: multipart/x-mixed-replace; boundary=SamsungTechwin, Transfer-Encoding: chunked. Hay que usar HttpCompletionOption.ResponseHeadersRead y leer el stream incrementalmente. Con ReadAsStringAsync el cliente espera un cuerpo que no termina nunca y muere por timeout. Es el error que más tiempo cuesta si no se sabe.

  2. 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).

  3. Cuando cambia una zona, la cámara reenvía todas las claves hermanas de ese tipo de detección, hayan cambiado o no:

    Channel.0.VideoAnalytics.Intrusion=True
    Channel.0.VideoAnalytics.Intrusion.DefinedAreaID.1=True    ← cambió
    Channel.0.VideoAnalytics.Intrusion.DefinedAreaID.2=False   ← NO cambió
    
    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.

  4. Keepalive: chunk vacío cada ~60s. Sirve como detector de cámara viva; si no llega en ~90s, reconectar.

  5. 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.

  6. 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".