Intégration externe - trafic ferroviaire, bus

Bonjour à tous,

Je me suis laissé convaincre après avoir lu les bonnes indications de @prohand et @guim31 ici.

Dit, fait. Grâce à un Docker, j’ai pu trouver les données de trafic pour les arrêts allemands, puis j’ai simplement cherché avec des villes en Suisse (ça marche), des villes en Autriche (ça marche) et puis je suis devenu très audacieux, j’ai cherché une ville française sur la côte atlantique, très loin de l’espace germanophone… et voilà, ça marche aussi.

Mon script développé prend une voie spéciale. Grâce à la géolocalisation de l’utilisateur à son domicile dans Gladys Assistant, j’ai les données géographiques.
Grâce à ces données géographiques, une recherche circulaire est lancée, d’abord la station de bus la plus proche, puis le tramway le plus proche et enfin la gare la plus proche sont trouvés.

Dans l’étape suivante, les cinq départs suivants sont ensuite affichés directement.

Ainsi, l’utilisateur sait immédiatement, à chaque appel de Gladys Assistant, ce qui part où et quand ensuite.

Voici la sortie de test de La Rochelle:

[/share/Container] # /share/Container/abfahrten-ortstest.sh 46.1540 -1.1450
Recherche des arrêts autour de 46.1540, -1.1450 ...
Sélection: BUS - Gare La Rochelle - De Gaulle (environ 120 m)
Aucun arrêt de TRAM trouvé.
Sélection: RAIL - La Rochelle (environ 156 m)
Aucun arrêt de FERRY trouvé.

Appel des départs pour les ID d'arrêt sélectionnés ...


LES 5 PROCHAINS DÉPARTS AU TOTAL
Heure   | Arrêt                        | Type  | Ligne    | Destination                  | Données   | Montée

2026-10-12T19:57:00Z | Gare La Rochelle - De Gaulle  | BUS   | 3        | Lagord - P+R Les Greffières  | Horaires | -
2026-10-12T20:02:00Z | Gare La Rochelle - De Gaulle  | BUS   | 3        | Cottes Mailles - P+R Simone Veil | Horaires | -
2026-10-12T20:03:00Z | La Rochelle                   | TRAIN | TER 864767 |                               | Horaires | -
2026-10-13T03:28:00Z | La Rochelle                   | TRAIN | TER 864801 |                               | Horaires | -
2026-10-13T03:36:00Z | Gare La Rochelle - De Gaulle  | BUS   | 3        | Lagord - P+R Les Greffières  | Horaires | -

TRAM* signifie: Transitous a signalé OTHER à un ID de station de tramway sélectionné.
Les 5 entrées affichées sont limitées aux ID de station interrogés.
Les arrêts spéciaux sans départs dans la première recherche API peuvent être manquants.

Je suis maintenant confronté à un problème:
Je ne peux pas évaluer la logique des arrêts de départ français, suisses ou autrichiens et je cherche donc des testeurs.

Il suffit en fait d’installer un Docker. YAML est disponible. Ensuite, démarrer le script via SSH et vérifier les résultats et me renvoyer d’éventuelles remarques.

Alors, qui a envie ?
Il suffit de se manifester.

Si la logique est correcte, alors nous passons à l’intégration.

Salutations
Tom

Voici encore les sorties de test pour la lecture:

Autriche:

[/share/Container] # /share/Container/abfahrten-ortstest.sh 47.7260 16.2140
Recherche des arrêts autour de 47.7260, 16.2140 ...
Sélection: BUS - Bad Erlach Gemeindeamt (environ 182 m)
Aucun arrêt de TRAM trouvé.
Sélection: RAIL - Bad Erlach Bahnhof (environ 620 m)
Aucun arrêt de FERRY trouvé.

Appel des départs pour les ID d'arrêt sélectionnés ...

LES 5 PROCHAINS DÉPARTS AU TOTAL
Heure   | Arrêt                        | Type  | Ligne    | Destination                  | Données   | Montée

