Skip to content

About

LLM vigilando camaras wip

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

RESUME / HANDOVER — camhub (Guardia Virtual)

Última actualización: 2026-08-23. Sistema funcional y probado contra cámara real + LLM local.

1. Objetivo

Controlar cámaras de seguridad desde el teléfono (Android/iPhone) hablando con una LLM local con visión: preguntar qué ve ("¿hay gente?"), pedir snapshots, mover cámaras PTZ, más adelante ver grabaciones y resumen de eventos — como un guardia monitoreando la propiedad. Todo en red local, sin nube.

2. Topología

Componente Detalle
Cámara 192.168.100.200:34567, protocolo DVRIP (marca ICSee/XMEye/NetSurveillance). Usuario admin, contraseña vacía (""), canal 0.
PC del hub 192.168.100.76 (misma LAN que la cámara).
Teléfono accede a http://192.168.100.76:8090 (PWA instalable). IP observada: 192.168.100.199.
LLM llama.cpp en http://127.0.0.1:8082/v1, modelo D:\AI\LLMS\Qwen3.8-27B-UD-Q5_K_S.gguf + mmproj-BF16.gguf.
GPU RTX 4090 (24 GB). Modelo Q5_K_S ≈ 18.6 GB.

Flags actuales del llama-server (del usuario): --parallel 1 -c 154000 --reasoning-budget -1 --ngl 99 -fa on -ct vbr --spec-type draft-mtp --jinja --chat-template-file C:\Users\Jorge\Desktop\BuunLlama\chat_template_v19.jinja

Capacidades verificadas del modelo: visión ✅ (image_url base64 en content), tool calling ✅ (finish_reason=tool_calls, args JSON correctos), reasoning (devuelve campo reasoning_content).

3. Estructura del proyecto — D:\WetaFluidos\camhub\

camhub/
├── config.json            # cámaras, endpoint LLM, puerto/servidor (contraseña opcional)
├── run.py                 # entrada: python run.py (uvicorn, host 0.0.0.0:8090)
├── requirements.txt       # fastapi, uvicorn, httpx, av, numpy, pillow (todos ya instalados en Python 3.12.7)
├── RESUME.md              # este archivo
├── camhub/
│   ├── dvrip.py           # COPIA VENDORADA de asyncio_dvrip.py (repo icsee-client-app) + 2 parches (ver §5)
│   ├── cameras.py         # CameraManager: conexiones, stream, snapshot, PTZ, watchdog
│   ├── llm.py             # cliente OpenAI-compatible a llama.cpp (con logging de tiempos)
│   ├── server.py          # FastAPI: API + agente LLM (run_guardian) + PWA estática + arranque del bot TG
│   └── telegram_bot.py    # bot de Telegram (long polling, Bot API vía httpx; sin deps nuevas)
└── static/                # PWA: index.html, app.js, manifest.json, sw.js, iconos

Repo original clonado en D:\WetaFluidos\icsee-client-app (fuente del protocolo; no modificar).

4. Arquitectura del hub

  • Todo corre sobre el event loop de uvicorn (no hay puente de hilos como en la app desktop original).
  • Dos conexiones por cámara (patrón del repo original): command_conn (login, snapshot, PTZ, reloj, salud) y stream_conn (dedicada a start_monitor(), que bloquea el socket).
  • Stream: intenta Extra1 (baja latencia), si el Claim falla usa Main. Los NAL H.264/H.265 se decodifican con PyAV → PIL JPEG (quality 70) en asyncio.to_thread; se guarda solo el frame más reciente (~8 fps máx).
  • Endpoints:
    • GET /api/cameras — estado (connected, streaming, last_frame_age_s, last_error)
    • GET /api/cameras/{id}/snapshot — JPEG instantáneo
    • GET /api/cameras/{id}/live — MJPEG (multipart/x-mixed-replace) para <img> en el teléfono
    • POST /api/cameras/{id}/ptz — {command: up|down|left|right|zoom_in|zoom_out|focus_near|focus_far|stop, step: 1-10}
    • POST /api/chat — {message} → agente LLM; devuelve {reply, images:[{camera_id,name,jpeg_b64}]}
    • GET /api/health
  • Agente LLM (server.py): system prompt de "guardia virtual" + herramientas get_snapshot, ptz, list_cameras, get_camera_time. Bucle máximo 8 iteraciones. Las imágenes de snapshots se inyectan como mensaje user con partes [text, image_url] después del resultado tool (formato compatible con llama.cpp). Historial global solo-texto limitado a 16 mensajes (las imágenes NO persisten entre turnos). _chat_lock serializa chats.
  • Watchdog: cada 20 s hace get_time() por la conexión de comandos; reconecta tras 2 fallos seguidos (no interrumpe un stream activo por un fallo transitorio).

