Sistema completo para monitoreo y gestion de flotas de vehiculos con: - Backend FastAPI con PostgreSQL/TimescaleDB - Frontend React con TypeScript y TailwindCSS - App movil React Native con Expo - Soporte para dispositivos GPS, Meshtastic y celulares - Video streaming en vivo con MediaMTX - Geocercas, alertas, viajes y reportes - Autenticacion JWT y WebSockets en tiempo real Documentacion completa y guias de usuario incluidas.
412 lines
12 KiB
Python
412 lines
12 KiB
Python
"""
|
|
Servicio para gestión de video y cámaras.
|
|
|
|
Integración con MediaMTX para streaming de video.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import List, Optional
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import settings
|
|
from app.core.security import decrypt_sensitive_data, encrypt_sensitive_data
|
|
from app.models.camara import Camara
|
|
from app.models.grabacion import Grabacion
|
|
from app.models.evento_video import EventoVideo
|
|
from app.schemas.video import CamaraStreamURL
|
|
|
|
|
|
class VideoService:
|
|
"""Servicio para gestión de video y streaming."""
|
|
|
|
def __init__(self, db: AsyncSession):
|
|
"""
|
|
Inicializa el servicio.
|
|
|
|
Args:
|
|
db: Sesión de base de datos async.
|
|
"""
|
|
self.db = db
|
|
self.mediamtx_api = f"http://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_API_PORT}"
|
|
|
|
async def obtener_urls_stream(
|
|
self,
|
|
camara_id: int,
|
|
) -> Optional[CamaraStreamURL]:
|
|
"""
|
|
Obtiene las URLs de streaming de una cámara.
|
|
|
|
Args:
|
|
camara_id: ID de la cámara.
|
|
|
|
Returns:
|
|
URLs de streaming disponibles.
|
|
"""
|
|
result = await self.db.execute(
|
|
select(Camara).where(Camara.id == camara_id)
|
|
)
|
|
camara = result.scalar_one_or_none()
|
|
|
|
if not camara or not camara.activa:
|
|
return None
|
|
|
|
# Construir URLs según el path de MediaMTX
|
|
path = camara.mediamtx_path or f"cam{camara.id}"
|
|
|
|
rtsp_url = None
|
|
hls_url = None
|
|
webrtc_url = None
|
|
|
|
if camara.url_stream:
|
|
# Usar URL directa de la cámara
|
|
rtsp_url = camara.url_stream_completa
|
|
else:
|
|
# Usar MediaMTX como proxy
|
|
rtsp_url = f"rtsp://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_RTSP_PORT}/{path}"
|
|
|
|
# URLs de MediaMTX para diferentes protocolos
|
|
hls_url = f"http://{settings.MEDIAMTX_HOST}:8888/{path}/index.m3u8"
|
|
webrtc_url = f"http://{settings.MEDIAMTX_HOST}:{settings.MEDIAMTX_WEBRTC_PORT}/{path}"
|
|
|
|
return CamaraStreamURL(
|
|
camara_id=camara.id,
|
|
camara_nombre=camara.nombre,
|
|
rtsp_url=rtsp_url,
|
|
hls_url=hls_url,
|
|
webrtc_url=webrtc_url,
|
|
estado=camara.estado,
|
|
)
|
|
|
|
async def verificar_estado_camaras(self) -> List[dict]:
|
|
"""
|
|
Verifica el estado de todas las cámaras activas.
|
|
|
|
Returns:
|
|
Lista con estado de cada cámara.
|
|
"""
|
|
result = await self.db.execute(
|
|
select(Camara).where(Camara.activa == True)
|
|
)
|
|
camaras = result.scalars().all()
|
|
|
|
estados = []
|
|
for camara in camaras:
|
|
estado = await self._verificar_stream(camara)
|
|
estados.append({
|
|
"camara_id": camara.id,
|
|
"nombre": camara.nombre,
|
|
"vehiculo_id": camara.vehiculo_id,
|
|
"estado_anterior": camara.estado,
|
|
"estado_actual": estado,
|
|
"cambio": camara.estado != estado,
|
|
})
|
|
|
|
# Actualizar estado si cambió
|
|
if camara.estado != estado:
|
|
camara.estado = estado
|
|
if estado == "conectada":
|
|
camara.ultima_conexion = datetime.now(timezone.utc)
|
|
|
|
await self.db.commit()
|
|
return estados
|
|
|
|
async def _verificar_stream(
|
|
self,
|
|
camara: Camara,
|
|
) -> str:
|
|
"""
|
|
Verifica si un stream está activo.
|
|
|
|
Args:
|
|
camara: Cámara a verificar.
|
|
|
|
Returns:
|
|
Estado del stream.
|
|
"""
|
|
if not camara.url_stream and not camara.mediamtx_path:
|
|
return "desconectada"
|
|
|
|
path = camara.mediamtx_path or f"cam{camara.id}"
|
|
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
# Verificar en MediaMTX API
|
|
response = await client.get(
|
|
f"{self.mediamtx_api}/v3/paths/get/{path}",
|
|
timeout=5.0,
|
|
)
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
if data.get("ready"):
|
|
return "conectada"
|
|
return "desconectada"
|
|
return "desconectada"
|
|
|
|
except httpx.HTTPError:
|
|
return "error"
|
|
|
|
async def iniciar_grabacion(
|
|
self,
|
|
camara_id: int,
|
|
tipo: str = "manual",
|
|
) -> Optional[Grabacion]:
|
|
"""
|
|
Inicia una grabación de una cámara.
|
|
|
|
Args:
|
|
camara_id: ID de la cámara.
|
|
tipo: Tipo de grabación.
|
|
|
|
Returns:
|
|
Registro de grabación creado.
|
|
"""
|
|
result = await self.db.execute(
|
|
select(Camara).where(Camara.id == camara_id)
|
|
)
|
|
camara = result.scalar_one_or_none()
|
|
|
|
if not camara or camara.estado != "conectada":
|
|
return None
|
|
|
|
# Generar nombre de archivo
|
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
archivo_nombre = f"cam{camara_id}_{timestamp}.mp4"
|
|
archivo_url = f"{settings.UPLOAD_DIR}/videos/{camara.vehiculo_id}/{archivo_nombre}"
|
|
|
|
grabacion = Grabacion(
|
|
camara_id=camara_id,
|
|
vehiculo_id=camara.vehiculo_id,
|
|
inicio_tiempo=datetime.now(timezone.utc),
|
|
archivo_url=archivo_url,
|
|
archivo_nombre=archivo_nombre,
|
|
tipo=tipo,
|
|
estado="grabando",
|
|
)
|
|
|
|
self.db.add(grabacion)
|
|
|
|
# Actualizar estado de cámara
|
|
camara.estado = "grabando"
|
|
|
|
await self.db.commit()
|
|
await self.db.refresh(grabacion)
|
|
|
|
# Enviar comando a MediaMTX para iniciar grabación
|
|
await self._iniciar_grabacion_mediamtx(camara, archivo_url)
|
|
|
|
return grabacion
|
|
|
|
async def detener_grabacion(
|
|
self,
|
|
grabacion_id: int,
|
|
) -> Optional[Grabacion]:
|
|
"""
|
|
Detiene una grabación en curso.
|
|
|
|
Args:
|
|
grabacion_id: ID de la grabación.
|
|
|
|
Returns:
|
|
Grabación actualizada.
|
|
"""
|
|
result = await self.db.execute(
|
|
select(Grabacion).where(Grabacion.id == grabacion_id)
|
|
)
|
|
grabacion = result.scalar_one_or_none()
|
|
|
|
if not grabacion or grabacion.estado != "grabando":
|
|
return None
|
|
|
|
# Detener grabación en MediaMTX
|
|
result_cam = await self.db.execute(
|
|
select(Camara).where(Camara.id == grabacion.camara_id)
|
|
)
|
|
camara = result_cam.scalar_one_or_none()
|
|
|
|
if camara:
|
|
await self._detener_grabacion_mediamtx(camara)
|
|
camara.estado = "conectada"
|
|
|
|
# Actualizar registro
|
|
grabacion.fin_tiempo = datetime.now(timezone.utc)
|
|
grabacion.duracion_segundos = int(
|
|
(grabacion.fin_tiempo - grabacion.inicio_tiempo).total_seconds()
|
|
)
|
|
grabacion.estado = "procesando" # Se procesará para generar thumbnail, etc.
|
|
|
|
await self.db.commit()
|
|
await self.db.refresh(grabacion)
|
|
|
|
return grabacion
|
|
|
|
async def _iniciar_grabacion_mediamtx(
|
|
self,
|
|
camara: Camara,
|
|
archivo_url: str,
|
|
) -> bool:
|
|
"""Envía comando a MediaMTX para iniciar grabación."""
|
|
# MediaMTX usa configuración para grabación automática
|
|
# o se puede usar ffmpeg para grabar el stream
|
|
# Esta es una implementación simplificada
|
|
try:
|
|
# En una implementación real, se usaría la API de MediaMTX
|
|
# o se ejecutaría ffmpeg como proceso
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
async def _detener_grabacion_mediamtx(
|
|
self,
|
|
camara: Camara,
|
|
) -> bool:
|
|
"""Envía comando a MediaMTX para detener grabación."""
|
|
try:
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
async def registrar_evento_video(
|
|
self,
|
|
camara_id: int,
|
|
tipo: str,
|
|
severidad: str,
|
|
lat: float = None,
|
|
lng: float = None,
|
|
velocidad: float = None,
|
|
descripcion: str = None,
|
|
confianza: float = None,
|
|
snapshot_url: str = None,
|
|
) -> EventoVideo:
|
|
"""
|
|
Registra un evento de video detectado.
|
|
|
|
Args:
|
|
camara_id: ID de la cámara.
|
|
tipo: Tipo de evento.
|
|
severidad: Severidad del evento.
|
|
lat, lng: Coordenadas.
|
|
velocidad: Velocidad al momento del evento.
|
|
descripcion: Descripción del evento.
|
|
confianza: Confianza de la detección (0-100).
|
|
snapshot_url: URL de la imagen del evento.
|
|
|
|
Returns:
|
|
Evento creado.
|
|
"""
|
|
result = await self.db.execute(
|
|
select(Camara).where(Camara.id == camara_id)
|
|
)
|
|
camara = result.scalar_one_or_none()
|
|
|
|
if not camara:
|
|
raise ValueError(f"Cámara {camara_id} no encontrada")
|
|
|
|
evento = EventoVideo(
|
|
camara_id=camara_id,
|
|
vehiculo_id=camara.vehiculo_id,
|
|
tipo=tipo,
|
|
severidad=severidad,
|
|
tiempo=datetime.now(timezone.utc),
|
|
lat=lat,
|
|
lng=lng,
|
|
velocidad=velocidad,
|
|
descripcion=descripcion,
|
|
confianza=confianza,
|
|
snapshot_url=snapshot_url,
|
|
)
|
|
|
|
self.db.add(evento)
|
|
await self.db.commit()
|
|
await self.db.refresh(evento)
|
|
|
|
# Iniciar grabación de evento si está configurado
|
|
if camara.grabacion_evento:
|
|
await self.iniciar_grabacion(camara_id, tipo="evento")
|
|
|
|
return evento
|
|
|
|
async def obtener_grabaciones(
|
|
self,
|
|
vehiculo_id: int = None,
|
|
camara_id: int = None,
|
|
desde: datetime = None,
|
|
hasta: datetime = None,
|
|
tipo: str = None,
|
|
limite: int = 50,
|
|
) -> List[Grabacion]:
|
|
"""
|
|
Obtiene grabaciones filtradas.
|
|
|
|
Args:
|
|
vehiculo_id: Filtrar por vehículo.
|
|
camara_id: Filtrar por cámara.
|
|
desde: Fecha inicio.
|
|
hasta: Fecha fin.
|
|
tipo: Tipo de grabación.
|
|
limite: Límite de resultados.
|
|
|
|
Returns:
|
|
Lista de grabaciones.
|
|
"""
|
|
query = (
|
|
select(Grabacion)
|
|
.where(Grabacion.estado != "eliminado")
|
|
.order_by(Grabacion.inicio_tiempo.desc())
|
|
.limit(limite)
|
|
)
|
|
|
|
if vehiculo_id:
|
|
query = query.where(Grabacion.vehiculo_id == vehiculo_id)
|
|
if camara_id:
|
|
query = query.where(Grabacion.camara_id == camara_id)
|
|
if desde:
|
|
query = query.where(Grabacion.inicio_tiempo >= desde)
|
|
if hasta:
|
|
query = query.where(Grabacion.inicio_tiempo <= hasta)
|
|
if tipo:
|
|
query = query.where(Grabacion.tipo == tipo)
|
|
|
|
result = await self.db.execute(query)
|
|
return result.scalars().all()
|
|
|
|
async def configurar_camara_mediamtx(
|
|
self,
|
|
camara: Camara,
|
|
) -> bool:
|
|
"""
|
|
Configura una cámara en MediaMTX.
|
|
|
|
Args:
|
|
camara: Cámara a configurar.
|
|
|
|
Returns:
|
|
True si se configuró correctamente.
|
|
"""
|
|
if not camara.url_stream:
|
|
return False
|
|
|
|
path = camara.mediamtx_path or f"cam{camara.id}"
|
|
|
|
# Construir configuración para MediaMTX
|
|
config = {
|
|
"name": path,
|
|
"source": camara.url_stream_completa,
|
|
"sourceOnDemand": True,
|
|
"record": camara.grabacion_continua,
|
|
}
|
|
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.post(
|
|
f"{self.mediamtx_api}/v3/config/paths/add/{path}",
|
|
json=config,
|
|
timeout=10.0,
|
|
)
|
|
return response.status_code in [200, 201]
|
|
|
|
except httpx.HTTPError:
|
|
return False
|