FlotillasGPS - Sistema completo de monitoreo de flotillas GPS

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.
This commit is contained in:
FlotillasGPS Developer
2026-01-21 08:18:00 +00:00
commit 51d78bacf4
248 changed files with 50171 additions and 0 deletions

View File

@@ -0,0 +1,411 @@
"""
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