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