2026-10-12T19:49:00Z | Bad Erlach Bahnhof            | TRAIN | R 2775   | Friedberg                     | Horaires | -
2026-10-12T20:11:00Z | Bad Erlach Bahnhof            | TRAIN | R 2776   | Wiener Neustadt Hauptbahnhof  | Horaires | -
2026-10-12T20:49:00Z | Bad Erlach Bahnhof            | TRAIN | R 2779   | Friedberg                     | Horaires | -
2026-10-12T21:49:00Z | Bad Erlach Bahnhof            | TRAIN | R 2783   | Aspang Markt Bahnhof          | Horaires | -
2026-10-13T02:13:00Z | Bad Erlach Bahnhof            | TRAIN | R 2700   | Wiener Neustadt Hauptbahnhof  | Horaires | -


Suisse:

[/share/Container] # /share/Container/abfahrten-ortstest.sh 47.1240 9.3120
Recherche des arrêts autour de 47.1240, 9.3120 ...
Sélection: BUS - Walenstadt, Rathausplatz (environ 216 m)
Aucun arrêt de TRAM trouvé.
Sélection: RAIL - Walenstadt (environ 476 m)
Aucun arrêt de FERRY trouvé.

Appel des départs pour les ID d'arrêt sélectionnés ...



LES 5 PROCHAINS DÉPARTS AU TOTAL
Heure   | Arrêt                        | Type  | Ligne    | Destination                  | Données   | Montée

2026-10-12T19:44:00Z | Walenstadt                    | TRAIN | IR35     | Zürich HB                    | Horaires | 4
2026-10-12T19:46:00Z | Walenstadt                    | S-TRAIN | S17      | Sargans                       | Horaires | 3
2026-10-12T19:50:00Z | Walenstadt, Bahnhof           | BUS   | 444      | Murg, Bahnhof                 | Horaires | A
2026-10-12T20:13:00Z | Walenstadt                    | S-TRAIN | S17      | Rapperswil SG                 | Temps réel  | 4
2026-10-12T20:15:00Z | Walenstadt                    | TRAIN | IR35     | Chur                          | Horaires | 3

ou cas spécial Hambourg (trafic de ligne avec ferry):

LES 5 PROCHAINS DÉPARTS AU TOTAL
Heure   | Arrêt                        | Type  | Ligne    | Destination                  | Données   | Montée

2026-10-12T19:09:00Z | Stadthausbrücke              | TRAIN | S1       | Hamburg Airport (Flughafen)   | Temps réel  | -
2026-10-12T19:10:00Z | Arningstraße                 | FERRY | 72       | Landungsbrücken              | Temps réel  | -
2026-10-12T19:11:00Z | Stadthausbrücke              | TRAIN | S3       | Pinneberg                     | Temps réel  | -
2026-10-12T19:11:00Z | Stadthausbrücke              | TRAIN | S3       | Neugraben                     | Temps réel  | -
2026-10-12T19:14:00Z | Stadthausbrücke              | TRAIN | S1       | Hamburg Airport (Flughafen)   | Temps réel  | -

Je te dirai part direct dans l’intégration mais si tu veux des résultats avant, pas de souci.

Dis-moi comment faire le test et je le ferai avec plaisir.

Salut Tom :waving_hand:

Partant pour la France ! J’habite près de Toulouse, alors j’ai testé ta logique directement sur l’API Transitous : une vingtaine d’endroits (Toulouse et alentours, Bordeaux, Paris, Lyon, Marseille, Nantes, Lille, Grenoble, La Rochelle). Bonne nouvelle : les données sont bonnes, les horaires Tisséo, SNCF et RER collent. Quelques points sur la logique :

  • La recherche autour du point ne renvoie que 5 arrêts (si tu passes par reverse-geocode). Dès qu’il y a 5 arrêts de bus plus près, le tram et la gare disparaissent : à Marseille Vieux-Port, il rate le tram et Saint-Charles, à Grenoble et Muret la gare. Avec map/stops sur une zone d’1 km, on récupère tous les arrêts et ça marche.
  • Destination : prends tripTo.name. À la SNCF, headsign est vide, et pour le RER il contient un code de mission (« ZUMO », « ERTE »…), pas la destination.
  • Le métro manque : ajoute SUBWAY (et AERIAL_LIFT pour le téléphérique de Toulouse :grinning_face_with_smiling_eyes:).
  • Annulations : l’API marque les trajets annulés (cancelled). À Toulouse ce soir, 3 trams sur 11 l’étaient, et ton tableau les afficherait comme normaux.
  • Retards : departure contre scheduledDeparture, ça permet d’afficher « +5 min ».
  • Heures en UTC : chaque arrêt a son fuseau (tz), à convertir en heure locale.
  • Arrêts fantômes sans aucun mode : à ignorer.
  • Top 5 global : en ville, le métro prend les 5 places. N départs par mode, ce serait mieux.
  • L’arrêt le plus proche n’est pas toujours le bon : à Capitole, c’est un arrêt desservi seulement par une navette (prochain départ demain matin). Laisser l’utilisateur choisir ses arrêts, avec la recherche auto en suggestion, ce serait top.

