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,23 @@
"""
Módulo de servicios de lógica de negocio.
"""
from app.services.ubicacion_service import UbicacionService
from app.services.geocerca_service import GeocercaService
from app.services.alerta_service import AlertaService
from app.services.viaje_service import ViajeService
from app.services.traccar_service import TraccarService
from app.services.video_service import VideoService
from app.services.reporte_service import ReporteService
from app.services.notificacion_service import NotificacionService
__all__ = [
"UbicacionService",
"GeocercaService",
"AlertaService",
"ViajeService",
"TraccarService",
"VideoService",
"ReporteService",
"NotificacionService",
]

View File

@@ -0,0 +1,495 @@
"""
Servicio para gestión y generación de alertas.
Motor de reglas que detecta y genera alertas basándose
en ubicaciones, velocidad, geocercas, batería, etc.
"""
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.alerta import Alerta
from app.models.tipo_alerta import TipoAlerta
from app.models.vehiculo import Vehiculo
from app.schemas.alerta import AlertaCreate, AlertaResponse
from app.services.geocerca_service import GeocercaService
class AlertaService:
"""Servicio para gestión de alertas."""
# Cache de tipos de alerta (código -> id)
_tipos_alerta_cache: dict = {}
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
self.geocerca_service = GeocercaService(db)
async def _obtener_tipo_alerta_id(self, codigo: str) -> Optional[int]:
"""
Obtiene el ID de un tipo de alerta por su código.
Args:
codigo: Código del tipo de alerta.
Returns:
ID del tipo de alerta o None.
"""
if codigo in self._tipos_alerta_cache:
return self._tipos_alerta_cache[codigo]
result = await self.db.execute(
select(TipoAlerta).where(TipoAlerta.codigo == codigo)
)
tipo = result.scalar_one_or_none()
if tipo:
self._tipos_alerta_cache[codigo] = tipo.id
return tipo.id
return None
async def verificar_velocidad(
self,
vehiculo_id: int,
velocidad: float,
lat: float,
lng: float,
limite_general: float = None,
) -> Optional[Alerta]:
"""
Verifica si la velocidad excede el límite.
Args:
vehiculo_id: ID del vehículo.
velocidad: Velocidad actual en km/h.
lat: Latitud actual.
lng: Longitud actual.
limite_general: Límite de velocidad general (si no, usa config).
Returns:
Alerta creada si excede el límite, None si no.
"""
limite = limite_general or settings.ALERT_SPEED_LIMIT_DEFAULT
if velocidad <= limite:
return None
tipo_alerta_id = await self._obtener_tipo_alerta_id("EXCESO_VELOCIDAD")
if not tipo_alerta_id:
return None
# Verificar si ya existe una alerta reciente (últimos 5 minutos)
tiempo_limite = datetime.now(timezone.utc) - timedelta(minutes=5)
result = await self.db.execute(
select(Alerta)
.where(
and_(
Alerta.vehiculo_id == vehiculo_id,
Alerta.tipo_alerta_id == tipo_alerta_id,
Alerta.creado_en >= tiempo_limite,
)
)
)
if result.scalar_one_or_none():
return None # Ya existe una alerta reciente
# Crear alerta
alerta = Alerta(
vehiculo_id=vehiculo_id,
tipo_alerta_id=tipo_alerta_id,
severidad="media" if velocidad < limite * 1.2 else "alta",
mensaje=f"Exceso de velocidad: {velocidad:.1f} km/h (límite: {limite} km/h)",
lat=lat,
lng=lng,
velocidad=velocidad,
valor=velocidad,
umbral=limite,
)
self.db.add(alerta)
await self.db.commit()
await self.db.refresh(alerta)
return alerta
async def verificar_geocercas(
self,
vehiculo_id: int,
lat: float,
lng: float,
estado_anterior: dict = None,
) -> List[Alerta]:
"""
Verifica transiciones de entrada/salida de geocercas.
Args:
vehiculo_id: ID del vehículo.
lat: Latitud actual.
lng: Longitud actual.
estado_anterior: Estado de geocercas anterior {geocerca_id: dentro}.
Returns:
Lista de alertas generadas.
"""
alertas = []
estado_anterior = estado_anterior or {}
resultados = await self.geocerca_service.verificar_todas_geocercas(
lat, lng, vehiculo_id
)
for r in resultados:
geocerca_id = r["geocerca_id"]
dentro = r["dentro"]
estaba_dentro = estado_anterior.get(geocerca_id, None)
# Entrada a geocerca
if dentro and not estaba_dentro and r["alerta_entrada"]:
tipo_id = await self._obtener_tipo_alerta_id("ENTRADA_GEOCERCA")
if tipo_id:
alerta = Alerta(
vehiculo_id=vehiculo_id,
tipo_alerta_id=tipo_id,
severidad="baja",
mensaje=f"Entrada a geocerca: {r['geocerca_nombre']}",
lat=lat,
lng=lng,
)
self.db.add(alerta)
alertas.append(alerta)
# Salida de geocerca
elif not dentro and estaba_dentro and r["alerta_salida"]:
tipo_id = await self._obtener_tipo_alerta_id("SALIDA_GEOCERCA")
if tipo_id:
alerta = Alerta(
vehiculo_id=vehiculo_id,
tipo_alerta_id=tipo_id,
severidad="media",
mensaje=f"Salida de geocerca: {r['geocerca_nombre']}",
lat=lat,
lng=lng,
)
self.db.add(alerta)
alertas.append(alerta)
if alertas:
await self.db.commit()
return alertas
async def verificar_bateria_baja(
self,
vehiculo_id: int,
bateria: float,
lat: float,
lng: float,
dispositivo_id: int = None,
) -> Optional[Alerta]:
"""
Verifica si la batería del dispositivo está baja.
Args:
vehiculo_id: ID del vehículo.
bateria: Porcentaje de batería.
lat: Latitud actual.
lng: Longitud actual.
dispositivo_id: ID del dispositivo (opcional).
Returns:
Alerta creada si la batería está baja.
"""
if bateria > settings.ALERT_BATTERY_LOW_PERCENT:
return None
tipo_alerta_id = await self._obtener_tipo_alerta_id("BATERIA_BAJA")
if not tipo_alerta_id:
return None
# Verificar si ya existe una alerta reciente (últimas 2 horas)
tiempo_limite = datetime.now(timezone.utc) - timedelta(hours=2)
result = await self.db.execute(
select(Alerta)
.where(
and_(
Alerta.vehiculo_id == vehiculo_id,
Alerta.tipo_alerta_id == tipo_alerta_id,
Alerta.creado_en >= tiempo_limite,
)
)
)
if result.scalar_one_or_none():
return None
severidad = "alta" if bateria < 10 else "media"
alerta = Alerta(
vehiculo_id=vehiculo_id,
dispositivo_id=dispositivo_id,
tipo_alerta_id=tipo_alerta_id,
severidad=severidad,
mensaje=f"Batería baja del dispositivo: {bateria:.0f}%",
lat=lat,
lng=lng,
valor=bateria,
umbral=settings.ALERT_BATTERY_LOW_PERCENT,
)
self.db.add(alerta)
await self.db.commit()
await self.db.refresh(alerta)
return alerta
async def verificar_sin_señal(self) -> List[Alerta]:
"""
Verifica vehículos que no han reportado ubicación.
Busca vehículos activos cuya última ubicación sea mayor
al tiempo configurado.
Returns:
Lista de alertas generadas.
"""
alertas = []
tiempo_limite = datetime.now(timezone.utc) - timedelta(
minutes=settings.ALERT_NO_SIGNAL_MINUTES
)
result = await self.db.execute(
select(Vehiculo)
.where(Vehiculo.activo == True)
.where(Vehiculo.en_servicio == True)
.where(Vehiculo.ultima_ubicacion_tiempo < tiempo_limite)
)
vehiculos = result.scalars().all()
tipo_alerta_id = await self._obtener_tipo_alerta_id("SIN_SEÑAL")
if not tipo_alerta_id:
return alertas
for v in vehiculos:
# Verificar si ya existe una alerta reciente (últimas 2 horas)
tiempo_alerta_limite = datetime.now(timezone.utc) - timedelta(hours=2)
result = await self.db.execute(
select(Alerta)
.where(
and_(
Alerta.vehiculo_id == v.id,
Alerta.tipo_alerta_id == tipo_alerta_id,
Alerta.creado_en >= tiempo_alerta_limite,
)
)
)
if result.scalar_one_or_none():
continue
minutos_sin_señal = int(
(datetime.now(timezone.utc) - v.ultima_ubicacion_tiempo).total_seconds() / 60
)
alerta = Alerta(
vehiculo_id=v.id,
tipo_alerta_id=tipo_alerta_id,
severidad="alta",
mensaje=f"Sin señal GPS por {minutos_sin_señal} minutos",
lat=v.ultima_lat,
lng=v.ultima_lng,
valor=minutos_sin_señal,
umbral=settings.ALERT_NO_SIGNAL_MINUTES,
)
self.db.add(alerta)
alertas.append(alerta)
if alertas:
await self.db.commit()
return alertas
async def crear_alerta(
self,
alerta_data: AlertaCreate,
) -> Alerta:
"""
Crea una alerta manualmente.
Args:
alerta_data: Datos de la alerta.
Returns:
Alerta creada.
"""
alerta = Alerta(
vehiculo_id=alerta_data.vehiculo_id,
conductor_id=alerta_data.conductor_id,
tipo_alerta_id=alerta_data.tipo_alerta_id,
dispositivo_id=alerta_data.dispositivo_id,
severidad=alerta_data.severidad,
mensaje=alerta_data.mensaje,
descripcion=alerta_data.descripcion,
lat=alerta_data.lat,
lng=alerta_data.lng,
direccion=alerta_data.direccion,
velocidad=alerta_data.velocidad,
valor=alerta_data.valor,
umbral=alerta_data.umbral,
datos_extra=alerta_data.datos_extra,
)
self.db.add(alerta)
await self.db.commit()
await self.db.refresh(alerta)
return alerta
async def marcar_atendida(
self,
alerta_id: int,
usuario_id: int,
notas: str = None,
) -> Optional[Alerta]:
"""
Marca una alerta como atendida.
Args:
alerta_id: ID de la alerta.
usuario_id: ID del usuario que atiende.
notas: Notas de atención (opcional).
Returns:
Alerta actualizada o None si no existe.
"""
result = await self.db.execute(
select(Alerta).where(Alerta.id == alerta_id)
)
alerta = result.scalar_one_or_none()
if not alerta:
return None
alerta.atendida = True
alerta.atendida_por_id = usuario_id
alerta.atendida_en = datetime.now(timezone.utc)
alerta.notas_atencion = notas
await self.db.commit()
await self.db.refresh(alerta)
return alerta
async def obtener_alertas_pendientes(
self,
vehiculo_id: int = None,
severidad: str = None,
limite: int = 50,
) -> List[Alerta]:
"""
Obtiene alertas pendientes de atender.
Args:
vehiculo_id: Filtrar por vehículo (opcional).
severidad: Filtrar por severidad (opcional).
limite: Límite de resultados.
Returns:
Lista de alertas pendientes.
"""
query = (
select(Alerta)
.where(Alerta.atendida == False)
.order_by(
Alerta.severidad.desc(), # Críticas primero
Alerta.creado_en.desc()
)
.limit(limite)
)
if vehiculo_id:
query = query.where(Alerta.vehiculo_id == vehiculo_id)
if severidad:
query = query.where(Alerta.severidad == severidad)
result = await self.db.execute(query)
return result.scalars().all()
async def obtener_estadisticas(
self,
desde: datetime = None,
hasta: datetime = None,
) -> dict:
"""
Obtiene estadísticas de alertas.
Args:
desde: Fecha inicio (opcional).
hasta: Fecha fin (opcional).
Returns:
Diccionario con estadísticas.
"""
desde = desde or (datetime.now(timezone.utc) - timedelta(days=30))
hasta = hasta or datetime.now(timezone.utc)
# Total de alertas
result = await self.db.execute(
select(func.count(Alerta.id))
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
)
total = result.scalar()
# Pendientes
result = await self.db.execute(
select(func.count(Alerta.id))
.where(Alerta.atendida == False)
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
)
pendientes = result.scalar()
# Por severidad
result = await self.db.execute(
select(Alerta.severidad, func.count(Alerta.id))
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
.group_by(Alerta.severidad)
)
por_severidad = {row[0]: row[1] for row in result.all()}
# Por tipo
result = await self.db.execute(
select(TipoAlerta.codigo, TipoAlerta.nombre, func.count(Alerta.id))
.join(TipoAlerta, Alerta.tipo_alerta_id == TipoAlerta.id)
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
.group_by(TipoAlerta.codigo, TipoAlerta.nombre)
.order_by(func.count(Alerta.id).desc())
)
por_tipo = [
{"codigo": row[0], "nombre": row[1], "cantidad": row[2]}
for row in result.all()
]
return {
"total": total,
"pendientes": pendientes,
"atendidas": total - pendientes,
"criticas": por_severidad.get("critica", 0),
"altas": por_severidad.get("alta", 0),
"medias": por_severidad.get("media", 0),
"bajas": por_severidad.get("baja", 0),
"por_tipo": por_tipo,
}

