Hallazgos de reconocimiento — 2026-09-20¶
Entorno¶
Servidor Milestone (192.0.2.50)¶
- Windows Server 2022 Standard Evaluation, host
SRV-MILESTONE - Acceso:
ssh 192.0.2.50 -l Administrator -i ~/.ssh/id_ed25519 - XProtect VMS 2026 R1 (26.1.19310.1) — all-in-one: Management Server, Recording Server, Event Server, Mobile Server, API Gateway, Data Collector
- Device Pack 14.1a (14.1.2294.1) + Legacy Device Pack 3.0a
- Smart Client 2026 R1 y Management Client instalados localmente
- .NET 8.0.26 + ASP.NET Core 8 Hosting Bundle presentes
- SQL Server
MSSQLSERVERlocal, DBSurveillance(+Surveillance_IDP,Surveillance_IM) sqlcmddisponible enC:\Program Files\Microsoft SQL Server\Client SDK\ODBC\170\Tools\Binn\
Puertos en escucha relevantes¶
| Puerto | Proceso | Uso |
|---|---|---|
| 9090 | VideoOS.Event.Server.exe (PID 2844) |
Analytics Events — el que queremos usar |
| 7563 | Recording Server | streaming |
| 80 / 443 / 22331 | http.sys | Management Server / API Gateway |
El puerto 9090 ya está escuchando, o sea Analytics Events está habilitado en el Event Server. Falta confirmar si tiene lista blanca de IPs de origen.
Plugins MIP ya instalados (C:\Program Files\Milestone\MIPPlugins)¶
Hanwha: HanwhaVisionCameraSetting, HanwhaVisionIntercom(+Server),
HanwhaVisionIPSpeaker, HanwhaVisionLivePlug-in, HanwhaVisionVehicleManagement,
AICameraServer, AIPack, AISearch* (Face, LicensePlate, Person, Vehicle,
Barcode, RoadAI, Similarity, WiseDetector)
Hikvision: HikMngtPlugin, HikANPRPlugin, HikFacePlugin, HikThermalPlugin,
HikPeopleCountingPlugin, HikSpeakerPlugin
Comunes: AlarmServicePlugin, CommonPlugin, SpeakerActionPlugin
Hay
HanwhaVisionPluginServeryHanwhaVisionPluginClient1.09.00 instalados. Sirven como referencia de estructura de un plugin MIP de terceros, y también hay que verificar que nuestro desarrollo no colisione/duplique con ellos.
Red hacia cámaras¶
El servidor Windows llega a las cámaras por VPN FortiClient
(Ethernet 3 = 192.0.2.200). El host Linux donde corre Claude NO llega
a las cámaras: todo sondeo a cámaras debe hacerse vía SSH en el Windows.
Inventario actual en Milestone (DB Surveillance)¶
| Hardware | URI | User | Modelo | Driver |
|---|---|---|---|---|
| Hanwha Vision TNO-4050T | http://192.0.2.10/ | admin | TNO-4050T | 788 |
| HikVision DS-2TD2138-15/QY | http://192.0.2.21/ | admin | DS-2TD2138-15/QY | 793 |
| Hikvision DS-2TD2138-25/QY | http://192.0.2.20/ | admin | DS-2TD2138-25/QY | 50001 |
Grupos de cámaras existentes: hanwa (1 cámara: CAM-01), hik (2 cámaras).
Los grupos existen por cada DeviceType (Camera, Input, Output, Metadata, AudioInput/Output).
Tablas útiles: Hardware, Devices, DeviceGroups, DeviceGroupMembers.
Claves: Hardware.IDHardware ← Devices.IDHardware; DeviceGroupMembers une
IDDeviceGroup ↔ IDDevice. Hardware.EncryptedPassword guarda la credencial
(encriptada con DPAPI del servicio — no trivial de descifrar desde afuera).
Cámara de laboratorio: Hanwha TNO-4050T @ 192.0.2.10¶
Model=TNO-4050T (térmica)
FirmwareVersion=2.11.13_20260608_R168
CGIVersion=2.6.0
ONVIFVersion=20.12
ConnectedMACAddress=AA:BB:CC:DD:EE:FF
DeviceType=NWC
⚠️ Trampa importante: curl 7.79.1 NO puede autenticar contra esta cámara¶
C:\Windows\System32\curl.exe en Server 2022 es curl 7.79.1 y falla el
handshake Digest contra el lighttpd de la cámara: recibe el 401 con
WWW-Authenticate: Digest realm="iPolis_..." charset="UTF-8" qop="auth",
reutiliza la conexión y nunca manda la segunda request. Termina con
exit=67 (login denied) y salida vacía.
Las credenciales son correctas. El método estaba mal.
El script actual del escritorio (eventos_hanwha.ps1) hoy devolvería vacío
por esta razón.
Solución: usar System.Net.Http.HttpClient con CredentialCache + "Digest",
que sí negocia bien:
Add-Type -AssemblyName System.Net.Http
$cc = New-Object System.Net.CredentialCache
$cc.Add((New-Object Uri("http://$ip/")), "Digest",
(New-Object System.Net.NetworkCredential($user,$pass)))
$h = New-Object System.Net.Http.HttpClientHandler; $h.Credentials = $cc
$c = New-Object System.Net.Http.HttpClient($h)
$r = $c.GetAsync("http://$ip/stw-cgi/...").Result
En .NET 8 (SocketsHttpHandler) el Digest funciona igual de bien, así que el
desarrollo final no tiene este problema.
🎯 EL HALLAZGO CLAVE: la zona SÍ se puede discriminar¶
El problema¶
eventstatus.cgi?action=check (lo que usa el script actual) devuelve el evento
agregado, sin zona:
Channel.0.VideoAnalytics.Intrusion=False
Channel.0.VideoAnalytics.Appearing=True ← ¿en qué zona? no se sabe
La solución: &SchemaBased=True¶
Channel.0.MotionDetection.RegionID.1=False
Channel.0.VideoAnalytics.Passing=False
Channel.0.VideoAnalytics.Passing.LineID.1=False ← por línea
Channel.0.VideoAnalytics.Intrusion=False
Channel.0.VideoAnalytics.Intrusion.DefinedAreaID.1=False ← por zona
Channel.0.VideoAnalytics.Intrusion.DefinedAreaID.2=False
Channel.0.VideoAnalytics.Entering.DefinedAreaID.1=False
Channel.0.VideoAnalytics.Entering.DefinedAreaID.2=False
Channel.0.VideoAnalytics.Exiting.DefinedAreaID.1=False
Channel.0.VideoAnalytics.Exiting.DefinedAreaID.2=False
Channel.0.VideoAnalytics.Appearing=True
Channel.0.VideoAnalytics.Appearing.DefinedAreaID.1=False
Channel.0.VideoAnalytics.Appearing.DefinedAreaID.2=True ← ZONA 2 ✅
Channel.0.VideoAnalytics.Loitering.DefinedAreaID.1=False
Channel.0.VideoAnalytics.Loitering.DefinedAreaID.2=False
No hace falta scraping, ni metadata stream RTSP, ni ONVIF PullPoint.
Esquema declarado por la propia cámara¶
eventstatus.cgi?msubmenu=eventstatusschema&action=view&EventName=VideoAnalytics
devuelve el contrato explícito:
Channel.<int>.VideoAnalytics.Passing / .Passing.LineID.<int>
Channel.<int>.VideoAnalytics.Intrusion / .Intrusion.DefinedAreaID.<int>
Channel.<int>.VideoAnalytics.Entering / .Entering.DefinedAreaID.<int>
Channel.<int>.VideoAnalytics.Exiting / .Exiting.DefinedAreaID.<int>
Channel.<int>.VideoAnalytics.Appearing / .Appearing.DefinedAreaID.<int>
Channel.<int>.VideoAnalytics.Loitering / .Loitering.DefinedAreaID.<int>
EventName acepta también AlarmInput, AlarmOutput, MotionDetection,
VideoAnalytics, y más (ver attributes.cgi/eventstatus).
Esto permite auto-descubrir qué reporta cada modelo en vez de hardcodear.
Mejor patrón de sondeo: action=monitordiff¶
attributes.cgi/eventstatus expone tres acciones:
- check — snapshot puntual (lo que usa el script actual, en un while($true) → polling agresivo)
- monitor — long-polling, estado completo, con Periodicity (1..5 s)
- monitordiff — long-polling que devuelve sólo lo que cambió ✅
Todas soportan SchemaBased, IncludeTimestamp=True (devuelve Timestamp ISO-8601)
y filtro Channel.#.EventType. monitordiff + SchemaBased=True + IncludeTimestamp=True
es el patrón correcto: baja latencia, poca CPU en cámara, timestamp de la cámara.
Nota: el SunapiSeqId del script actual no aparece en attributes.cgi para
check — parece ignorado. El mecanismo real de secuencia lo maneja monitor/monitordiff.
Geometría de las zonas (para dibujarlas en la UI)¶
eventsources.cgi?msubmenu=videoanalysis2&action=view (JSON) devuelve
ROIs, Lines y DefinedAreas con coordenadas, y por cada DefinedArea
el Mode (qué analíticas tiene habilitadas) y duraciones:
{ "DefinedArea": 1, "Type": "Inside",
"Mode": ["AppearDisappear","Entering","Exiting","Intrusion","Loitering"],
"Coordinates": [{"x":332,"y":99},{"x":0,"y":621},{"x":458,"y":637},{"x":479,"y":167}],
"AppearanceDuration": 10, "LoiteringDuration": 17, "IntrusionDuration": 1 }
La cámara no guarda un nombre para cada zona, sólo DefinedAreaID 1..N.
El mapeo DefinedAreaID 2 → "Portón Norte" tiene que vivir en nuestra aplicación.
videoanalysis (la v1, texto plano) da lo mismo pero recortado — usar videoanalysis2.
Otros datos¶
eventstatus.cgi?msubmenu=eventscheme&action=view→Type=Proprietary(se puede cambiar aONVIF; no tocar, cambiaría el formato de eventos y podría afectar al driver de Milestone que ya está usando la cámara).- Coordenadas en este modelo van en un espacio 480x640 (nótese invertido, cámara térmica 640x480 rotada en el sistema de coordenadas).
Estado de verificación¶
| Ítem | Estado |
|---|---|
| SSH al servidor Milestone | ✅ verificado |
| Inventario de cámaras/grupos desde SQL | ✅ verificado |
| Puerto 9090 escuchando en Event Server | ✅ verificado |
| Credenciales de cámara + Digest vía HttpClient | ✅ verificado |
Discriminación por zona (SchemaBased=True) |
✅ verificado con evento real (zona 2) |
| Geometría de zonas | ✅ verificado |
monitordiff probado en vivo |
❌ pendiente |
| Envío real a Analytics Events 9090 | ❌ pendiente |
| Lista blanca de IPs en Analytics Events | ❌ pendiente |
| Contraste con el plugin Hanwha 1.09 ya instalado | ❌ pendiente |
Segunda ronda — validaciones de la plataforma Milestone¶
EventTypes: cómo lo resuelve el plugin Hikvision ya instalado ✅¶
SELECT GeneratorType, COUNT(*) FROM EventTypes GROUP BY GeneratorType:
| GeneratorType | # |
|---|---|
| DriverDevice | 194 |
| MIPDevice | 67 |
| SystemDashboard | 43 |
| Device | 25 |
| Recorder | 19 |
| Server / System / Hardware / DriverHardware / External / Timer / RecorderPlugin | resto |
Los 67 MIPDevice son todos del mismo IDGenerator
9F6E7E9D-3FD2-49B2-9CA0-F7E7F8F8C3A7 (HikMngtPlugin) y revelan el patrón:
Intrusion_0 .. Intrusion_5 ← índice de zona en el nombre
Line Crossing_0 .. Line Crossing_5
Region Entrance_0 .. Region Entrance_5
Region Exiting_0 .. Region Exiting_5
Temperature Alarm_0 .. _5
Temperature Pre-alarm_0 .. _5
Driving on the Lane Line_0 .. _5
Face Detection, Fire Detection, Illegal Parking,
Object Removal, Speeding, ... ← sin sufijo: no tienen zona
Conclusión: sufijo _N = índice de zona/regla; sin sufijo = evento sin zona.
Es exactamente el patrón que necesitamos y ya está validado en este sistema.
Hikvision no crea tipos separados de fin de evento.
⚠️ Matiz: esos tipos son
GeneratorType=MIPDevice, que sólo puede registrar un plug-in MIP. Nuestro servicio externo los registrará como Analytics Events (otro generator). El patrón de nombres se copia igual; el mecanismo de registro es distinto. Funcionalmente equivalente para reglas y alarmas, porque el origen del evento sigue siendo la cámara.
Hoy no hay ningún Analytics Event Type definido: los únicos External son los 3
built-in (RequestPlayAudio, RequestStartRecording, RequestStopRecording).
Arrancamos de cero, sin riesgo de colisión.
🎯 Credenciales: resuelto sin descifrar nada ✅¶
No hace falta atacar Hardware.EncryptedPassword (DPAPI) ni pedirle al usuario
que cargue 100+ credenciales. El MIP SDK expone la lectura de contraseña, igual
que el botón del Management Client. Verificado en vivo:
Install-Module MilestonePSTools -Scope AllUsers -Force -AllowClobber -SkipPublisherCheck
Connect-Vms -ServerAddress "http://localhost" -AcceptEula
Get-VmsHardware | Where-Object { $_.Name -match 'Hanwha' } | Get-VmsHardwarePassword
# → devuelve la contraseña en claro (verificado: coincide con la real)
Implicancia de diseño: el servicio no necesita su propio almacén de credenciales. Se conecta al Management Server con una cuenta de servicio y obtiene credencial + IP de cada cámara desde la config de Milestone, que ya es la fuente de verdad. Alta de cámara nueva = cero configuración extra.
Requisito: la cuenta de servicio necesita permiso de administrador en el VMS para leer contraseñas de hardware.
Nota de bootstrap: en sesión no interactiva hay que preinstalar el proveedor
NuGet, si no Install-Module falla con "Windows PowerShell is in
NonInteractive mode":
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers -Confirm:$false
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Plataforma de desarrollo ✅¶
- El servidor Windows tiene salida a internet (PowerShell Gallery responde 200) → se pueden bajar paquetes NuGet y el MIP SDK.
- MIP SDK no está instalado como SDK, pero los assemblies están presentes en
C:\Program Files\Milestone\XProtect Event Server:VideoOS.Platform.dll,VideoOS.Platform.Common.dll,VideoOS.Platform.Primitives.dll,VideoOS.Platform.SDKvía NuGet. MilestonePSTools25.2.61 quedó instalado en el servidor (útil para prototipado y scripts de verificación).- .NET 8 + ASP.NET Core Hosting Bundle ya presentes → se puede publicar un servicio .NET 8 sin instalar runtime adicional.
Capacidad de zonas de la cámara¶
attributes.cgi/eventsources no declara un maxCount de DefinedArea/Line;
los índices son abiertos (DefinedArea.#). La cámara de lab tiene 2
DefinedAreas y 1 Line configuradas. Los HandoverIndex van 0..32.
Decisión tomada: soportar 5 zonas y 5 líneas, alineado con el criterio de
Hikvision (que usa 6: _0.._5) y para no inflar la lista de tipos.
Se usa la numeración nativa de Hanwha, que es 1-based
(DefinedAreaID.1...5), a diferencia de Hikvision que es 0-based — así el
nombre del tipo coincide con lo que muestra el web de la cámara.