Petite réserve honnête : la SNCF n’a pas de temps réel dans Transitous, donc les jours de grève on verra des trains qui ne roulent pas :sweat_smile:

Si tu partages ton script, je le relance sur les mêmes endroits pour comparer :folded_hands:

À propos de 1 : J’ai pris cela en compte, c’est-à-dire que la première station la plus proche est prise. Donc la station de bus la plus proche, puis la recherche continue jusqu’à la première TRAM la plus proche et ainsi de suite jusqu’à la première gare la plus proche.
C’est-à-dire : Si cinq stations de bus et une seule gare sont trouvées lors de la recherche, le résultat affichera une station de bus et une gare.
À propos de 2 : Objectif … je vais l’adopter, un téléphérique est déjà un exemple extrême, mais avec plaisir.
À propos de 3 : SUBWAY est bien, je l’adopte.

À propos de 4 : Annulations… oui, j’y ai pensé, mais aujourd’hui, j’étais encore occupé avec la structure de base du script. Cela viendra ensuite pour la relecture.

À propos de 5 : Arrêts fantômes : Cela semble être une spécialité française, en Allemagne, il existe un répertoire central des arrêts (je ne le savais pas non plus jusqu’à aujourd’hui), où toutes les entreprises de transport enregistrent leurs arrêts desservis.

À propos de 6 : Bonne suggestion, actuellement, je travaille encore sur le bon timing. Je laisserai probablement à l’utilisateur dans la section de configuration de l’intégration le choix de ce qu’il veut voir (bus, train, tram, ferry, avion) et rendrai la zone de recherche configurable.

Voici mon script à l’état actuel :
Le script s’exécute contre mon Docker dans lequel se trouve l’API.

Voici mon script, peut-être que tu remarqueras encore quelque chose, je vais maintenant boire une Piña Colada après 10 heures de travail :

#!/bin/sh

Trouver les arrêts proches et regrouper les prochains départs.

Appel : abfahrten-ortstest.sh LATITUDE LONGITUDE [RAYON_METRES]

set -eu

API="https://api.transitous.org/api/v6/stoptimes"
UA=""

if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
echo "Appel : $0 LATITUDE LONGITUDE [RAYON_METRES]" >&2
exit 2
fi

LAT=$1
LON=$2
RADIUS=${3:-1200}

for V in "$LAT" "$LON" "$RADIUS"; do
case "$V" in
''|[!0-9.-]|.|-|..)
echo "Valeur numérique invalide : $V" >&2
exit 2
;;
esac
done

TMP="/tmp/abfahrten-ortstest.$$"
RAW="${TMP}.json"
ROWS="${TMP}.tsv"
QUERIES="${TMP}.queries"
RESP="${TMP}.responses"
OUT="${TMP}.out"
: > "$QUERIES"
: > "$RESP"
: > "$OUT"
trap 'rm -f "$RAW" "$ROWS" "$QUERIES" "$RESP" "$OUT" "${TMP}.one"' EXIT HUP INT TERM

echo "Recherche des arrêts autour de ${LAT}, ${LON} ..."