View File

@@ -0,0 +1,351 @@
"""
Servicio para gestión de geocercas.
Proporciona funcionalidades para verificar si un punto está dentro
de una geocerca y calcular distancias.
"""
import json
import math
from typing import List, Optional, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.geocerca import Geocerca
class GeocercaService:
"""Servicio para operaciones con geocercas."""
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
async def verificar_punto_en_geocerca(
self,
lat: float,
lng: float,
geocerca_id: int,
) -> Tuple[bool, Optional[float]]:
"""
Verifica si un punto está dentro de una geocerca.
Args:
lat: Latitud del punto.
lng: Longitud del punto.
geocerca_id: ID de la geocerca.
Returns:
Tupla (está_dentro, distancia_al_borde_metros).
distancia es None si está dentro, o la distancia al borde si está fuera.
"""
result = await self.db.execute(
select(Geocerca).where(Geocerca.id == geocerca_id)
)
geocerca = result.scalar_one_or_none()
if not geocerca:
return False, None
if geocerca.tipo == "circular":
return self._punto_en_circulo(
lat, lng,
geocerca.centro_lat, geocerca.centro_lng,
geocerca.radio_metros
)
else:
coordenadas = json.loads(geocerca.coordenadas_json) if geocerca.coordenadas_json else []
return self._punto_en_poligono(lat, lng, coordenadas)
async def obtener_geocercas_activas_para_vehiculo(
self,
vehiculo_id: int,
) -> List[Geocerca]:
"""
Obtiene las geocercas activas aplicables a un vehículo.
Args:
vehiculo_id: ID del vehículo.
Returns:
Lista de geocercas aplicables.
"""
# Geocercas sin vehículos asignados (aplican a todos)
# o con este vehículo asignado
result = await self.db.execute(
select(Geocerca)
.where(Geocerca.activa == True)
)
todas_geocercas = result.scalars().all()
geocercas_aplicables = []
for g in todas_geocercas:
# Si no tiene vehículos asignados, aplica a todos
if not g.vehiculos_asignados:
geocercas_aplicables.append(g)
# Si tiene vehículos asignados, verificar si incluye este
elif any(v.id == vehiculo_id for v in g.vehiculos_asignados):
geocercas_aplicables.append(g)
return geocercas_aplicables
async def verificar_todas_geocercas(
self,
lat: float,
lng: float,
vehiculo_id: int,
) -> List[dict]:
"""
Verifica un punto contra todas las geocercas aplicables.
Args:
lat: Latitud del punto.
lng: Longitud del punto.
vehiculo_id: ID del vehículo.
Returns:
Lista de geocercas con información de si está dentro o fuera.
"""
geocercas = await self.obtener_geocercas_activas_para_vehiculo(vehiculo_id)
resultados = []
for g in geocercas:
if g.tipo == "circular":
dentro, distancia = self._punto_en_circulo(
lat, lng,
g.centro_lat, g.centro_lng,
g.radio_metros
)
else:
coordenadas = json.loads(g.coordenadas_json) if g.coordenadas_json else []
dentro, distancia = self._punto_en_poligono(lat, lng, coordenadas)
resultados.append({
"geocerca_id": g.id,
"geocerca_nombre": g.nombre,
"dentro": dentro,
"distancia_metros": distancia,
"alerta_entrada": g.alerta_entrada,
"alerta_salida": g.alerta_salida,
"velocidad_maxima": g.velocidad_maxima,
})
return resultados
def _punto_en_circulo(
self,
lat: float,
lng: float,
centro_lat: float,
centro_lng: float,
radio_metros: float,
) -> Tuple[bool, Optional[float]]:
"""
Verifica si un punto está dentro de un círculo.
Args:
lat, lng: Coordenadas del punto.
centro_lat, centro_lng: Centro del círculo.
radio_metros: Radio del círculo.
Returns:
(está_dentro, distancia_al_borde).
"""
distancia = self._distancia_haversine(lat, lng, centro_lat, centro_lng)
distancia_metros = distancia * 1000 # km a metros
dentro = distancia_metros <= radio_metros
if dentro:
return True, None
else:
return False, distancia_metros - radio_metros
def _punto_en_poligono(
self,
lat: float,
lng: float,
coordenadas: List[List[float]],
) -> Tuple[bool, Optional[float]]:
"""
Verifica si un punto está dentro de un polígono.
Usa el algoritmo ray casting.
Args:
lat, lng: Coordenadas del punto.
coordenadas: Lista de coordenadas [[lat, lng], ...].
Returns:
(está_dentro, distancia_al_borde).
"""
if not coordenadas or len(coordenadas) < 3:
return False, None
n = len(coordenadas)
dentro = False
j = n - 1
for i in range(n):
yi, xi = coordenadas[i][0], coordenadas[i][1]
yj, xj = coordenadas[j][0], coordenadas[j][1]
if ((yi > lat) != (yj > lat)) and (
lng < (xj - xi) * (lat - yi) / (yj - yi) + xi
):
dentro = not dentro
j = i
if dentro:
return True, None
else:
# Calcular distancia al borde más cercano
distancia_min = float('inf')
for i in range(n):
j = (i + 1) % n
d = self._distancia_punto_segmento(
lat, lng,
coordenadas[i][0], coordenadas[i][1],
coordenadas[j][0], coordenadas[j][1]
)
if d < distancia_min:
distancia_min = d
return False, distancia_min * 1000 # km a metros
def _distancia_haversine(
self,
lat1: float,
lng1: float,
lat2: float,
lng2: float,
) -> float:
"""
Calcula la distancia entre dos puntos usando Haversine.
Args:
lat1, lng1: Primer punto.
lat2, lng2: Segundo punto.
Returns:
Distancia en kilómetros.
"""
R = 6371 # Radio de la Tierra en km
lat1_rad = math.radians(lat1)
lat2_rad = math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlng = math.radians(lng2 - lng1)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlng / 2) ** 2
)
c = 2 * math.asin(math.sqrt(a))
return R * c
def _distancia_punto_segmento(
self,
px: float,
py: float,
x1: float,
y1: float,
x2: float,
y2: float,
) -> float:
"""
Calcula la distancia de un punto a un segmento de línea.
Args:
px, py: Punto.
x1, y1, x2, y2: Extremos del segmento.
Returns:
Distancia en kilómetros.
"""
# Longitud del segmento al cuadrado
l2 = (x2 - x1) ** 2 + (y2 - y1) ** 2
if l2 == 0:
# El segmento es un punto
return self._distancia_haversine(px, py, x1, y1)
# Proyección del punto sobre la línea
t = max(0, min(1, ((px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)) / l2))
# Punto más cercano en el segmento
proj_x = x1 + t * (x2 - x1)
proj_y = y1 + t * (y2 - y1)
return self._distancia_haversine(px, py, proj_x, proj_y)
@staticmethod
def calcular_area_poligono(coordenadas: List[List[float]]) -> float:
"""
Calcula el área de un polígono en metros cuadrados.
Args:
coordenadas: Lista de coordenadas [[lat, lng], ...].
Returns:
Área en metros cuadrados.
"""
if len(coordenadas) < 3:
return 0.0
# Usar la fórmula del topógrafo (Shoelace) con conversión a metros
n = len(coordenadas)
area = 0.0
# Factor de conversión aproximado para grados a metros
# (varía según la latitud)
lat_media = sum(c[0] for c in coordenadas) / n
m_per_deg_lat = 111132.92 - 559.82 * math.cos(2 * math.radians(lat_media))
m_per_deg_lng = 111412.84 * math.cos(math.radians(lat_media))
for i in range(n):
j = (i + 1) % n
xi = coordenadas[i][1] * m_per_deg_lng
yi = coordenadas[i][0] * m_per_deg_lat
xj = coordenadas[j][1] * m_per_deg_lng
yj = coordenadas[j][0] * m_per_deg_lat
area += xi * yj - xj * yi
return abs(area) / 2
@staticmethod
def calcular_perimetro_poligono(coordenadas: List[List[float]]) -> float:
"""
Calcula el perímetro de un polígono en metros.
Args:
coordenadas: Lista de coordenadas [[lat, lng], ...].
Returns:
Perímetro en metros.
"""
if len(coordenadas) < 2:
return 0.0
servicio = GeocercaService(None) # Solo para usar método estático
perimetro = 0.0
n = len(coordenadas)
for i in range(n):
j = (i + 1) % n
d = servicio._distancia_haversine(
coordenadas[i][0], coordenadas[i][1],
coordenadas[j][0], coordenadas[j][1]
)
perimetro += d * 1000 # km a metros
return perimetro