5. Parches aplicados al protocolo vendorado (¡importantes!)

El código original del repo tenía bugs que rompían con esta cámara:

  1. JPEG EXIF (dvrip.py, reassemble_bin_payload): el original solo reconocía snapshots JFIF (FFD8 FFE0); esta cámara manda EXIF (FFD8 FFE3) y lanzaba ValueError. Fix: detectar cualquier packet[:2] == b"\xff\xd8", leer hasta encontrar EOI FFD9 y cortar ahí (el paquete trae una cola de 2 bytes extra después del EOI; no usar endswith).
  2. NoneType en receive_with_timeout: si el socket muere a mitad de lectura, socket_recv devuelve None y el original hacía buf.extend(None) → TypeError matando el stream worker. Fix: if data is None: return None.
  3. (cameras.py) watchdog original reconectaba tras 1 fallo y mataba el video en vivo del teléfono; ahora exige 2 fallos.

6. Estado actual

Funciona (probado):

  • Login DVRIP con admin/contraseña vacía ✅
  • Snapshot real: JPEG válido 640×720 (la cámara muestra imagen vertical/doble vista) ✅
  • Video en vivo MJPEG desde el teléfono ✅ (el usuario ya lo vio)
  • Chat end-to-end: 10.3 s, respuesta correcta describiendo la escena + snapshot adjunto ✅
  • PWA instalable (manifest + service worker + iconos) ✅

Implementado pero NO probado contra la cámara real:

  • PTZ (endpoint y herramienta LLM listos; no se movió la cámara para no alterar el encuadre)
  • Bot de Telegram (ver §10): código completo y verificado por import; falta el token de @BotFather para probarlo end-to-end.

No hecho (próximas fases sugeridas):

  • Detección proactiva de personas: YOLO sobre alarmas/movimiento DVRIP (alarm_worker existe en dvrip.py) → bitácora de eventos con timestamp+miniatura para "¿viste gente anoche?"
  • Grabaciones: el protocolo DVRIP NO tiene API de playback. Opción robusta: el hub graba el stream con ffmpeg a disco (por día/cámara) y "ver grabación" = reproducir esos archivos. Alternativa frágil: emular teclado (keyScript) para navegar la UI web de la cámara.
  • Autostart del hub como servicio de Windows (hoy se arranca a mano: python run.py).
  • Auth: config.json → server.password está vacío (solo LAN). Si se llena, la PWA debe enviar header X-Auth.
  • Más cámaras: agregar objetos al array cameras en config.json.

7. Restricción conocida: --parallel 1

Este agente (DSH) corre sobre el MISMO llama.cpp. Con un solo slot, cuando el agente está generando, las peticiones del hub quedan en cola (se observó un colgón de 300 s por esto; no es bug del hub). Opciones:

  • --reasoning-budget 512 (en vez de -1): respuestas del guardia mucho más rápidas.
  • --parallel 2 -c 32768: un slot para el hub y otro para el agente (bajar contexto; verificar VRAM).

8. Comandos útiles

# Arrancar el hub
cd D:\WetaFluidos\camhub; python run.py

# Estado
Invoke-RestMethod http://127.0.0.1:8090/api/health

# Snapshot directo
Invoke-WebRequest http://127.0.0.1:8090/api/cameras/camA/snapshot -OutFile snap.jpg

# Chat
Invoke-RestMethod http://127.0.0.1:8090/api/chat -Method Post -ContentType 'application/json' -Body '{"message":"Que ves?"}'

# PTZ (paso pequeño)
Invoke-RestMethod http://127.0.0.1:8090/api/cameras/camA/ptz -Method Post -ContentType 'application/json' -Body '{"command":"left","step":2}'

Notas de entorno: en esta máquina el TLS sale roto dentro del sandbox de DSH (SEC_E_NO_CREDENTIALS) — git/curl/Invoke-RestMethod HTTPS fallan salvo con permiso danger-full-access. HTTP plano y TCP funcionan bien.

9. Nota operativa: procesos huérfanos

Al matar el job del hub, el wrapper pwsh muere pero el hijo python run.py puede quedar VIVO ocupando el puerto 8090. Antes de arrancar una instancia nueva SIEMPRE verificar:

netstat -ano | findstr :8090        # si hay PID LISTENING -> Stop-Process -Id <pid> -Force

Los "crashes" con exit code 1 sin traza eran en realidad [Errno 10048] bind conflict contra un huérfano. Ahora el hub escribe log propio en camhub\camhub.log (independiente de la salida del job).

