Affichage « temps réel » des prochains départs T2C (Clermont-Ferrand) basé sur GTFS statique + GTFS-RT Trip Updates .
Le repo contient :
- un serveur Node/Express qui télécharge et indexe le GTFS statique, puis expose une API.
- un frontend statique (SPA simple) pour configurer la ligne et l’arrêt, et un mode “panneau” .
- un projet ESP32 + matrice HUB75 qui consomme l’API et peut aussi afficher des messages custom.
- des scripts Python pour envoyer des bitmaps vers l’ESP32.
- Téléchargement et parsing du GTFS statique (routes/stops/trips/stop_times).
- Lecture GTFS-RT (TripUpdates) avec cache court côté serveur.
- API :
- liste des lignes
- suggestions d’arrêts par ligne
- prochains départs par sens (grouping direction/headsign)
- Frontend :
- mode config (inputs)
- mode display (plein écran) via query string
- Auth API par clé API + scopes (ex:
departures:read).
src/: serveur Node “moderne” (celui utilisé par le Dockerfile)src/index.js: bootstrap (config, services, serveur)src/config.js: lecture des variables d’environnementsrc/app.js: init Express (helmet, compression, logs, static)src/routes/api.js: routes/api/*+ middleware auth scopessrc/controllers/*: contrôleurs routes/stops/departures/healthsrc/services/*:gtfsStatic.service.js: download ZIP, parse CSV, build indexesgtfsRt.service.js: fetch GTFS-RT, cache, decode protobufdepartures.service.js: logique de calcul des départskeysStore.service.js: chargementkeys.json+ lookup token (SHA-256)
src/middleware/*: request id, auth API key, 404, error handlersrc/utils/*: normalisation, fetch avec timeout
public/: frontend statiqueindex.html,style.css,app.js- appelle
/api/routes,/api/stops,/api/departures
docker-compose.yaml: déploiement container (NAS/serveur)Dockerfile: build Node 22 alpinescreen-esp/: projet PlatformIO ESP32src/main.cpp: affichage matrice + fetch API + serveur HTTP localinclude/config.h: pins HUB75 / résolutionpython-send-image/: scripts PIL -> RGB565 -> POST vers ESP
server.js: ancien serveur monolithique (présent pour référence). Le serveur actif est celui danssrc/.
- GTFS statique (ZIP) : variable
GTFS_ZIP_URL - GTFS-RT TripUpdates : variable
GTFS_RT_URL
Le serveur télécharge le ZIP, construit des index :
routeByShortName: ex"A" -> {route_id, color, longName}stopsByName: nom normalisé -> liste destop_idrouteStopsByRouteId: set des arrêts desservis par une routetripInfoById:trip_id -> {headsign, directionId, routeId}canonicalHeadsignByRouteDir: headsign « majoritaire » par directionstopDirsByRouteStop: inférence direction possible par stopId
Ensuite, pour /api/departures, le serveur lit le GTFS-RT, filtre par route, garde les stopTimeUpdate correspondant à l’arrêt, et regroupe par direction.
Toutes les routes API sont sous /api.
GET /api/_health/live: OK si process upGET /api/_health/ready: 200 si GTFS statique chargé, sinon 503
GET /api/routes(scoperoutes:read)GET /api/stops?route=A[&q=jaude](scopestops:read)GET /api/departures?route=A&stopName=Jaude&max=2(scopedepartures:read)- Variante plus robuste :
stopIdau lieu destopName:GET /api/departures?route=A&stopId=STOP_ID&max=2
- Variante plus robuste :
Réponse typique /api/departures :
{
"route": "A",
"routeColor": "#ffcc33",
"updatedAt": "2026-01-08T...Z",
"maxDepartures": 2,
"stopName": "Jaude",
"stopId": "...",
"directions": [
{
"dirKey": "dir_0",
"headsign": "...",
"directionId": 0,
"departures": [
{"timeIso":"...","inMinutes":3},
{"timeIso":"...","inMinutes":12}
]
}
]
}Le serveur protège routes/stops/departures via src/middleware/apiKeyAuth.js.
- Le client envoie la clé soit :
Authorization: Bearer <TOKEN>- ou
X-API-Key: <TOKEN>
- Le serveur compare
sha256(token)avectokenSha256stocké danskeys.json(comparaison timing-safe). - Chaque clé a des
scopes. Exemple :- app mobile :
routes:read,stops:read,departures:read - esp32 :
departures:read
- app mobile :
keys.json est monté en volume Docker (read-only) et doit rester hors git .
{
"keys": [
{ "id": "mobile", "tokenSha256": "<hex_sha256>", "scopes": ["routes:read","stops:read","departures:read"] },
{ "id": "esp32", "tokenSha256": "<hex_sha256>", "scopes": ["departures:read"] }
]
}Principe :
- tu génères un token secret (random).
- tu calcules son SHA-256 en hex .
- tu mets le SHA dans
keys.json, et tu donnes le token en clair au client (mobile/ESP32).
openssl rand -base64 32 | tr -d '\n' | openssl dgst -sha256
Option OpenSSL :
openssl rand -base64 32Option Node :
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"Option OpenSSL :
echo -n "TOKEN_ICI" | openssl dgst -sha256Tu récupères l’empreinte hex (sans espaces) et tu la mets dans tokenSha256.
Option Node :
node -e "const c=require('crypto');const t=process.argv[1];console.log(c.createHash('sha256').update(t,'utf8').digest('hex'))" "TOKEN_ICI"- Pour invalider un client : remplacer son entrée dans
keys.jsonpar un nouveau SHA. - Redémarrer le container pour recharger le fichier (ou ajouter un reload si tu veux).
Le frontend est servi par Express (static), et appelle l’API :
- suggestions lignes :
/api/routes - suggestions arrêts :
/api/stops?route=A&q=... - départs :
/api/departures?route=A&stopName=...&max=...(oustopId=...)
Ajouter des query params :
?mode=display&route=A&stop=Jaude&max=2- recommandé :
&stopId=STOP_ID
Notes :
- en mode display, les inputs sont désactivés et la mise en page passe en plein écran.
Si tu protèges /api/* et que le frontend est servi par le même serveur, mettre une clé dans public/app.js l’expose .
Options possibles :
- laisser le frontend non-auth (API publique) et réserver l’auth aux clients “privés”
- ou ajouter une route “backend” non protégée (proxy) côté serveur uniquement pour le frontend
- ou faire une auth navigateur (cookie/session) si usage interne
(Actuellement, le frontend appelle /api/* sans headers : si l’auth est active, il faut adapter fetch() pour envoyer la clé, ou désactiver l’auth sur les endpoints utilisés par le navigateur.)
Le code screen-esp/src/main.cpp :
- se connecte au WiFi
- interroge périodiquement l’API :
GET /api/departures?route=A&stopName=Jaude&max=2
- affiche 2 lignes (2 sens) sur une matrice HUB75 128x32 (2 panneaux 64x32 chainés)
- fait défiler le terminus si trop long
Il embarque aussi un petit serveur HTTP local (port 80) pour :
POST /config: changerroute/stopPOST /message/text: push message texte (défilement si long)POST /message/bitmap: push bitmap RGB565POST /message/clear: purge les messagesPOST /display/brightness: luminosité (0-255)
- installer :
npm install- lancer :
npm run startLe serveur écoute sur APP_PORT (défaut 8080).
Le Dockerfile exécute node src/index.js et sert aussi public/.
Avec docker-compose.yaml :
- expose
8086:8080(port externe 8086) - monte
./keys.jsondans/app/keys.json(read-only) - configure via variables d’environnement
docker compose up -d --buildAPP_PORT(défaut 8080)GTFS_ZIP_URLGTFS_RT_URLGTFS_REFRESH_HOURS(défaut 24)GTFS_RT_CACHE_SECONDS(défaut 5)FETCH_TIMEOUT_MS(défaut 8000)LOG_LEVEL(défaut info)TRUST_PROXY(true/false)KEYS_FILE(défautkeys.json)
keys.jsondans.gitignore(OK) et monté en read-only.- Log : headers
authorizationetx-api-keysont redacted (src/logger.js). - Si Cloudflare Tunnel / reverse proxy : garder
TRUST_PROXY=true. - En prod, préférer des tokens longs (>= 256 bits) et rotation régulière.
server.jsest une version monolithique historique. La version maintenue estsrc/*.- Le frontend actuel ne gère pas l’auth. Si tu actives l’auth sur
/api/routes|stops|departures, adaptepublic/app.jsou expose des endpoints dédiés.
