Última actualización: 2026-08-23. Sistema funcional y probado contra cámara real + LLM local.
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.
| 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).
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).
- 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) ystream_conn(dedicada astart_monitor(), que bloquea el socket). - Stream: intenta
Extra1(baja latencia), si el Claim falla usaMain. Los NAL H.264/H.265 se decodifican con PyAV → PIL JPEG (quality 70) enasyncio.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áneoGET /api/cameras/{id}/live— MJPEG (multipart/x-mixed-replace) para<img>en el teléfonoPOST /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 mensajeusercon 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_lockserializa 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).
El código original del repo tenía bugs que rompían con esta cámara:
- JPEG EXIF (dvrip.py,
reassemble_bin_payload): el original solo reconocía snapshots JFIF (FFD8 FFE0); esta cámara manda EXIF (FFD8 FFE3) y lanzabaValueError. Fix: detectar cualquierpacket[:2] == b"\xff\xd8", leer hasta encontrar EOIFFD9y cortar ahí (el paquete trae una cola de 2 bytes extra después del EOI; no usarendswith). - NoneType en
receive_with_timeout: si el socket muere a mitad de lectura,socket_recvdevuelveNoney el original hacíabuf.extend(None)→ TypeError matando el stream worker. Fix:if data is None: return None. - (cameras.py) watchdog original reconectaba tras 1 fallo y mataba el video en vivo del teléfono; ahora exige 2 fallos.
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_workerexiste 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.passwordestá vacío (solo LAN). Si se llena, la PWA debe enviar headerX-Auth. - Más cámaras: agregar objetos al array
camerasen config.json.
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).
# 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.
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> -ForceLos "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.
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 tantoPOST /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 deallowed_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:
- En Telegram hablar con @BotFather →
/newbot→ pegar el token entelegram.bot_token. - 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). telegram.enabled = true, reiniciar el hub. Encamhub.logdebe aparecertelegram: bot @TuBot conectado. Probar con/statusy 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.
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.
- Secuencia:
OPTalk Claim(msgid 1434) → respuesta JSONRet: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_conndedicada (sin stream) para el talk, como hace go2rtc con su backchannel. - El usuario
admintiene permisoTalk_01en su AuthorityList (verificado congetAuthorityList()). - El ICSee (app móvil, paquete
com.xm.csee) usa exactamente este mecanismo; no hace falta APK ni P2P.
camhub/tts.py: texto → WAV → ffmpeg-ar 8000 -ac 1 -c:a pcm_alaw -t 15dentro de WAV → Python descarta el header RIFF → A-law crudo. Topes: 300 chars / 15 s.- Motor prioritario: Piper TTS (neural, 100% local):
piper-ttspor pip + voz argentinaes_AR-daniela-high(por defecto, ~114 MB) encamhub/models/; también descargadaes_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.jsondebe ir al lado).
- Motor prioritario: Piper TTS (neural, 100% local):
DVRIPCam.talk(alaw)(dvrip.py): claim→start→chunks manteniendo el lockbusy(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 sobrecommand_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).
_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.pycon 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.pycon 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: dosspeak()seguidos víaCameraManager(camino real del hub) +get_time×3 y snapshot después →command_connsana. Valida el fix del bug de duplex (abajo).
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):
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, ycommand_connqueda limpia para snapshots/PTZ/salud.DVRIPCam.talk(): respiro pre-claim subido a 1.0 s (valor probado para conexiones frescas).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.
- 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.
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).