Skip to content

Commit bd62722

Browse files
Glauber Varjãoclaude
andcommitted
feat: Event Listener — recebe fotos faciais via HTTP Host Notification
Novo módulo core/event_listener.py: - EventListener: servidor HTTP que recebe eventos dos terminais - Extrai foto JPEG do multipart do evento de autenticação facial - Salva automaticamente no _FaceCache (face_cache/{employeeNo}.jpg) - configure_http_host(): configura terminal para enviar eventos - Habilita uploadVerificationPic + saveVerificationPic - Callbacks on_event e on_photo para integração com UI Testado: configuração aceita pelo DS-K1T672MX (ok=True) Listener funciona em 172.16.143.55:8889 Bump v4.2.1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2acdc1b commit bd62722

5 files changed

Lines changed: 381 additions & 7 deletions

File tree

build_info.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
BUILD_TIMESTAMP = "2026-04-01 21:22"
1+
BUILD_TIMESTAMP = "2026-04-01 21:36"

core/event_listener.py

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
"""
2+
Protector ISAPI Manager — Event Listener (HTTP Host Mode)
3+
Recebe eventos em tempo real dos terminais Hikvision via HTTP POST.
4+
5+
Quando o terminal autentica uma face, envia o evento com a foto capturada
6+
em formato multipart. Este listener extrai a foto e salva no cache local.
7+
8+
Ref: ISAPI AccessControl Protocol — item 4.1 a 4.6
9+
"""
10+
11+
import json
12+
import logging
13+
import os
14+
import re
15+
import socket
16+
import threading
17+
from datetime import datetime
18+
from http.server import HTTPServer, BaseHTTPRequestHandler
19+
from typing import Callable, Optional, Dict, Any
20+
21+
logger = logging.getLogger("event_listener")
22+
23+
24+
def _extract_jpeg(data: bytes) -> Optional[bytes]:
25+
"""Extrai JPEG de dados multipart ou raw."""
26+
if not data:
27+
return None
28+
start = data.find(b'\xff\xd8\xff')
29+
if start >= 0:
30+
end = data.rfind(b'\xff\xd9')
31+
if end > start:
32+
return data[start:end + 2]
33+
return None
34+
35+
36+
def _parse_multipart_event(body: bytes, content_type: str) -> Dict[str, Any]:
37+
"""Parseia evento multipart do terminal Hikvision.
38+
39+
O terminal envia:
40+
- Parte JSON/XML com dados do evento (employeeNo, nome, resultado, etc)
41+
- Parte binária com a foto capturada (JPEG)
42+
43+
Returns:
44+
{"event_data": dict, "photo": bytes_or_none, "raw_parts": [...]}
45+
"""
46+
result = {"event_data": {}, "photo": None, "raw_parts": []}
47+
48+
# Extrair boundary do Content-Type
49+
boundary = None
50+
if "boundary=" in content_type:
51+
boundary = content_type.split("boundary=")[-1].strip().strip('"')
52+
53+
if boundary:
54+
separator = f"--{boundary}".encode()
55+
parts = body.split(separator)
56+
57+
for part in parts:
58+
if not part or part.strip() in (b"", b"--", b"--\r\n"):
59+
continue
60+
61+
# Separar headers do body da parte
62+
header_end = part.find(b"\r\n\r\n")
63+
if header_end < 0:
64+
header_end = part.find(b"\n\n")
65+
if header_end < 0:
66+
continue
67+
68+
part_headers = part[:header_end].decode("utf-8", errors="replace").lower()
69+
part_body = part[header_end + 4:] # skip \r\n\r\n
70+
71+
result["raw_parts"].append({
72+
"headers": part_headers,
73+
"size": len(part_body)
74+
})
75+
76+
# Parte JSON
77+
if "application/json" in part_headers:
78+
try:
79+
text = part_body.decode("utf-8", errors="replace").strip()
80+
# Remover trailing boundary markers
81+
if text.endswith("--"):
82+
text = text[:-2].strip()
83+
result["event_data"] = json.loads(text)
84+
except Exception:
85+
pass
86+
87+
# Parte XML
88+
elif "application/xml" in part_headers or "text/xml" in part_headers:
89+
try:
90+
import xml.etree.ElementTree as ET
91+
text = part_body.decode("utf-8", errors="replace").strip()
92+
if text.endswith("--"):
93+
text = text[:-2].strip()
94+
root = ET.fromstring(text)
95+
# Converter XML básico para dict
96+
event_dict = {}
97+
for child in root.iter():
98+
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
99+
if child.text and child.text.strip():
100+
event_dict[tag] = child.text.strip()
101+
if event_dict:
102+
result["event_data"] = event_dict
103+
except Exception:
104+
pass
105+
106+
# Parte imagem
107+
elif "image/" in part_headers or b"\xff\xd8\xff" in part_body[:10]:
108+
img = _extract_jpeg(part_body)
109+
if img and len(img) > 100:
110+
result["photo"] = img
111+
112+
else:
113+
# Sem boundary — tentar parsear como JSON puro
114+
try:
115+
result["event_data"] = json.loads(body.decode("utf-8", errors="replace"))
116+
except Exception:
117+
pass
118+
# Tentar extrair JPEG mesmo sem boundary
119+
img = _extract_jpeg(body)
120+
if img and len(img) > 100:
121+
result["photo"] = img
122+
123+
return result
124+
125+
126+
class EventHandler(BaseHTTPRequestHandler):
127+
"""Handler HTTP que recebe eventos dos terminais Hikvision."""
128+
129+
# Referência ao listener (setada pelo EventListener)
130+
listener = None
131+
132+
def do_POST(self, *args, **kwargs):
133+
"""Recebe evento POST do terminal."""
134+
try:
135+
content_length = int(self.headers.get("Content-Length", 0))
136+
content_type = self.headers.get("Content-Type", "")
137+
body = self.rfile.read(content_length) if content_length > 0 else b""
138+
139+
# Resposta obrigatória: "HTTP/1.1 200 " (com espaço) — ref item 4.4
140+
self.send_response(200)
141+
self.send_header("Content-Type", "text/plain")
142+
self.end_headers()
143+
self.wfile.write(b"OK")
144+
145+
# Processar evento em thread separada para não bloquear
146+
if body and self.listener:
147+
threading.Thread(
148+
target=self.listener._process_event,
149+
args=(body, content_type, self.client_address[0]),
150+
daemon=True
151+
).start()
152+
153+
except Exception as e:
154+
logger.error(f"Erro no handler: {e}")
155+
try:
156+
self.send_response(200)
157+
self.end_headers()
158+
except Exception:
159+
pass
160+
161+
def do_GET(self, *args, **kwargs):
162+
"""Health check."""
163+
self.send_response(200)
164+
self.send_header("Content-Type", "application/json")
165+
self.end_headers()
166+
self.wfile.write(json.dumps({
167+
"status": "running",
168+
"service": "Protector Event Listener",
169+
"events_received": self.listener.event_count if self.listener else 0,
170+
"photos_cached": self.listener.photo_count if self.listener else 0,
171+
}).encode())
172+
173+
def log_message(self, format, *args):
174+
"""Silenciar log padrão do HTTPServer."""
175+
pass
176+
177+
178+
class EventListener:
179+
"""Servidor HTTP que escuta eventos dos terminais Hikvision.
180+
181+
Uso:
182+
listener = EventListener(port=8888, on_event=callback)
183+
listener.start() # inicia em background thread
184+
# ... quando terminar:
185+
listener.stop()
186+
"""
187+
188+
def __init__(self, port: int = 8888,
189+
on_event: Optional[Callable] = None,
190+
on_photo: Optional[Callable] = None):
191+
"""
192+
Args:
193+
port: Porta HTTP para escutar
194+
on_event: Callback(event_data: dict, photo: bytes|None, source_ip: str)
195+
on_photo: Callback(employee_no: str, jpeg: bytes, source_ip: str)
196+
"""
197+
self.port = port
198+
self.on_event = on_event
199+
self.on_photo = on_photo
200+
self.event_count = 0
201+
self.photo_count = 0
202+
self._server = None
203+
self._thread = None
204+
self._running = False
205+
206+
def start(self):
207+
"""Inicia o listener em background thread."""
208+
if self._running:
209+
return
210+
211+
EventHandler.listener = self
212+
213+
try:
214+
self._server = HTTPServer(("0.0.0.0", self.port), EventHandler)
215+
self._running = True
216+
self._thread = threading.Thread(
217+
target=self._server.serve_forever,
218+
daemon=True,
219+
name="event-listener"
220+
)
221+
self._thread.start()
222+
logger.info(f"Event Listener iniciado na porta {self.port}")
223+
except OSError as e:
224+
logger.error(f"Falha ao iniciar listener na porta {self.port}: {e}")
225+
raise
226+
227+
def stop(self):
228+
"""Para o listener."""
229+
self._running = False
230+
if self._server:
231+
self._server.shutdown()
232+
self._server = None
233+
logger.info("Event Listener parado")
234+
235+
@property
236+
def is_running(self) -> bool:
237+
return self._running
238+
239+
def get_local_ip(self) -> str:
240+
"""Detecta o IP local da máquina para configurar no terminal."""
241+
try:
242+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
243+
s.connect(("8.8.8.8", 80))
244+
ip = s.getsockname()[0]
245+
s.close()
246+
return ip
247+
except Exception:
248+
return "127.0.0.1"
249+
250+
def _process_event(self, body: bytes, content_type: str, source_ip: str):
251+
"""Processa evento recebido do terminal."""
252+
try:
253+
self.event_count += 1
254+
parsed = _parse_multipart_event(body, content_type)
255+
event_data = parsed.get("event_data", {})
256+
photo = parsed.get("photo")
257+
258+
# Extrair employeeNo do evento
259+
emp_no = (
260+
event_data.get("employeeNoString")
261+
or event_data.get("employeeNo")
262+
or event_data.get("EmployeeNo")
263+
or ""
264+
)
265+
266+
# Log resumido
267+
event_type = event_data.get("eventType", event_data.get("EventType", "?"))
268+
name = event_data.get("name", event_data.get("Name", ""))
269+
major = event_data.get("majorEventType", "")
270+
minor = event_data.get("minorEventType", event_data.get("subEventType", ""))
271+
272+
logger.info(
273+
f"Evento: ip={source_ip} type={event_type} major={major} minor={minor} "
274+
f"emp={emp_no} name={name} photo={len(photo) if photo else 0}B"
275+
)
276+
277+
# Salvar foto no cache
278+
if photo and emp_no:
279+
self.photo_count += 1
280+
try:
281+
# Importar cache aqui para evitar circular import
282+
from core.isapi_client import _FaceCache
283+
_FaceCache().save(emp_no, photo)
284+
logger.info(f"Foto salva no cache: {emp_no} ({len(photo)//1024}KB)")
285+
except Exception as e:
286+
logger.warning(f"Erro ao salvar foto no cache: {e}")
287+
288+
# Callback de foto
289+
if self.on_photo:
290+
try:
291+
self.on_photo(emp_no, photo, source_ip)
292+
except Exception as e:
293+
logger.warning(f"Erro no callback on_photo: {e}")
294+
295+
# Callback geral
296+
if self.on_event:
297+
try:
298+
self.on_event(event_data, photo, source_ip)
299+
except Exception as e:
300+
logger.warning(f"Erro no callback on_event: {e}")
301+
302+
except Exception as e:
303+
logger.error(f"Erro ao processar evento: {e}")
304+
305+
306+
def configure_http_host(client, listener_ip: str, listener_port: int = 8888,
307+
host_id: int = 1, endpoint: str = "/event") -> Dict[str, Any]:
308+
"""Configura o terminal para enviar eventos para o listener.
309+
310+
Args:
311+
client: ISAPIClient do terminal
312+
listener_ip: IP do servidor que vai receber os eventos
313+
listener_port: Porta do listener
314+
host_id: ID do host (1-8)
315+
endpoint: Path do endpoint no listener
316+
317+
Returns:
318+
{"ok": bool, "error": str}
319+
"""
320+
import xml.etree.ElementTree as ET
321+
322+
# 1. Configurar HTTP Host Notification
323+
xml_host = f"""<?xml version="1.0" encoding="UTF-8"?>
324+
<HttpHostNotificationList version="2.0" xmlns="http://www.isapi.org/ver20/XMLSchema">
325+
<HttpHostNotification>
326+
<id>{host_id}</id>
327+
<url>{endpoint}</url>
328+
<protocolType>HTTP</protocolType>
329+
<parameterFormatType>JSON</parameterFormatType>
330+
<addressingFormatType>ipaddress</addressingFormatType>
331+
<ipAddress>{listener_ip}</ipAddress>
332+
<portNo>{listener_port}</portNo>
333+
<httpAuthenticationMethod>none</httpAuthenticationMethod>
334+
</HttpHostNotification>
335+
</HttpHostNotificationList>"""
336+
337+
r = client.put("/ISAPI/Event/notification/httpHosts",
338+
data=xml_host, content_type="application/xml")
339+
if not r.get("ok"):
340+
return {"ok": False, "error": f"httpHosts falhou: {r.get('error', r.get('status_code'))}"}
341+
342+
# 2. Habilitar upload de foto de verificação
343+
acs_cfg = json.dumps({"AcsCfg": {
344+
"uploadVerificationPic": True,
345+
"saveVerificationPic": True
346+
}})
347+
r2 = client.put("/ISAPI/AccessControl/AcsCfg?format=json",
348+
data=acs_cfg, content_type="application/json")
349+
if not r2.get("ok"):
350+
# Não é erro fatal — pode ser que o terminal já tenha habilitado
351+
logger.warning(f"AcsCfg: {r2.get('error', r2.get('status_code'))}")
352+
353+
# 3. Configurar filtro de eventos (opcional — para receber apenas face events)
354+
# minorEvent 0x4b = face authentication completed
355+
try:
356+
sub_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
357+
<EventTriggerList version="2.0" xmlns="http://www.isapi.org/ver20/XMLSchema">
358+
<EventTrigger>
359+
<id>AccessControl</id>
360+
<EventTriggerNotificationList>
361+
<EventTriggerNotification>
362+
<id>{host_id}</id>
363+
<notificationMethod>HTTP</notificationMethod>
364+
<notificationRecurrence>beginning</notificationRecurrence>
365+
</EventTriggerNotification>
366+
</EventTriggerNotificationList>
367+
</EventTrigger>
368+
</EventTriggerList>"""
369+
client.put("/ISAPI/Event/triggers",
370+
data=sub_xml, content_type="application/xml")
371+
except Exception:
372+
pass # Não é crítico
373+
374+
return {"ok": True, "error": ""}

0 commit comments

Comments
 (0)