Otra observación: la cámara suelta la conexión de comandos cada ~5-7 min (health check timeout, error vacío); el watchdog reconecta solo tras 2 fallos seguidos. Durante esos ~40 s los snapshots pueden fallar; es normal.

10. Integración con Telegram (nuevo, 2026-08-23)

camhub/telegram_bot.py: bot con long polling contra api.telegram.org (sin webhook, sin puertos abiertos, sin nube intermedia). Corre como task asyncio dentro del lifespan de FastAPI; usa httpx (ya en requirements), no añade dependencias.

  • El agente LLM se refactorizó: el bucle vive ahora en server.run_guardian(message, history) y lo usan tanto POST /api/chat (historial global _history) como el bot (historial propio por chat_id, 16 mensajes, solo texto). Ambos comparten _chat_lock → no se pisan en la LLM ni en las cámaras.
  • Comandos: /snap [camara] (foto inmediata), /status, /help. Cualquier otro texto → guardia LLM; la respuesta va como texto + fotos (sendPhoto con caption). Respeta allowlist de allowed_user_ids.
  • Robustez: 409 (otro proceso pollingeando con el mismo token) → espera 30 s y avisa (suele ser hub duplicado/huérfano, ver §9); 429 → respeta retry_after; errores de red → reintento en 5 s; mensajes >4096 chars se trocean.

Config (config.json → telegram):

"telegram": { "enabled": false, "bot_token": "", "allowed_user_ids": [] }

Puesta en marcha:

  1. En Telegram hablar con @BotFather → /newbot → pegar el token en telegram.bot_token.
  2. Saber tu user id: hablar con @userinfobot → ponerlo en allowed_user_ids (recomendado; si queda vacío el bot responde a cualquiera que lo encuentre).
  3. telegram.enabled = true, reiniciar el hub. En camhub.log debe aparecer telegram: bot @TuBot conectado. Probar con /status y luego un "¿qué ves?".

Nota de entorno: si el hub se arranca dentro del sandbox de DSH, el HTTPS a api.telegram.org falla (SEC_E_NO_CREDENTIALS, ver §8) — arrancar el hub desde un PowerShell normal o darle permiso danger-full-access al job. El llama.cpp sigue siendo cuello de botella compartido (--parallel 1, §7): un chat desde Telegram se cola igual que uno desde la PWA.

11. Talkback: la LLM habla por el altavoz de la cámara (nuevo, 2026-08-24) ✅ FUNCIONA

La cámara SÍ soporta audio bidireccional (OPTalk). La conclusión anterior ("firmware sin OPTalk") estaba equivocada: el problema era pacing, no falta de soporte.

Hallazgos clave (probados contra la X6S-WEQ-H real)

  • Secuencia: OPTalk Claim (msgid 1434) → respuesta JSON Ret:100 → Start (1430) → paquetes de audio (1432) con subheader \x00\x00\x1FA\x0E\x02 + len(LE) en chunks de 320 bytes A-law 8 kHz. La cámara ackea cada chunk (93/93 en la prueba).
  • Pacing crítico: si el claim llega justo después de login u otro comando, la cámara cierra la conexión (EOF limpio, 0 bytes) — parecía "comando desconocido". Con ~0.5–1 s de respiro antes del claim y ~0.3 s entre claim y start → Ret:100. Esto también explica el misterio del OPMonitor Start (mismo síntoma: EOF sin delay, video fluyendo con delay; los frames llegan con msgid 1412, no 1411).
  • Ret:103 = "Request not permitted" si hay video activo en la MISMA sesión/conexión → por eso el hub usa la command_conn dedicada (sin stream) para el talk, como hace go2rtc con su backchannel.
  • El usuario admin tiene permiso Talk_01 en su AuthorityList (verificado con getAuthorityList()).
  • El ICSee (app móvil, paquete com.xm.csee) usa exactamente este mecanismo; no hace falta APK ni P2P.

