API REST para obtener horarios y paradas de TUSSAM (Transportes Urbanos de Sevilla) en tiempo real. Diseñada para alimentar apps, webs, integraciones y cualquier cliente HTTP.
TUSSAM ofrece una API interna en reddelineas.tussam.es con las siguientes limitaciones:
- Rate limit estricto: bloquea peticiones frecuentes con HTTP 429.
- Sin CORS abierto: no permite llamadas desde navegadores ni apps.
- Formato propietario: coordenadas multiplicadas por 10⁶, fechas con formato específico.
- Sin direcciones: solo coordenadas GPS, sin calle ni número.
Esta API actúa como proxy inteligente que resuelve esos problemas:
- Cacheo automático de tiempos de llegada (TTL: 1 minuto).
- Single-flight por parada: peticiones simultáneas comparten una sola llamada a TUSSAM.
- Fallback con cache antigua si TUSSAM falla temporalmente.
- Normalización de coordenadas (÷ 10⁶ → grados decimales).
- Geocodificación inversa pregrabada: cada parada tiene calle y número.
- Reintentos con backoff y
Retry-Afterpara manejar los 429 de TUSSAM. - CORS abierto para llamadas desde cualquier cliente.
- Endpoint agregado: una sola llamada devuelve paradas cercanas y tiempos.
flowchart TB
subgraph Clientes["Clientes"]
App["Apps móviles"]
Web["Web"]
CLI["CLI / servicios"]
end
subgraph API["API TUSSAM (FastAPI)"]
direction TB
RL["Rate Limiter\n(dispositivo + IP)"]
Auth["API Key Auth\n(solo sync)"]
EP["Endpoints REST\n/cercanas, /paradas, ..."]
Cache["Cache tiempos\n(SQLite, TTL fresco 1 min)"]
end
subgraph Externo["APIs Externas"]
TUSSAM["API TUSSAM\nreddelineas.tussam.es"]
Nominatim["Nominatim\ngeocoding inverso"]
end
subgraph Almacenamiento["Persistencia"]
SQLite["SQLite (WAL)\n967 paradas\n49 líneas\n1.756 relaciones"]
end
App --> RL
Web --> RL
CLI --> RL
RL --> EP
EP --> Cache
Cache --> TUSSAM
Cache -.->|"fallback si TUSSAM falla"| EP
EP --> SQLite
Auth --> EP
style API fill:#e8f5e9,stroke:#4caf50
style SQLite fill:#fff3e0,stroke:#ff9800
sequenceDiagram
participant Cliente as Cliente HTTP
participant API as TUSSAM API
participant Cache as SQLite Cache
participant SQLite as SQLite (paradas)
participant TUSSAM as API TUSSAM
Cliente->>API: GET /cercanas?lat=37.38&lon=-5.99
API->>SQLite: SELECT * FROM paradas
SQLite-->>API: 967 paradas
Note over API: 1. Bounding box (descarta ~85%)
Note over API: 2. Haversine (30 restantes)
Note over API: 3. Ordenar por distancia
loop Por cada parada (máx 3)
API->>Cache: ¿tiempos cacheados?
alt Cache hit (< 1 min)
Cache-->>API: tiempos cacheados
else Cache miss / expirado
API->>TUSSAM: GET /API/infotus-ui/tiempos/{codigo}
TUSSAM-->>API: tiempos en segundos o 429/5xx
API->>Cache: guardar en cache
else TUSSAM no disponible y hay cache antigua
Cache-->>API: tiempos stale
end
end
API-->>Cliente: {ubicacion, paradas: [{...}, direccion, tiempos]}
El repositorio incluye data/tussam.db con datos precargados (sincronizados el 16 de febrero de 2026). No necesitas ejecutar ningún sync para empezar.
erDiagram
paradas {
TEXT codigo PK "Código de la parada (ej: 43)"
TEXT nombre "Nombre descriptivo"
REAL latitud "Coordenada GPS"
REAL longitud "Coordenada GPS"
TEXT calle "Calle geocodificada"
TEXT numero "Número de portal"
TEXT codigo_postal "CP (41xxx)"
TEXT municipio "Sevilla"
TEXT provincia "Sevilla"
TEXT comunidad_autonoma "Andalucía"
TEXT direccion_completa "Calle + número"
}
lineas {
TEXT numero PK "Número de línea (ej: 01)"
TEXT nombre "Nombre oficial"
TEXT color "Color hex (ej: #f54129)"
}
paradas_lineas {
TEXT parada_codigo FK
TEXT linea_numero FK
INTEGER sentido "1 = ida, 2 = vuelta"
INTEGER orden "Posición en el recorrido"
}
tiempos_cache {
TEXT parada_codigo PK
TEXT tiempos_json "JSON con tiempos"
TIMESTAMP cached_at "TTL: 1 minuto"
}
paradas ||--o{ paradas_lineas : tiene
lineas ||--o{ paradas_lineas : tiene
paradas ||--o{ tiempos_cache : cachea
| Tabla | Registros | Descripción |
|---|---|---|
paradas |
967 | Paradas de autobús con dirección geocodificada |
lineas |
49 | Líneas de TUSSAM con nombre y color |
paradas_lineas |
1.756 | Relación N:M (qué líneas paran en cada parada) |
tiempos_cache |
efímero | Cache de tiempos de llegada (TTL: 1 min) |
| Campo | Paradas cubiertas | Porcentaje |
|---|---|---|
calle |
967/967 | 100% |
numero |
795/967 | 82,2% |
codigo_postal |
967/967 | 100% |
municipio |
967/967 | 100% |
direccion_completa |
967/967 | 100% |
El 17,8% sin número son paradas en glorietas, puentes, avenidas sin portales o estaciones (ej: «Prado San Sebastián», «Aeropuerto de Sevilla»). Estas tienen numero = "s/n".
docker pull ghcr.io/686f6c61/api-tussam:main
export SYNC_API_KEY=$(openssl rand -hex 32)
docker run -d -p 8081:8080 \
-e SYNC_API_KEY="$SYNC_API_KEY" \
-v $(pwd)/data:/app/data \
ghcr.io/686f6c61/api-tussam:maingit clone https://github.com/686f6c61/API-TUSSAM.git
cd API-TUSSAM
export SYNC_API_KEY=$(openssl rand -hex 32)
docker compose up -dLa API estará en http://localhost:8081. La base de datos con 967 paradas viene incluida en el repositorio y se monta como volumen.
export SYNC_API_KEY=$(openssl rand -hex 32)
docker compose --profile smoke up -d --buildAbre http://localhost:8082. La app de examples/smoke-app permite convertir calle, número y código postal en coordenadas con OpenStreetMap Nominatim y probar /cercanas contra el Docker local.
git clone https://github.com/686f6c61/API-TUSSAM.git
cd API-TUSSAM
pip install -e .
export SYNC_API_KEY=$(openssl rand -hex 32)
uvicorn app.main:app --port 8080curl "http://localhost:8081/cercanas?lat=37.3891&lon=-5.9845&max_paradas=2"{
"ubicacion": {"lat": 37.3891, "lon": -5.9845, "bearing": null},
"paradas": [
{
"codigo": "43",
"nombre": "Recaredo (Puerta Carmona)",
"latitud": 37.389663,
"longitud": -5.984265,
"distancia": 66,
"calle": "Calle Recaredo",
"numero": "6-7",
"codigo_postal": "41003",
"municipio": "Sevilla",
"direccion": "Calle Recaredo 6-7",
"tiempos": [
{
"linea": "C4",
"color": "#008431",
"tiempo_minutos": 4,
"destino": "PLAZA DE ARMAS",
"distancia_metros": 783
}
]
}
]
}| Método | Ruta | Auth | Descripción |
|---|---|---|---|
| GET | / |
No | Información de la API |
| GET | /health |
No | Health check (verifica DB) |
| GET | /cercanas |
No | Principal: paradas cercanas + tiempos |
| GET | /paradas |
No | Todas las paradas |
| GET | /paradas/cercanas |
No | Paradas cercanas (solo coordenadas) |
| GET | /paradas/{codigo} |
No | Una parada específica |
| GET | /paradas/{codigo}/tiempos |
No | Tiempos de una parada |
| GET | /lineas |
No | Todas las líneas |
| POST | /sync/paradas |
API Key | Sincronizar paradas |
| POST | /sync/lineas |
API Key | Sincronizar líneas |
| POST | /sync/all |
API Key | Sincronización completa |
Para clientes móviles, webs o integraciones, el flujo recomendado es usar /cercanas cuando se parte de coordenadas. Si el cliente ya conoce el código de una parada, /paradas/{codigo}/tiempos mantiene un contrato estable: cuando TUSSAM no responde, devuelve 200 con tiempos: [] y upstream_status: "unavailable" en lugar de propagar un 503.
Para documentación interactiva en desarrollo: http://localhost:8081/docs (Swagger UI) o /redoc. En producción se puede desactivar con ENABLE_DOCS=false.
GET /cercanas acepta los siguientes parámetros para filtrar y ordenar resultados:
| Parámetro | Tipo | Default | Descripción |
|---|---|---|---|
lat |
float | obligatorio | Latitud del usuario |
lon |
float | obligatorio | Longitud del usuario |
radio |
int | 300 | Radio de búsqueda en metros |
max_paradas |
int | 3 | Máximo de paradas a devolver |
bearing |
float | null | Orientación del usuario (0-360°) |
bearing_tolerance |
float | 60 | Tolerancia para el filtro de orientación |
tiempo_max |
int | null | Solo buses que lleguen en ≤ X minutos |
lineas |
string | null | Filtrar líneas: "01,C4,21" |
incluir_mapa |
bool | false | Añadir URL de OpenStreetMap |
formato |
string | "json" | "json" o "geojson" |
Cuando hay dos paradas en sentidos opuestos (una por cada acera), puedes filtrar por la orientación del usuario:
GET /cercanas?lat=37.3891&lon=-5.9845&bearing=180&bearing_tolerance=45
Esto devuelve solo las paradas cuya orientación desde el usuario difiere en 45° o menos de 180° (sur). Las paradas en sentido opuesto (bearing ~0°) se descartan. El bearing puede venir de cualquier cliente con brújula, sensores de orientación o lógica propia.
La base de datos incluida ya tiene 967 paradas, 49 líneas y sus relaciones. Si necesitas refrescar los datos:
# Sincronizar paradas y líneas desde la API de TUSSAM
curl -X POST http://localhost:8081/sync/all -H "X-API-Key: $SYNC_API_KEY"Cuando se añaden paradas nuevas (por cambios en las líneas), sus campos calle y numero pueden estar vacíos. Se rellenan desde el endpoint protegido de sincronización:
curl -X POST http://localhost:8081/sync/direcciones -H "X-API-Key: $SYNC_API_KEY"La API consulta Nominatim (OpenStreetMap), respeta su rate limit de 1 petición por segundo y guarda los resultados directamente en SQLite.
La API no hace geocodificación en caliente durante las peticiones. Las direcciones se pregrabaron una vez y se almacenan directamente en la tabla paradas. Esto garantiza respuestas instantáneas.
El scheduler integrado ejecuta la sincronización cada domingo a las 04:00 UTC. Configurable por variables de entorno:
# En docker-compose.yml
SYNC_ENABLED=true # Activar/desactivar
SYNC_DAY=sun # Día de la semana
SYNC_HOUR=4 # Hora UTC
SYNC_MINUTE=0 # MinutoLa API implementa dos niveles de rate limiting para protegerse y proteger a TUSSAM:
| Nivel | Límite | Cabecera | Propósito |
|---|---|---|---|
| Dispositivo | 60 req/min | X-Device-ID |
Limitar por cliente/dispositivo |
| IP (fallback) | 300 req/min | Dirección IP | Protección anti-DDoS |
Las cabeceras X-RateLimit-Remaining, X-RateLimit-Limit y X-RateLimit-Reset se incluyen en cada respuesta. Cuando se alcanza el límite, la API responde 429 Too Many Requests con Retry-After. Este limitador es local al proceso; para despliegues con varios workers o réplicas conviene aplicar límites también en el proxy, CDN o balanceador.
- Endpoints públicos (
GET): sin autenticación, protegidos solo por rate limiting. - Endpoints de sync (
POST /sync/*): requieren API Key mediante cabeceraX-API-Key. - CORS: restringido a métodos
GETyPOST; los orígenes se configuran conCORS_ORIGINS. - Docs: activas por defecto en desarrollo, configurables con
ENABLE_DOCS. - Hosts: se pueden limitar con
ALLOWED_HOSTScuando hay dominios de despliegue conocidos. - API Key:
SYNC_API_KEYno tiene valor por defecto. Define una clave aleatoria antes de arrancar.
# Ejemplo con API Key
curl -X POST http://localhost:8081/sync/all -H "X-API-Key: mi-clave-segura"| Variable | Default | Descripción |
|---|---|---|
APP_ENV |
development |
Entorno de ejecución (production aplica defaults más restrictivos) |
SYNC_API_KEY |
(requerida) | Clave para endpoints de sync |
ALLOW_UNAUTHENTICATED_SYNC |
false |
Permite sync sin API key solo para desarrollo local explícito |
ENABLE_DOCS |
true en dev, false en prod |
Habilitar /docs, /redoc y /openapi.json |
CORS_ORIGINS |
* en dev, vacío en prod |
Orígenes CORS separados por coma |
ALLOWED_HOSTS |
vacío | Hosts permitidos separados por coma |
TIEMPOS_CACHE_TTL_SECONDS |
60 |
TTL de cache fresca para tiempos de llegada |
TIEMPOS_STALE_TTL_SECONDS |
600 |
Tiempo máximo para devolver cache antigua si TUSSAM falla |
TUSSAM_MAX_CONCURRENT_REQUESTS |
4 |
Límite de concurrencia saliente hacia TUSSAM |
TUSSAM_SYNC_REQUEST_DELAY_SECONDS |
0.2 |
Pausa entre peticiones de sincronización a TUSSAM |
SYNC_ENABLED |
true |
Activar scheduler semanal |
SYNC_DAY |
sun |
Día de la semana para sync |
SYNC_HOUR |
4 |
Hora UTC para sync |
SYNC_MINUTE |
0 |
Minuto para sync |
TUSSAM/
├── app/
│ ├── __init__.py # Paquete principal
│ ├── main.py # Endpoints FastAPI, rate limiting, auth
│ ├── database.py # SQLite: esquema, queries, conexión persistente
│ ├── scheduler.py # Scheduler semanal con APScheduler
│ └── services/
│ └── tussam.py # Cliente HTTP para API TUSSAM y Nominatim
├── docs/
│ ├── API.md # Documentación completa de la API
│ └── docker.md # Guía de despliegue con Docker
├── tests/
│ ├── conftest.py # Fixtures compartidos
│ ├── test_database.py # 22 tests de base de datos
│ ├── test_main.py # 39 tests de endpoints
│ ├── test_tussam_service.py # 27 tests del servicio
│ ├── test_scheduler.py # 8 tests del scheduler
│ └── test_e2e.py # 30 tests end-to-end
├── examples/
│ └── smoke-app/ # App estática para validar Docker en navegador
├── data/ # Base de datos SQLite (incluida en repo)
├── docker-compose.yml
├── Dockerfile
├── pyproject.toml
├── LICENSE
├── CHANGELOG.md
└── README.md
| Componente | Tecnología | Justificación |
|---|---|---|
| Framework web | FastAPI 0.109+ | Async nativo, validación automática, OpenAPI, alto rendimiento |
| Base de datos | SQLite + aiosqlite | Sin servidor, un solo archivo, perfecto para ~1000 registros |
| Cliente HTTP | httpx 0.26+ | Async, soporte HTTP/2, timeouts configurables |
| Servidor | Uvicorn | Servidor ASGI de alto rendimiento |
| Scheduler | APScheduler 3.10+ | Programación de tareas async |
| Contenedor | Docker + Python 3.11-slim | Imagen ligera (~150 MB), fácil despliegue |
| Tests | pytest + pytest-asyncio | Testing async, fixtures, cobertura |
# Ejecutar todos los tests (excepto E2E que requieren API real)
pytest tests/ --ignore=tests/test_e2e.py --ignore=tests/test_scheduler.py
# Con cobertura
pytest tests/ --ignore=tests/test_e2e.py --ignore=tests/test_scheduler.py --cov=app88 tests unitarios cubren:
- Base de datos: esquema, CRUD, cache, migraciones, transacciones
- Endpoints: todas las rutas, códigos HTTP, validación de parámetros, GeoJSON
- Servicio TUSSAM: paradas cercanas, bearing, rate limiting, sync
- Scheduler: jobs, variables de entorno, manejo de errores
MIT — ver LICENSE.