View File

@@ -0,0 +1,348 @@
"""
Servicio para envío de notificaciones.
Maneja el envío de notificaciones por email, push y SMS.
"""
import json
from datetime import datetime, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import List, Optional
import aiosmtplib
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.alerta import Alerta
class NotificacionService:
"""Servicio para envío de notificaciones."""
def __init__(self, db: AsyncSession = None):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async (opcional).
"""
self.db = db
async def enviar_notificacion_alerta(
self,
alerta: Alerta,
destinatarios_email: List[str] = None,
) -> dict:
"""
Envía notificaciones para una alerta.
Args:
alerta: Alerta a notificar.
destinatarios_email: Lista de emails (opcional, usa config si no se especifica).
Returns:
Resultado del envío.
"""
resultado = {
"email_enviado": False,
"push_enviado": False,
"sms_enviado": False,
}
# Determinar si enviar cada tipo de notificación
tipo_alerta = alerta.tipo_alerta
if tipo_alerta.notificar_email:
resultado["email_enviado"] = await self.enviar_email_alerta(
alerta,
destinatarios_email,
)
if tipo_alerta.notificar_push:
resultado["push_enviado"] = await self.enviar_push_alerta(alerta)
if tipo_alerta.notificar_sms:
resultado["sms_enviado"] = await self.enviar_sms_alerta(alerta)
# Actualizar estado de notificaciones en la alerta
if self.db:
alerta.notificacion_email_enviada = resultado["email_enviado"]
alerta.notificacion_push_enviada = resultado["push_enviado"]
alerta.notificacion_sms_enviada = resultado["sms_enviado"]
await self.db.commit()
return resultado
async def enviar_email_alerta(
self,
alerta: Alerta,
destinatarios: List[str] = None,
) -> bool:
"""
Envía notificación de alerta por email.
Args:
alerta: Alerta a notificar.
destinatarios: Lista de emails.
Returns:
True si se envió correctamente.
"""
if not settings.SMTP_HOST or not settings.SMTP_USER:
return False
destinatarios = destinatarios or [settings.SMTP_FROM_EMAIL]
# Crear mensaje
mensaje = MIMEMultipart("alternative")
mensaje["Subject"] = f"[{alerta.severidad.upper()}] {alerta.mensaje[:50]}"
mensaje["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL}>"
mensaje["To"] = ", ".join(destinatarios)
# Contenido HTML
html_content = self._crear_html_alerta(alerta)
mensaje.attach(MIMEText(html_content, "html"))
# Contenido texto plano
text_content = self._crear_texto_alerta(alerta)
mensaje.attach(MIMEText(text_content, "plain"))
try:
async with aiosmtplib.SMTP(
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
use_tls=settings.SMTP_TLS,
) as smtp:
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
await smtp.send_message(mensaje)
return True
except Exception as e:
print(f"Error enviando email: {e}")
return False
async def enviar_push_alerta(
self,
alerta: Alerta,
) -> bool:
"""
Envía notificación push de alerta.
Args:
alerta: Alerta a notificar.
Returns:
True si se envió correctamente.
"""
if not settings.FIREBASE_ENABLED:
return False
# TODO: Implementar con Firebase Cloud Messaging
# from firebase_admin import messaging
#
# message = messaging.Message(
# notification=messaging.Notification(
# title=f"Alerta: {alerta.tipo_alerta.nombre}",
# body=alerta.mensaje,
# ),
# topic="alertas",
# )
# messaging.send(message)
return False
async def enviar_sms_alerta(
self,
alerta: Alerta,
) -> bool:
"""
Envía notificación SMS de alerta.
Args:
alerta: Alerta a notificar.
Returns:
True si se envió correctamente.
"""
# TODO: Implementar con Twilio u otro proveedor SMS
return False
async def enviar_email(
self,
destinatarios: List[str],
asunto: str,
contenido_html: str,
contenido_texto: str = None,
) -> bool:
"""
Envía un email genérico.
Args:
destinatarios: Lista de emails.
asunto: Asunto del email.
contenido_html: Contenido HTML.
contenido_texto: Contenido texto plano (opcional).
Returns:
True si se envió correctamente.
"""
if not settings.SMTP_HOST or not settings.SMTP_USER:
return False
mensaje = MIMEMultipart("alternative")
mensaje["Subject"] = asunto
mensaje["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL}>"
mensaje["To"] = ", ".join(destinatarios)
mensaje.attach(MIMEText(contenido_html, "html"))
if contenido_texto:
mensaje.attach(MIMEText(contenido_texto, "plain"))
try:
async with aiosmtplib.SMTP(
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
use_tls=settings.SMTP_TLS,
) as smtp:
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
await smtp.send_message(mensaje)
return True
except Exception as e:
print(f"Error enviando email: {e}")
return False
def _crear_html_alerta(self, alerta: Alerta) -> str:
"""Crea el contenido HTML para el email de alerta."""
color_severidad = {
"baja": "#10B981",
"media": "#F59E0B",
"alta": "#EF4444",
"critica": "#DC2626",
}
color = color_severidad.get(alerta.severidad, "#6B7280")
html = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: {color}; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }}
.footer {{ padding: 10px; text-align: center; color: #6b7280; font-size: 12px; }}
.badge {{ display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; }}
.info-row {{ margin: 10px 0; }}
.label {{ color: #6b7280; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2 style="margin: 0;">Alerta: {alerta.tipo_alerta.nombre if alerta.tipo_alerta else 'Sistema'}</h2>
<span class="badge" style="background-color: rgba(255,255,255,0.2);">
{alerta.severidad.upper()}
</span>
</div>
<div class="content">
<p><strong>{alerta.mensaje}</strong></p>
<div class="info-row">
<span class="label">Fecha/Hora:</span>
{alerta.creado_en.strftime('%Y-%m-%d %H:%M:%S')}
</div>
{'<div class="info-row"><span class="label">Vehiculo ID:</span> ' + str(alerta.vehiculo_id) + '</div>' if alerta.vehiculo_id else ''}
{'<div class="info-row"><span class="label">Ubicacion:</span> ' + str(alerta.lat) + ', ' + str(alerta.lng) + '</div>' if alerta.lat else ''}
{'<div class="info-row"><span class="label">Velocidad:</span> ' + str(alerta.velocidad) + ' km/h</div>' if alerta.velocidad else ''}
{f'<div class="info-row"><span class="label">Descripcion:</span> {alerta.descripcion}</div>' if alerta.descripcion else ''}
</div>
<div class="footer">
<p>Este es un mensaje automatico de {settings.APP_NAME}</p>
</div>
</div>
</body>
</html>
"""
return html
def _crear_texto_alerta(self, alerta: Alerta) -> str:
"""Crea el contenido de texto plano para el email de alerta."""
texto = f"""
ALERTA: {alerta.tipo_alerta.nombre if alerta.tipo_alerta else 'Sistema'}
Severidad: {alerta.severidad.upper()}
{alerta.mensaje}
Fecha/Hora: {alerta.creado_en.strftime('%Y-%m-%d %H:%M:%S')}
"""
if alerta.vehiculo_id:
texto += f"Vehiculo ID: {alerta.vehiculo_id}\n"
if alerta.lat and alerta.lng:
texto += f"Ubicacion: {alerta.lat}, {alerta.lng}\n"
if alerta.velocidad:
texto += f"Velocidad: {alerta.velocidad} km/h\n"
texto += f"\n--\nEste es un mensaje automatico de {settings.APP_NAME}"
return texto
async def enviar_recordatorio_mantenimiento(
self,
vehiculo_nombre: str,
vehiculo_placa: str,
tipo_mantenimiento: str,
fecha_programada: str,
destinatarios: List[str],
) -> bool:
"""
Envía recordatorio de mantenimiento por email.
Args:
vehiculo_nombre: Nombre del vehículo.
vehiculo_placa: Placa del vehículo.
tipo_mantenimiento: Tipo de mantenimiento.
fecha_programada: Fecha programada.
destinatarios: Lista de emails.
Returns:
True si se envió correctamente.
"""
asunto = f"Recordatorio: Mantenimiento próximo - {vehiculo_placa}"
html = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background-color: #3B82F6; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
.content {{ background-color: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Recordatorio de Mantenimiento</h2>
</div>
<div class="content">
<p>Se aproxima la fecha de mantenimiento programado:</p>
<ul>
<li><strong>Vehiculo:</strong> {vehiculo_nombre} ({vehiculo_placa})</li>
<li><strong>Tipo:</strong> {tipo_mantenimiento}</li>
<li><strong>Fecha programada:</strong> {fecha_programada}</li>
</ul>
<p>Por favor, programe el mantenimiento con anticipacion.</p>
</div>
</div>
</body>
</html>
"""
return await self.enviar_email(destinatarios, asunto, html)

View File

@@ -0,0 +1,529 @@
"""
Servicio para generación de reportes.
Genera reportes en PDF y Excel para diferentes tipos de datos.
"""
import io
import json
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.alerta import Alerta
from app.models.carga_combustible import CargaCombustible
from app.models.mantenimiento import Mantenimiento
from app.models.ubicacion import Ubicacion
from app.models.vehiculo import Vehiculo
from app.models.viaje import Viaje
from app.schemas.reporte import (
DashboardGrafico,
DashboardResumen,
ReporteRequest,
ReporteResponse,
)
class ReporteService:
"""Servicio para generación de reportes."""
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
async def obtener_dashboard_resumen(self) -> DashboardResumen:
"""
Obtiene el resumen para el dashboard principal.
Returns:
Datos del dashboard.
"""
ahora = datetime.now(timezone.utc)
inicio_hoy = ahora.replace(hour=0, minute=0, second=0, microsecond=0)
# Contadores de vehículos
result = await self.db.execute(
select(func.count(Vehiculo.id)).where(Vehiculo.activo == True)
)
total_vehiculos = result.scalar()
result = await self.db.execute(
select(func.count(Vehiculo.id))
.where(Vehiculo.activo == True)
.where(Vehiculo.en_servicio == True)
)
vehiculos_activos = result.scalar()
# Vehículos en movimiento (velocidad > 5 km/h, última ubicación < 5 min)
tiempo_reciente = ahora - timedelta(minutes=5)
result = await self.db.execute(
select(func.count(Vehiculo.id))
.where(Vehiculo.activo == True)
.where(Vehiculo.ultima_velocidad > 5)
.where(Vehiculo.ultima_ubicacion_tiempo >= tiempo_reciente)
)
vehiculos_en_movimiento = result.scalar()
# Vehículos detenidos (velocidad < 5, ubicación reciente)
result = await self.db.execute(
select(func.count(Vehiculo.id))
.where(Vehiculo.activo == True)
.where(Vehiculo.ultima_velocidad <= 5)
.where(Vehiculo.ultima_ubicacion_tiempo >= tiempo_reciente)
)
vehiculos_detenidos = result.scalar()
# Sin señal (última ubicación > 30 min)
tiempo_sin_señal = ahora - timedelta(minutes=30)
result = await self.db.execute(
select(func.count(Vehiculo.id))
.where(Vehiculo.activo == True)
.where(Vehiculo.en_servicio == True)
.where(Vehiculo.ultima_ubicacion_tiempo < tiempo_sin_señal)
)
vehiculos_sin_señal = result.scalar()
# Conductores (simplificado)
from app.models.conductor import Conductor
result = await self.db.execute(
select(func.count(Conductor.id)).where(Conductor.activo == True)
)
conductores_activos = result.scalar()
# Alertas
result = await self.db.execute(
select(func.count(Alerta.id)).where(Alerta.atendida == False)
)
alertas_pendientes = result.scalar()
result = await self.db.execute(
select(func.count(Alerta.id))
.where(Alerta.atendida == False)
.where(Alerta.severidad == "critica")
)
alertas_criticas = result.scalar()
result = await self.db.execute(
select(func.count(Alerta.id)).where(Alerta.creado_en >= inicio_hoy)
)
alertas_hoy = result.scalar()
# Viajes de hoy
result = await self.db.execute(
select(func.count(Viaje.id)).where(Viaje.inicio_tiempo >= inicio_hoy)
)
viajes_hoy = result.scalar()
result = await self.db.execute(
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
.where(Viaje.inicio_tiempo >= inicio_hoy)
)
distancia_hoy = result.scalar() or 0
# Mantenimientos
result = await self.db.execute(
select(func.count(Mantenimiento.id))
.where(Mantenimiento.estado == "vencido")
)
mantenimientos_vencidos = result.scalar()
proximos_7_dias = ahora + timedelta(days=7)
result = await self.db.execute(
select(func.count(Mantenimiento.id))
.where(Mantenimiento.estado == "programado")
.where(Mantenimiento.fecha_programada <= proximos_7_dias.date())
)
mantenimientos_proximos = result.scalar()
return DashboardResumen(
total_vehiculos=total_vehiculos,
vehiculos_activos=vehiculos_activos,
vehiculos_en_movimiento=vehiculos_en_movimiento,
vehiculos_detenidos=vehiculos_detenidos,
vehiculos_sin_señal=vehiculos_sin_señal,
total_conductores=conductores_activos,
conductores_activos=conductores_activos,
alertas_pendientes=alertas_pendientes,
alertas_criticas=alertas_criticas,
alertas_hoy=alertas_hoy,
viajes_hoy=viajes_hoy,
distancia_hoy_km=float(distancia_hoy),
mantenimientos_vencidos=mantenimientos_vencidos,
mantenimientos_proximos=mantenimientos_proximos,
actualizado_en=ahora,
)
async def obtener_dashboard_graficos(self) -> DashboardGrafico:
"""
Obtiene datos para gráficos del dashboard.
Returns:
Datos para gráficos.
"""
ahora = datetime.now(timezone.utc)
# Distancia por día (últimos 7 días)
distancia_diaria = []
for i in range(6, -1, -1):
fecha = ahora - timedelta(days=i)
inicio_dia = fecha.replace(hour=0, minute=0, second=0, microsecond=0)
fin_dia = inicio_dia + timedelta(days=1)
result = await self.db.execute(
select(func.coalesce(func.sum(Viaje.distancia_km), 0))
.where(Viaje.inicio_tiempo >= inicio_dia)
.where(Viaje.inicio_tiempo < fin_dia)
)
km = result.scalar() or 0
distancia_diaria.append({
"fecha": inicio_dia.strftime("%Y-%m-%d"),
"km": float(km),
})
# Viajes por día
viajes_diarios = []
for i in range(6, -1, -1):
fecha = ahora - timedelta(days=i)
inicio_dia = fecha.replace(hour=0, minute=0, second=0, microsecond=0)
fin_dia = inicio_dia + timedelta(days=1)
result = await self.db.execute(
select(func.count(Viaje.id))
.where(Viaje.inicio_tiempo >= inicio_dia)
.where(Viaje.inicio_tiempo < fin_dia)
)
cantidad = result.scalar() or 0
viajes_diarios.append({
"fecha": inicio_dia.strftime("%Y-%m-%d"),
"cantidad": cantidad,
})
# Alertas por tipo (últimos 7 días)
inicio_semana = ahora - timedelta(days=7)
from app.models.tipo_alerta import TipoAlerta
result = await self.db.execute(
select(TipoAlerta.nombre, func.count(Alerta.id))
.join(TipoAlerta, Alerta.tipo_alerta_id == TipoAlerta.id)
.where(Alerta.creado_en >= inicio_semana)
.group_by(TipoAlerta.nombre)
.order_by(func.count(Alerta.id).desc())
.limit(5)
)
alertas_por_tipo = [
{"tipo": row[0], "cantidad": row[1]}
for row in result.all()
]
# Consumo de combustible (últimos 30 días)
consumo_combustible = []
for i in range(29, -1, -1):
fecha = ahora - timedelta(days=i)
inicio_dia = fecha.replace(hour=0, minute=0, second=0, microsecond=0)
fin_dia = inicio_dia + timedelta(days=1)
result = await self.db.execute(
select(func.coalesce(func.sum(CargaCombustible.litros), 0))
.where(CargaCombustible.fecha >= inicio_dia)
.where(CargaCombustible.fecha < fin_dia)
)
litros = result.scalar() or 0
consumo_combustible.append({
"fecha": inicio_dia.strftime("%Y-%m-%d"),
"litros": float(litros),
})
return DashboardGrafico(
distancia_diaria=distancia_diaria,
viajes_diarios=viajes_diarios,
alertas_por_tipo=alertas_por_tipo,
consumo_combustible=consumo_combustible,
)
async def generar_reporte(
self,
request: ReporteRequest,
) -> ReporteResponse:
"""
Genera un reporte según los parámetros especificados.
Args:
request: Parámetros del reporte.
Returns:
Información del reporte generado.
"""
reporte_id = str(uuid.uuid4())
# Recopilar datos según el tipo de reporte
datos = await self._recopilar_datos_reporte(request)
# Generar archivo según formato
if request.formato == "pdf":
archivo_url = await self._generar_pdf(reporte_id, request.tipo, datos)
elif request.formato == "excel":
archivo_url = await self._generar_excel(reporte_id, request.tipo, datos)
else: # csv
archivo_url = await self._generar_csv(reporte_id, request.tipo, datos)
return ReporteResponse(
id=reporte_id,
tipo=request.tipo,
formato=request.formato,
estado="completado",
archivo_url=archivo_url,
creado_en=datetime.now(timezone.utc),
completado_en=datetime.now(timezone.utc),
)
async def _recopilar_datos_reporte(
self,
request: ReporteRequest,
) -> Dict[str, Any]:
"""Recopila los datos necesarios para el reporte."""
datos = {
"periodo_inicio": request.fecha_inicio,
"periodo_fin": request.fecha_fin,
}
if request.tipo == "viajes":
datos["viajes"] = await self._obtener_datos_viajes(
request.fecha_inicio,
request.fecha_fin,
request.vehiculos_ids,
)
elif request.tipo == "alertas":
datos["alertas"] = await self._obtener_datos_alertas(
request.fecha_inicio,
request.fecha_fin,
request.vehiculos_ids,
)
elif request.tipo == "combustible":
datos["combustible"] = await self._obtener_datos_combustible(
request.fecha_inicio,
request.fecha_fin,
request.vehiculos_ids,
)
elif request.tipo == "mantenimiento":
datos["mantenimiento"] = await self._obtener_datos_mantenimiento(
request.fecha_inicio,
request.fecha_fin,
request.vehiculos_ids,
)
return datos
async def _obtener_datos_viajes(
self,
desde: datetime,
hasta: datetime,
vehiculos_ids: List[int] = None,
) -> List[dict]:
"""Obtiene datos de viajes para el reporte."""
query = (
select(Viaje)
.where(Viaje.inicio_tiempo >= desde)
.where(Viaje.inicio_tiempo <= hasta)
.order_by(Viaje.inicio_tiempo)
)
if vehiculos_ids:
query = query.where(Viaje.vehiculo_id.in_(vehiculos_ids))
result = await self.db.execute(query)
viajes = result.scalars().all()
return [
{
"id": v.id,
"vehiculo_id": v.vehiculo_id,
"inicio": v.inicio_tiempo.isoformat(),
"fin": v.fin_tiempo.isoformat() if v.fin_tiempo else None,
"distancia_km": v.distancia_km,
"duracion_segundos": v.duracion_segundos,
"velocidad_promedio": v.velocidad_promedio,
"velocidad_maxima": v.velocidad_maxima,
"estado": v.estado,
}
for v in viajes
]
async def _obtener_datos_alertas(
self,
desde: datetime,
hasta: datetime,
vehiculos_ids: List[int] = None,
) -> List[dict]:
"""Obtiene datos de alertas para el reporte."""
query = (
select(Alerta)
.where(Alerta.creado_en >= desde)
.where(Alerta.creado_en <= hasta)
.order_by(Alerta.creado_en)
)
if vehiculos_ids:
query = query.where(Alerta.vehiculo_id.in_(vehiculos_ids))
result = await self.db.execute(query)
alertas = result.scalars().all()
return [
{
"id": a.id,
"vehiculo_id": a.vehiculo_id,
"tipo_alerta_id": a.tipo_alerta_id,
"severidad": a.severidad,
"mensaje": a.mensaje,
"creado_en": a.creado_en.isoformat(),
"atendida": a.atendida,
}
for a in alertas
]
async def _obtener_datos_combustible(
self,
desde: datetime,
hasta: datetime,
vehiculos_ids: List[int] = None,
) -> List[dict]:
"""Obtiene datos de combustible para el reporte."""
query = (
select(CargaCombustible)
.where(CargaCombustible.fecha >= desde)
.where(CargaCombustible.fecha <= hasta)
.order_by(CargaCombustible.fecha)
)
if vehiculos_ids:
query = query.where(CargaCombustible.vehiculo_id.in_(vehiculos_ids))
result = await self.db.execute(query)
cargas = result.scalars().all()
return [
{
"id": c.id,
"vehiculo_id": c.vehiculo_id,
"fecha": c.fecha.isoformat(),
"litros": c.litros,
"precio_litro": c.precio_litro,
"total": c.total,
"odometro": c.odometro,
"estacion": c.estacion,
}
for c in cargas
]
async def _obtener_datos_mantenimiento(
self,
desde: datetime,
hasta: datetime,
vehiculos_ids: List[int] = None,
) -> List[dict]:
"""Obtiene datos de mantenimiento para el reporte."""
query = (
select(Mantenimiento)
.where(Mantenimiento.fecha_programada >= desde.date())
.where(Mantenimiento.fecha_programada <= hasta.date())
.order_by(Mantenimiento.fecha_programada)
)
if vehiculos_ids:
query = query.where(Mantenimiento.vehiculo_id.in_(vehiculos_ids))
result = await self.db.execute(query)
mantenimientos = result.scalars().all()
return [
{
"id": m.id,
"vehiculo_id": m.vehiculo_id,
"tipo_mantenimiento_id": m.tipo_mantenimiento_id,
"estado": m.estado,
"fecha_programada": m.fecha_programada.isoformat(),
"fecha_realizada": m.fecha_realizada.isoformat() if m.fecha_realizada else None,
"costo_real": m.costo_real,
}
for m in mantenimientos
]
async def _generar_pdf(
self,
reporte_id: str,
tipo: str,
datos: Dict[str, Any],
) -> str:
"""Genera un reporte en PDF."""
# Implementación simplificada
# En producción se usaría WeasyPrint o similar
archivo_path = f"{settings.REPORTS_DIR}/{reporte_id}.pdf"
# TODO: Implementar generación de PDF con WeasyPrint
return archivo_path
async def _generar_excel(
self,
reporte_id: str,
tipo: str,
datos: Dict[str, Any],
) -> str:
"""Genera un reporte en Excel."""
try:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = tipo.capitalize()
# Escribir datos según el tipo
if tipo in datos:
items = datos[tipo]
if items:
# Headers
headers = list(items[0].keys())
for col, header in enumerate(headers, 1):
ws.cell(row=1, column=col, value=header)
# Data
for row, item in enumerate(items, 2):
for col, key in enumerate(headers, 1):
ws.cell(row=row, column=col, value=item.get(key))
archivo_path = f"{settings.REPORTS_DIR}/{reporte_id}.xlsx"
wb.save(archivo_path)
return archivo_path
except ImportError:
return ""
async def _generar_csv(
self,
reporte_id: str,
tipo: str,
datos: Dict[str, Any],
) -> str:
"""Genera un reporte en CSV."""
import csv
archivo_path = f"{settings.REPORTS_DIR}/{reporte_id}.csv"
if tipo in datos:
items = datos[tipo]
if items:
with open(archivo_path, 'w', newline='') as f:
writer = csv.DictWriter(f, fieldnames=items[0].keys())
writer.writeheader()
writer.writerows(items)
return archivo_path

View File

@@ -0,0 +1,286 @@
"""
Servicio para integración con Traccar.
Recibe datos de ubicación desde Traccar via forward
y los procesa en el sistema.
"""
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.dispositivo import Dispositivo
from app.schemas.ubicacion import TraccarLocationCreate, UbicacionCreate
from app.services.ubicacion_service import UbicacionService
class TraccarService:
"""Servicio para integración con Traccar GPS Server."""
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
self.ubicacion_service = UbicacionService(db)
self.api_url = settings.TRACCAR_API_URL
self.username = settings.TRACCAR_USERNAME
self.password = settings.TRACCAR_PASSWORD
async def procesar_posicion_traccar(
self,
posicion: TraccarLocationCreate,
) -> Optional[dict]:
"""
Procesa una posición recibida desde Traccar.
Args:
posicion: Datos de posición de Traccar.
Returns:
Resultado del procesamiento o None.
"""
# Buscar dispositivo por ID de Traccar
result = await self.db.execute(
select(Dispositivo)
.where(Dispositivo.identificador == str(posicion.deviceId))
.where(Dispositivo.protocolo == "traccar")
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
# Intentar buscar por IMEI en attributes
if posicion.attributes and "imei" in posicion.attributes:
result = await self.db.execute(
select(Dispositivo)
.where(Dispositivo.imei == posicion.attributes["imei"])
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
return None
# Convertir velocidad de nudos a km/h
velocidad = None
if posicion.speed is not None:
velocidad = posicion.speed * 1.852 # nudos a km/h
# Extraer datos adicionales de attributes
bateria = None
motor_encendido = None
odometro = None
if posicion.attributes:
bateria = posicion.attributes.get("batteryLevel")
motor_encendido = posicion.attributes.get("ignition")
# Odómetro puede venir en metros
odometro_metros = posicion.attributes.get("totalDistance")
if odometro_metros:
odometro = odometro_metros / 1000 # a km
# Crear schema de ubicación
ubicacion_data = UbicacionCreate(
vehiculo_id=dispositivo.vehiculo_id,
dispositivo_id=dispositivo.identificador,
lat=posicion.latitude,
lng=posicion.longitude,
velocidad=velocidad,
rumbo=posicion.course,
altitud=posicion.altitude,
precision=posicion.accuracy,
tiempo=posicion.fixTime,
fuente="traccar",
bateria_dispositivo=bateria,
motor_encendido=motor_encendido,
odometro=odometro,
)
# Procesar ubicación
resultado = await self.ubicacion_service.procesar_ubicacion(ubicacion_data)
if resultado:
return {
"status": "processed",
"vehiculo_id": dispositivo.vehiculo_id,
"dispositivo_id": dispositivo.identificador,
}
return None
async def sincronizar_dispositivos(self) -> dict:
"""
Sincroniza dispositivos desde Traccar.
Obtiene la lista de dispositivos de Traccar y los sincroniza
con la base de datos local.
Returns:
Resultado de la sincronización.
"""
if not self.username or not self.password:
return {"error": "Credenciales de Traccar no configuradas"}
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.api_url}/devices",
auth=(self.username, self.password),
timeout=30.0,
)
response.raise_for_status()
dispositivos_traccar = response.json()
except httpx.HTTPError as e:
return {"error": f"Error conectando a Traccar: {str(e)}"}
sincronizados = 0
for d in dispositivos_traccar:
# Verificar si ya existe
result = await self.db.execute(
select(Dispositivo)
.where(Dispositivo.identificador == str(d["id"]))
.where(Dispositivo.protocolo == "traccar")
)
dispositivo = result.scalar_one_or_none()
if not dispositivo:
# Solo registrar, no crear vehículo automáticamente
continue
# Actualizar información
dispositivo.nombre = d.get("name", dispositivo.nombre)
if d.get("lastUpdate"):
dispositivo.ultimo_contacto = datetime.fromisoformat(
d["lastUpdate"].replace("Z", "+00:00")
)
dispositivo.conectado = d.get("status", "") == "online"
sincronizados += 1
await self.db.commit()
return {
"total_traccar": len(dispositivos_traccar),
"sincronizados": sincronizados,
}
async def obtener_posicion_actual(
self,
dispositivo_id: str,
) -> Optional[dict]:
"""
Obtiene la posición actual de un dispositivo desde Traccar.
Args:
dispositivo_id: ID del dispositivo en Traccar.
Returns:
Posición actual o None.
"""
if not self.username or not self.password:
return None
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.api_url}/positions",
params={"deviceId": dispositivo_id},
auth=(self.username, self.password),
timeout=10.0,
)
response.raise_for_status()
posiciones = response.json()
if posiciones:
return posiciones[0]
except httpx.HTTPError:
pass
return None
async def enviar_comando(
self,
dispositivo_id: str,
tipo_comando: str,
data: dict = None,
) -> Optional[dict]:
"""
Envía un comando a un dispositivo via Traccar.
Args:
dispositivo_id: ID del dispositivo en Traccar.
tipo_comando: Tipo de comando (ej: "engineStop", "engineResume").
data: Datos adicionales del comando.
Returns:
Respuesta de Traccar o None.
"""
if not self.username or not self.password:
return None
comando = {
"deviceId": int(dispositivo_id),
"type": tipo_comando,
"attributes": data or {},
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.api_url}/commands/send",
json=comando,
auth=(self.username, self.password),
timeout=30.0,
)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
return {"error": str(e)}
async def obtener_reportes_traccar(
self,
dispositivo_id: str,
desde: datetime,
hasta: datetime,
tipo: str = "route",
) -> Optional[list]:
"""
Obtiene reportes desde Traccar.
Args:
dispositivo_id: ID del dispositivo.
desde: Fecha inicio.
hasta: Fecha fin.
tipo: Tipo de reporte (route, events, trips, stops).
Returns:
Lista de datos del reporte.
"""
if not self.username or not self.password:
return None
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.api_url}/reports/{tipo}",
params={
"deviceId": dispositivo_id,
"from": desde.isoformat(),
"to": hasta.isoformat(),
},
auth=(self.username, self.password),
timeout=60.0,
)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None

View File

@@ -0,0 +1,489 @@
"""
Servicio para procesamiento de ubicaciones GPS.
Maneja la recepción, procesamiento y análisis de datos de ubicación.
"""
import json
from datetime import datetime, timedelta, timezone
from typing import List, Optional, Tuple
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.dispositivo import Dispositivo
from app.models.ubicacion import Ubicacion
from app.models.vehiculo import Vehiculo
from app.schemas.ubicacion import (
HistorialUbicacionesResponse,
UbicacionCreate,
UbicacionResponse,
)
class UbicacionService:
"""Servicio para gestión de ubicaciones GPS."""
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
async def procesar_ubicacion(
self,
ubicacion_data: UbicacionCreate,
) -> Optional[UbicacionResponse]:
"""
Procesa una nueva ubicación recibida.
Args:
ubicacion_data: Datos de la ubicación a procesar.
Returns:
UbicacionResponse si se procesó correctamente, None si se descartó.
"""
# Determinar el vehículo
vehiculo_id = ubicacion_data.vehiculo_id
if not vehiculo_id and ubicacion_data.dispositivo_id:
# Buscar vehículo por identificador de dispositivo
result = await self.db.execute(
select(Dispositivo)
.where(Dispositivo.identificador == ubicacion_data.dispositivo_id)
.where(Dispositivo.activo == True)
)
dispositivo = result.scalar_one_or_none()
if dispositivo:
vehiculo_id = dispositivo.vehiculo_id
# Actualizar último contacto del dispositivo
dispositivo.ultimo_contacto = datetime.now(timezone.utc)
dispositivo.conectado = True
if ubicacion_data.bateria_dispositivo:
dispositivo.bateria = ubicacion_data.bateria_dispositivo
if ubicacion_data.satelites:
dispositivo.satelites = ubicacion_data.satelites
if not vehiculo_id:
return None
# Usar timestamp del servidor si no viene
tiempo = ubicacion_data.tiempo or datetime.now(timezone.utc)
# Crear registro de ubicación
ubicacion = Ubicacion(
tiempo=tiempo,
vehiculo_id=vehiculo_id,
lat=ubicacion_data.lat,
lng=ubicacion_data.lng,
velocidad=ubicacion_data.velocidad,
rumbo=ubicacion_data.rumbo,
altitud=ubicacion_data.altitud,
precision=ubicacion_data.precision,
hdop=ubicacion_data.hdop,
satelites=ubicacion_data.satelites,
fuente=ubicacion_data.fuente,
bateria_dispositivo=ubicacion_data.bateria_dispositivo,
bateria_vehiculo=ubicacion_data.bateria_vehiculo,
motor_encendido=ubicacion_data.motor_encendido,
odometro=ubicacion_data.odometro,
rpm=ubicacion_data.rpm,
temperatura_motor=ubicacion_data.temperatura_motor,
nivel_combustible=ubicacion_data.nivel_combustible,
)
self.db.add(ubicacion)
# Actualizar última ubicación conocida del vehículo
result = await self.db.execute(
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
if vehiculo:
vehiculo.ultima_lat = ubicacion_data.lat
vehiculo.ultima_lng = ubicacion_data.lng
vehiculo.ultima_velocidad = ubicacion_data.velocidad
vehiculo.ultimo_rumbo = ubicacion_data.rumbo
vehiculo.ultima_ubicacion_tiempo = tiempo
vehiculo.motor_encendido = ubicacion_data.motor_encendido
if ubicacion_data.odometro:
vehiculo.odometro_actual = ubicacion_data.odometro
await self.db.commit()
return UbicacionResponse(
tiempo=ubicacion.tiempo,
vehiculo_id=ubicacion.vehiculo_id,
lat=ubicacion.lat,
lng=ubicacion.lng,
velocidad=ubicacion.velocidad,
rumbo=ubicacion.rumbo,
altitud=ubicacion.altitud,
precision=ubicacion.precision,
satelites=ubicacion.satelites,
fuente=ubicacion.fuente,
bateria_dispositivo=ubicacion.bateria_dispositivo,
motor_encendido=ubicacion.motor_encendido,
odometro=ubicacion.odometro,
)
async def obtener_historial(
self,
vehiculo_id: int,
desde: datetime,
hasta: datetime,
simplificar: bool = True,
intervalo_segundos: Optional[int] = None,
) -> HistorialUbicacionesResponse:
"""
Obtiene el historial de ubicaciones de un vehículo.
Args:
vehiculo_id: ID del vehículo.
desde: Fecha/hora de inicio.
hasta: Fecha/hora de fin.
simplificar: Si simplificar la ruta (Douglas-Peucker).
intervalo_segundos: Intervalo de muestreo opcional.
Returns:
Historial de ubicaciones con estadísticas.
"""
query = (
select(Ubicacion)
.where(
and_(
Ubicacion.vehiculo_id == vehiculo_id,
Ubicacion.tiempo >= desde,
Ubicacion.tiempo <= hasta,
)
)
.order_by(Ubicacion.tiempo)
)
result = await self.db.execute(query)
ubicaciones = result.scalars().all()
# Aplicar muestreo por intervalo si se especifica
if intervalo_segundos and ubicaciones:
ubicaciones = self._muestrear_por_intervalo(
ubicaciones, intervalo_segundos
)
# Calcular estadísticas
distancia_km = self._calcular_distancia_total(ubicaciones)
tiempo_movimiento = self._calcular_tiempo_movimiento(ubicaciones)
velocidad_promedio = None
velocidad_maxima = None
if ubicaciones:
velocidades = [u.velocidad for u in ubicaciones if u.velocidad]
if velocidades:
velocidad_promedio = sum(velocidades) / len(velocidades)
velocidad_maxima = max(velocidades)
# Simplificar ruta si se solicita
if simplificar and len(ubicaciones) > 100:
ubicaciones = self._simplificar_ruta(ubicaciones, epsilon=0.0001)
ubicaciones_response = [
UbicacionResponse(
tiempo=u.tiempo,
vehiculo_id=u.vehiculo_id,
lat=u.lat,
lng=u.lng,
velocidad=u.velocidad,
rumbo=u.rumbo,
altitud=u.altitud,
precision=u.precision,
satelites=u.satelites,
fuente=u.fuente,
bateria_dispositivo=u.bateria_dispositivo,
motor_encendido=u.motor_encendido,
odometro=u.odometro,
)
for u in ubicaciones
]
return HistorialUbicacionesResponse(
vehiculo_id=vehiculo_id,
desde=desde,
hasta=hasta,
total_puntos=len(ubicaciones_response),
distancia_km=distancia_km,
tiempo_movimiento_segundos=tiempo_movimiento,
velocidad_promedio=velocidad_promedio,
velocidad_maxima=velocidad_maxima,
ubicaciones=ubicaciones_response,
)
async def obtener_ultima_ubicacion(
self,
vehiculo_id: int,
) -> Optional[UbicacionResponse]:
"""
Obtiene la última ubicación conocida de un vehículo.
Args:
vehiculo_id: ID del vehículo.
Returns:
Última ubicación o None.
"""
result = await self.db.execute(
select(Ubicacion)
.where(Ubicacion.vehiculo_id == vehiculo_id)
.order_by(Ubicacion.tiempo.desc())
.limit(1)
)
ubicacion = result.scalar_one_or_none()
if not ubicacion:
return None
return UbicacionResponse(
tiempo=ubicacion.tiempo,
vehiculo_id=ubicacion.vehiculo_id,
lat=ubicacion.lat,
lng=ubicacion.lng,
velocidad=ubicacion.velocidad,
rumbo=ubicacion.rumbo,
altitud=ubicacion.altitud,
precision=ubicacion.precision,
satelites=ubicacion.satelites,
fuente=ubicacion.fuente,
bateria_dispositivo=ubicacion.bateria_dispositivo,
motor_encendido=ubicacion.motor_encendido,
odometro=ubicacion.odometro,
)
async def obtener_ubicaciones_flota(
self,
) -> List[dict]:
"""
Obtiene las últimas ubicaciones de todos los vehículos activos.
Returns:
Lista de ubicaciones actuales de la flota.
"""
result = await self.db.execute(
select(Vehiculo)
.where(Vehiculo.activo == True)
.where(Vehiculo.ultima_lat.isnot(None))
)
vehiculos = result.scalars().all()
ubicaciones = []
for v in vehiculos:
# Determinar si está en movimiento
en_movimiento = False
if v.ultima_velocidad and v.ultima_velocidad > 5:
en_movimiento = True
ubicaciones.append({
"id": v.id,
"nombre": v.nombre,
"placa": v.placa,
"color_marcador": v.color_marcador,
"icono": v.icono,
"lat": v.ultima_lat,
"lng": v.ultima_lng,
"velocidad": v.ultima_velocidad,
"rumbo": v.ultimo_rumbo,
"tiempo": v.ultima_ubicacion_tiempo,
"motor_encendido": v.motor_encendido,
"en_movimiento": en_movimiento,
"conductor_nombre": v.conductor.nombre_completo if v.conductor else None,
})
return ubicaciones
def _calcular_distancia_total(
self,
ubicaciones: List[Ubicacion],
) -> float:
"""
Calcula la distancia total recorrida entre ubicaciones.
Usa la fórmula de Haversine para calcular distancias.
Args:
ubicaciones: Lista de ubicaciones ordenadas por tiempo.
Returns:
Distancia total en kilómetros.
"""
if len(ubicaciones) < 2:
return 0.0
import math
total_km = 0.0
for i in range(1, len(ubicaciones)):
lat1 = math.radians(ubicaciones[i - 1].lat)
lat2 = math.radians(ubicaciones[i].lat)
dlat = lat2 - lat1
dlon = math.radians(ubicaciones[i].lng - ubicaciones[i - 1].lng)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
)
c = 2 * math.asin(math.sqrt(a))
r = 6371 # Radio de la Tierra en km
total_km += r * c
return round(total_km, 2)
def _calcular_tiempo_movimiento(
self,
ubicaciones: List[Ubicacion],
) -> int:
"""
Calcula el tiempo en movimiento (velocidad > 5 km/h).
Args:
ubicaciones: Lista de ubicaciones ordenadas.
Returns:
Tiempo en movimiento en segundos.
"""
if len(ubicaciones) < 2:
return 0
tiempo_total = 0
for i in range(1, len(ubicaciones)):
if (
ubicaciones[i - 1].velocidad
and ubicaciones[i - 1].velocidad > 5
):
delta = (
ubicaciones[i].tiempo - ubicaciones[i - 1].tiempo
).total_seconds()
tiempo_total += delta
return int(tiempo_total)
def _muestrear_por_intervalo(
self,
ubicaciones: List[Ubicacion],
intervalo_segundos: int,
) -> List[Ubicacion]:
"""
Muestrea ubicaciones por intervalo de tiempo.
Args:
ubicaciones: Lista de ubicaciones.
intervalo_segundos: Intervalo de muestreo.
Returns:
Lista filtrada de ubicaciones.
"""
if not ubicaciones:
return []
resultado = [ubicaciones[0]]
ultimo_tiempo = ubicaciones[0].tiempo
for u in ubicaciones[1:]:
delta = (u.tiempo - ultimo_tiempo).total_seconds()
if delta >= intervalo_segundos:
resultado.append(u)
ultimo_tiempo = u.tiempo
# Siempre incluir el último punto
if resultado[-1] != ubicaciones[-1]:
resultado.append(ubicaciones[-1])
return resultado
def _simplificar_ruta(
self,
ubicaciones: List[Ubicacion],
epsilon: float = 0.0001,
) -> List[Ubicacion]:
"""
Simplifica la ruta usando el algoritmo Douglas-Peucker.
Args:
ubicaciones: Lista de ubicaciones.
epsilon: Tolerancia de simplificación.
Returns:
Lista simplificada de ubicaciones.
"""
if len(ubicaciones) <= 2:
return ubicaciones
# Convertir a lista de puntos
points = [(u.lat, u.lng, u) for u in ubicaciones]
# Douglas-Peucker
simplified = self._douglas_peucker(points, epsilon)
return [p[2] for p in simplified]
def _douglas_peucker(
self,
points: List[Tuple],
epsilon: float,
) -> List[Tuple]:
"""Implementación del algoritmo Douglas-Peucker."""
if len(points) <= 2:
return points
# Encontrar el punto más lejano de la línea
dmax = 0
index = 0
end = len(points) - 1
for i in range(1, end):
d = self._perpendicular_distance(
points[i], points[0], points[end]
)
if d > dmax:
index = i
dmax = d
# Si la distancia máxima es mayor que epsilon, simplificar recursivamente
if dmax > epsilon:
# Dividir en dos segmentos
rec1 = self._douglas_peucker(points[: index + 1], epsilon)
rec2 = self._douglas_peucker(points[index:], epsilon)
# Combinar (evitar duplicar el punto medio)
return rec1[:-1] + rec2
else:
return [points[0], points[end]]
def _perpendicular_distance(
self,
point: Tuple,
line_start: Tuple,
line_end: Tuple,
) -> float:
"""Calcula la distancia perpendicular de un punto a una línea."""
import math
x, y = point[0], point[1]
x1, y1 = line_start[0], line_start[1]
x2, y2 = line_end[0], line_end[1]
# Caso especial: línea de longitud cero
dx = x2 - x1
dy = y2 - y1
if dx == 0 and dy == 0:
return math.sqrt((x - x1) ** 2 + (y - y1) ** 2)
# Distancia perpendicular
numerator = abs(dy * x - dx * y + x2 * y1 - y2 * x1)
denominator = math.sqrt(dx ** 2 + dy ** 2)
return numerator / denominator

View File

@@ -0,0 +1,405 @@
"""
Servicio para gestión automática de viajes.
Detecta automáticamente el inicio y fin de viajes basándose
en el movimiento del vehículo.
"""
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.parada import Parada
from app.models.ubicacion import Ubicacion
from app.models.vehiculo import Vehiculo
from app.models.viaje import Viaje
from app.schemas.viaje import ViajeResponse
class ViajeService:
"""Servicio para detección y gestión de viajes."""
# Configuración de detección
VELOCIDAD_MINIMA_MOVIMIENTO = 5 # km/h
MINUTOS_PARADA_FIN_VIAJE = 5 # minutos para considerar fin de viaje
SEGUNDOS_MINIMOS_PARADA = 120 # segundos mínimos para registrar parada
def __init__(self, db: AsyncSession):
"""
Inicializa el servicio.
Args:
db: Sesión de base de datos async.
"""
self.db = db
async def procesar_ubicacion_viaje(
self,
vehiculo_id: int,
lat: float,
lng: float,
velocidad: float,
tiempo: datetime,
) -> Optional[dict]:
"""
Procesa una ubicación para detección de viajes.
Args:
vehiculo_id: ID del vehículo.
lat: Latitud.
lng: Longitud.
velocidad: Velocidad en km/h.
tiempo: Timestamp de la ubicación.
Returns:
Dict con información del evento de viaje si hubo cambio.
"""
# Obtener viaje en curso
viaje_activo = await self._obtener_viaje_activo(vehiculo_id)
en_movimiento = velocidad >= self.VELOCIDAD_MINIMA_MOVIMIENTO
if not viaje_activo:
# No hay viaje activo
if en_movimiento:
# Iniciar nuevo viaje
viaje = await self._iniciar_viaje(vehiculo_id, lat, lng, tiempo)
return {
"evento": "viaje_iniciado",
"viaje_id": viaje.id,
"vehiculo_id": vehiculo_id,
}
else:
# Hay viaje activo
if en_movimiento:
# Actualizar viaje (incrementar puntos GPS)
viaje_activo.puntos_gps += 1
# Verificar si había parada en curso y cerrarla
await self._cerrar_parada_en_curso(viaje_activo.id, vehiculo_id, tiempo)
await self.db.commit()
else:
# Vehículo detenido
resultado = await self._procesar_parada(
viaje_activo, vehiculo_id, lat, lng, tiempo
)
if resultado:
return resultado
return None
async def _obtener_viaje_activo(
self,
vehiculo_id: int,
) -> Optional[Viaje]:
"""Obtiene el viaje activo de un vehículo."""
result = await self.db.execute(
select(Viaje)
.where(Viaje.vehiculo_id == vehiculo_id)
.where(Viaje.estado == "en_curso")
)
return result.scalar_one_or_none()
async def _iniciar_viaje(
self,
vehiculo_id: int,
lat: float,
lng: float,
tiempo: datetime,
) -> Viaje:
"""Inicia un nuevo viaje."""
# Obtener conductor asignado
result = await self.db.execute(
select(Vehiculo).where(Vehiculo.id == vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
conductor_id = vehiculo.conductor_id if vehiculo else None
# Obtener odómetro actual
odometro_inicio = vehiculo.odometro_actual if vehiculo else None
viaje = Viaje(
vehiculo_id=vehiculo_id,
conductor_id=conductor_id,
inicio_tiempo=tiempo,
inicio_lat=lat,
inicio_lng=lng,
odometro_inicio=odometro_inicio,
estado="en_curso",
puntos_gps=1,
)
self.db.add(viaje)
await self.db.commit()
await self.db.refresh(viaje)
return viaje
async def _procesar_parada(
self,
viaje: Viaje,
vehiculo_id: int,
lat: float,
lng: float,
tiempo: datetime,
) -> Optional[dict]:
"""
Procesa una parada durante un viaje.
Returns:
Dict con evento si el viaje terminó.
"""
# Buscar parada en curso
result = await self.db.execute(
select(Parada)
.where(Parada.vehiculo_id == vehiculo_id)
.where(Parada.en_curso == True)
)
parada = result.scalar_one_or_none()
if not parada:
# Iniciar nueva parada
parada = Parada(
viaje_id=viaje.id,
vehiculo_id=vehiculo_id,
inicio_tiempo=tiempo,
lat=lat,
lng=lng,
en_curso=True,
)
self.db.add(parada)
await self.db.commit()
return None
# Calcular duración de la parada
duracion_segundos = (tiempo - parada.inicio_tiempo).total_seconds()
parada.duracion_segundos = int(duracion_segundos)
# Verificar si la parada es suficientemente larga para terminar el viaje
if duracion_segundos >= self.MINUTOS_PARADA_FIN_VIAJE * 60:
# Terminar viaje
return await self._finalizar_viaje(viaje, parada, tiempo)
await self.db.commit()
return None
async def _cerrar_parada_en_curso(
self,
viaje_id: int,
vehiculo_id: int,
tiempo: datetime,
) -> None:
"""Cierra una parada en curso si existe."""
result = await self.db.execute(
select(Parada)
.where(Parada.vehiculo_id == vehiculo_id)
.where(Parada.en_curso == True)
)
parada = result.scalar_one_or_none()
if parada:
duracion = (tiempo - parada.inicio_tiempo).total_seconds()
if duracion >= self.SEGUNDOS_MINIMOS_PARADA:
# Registrar parada
parada.fin_tiempo = tiempo
parada.duracion_segundos = int(duracion)
parada.en_curso = False
else:
# Parada muy corta, eliminar
await self.db.delete(parada)
async def _finalizar_viaje(
self,
viaje: Viaje,
parada: Parada,
tiempo: datetime,
) -> dict:
"""Finaliza un viaje."""
# Cerrar parada
parada.fin_tiempo = tiempo
parada.en_curso = False
# Calcular estadísticas del viaje
viaje.fin_tiempo = parada.inicio_tiempo # El viaje termina al inicio de la parada final
viaje.fin_lat = parada.lat
viaje.fin_lng = parada.lng
viaje.estado = "completado"
# Calcular duración
viaje.duracion_segundos = int(
(viaje.fin_tiempo - viaje.inicio_tiempo).total_seconds()
)
# Calcular estadísticas desde ubicaciones
await self._calcular_estadisticas_viaje(viaje)
await self.db.commit()
return {
"evento": "viaje_finalizado",
"viaje_id": viaje.id,
"vehiculo_id": viaje.vehiculo_id,
"distancia_km": viaje.distancia_km,
"duracion_segundos": viaje.duracion_segundos,
}
async def _calcular_estadisticas_viaje(
self,
viaje: Viaje,
) -> None:
"""Calcula las estadísticas de un viaje finalizado."""
# Obtener ubicaciones del viaje
result = await self.db.execute(
select(Ubicacion)
.where(Ubicacion.vehiculo_id == viaje.vehiculo_id)
.where(Ubicacion.tiempo >= viaje.inicio_tiempo)
.where(Ubicacion.tiempo <= viaje.fin_tiempo)
.order_by(Ubicacion.tiempo)
)
ubicaciones = result.scalars().all()
if not ubicaciones:
return
# Distancia
viaje.distancia_km = self._calcular_distancia(ubicaciones)
# Velocidades
velocidades = [u.velocidad for u in ubicaciones if u.velocidad is not None]
if velocidades:
viaje.velocidad_promedio = sum(velocidades) / len(velocidades)
viaje.velocidad_maxima = max(velocidades)
# Tiempo en movimiento
tiempo_movimiento = 0
tiempo_parado = 0
for i in range(1, len(ubicaciones)):
delta = (ubicaciones[i].tiempo - ubicaciones[i-1].tiempo).total_seconds()
if ubicaciones[i-1].velocidad and ubicaciones[i-1].velocidad >= self.VELOCIDAD_MINIMA_MOVIMIENTO:
tiempo_movimiento += delta
else:
tiempo_parado += delta
viaje.tiempo_movimiento_segundos = int(tiempo_movimiento)
viaje.tiempo_parado_segundos = int(tiempo_parado)
# Odómetro final
result = await self.db.execute(
select(Vehiculo).where(Vehiculo.id == viaje.vehiculo_id)
)
vehiculo = result.scalar_one_or_none()
if vehiculo:
viaje.odometro_fin = vehiculo.odometro_actual
def _calcular_distancia(
self,
ubicaciones: List[Ubicacion],
) -> float:
"""Calcula la distancia total entre ubicaciones."""
import math
if len(ubicaciones) < 2:
return 0.0
total_km = 0.0
for i in range(1, len(ubicaciones)):
lat1 = math.radians(ubicaciones[i - 1].lat)
lat2 = math.radians(ubicaciones[i].lat)
dlat = lat2 - lat1
dlon = math.radians(ubicaciones[i].lng - ubicaciones[i - 1].lng)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
)
c = 2 * math.asin(math.sqrt(a))
r = 6371
total_km += r * c
return round(total_km, 2)
async def obtener_viajes_vehiculo(
self,
vehiculo_id: int,
desde: datetime = None,
hasta: datetime = None,
limite: int = 50,
) -> List[Viaje]:
"""
Obtiene los viajes de un vehículo.
Args:
vehiculo_id: ID del vehículo.
desde: Fecha inicio (opcional).
hasta: Fecha fin (opcional).
limite: Límite de resultados.
Returns:
Lista de viajes.
"""
query = (
select(Viaje)
.where(Viaje.vehiculo_id == vehiculo_id)
.order_by(Viaje.inicio_tiempo.desc())
.limit(limite)
)
if desde:
query = query.where(Viaje.inicio_tiempo >= desde)
if hasta:
query = query.where(Viaje.inicio_tiempo <= hasta)
result = await self.db.execute(query)
return result.scalars().all()
async def obtener_replay_viaje(
self,
viaje_id: int,
) -> Optional[dict]:
"""
Obtiene los datos para replay de un viaje.
Args:
viaje_id: ID del viaje.
Returns:
Datos del viaje con ubicaciones y paradas.
"""
result = await self.db.execute(
select(Viaje).where(Viaje.id == viaje_id)
)
viaje = result.scalar_one_or_none()
if not viaje:
return None
# Obtener ubicaciones
result = await self.db.execute(
select(Ubicacion)
.where(Ubicacion.vehiculo_id == viaje.vehiculo_id)
.where(Ubicacion.tiempo >= viaje.inicio_tiempo)
.where(
Ubicacion.tiempo <= (viaje.fin_tiempo or datetime.now(timezone.utc))
)
.order_by(Ubicacion.tiempo)
)
ubicaciones = result.scalars().all()
# Obtener paradas
result = await self.db.execute(
select(Parada)
.where(Parada.viaje_id == viaje_id)
.order_by(Parada.inicio_tiempo)
)
paradas = result.scalars().all()
return {
"viaje": viaje,
"ubicaciones": ubicaciones,
"paradas": paradas,
}

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