if ! curl -fsS --get "$API" 
--data-urlencode "center=${LAT},${LON}" 
--data-urlencode "radius=${RADIUS}" 
--data-urlencode "n=100" 
--data-urlencode "language=de" 
-H "User-Agent: $UA" -o "$RAW"; then
echo "Erreur lors de la recherche des arrêts." >&2
exit 1
fi

if ! jq -e '.stopTimes | type == "array"' "$RAW" >/dev/null 2>&1; then
echo "Réponse API inattendue:"
jq . "$RAW" 2>/dev/null || cat "$RAW"
exit 1
fi

Approximation de distance pour le tri des arrêts proches.

jq -r --arg lat "$LAT" --arg lon "$LON" '
def cat:
if . == "BUS" then "BUS"
elif . == "FERRY" then "FERRY"
elif . == "TRAM" then "TRAM"
elif . == "SUBURBAN" or . == "REGIONAL_RAIL" then "RAIL"
else "OTHER:" + tostring
end;
def dist:
(((.place.lat|tonumber)-($lat|tonumber))111000) as $dy
| (((.place.lon|tonumber)-($lon|tonumber))72000) as $dx
| (($dx$dx+$dy$dy)|sqrt);
[
.stopTimes

| select(.place.name != null and .place.stopId != null)
| select(.place.lat != null and .place.lon != null)
| {
name:.place.name,
id:.place.stopId,
mode:(.mode // "UNKNOWN"),
category:((.mode // "UNKNOWN")|cat),
distance:dist
}
]
| unique_by([.name,.id,.mode])
| group_by([.name,.category])
| map({
name:.[0].name,
category:.[0].category,
distance:(map(.distance)|min),
modes:(map(.mode)|unique|join(",")),
ids:(map(.id)|unique)
})
| sort_by(.distance)
| .

| [.category,(.distance|floor|tostring),.name,.modes,(.ids|join("|"))]
| @tsv
' "$RAW" > "$ROWS"

if [ ! -s "$ROWS" ]; then
echo "Aucun arrêt trouvé. Augmenter le rayon ?"
exit 0
fi

Choisir le groupe le plus proche pour chaque catégorie.

for CAT in BUS TRAM RAIL FERRY; do
LINE=$(awk -F '\t' -v cat="$CAT" '$1 == cat {print; exit}' "$ROWS")
if [ -z "$LINE" ]; then
echo "Aucun arrêt pour $CAT trouvé."
continue
fi

NAME=$(printf '%s\n' "$LINE" | cut -f3)
DIST=$(printf '%s\n' "$LINE" | cut -f2)
IDS=$(printf '%s\n' "$LINE" | cut -f5)

echo "Sélection : $CAT - $NAME (environ ${DIST} m)"

OLD_IFS=$IFS
IFS='|'
for SID in $IDS; do
    printf '%s\t%s\t%s\n' "$CAT" "$NAME" "$SID" >> "$QUERIES"
done
IFS=$OLD_IFS

done

if [ ! -s "$QUERIES" ]; then
echo "Aucun ID d'arrêt disponible pour la requête."
exit 0
fi

echo
echo "Appel des départs pour les ID d'arrêt sélectionnés ..."

Chaque ID d'arrêt est interrogé individuellement. Les données sont ensuite fusionnées.

while IFS="$(printf '\t')" read -r CAT STOPNAME SID; do
[ -n "$SID" ] || continue
ONE="${TMP}.one"

if curl -fsS --get "$API" \
    --data-urlencode "stopId=$SID" \
    --data-urlencode "n=40" \
    --data-urlencode "language=de" \
    -H "User-Agent: $UA" -o "$ONE"; then

    if jq -e '.stopTimes | type == "array"' "$ONE" >/dev/null 2>&1; then
        jq -c --arg cat "$CAT" --arg stopname "$STOPNAME" --arg sid "$SID" '
          .stopTimes[]
          | {
              category:$cat,
              stop:(.place.name // $stopname),
              stopId:(.place.stopId // $sid),
              mode:(.mode // "UNKNOWN"),
              line:(.displayName // "?"),
              destination:(.headsign // "?"),
              departure:(.place.departure // .departure // null),
              scheduled:(.place.scheduledDeparture // .scheduledDeparture // null),
              track:(.place.track // "-"),
              realtime:(.realTime // false),
              cancelled:(.cancelled // false),
              trip:(.tripId // "")
            }
        ' "$ONE" >> "$RESP"
    else
        echo "Avertissement : Format inattendu pour l'ID d'arrêt $SID" >&2
    fi
else
    echo "Avertissement : Requête échouée pour l'ID d'arrêt $SID" >&2
fi

done < "$QUERIES"

if [ ! -s "$RESP" ]; then
echo "Aucun départ reçu pour les ID d'arrêt sélectionnés."
echo "L'API utilise peut-être un autre paramètre de requête."
echo "La recherche d'arrêts elle-même a réussi."
exit 0
fi

Normaliser les heures pour le tri.

Les heures ISO sont triées de manière lexicographique ; l'heure est affichée localement.

jq -s -r '
def rawtime:
if .departure != null then .departure
else .scheduled
end;
def clock:
if type == "string" then
(if length >= 20 and .[10:11] == "T" then
(try (("date -d @" + (.[0:19] + "Z" | fromdateiso8601 | tostring) + " +%H:%M") | .) catch "??:??")
elif length >= 5 then .[0:5]
else tostring
end)
elif type == "number" then
(try (./1000 | strftime("%H:%M")) catch tostring)
else "??:??"
end;
map(select(rawtime != null))
| map(. + {
sorttime:(rawtime|tostring),
shownTime:(rawtime|clock),
status:(if .cancelled then "ANNULÉ"
elif .realtime then "TEMPS RÉEL"
else "HORAIRE" end),
shownMode:(if .mode == "BUS" then "BUS"
elif .mode == "FERRY" then "FÉRY"
elif .mode == "TRAM" then "TRAM"
elif .mode == "SUBWAY" then "MÉTRO"
elif .mode == "SUBURBAN" then "S-BAHN"
elif (.mode == "HIGHSPEED_RAIL" or .mode == "HIGH_SPEED_RAIL"
or .mode == "LONG_DISTANCE") then "LONG"
elif (.mode == "REGIONAL_RAIL" or .mode == "REGIONAL_FAST_RAIL"
or .mode == "RAIL") then "TRAIN"
else .mode end)
})

Préférer le mode de transport explicite avant OTHER pour les messages en double du même trajet au même quai.

| sort_by([.sorttime, (if .mode == "OTHER" then 1 else 0 end)])
| unique_by([.stopId,.trip,.sorttime])
| sort_by(.sorttime)
| .[:5]
| .

| [
rawtime,
.stop,
.shownMode,
.line,
.destination,
.status,
.track
]
| @tsv
' "$RESP" > "$OUT"

echo
echo "LES 5 PROCHAINS DÉPARTS AU TOTAL"
printf '%-6s | %-29s | %-5s | %-8s | %-29s | %-9s | %s\n' 
"Heure" "Arrêt" "Type" "Ligne" "Destination" "Données" "Quai"
printf '%s\n' "---------------------------------------------------------------------------------------------------------------"

if [ -s "$OUT" ]; then
awk -F '\t' '{
printf "%-6s | %-29s | %-5s | %-8s | %-29s | %-9s | %s\n",
$1,$2,$3,$4,$5,$6,$7
}' "$OUT"
else
echo "Aucun départ avec une heure utilisable trouvé."
fi

echo
echo "TRAM* signifie : Transitous a signalé OTHER à un ID d'arrêt de tram sélectionné."
echo "Les 5 entrées affichées sont limitées aux ID d'arrêt interrogés."
echo "Les arrêts spéciaux sans départs dans la première recherche de l'API peuvent être manquants."

Pour un lieu choisi au hasard à Toulouse, j’obtiens le résultat suivant ;

[/share/Container] # /share/Container/abfahrten-ortstest.sh 43.581972 1.448832
Recherche des arrêts autour de 43.581972, 1.448832 ...
Sélection : BUS - Récollets Daste (environ 440 m)
Aucun arrêt pour TRAM trouvé.
Aucun arrêt pour RAIL trouvé.
Aucun arrêt pour FERRY trouvé.

Récupération des départs pour les identifiants d'arrêt sélectionnés ...

LES 5 PROCHAINS DÉPARTS AU TOTAL
Heure   | Arrêt                        | Type  | Ligne    | Destination                   | Données     | Quai

2026-10-12T20:49:00Z | Récollets Daste              | BUS   | L4       | Cours Dillon                  | Temps réel  | -
2026-10-12T21:19:00Z | Récollets Daste              | BUS   | L4       | Cours Dillon                  | Temps réel  | -
2026-10-12T21:47:00Z | Récollets Daste              | BUS   | L4       | Cours Dillon                  | Temps réel  | -
2026-10-12T22:16:00Z | Récollets Daste              | BUS   | L4       | Cours Dillon                  | Temps réel  | -
2026-10-12T22:46:00Z | Récollets Daste              | BUS   | L4       | Cours Dillon                  | Temps réel  | -


Est-ce que c’est correct ? Aucune idée ? La tête en bouillie ? Garanti … la Piña Colada m’appelle. :joy:

Merci Tom ! 10 heures d’affilée, la Piña Colada est amplement méritée :tropical_drink: Profite bien et détends-toi, le script peut attendre demain :grinning_face_with_smiling_eyes:

J’ai regardé ton script et refait ton test de Toulouse, voilà ce que j’ai vu (à lire à tête reposée ^^) :

Ton résultat de Toulouse est juste : la L4 vers Cours Dillon passe bien à 22h49, 23h19, 23h47, 0h16 et 0h46. Par contre, c’est affiché en UTC (20:49Z = 22:49 chez nous).

Mais ton point est à côté de Saint-Agne : bus à 160 m, métro B à 230 m, gare TER à 300 m. Le script a pris un bus à 440 m et n’a trouvé ni le métro ni la gare. Deux raisons :

  • La recherche passe par les 100 prochains départs de la zone. À Toulouse à 22h49, ça ne couvre qu’une demi-heure (22h49 → 23h17). Un arrêt qui n’a rien dans cette demi-heure est invisible : une gare TER avec un train par heure, ou un bus le soir. Pour trouver les arrêts sans dépendre des horaires, /api/v1/map/stops sur un rectangle autour du point renvoie tous les arrêts de la zone. Ensuite, stoptimes par arrêt comme tu le fais déjà.
  • Dans cat, le métro (SUBWAY), le TGV et les Intercités (HIGHSPEED_RAIL, LONG_DISTANCE) tombent dans OTHER, et la boucle ne parcourt que BUS, TRAM, RAIL et FERRY. Sur les 95 départs autour de ton point, 58 étaient des métros B, tous ignorés.

Quelques détails dans le script :

  • Destination vide : .headsign // "?" ne marche pas sur une chaîne vide, car en jq seuls null et false déclenchent le //. D’où le TER sans destination à La Rochelle. .tripTo.name donne toujours le terminus.
  • L’heure : clock fabrique le texte "date -d @…" mais jq ne peut pas lancer de commande, et c’est rawtime qui est affiché, pas shownTime. Plus simple : .[0:19] + "Z" | fromdateiso8601 | strflocaltime("%H:%M") en lançant jq avec TZ= le fuseau de l’arrêt (champ place.tz). Il faut juste que le système ait les fuseaux horaires (/usr/share/zoneinfo), sinon jq reste en UTC sans rien dire.
  • Annulations : il y a aussi tripCancelled (trajet entier supprimé), en plus de cancelled.
  • Distance : 72 000 m par degré de longitude, c’est juste en Allemagne, mais à Toulouse c’est plutôt 81 000. 111320 × cos(latitude) marche partout.
  • User-Agent vide : Transitous est un service communautaire, ils apprécient un User-Agent qui identifie l’appli (nom + lien du dépôt par exemple).

Pour les arrêts fantômes : avec ta méthode, ils ne gênent pas, puisqu’ils n’ont aucun départ. À filtrer seulement si tu passes par map/stops :wink: Et le téléphérique, ce n’est pas si extrême, à Toulouse c’est une vraie ligne qu’on prend tous les jours :joy:

L’idée de laisser choisir les modes et le rayon dans la config, c’est top :+1:

Bonne soirée !