Pipeline implementado

  • camhub/tts.py: texto → WAV → ffmpeg -ar 8000 -ac 1 -c:a pcm_alaw -t 15 dentro de WAV → Python descarta el header RIFF → A-law crudo. Topes: 300 chars / 15 s.
    • Motor prioritario: Piper TTS (neural, 100% local): piper-tts por pip + voz argentina es_AR-daniela-high (por defecto, ~114 MB) en camhub/models/; también descargada es_ES-davefx-medium (~63 MB). Fuente: HuggingFace rhasspy/piper-voices. Sintetiza en thread (asyncio.to_thread) para no bloquear el event loop.
    • Fallback automático: System.Speech de .NET vía powershell.exe (voz es* si existe; si no, inglés David/Zira) cuando piper-tts o el modelo no están.
    • Cambiar de voz Piper: env var CAMHUB_PIPER_MODEL=/ruta/modelo.onnx (el .onnx.json debe ir al lado).
  • DVRIPCam.talk(alaw) (dvrip.py): claim→start→chunks manteniendo el lock busy (bloquea snapshot/PTZ durante la frase, ~15 s máx). Incluye los sleeps de pacing, manejo de Ret=103 y 0.5 s de silencio A-law inicial (sin él el altavoz trunca la primera palabra; verificado por el usuario).
  • CameraManager.speak(cid, alaw) (cameras.py) → lo ejecuta sobre command_conn.
  • LLM: herramienta speak (server.py); Telegram: /decir <texto>; API: POST /api/cameras/{id}/speak. La voz sale en español argentino (Piper es_AR-daniela-high).

Pruebas exitosas (2026-08-24)

  • _ref/test_talk_e2e.py: sesión nueva, claim Ret:100, 29 474 bytes, 93 acks — audio audible (EN).
  • _ref/test_talk_hubpath.py: sesión establecida (SystemInfo+snapshot previos + 20 s), talk() real actualizado: 29 152 bytes enviados sin errores.
  • _ref/test_tts_es.py + test_talk_e2e.py con Piper es_ES: "Hola. Esta area esta bajo vigilancia." → 18 297 bytes, claim Ret:100, 58/58 acks — castellano audible por el altavoz.
  • test_talk_e2e.py con Piper es_AR-daniela-high: "Esta propiedad esta bajo vigilancia..." → 31 672 bytes, claim Ret:100, 99/99 acks — voz argentina audible (voz por defecto actual).
  • _ref/test_speak_consecutivo.py: dos speak() seguidos vía CameraManager (camino real del hub) + get_time×3 y snapshot después → command_conn sana. Valida el fix del bug de duplex (abajo).

Bug corregido (2026-08-24): hablar seguido crasheaba el keepalive

Síntoma: al hablar dos veces seguidas por Telegram, el segundo fallo daba 'utf-32-be' codec can't decode bytes in position 4-7 en el keepalive/salud; esperar un rato lo arreglaba (el watchdog reconectaba).

Causa raíz: tras OPTalk Start la cámara abre el canal en dúplex y empieza a devolver audio de su propio micro por la MISMA conexión. Como el talk corría sobre command_conn, esos paquetes binarios (subheader \x00\x00\x01\xfa\x0e\x02 + A-law) los leía send()→receive_json() como si fueran respuestas JSON → json.loads lanzaba UnicodeDecodeError, matando el task de keepalive ("Task exception was never retrieved") y fallando la salud hasta la reconexión.

Fix (3 partes):

  1. CameraManager.speak() ahora usa una conexión dedicada y efímera (login→talk→close, el backchannel de go2rtc): el audio de retorno cae en una conexión que se descarta, y command_conn queda limpia para snapshots/PTZ/salud.
  2. DVRIPCam.talk(): respiro pre-claim subido a 1.0 s (valor probado para conexiones frescas).
  3. DVRIPCam.receive_json(): ante datos no-JSON ya no lanza — loguea y devuelve {} (degradación elegante; el watchdog recupera si hace falta). Defensa en profundidad.

Notas

  • Para probar desde el hub hay que reiniciarlo (corre con el código anterior del usuario).
  • El fallback de "tips de alarma pregrabados" (alarm_cfg.json, VoiceEnable) ya no es necesario.
  • _ref/ acumula sondas y referencias de este trabajo (go2rtc, netsurv, fuzz, web UI, APKs fallidas); candidato a limpieza cuando el talkback esté en producción.

12. Ajustes de comportamiento del agente (2026-08-24)

Cambios en el system prompt (server.py::_system_prompt) y en la herramienta PTZ:

  • Movimientos grandes por defecto: si el usuario pide "N a la derecha/izquierda/etc.", la LLM lo hace en bloques de hasta 10 pasos sin snapshots intermedios y toma UN solo snapshot al final (comportamiento acordado con el usuario; antes lo negociaba en cada chat).
  • speak en español: la regla del prompt decía "frase en inglés" (cuando solo había voz EN); ahora indica español (voz Piper argentina).
  • Fix PTZ ptz_inverted (cameras.py): la respuesta de la herramienta devolvía el comando interno POST-swap ("me pediste derecha → camara movida: left"), lo que hacía que la LLM reportara "inconsistencias" de sentido que no existían (el movimiento era correcto). Ahora reporta la dirección pedida (semántica de pantalla).

About

LLM vigilando camaras wip

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages