External integration - Rail, bus traffic

Hello everyone,

I finally got convinced to try this after reading the helpful tips from @prohand and @guim31 here.

Well, I did it. Using a Docker, I was able to find traffic data for German bus stops. Then I simply searched for cities in Switzerland (works), cities in Austria (works), and then I got bold and searched for a French city on the Atlantic coast, far away from the German-speaking area… and look, it works too.

My developed script takes a special approach. Using the geolocation of the user at their home in Gladys Assistant, I have the geodata.
Using this geodata, a radius search is started, first the nearest bus stop, then the nearest tram, and finally the nearest train station is found.

In the next step, the next five departures are then displayed directly.

So the user knows immediately with every call from Gladys Assistant what is leaving where and when next.

Here is the test output from La Rochelle:

[/share/Container] # /share/Container/abfahrten-ortstest.sh 46.1540 -1.1450
Searching for bus stops around 46.1540, -1.1450 ...
Selection: BUS - Gare La Rochelle - De Gaulle (approx. 120 m)
No tram stop found.
Selection: RAIL - La Rochelle (approx. 156 m)
No ferry stop found.

Calling departures for the selected stop IDs ...


THE NEXT 5 DEPARTURES IN TOTAL
Time   | Bus Stop                   | Type  | Line    | Destination                  | Data     | Platform

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

TRAM* means: Transitous reported OTHER at a selected tram stop ID.
The 5 entries displayed are limited to the queried stop IDs.
Special stops without departures in the first API search may be missing.

Now I have a problem:
I can’t evaluate the logic of French, Swiss or Austrian departure stops and therefore I’m looking for testers.

It’s actually just a Docker to install. YAML is available. Then start the script via SSH and check the results and give me any feedback.

So who’s up for it?
Just let me know.

If the logic is correct, then it’s on to integration.

Regards
Tom

Here are the test outputs just for reading:

Austria:

[/share/Container] # /share/Container/abfahrten-ortstest.sh 47.7260 16.2140
Searching for bus stops around 47.7260, 16.2140 ...
Selection: BUS - Bad Erlach Gemeindeamt (approx. 182 m)
No tram stop found.
Selection: RAIL - Bad Erlach Bahnhof (approx. 620 m)
No ferry stop found.

Calling departures for the selected stop IDs ...

THE NEXT 5 DEPARTURES IN TOTAL
Time   | Bus Stop                   | Type  | Line    | Destination                  | Data     | Platform

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


Switzerland:

[/share/Container] # /share/Container/abfahrten-ortstest.sh 47.1240 9.3120
Searching for bus stops around 47.1240, 9.3120 ...
Selection: BUS - Walenstadt, Rathausplatz (approx. 216 m)
No tram stop found.
Selection: RAIL - Walenstadt (approx. 476 m)
No ferry stop found.

Calling departures for the selected stop IDs ...



THE NEXT 5 DEPARTURES IN TOTAL
Time   | Bus Stop                   | Type  | Line    | Destination                  | Data     | Platform

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

or special case Hamburg (ferry line service):

THE NEXT 5 DEPARTURES IN TOTAL
Time   | Bus Stop                   | Type  | Line    | Destination                  | Data     | Platform

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

I’ll tell you directly in the integration, but if you want results before that, no problem.

Let me know how to run the test and I’ll do it with pleasure.

Hi Tom :waving_hand:

Off to France! I live near Toulouse, so I tested your logic directly on the Transitous API: about twenty places (Toulouse and surroundings, Bordeaux, Paris, Lyon, Marseille, Nantes, Lille, Grenoble, La Rochelle). Good news: the data is good, the Tisséo, SNCF, and RER schedules match. A few points on the logic:

  • Search around the point only returns 5 stops (if you go through reverse-geocode). As soon as there are 5 bus stops closer, the tram and the station disappear: in Marseille Vieux-Port, it misses the tram and Saint-Charles, in Grenoble and Muret the station. With map/stops on a 1 km area, we get all the stops and it works.
  • Destination: use tripTo.name. At SNCF, headsign is empty, and for RER it contains a mission code (« ZUMO », « ERTE »…), not the destination.
  • Missing metro: add SUBWAY (and AERIAL_LIFT for the Toulouse cable car :grinning_face_with_smiling_eyes:).
  • Cancellations: the API marks canceled trips (cancelled). In Toulouse this evening, 3 out of 11 trams were canceled, and your table would display them as normal.
  • Delays: departure vs. scheduledDeparture, this allows displaying « +5 min ».
  • UTC times: each stop has its time zone (tz), to be converted to local time.
  • Ghost stops with no mode: to be ignored.
  • Global top 5: in the city, the metro takes the 5 spots. N departures per mode would be better.
  • The closest stop isn’t always the right one: at Capitole, it’s a stop served only by a shuttle (next departure tomorrow morning). Let the user choose their stops, with auto-search as a suggestion, that would be great.

Honest little reservation: SNCF doesn’t have real-time data in Transitous, so on strike days we’ll see trains that aren’t running :sweat_smile:

If you share your script, I’ll run it again on the same places to compare :folded_hands:

Regarding 1: I have taken that into account, i.e., the nearest bus stop is used first. Then it continues searching for the nearest TRAM stop and then for the nearest train station.
In other words: If there are five bus stops and only one train station in the search, the result will be one bus stop and one train station.
Regarding 2: Destination … I will take that on board, a cable car is indeed an extreme example, but I’m happy to do it.
Regarding 3: SUBWAY is good, I will take that on board.

Regarding 4: Cancellations… yes, I have that in mind, but today I was still busy with the basic setup of the script. That will be reviewed next.

Regarding 5: Ghost stops: This seems to be a French specialty, in Germany there is a central stop directory (I didn’t know that until today either), where all transport companies enter their served stops.

Regarding 6: Good point, I’m still working on the right timing. I will probably leave the user the choice in the configuration area of the integration: What do they want to see (bus, train, tram, ferry, plane) and make the search area configurable.

Here is my script as of today:
The script runs against my Docker, which contains the API.

Here is my script, maybe you will notice something else, I’m going to have a Pina Colada now after 10 hours:

#!/bin/sh

Find nearby stops and combine the next departures.

Usage: abfahrten-ortstest.sh LATITUDE LONGITUDE [RADIUS_METERS]

set -eu

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

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

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

for V in "$LAT" "$LON" "$RADIUS"; do
case "$V" in
''|[!0-9.-]|.|-|..)
echo "Invalid number: $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 "Searching for stops around ${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 "Error searching for stops." >&2
exit 1
fi

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

Approximate distance for sorting nearby stops.

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 "No stops found. Increase radius?"
exit 0
fi

Select the nearest group per category.

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 "No stop for $CAT found."
continue
fi

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

echo "Selection: $CAT - $NAME (approx. ${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 "No stop IDs for query available."
exit 0
fi

echo
echo "Querying departures for the selected stop IDs ..."

Query each stop ID individually. The data is only combined afterwards.

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 "Warning: Unexpected format for stop ID $SID" >&2
    fi
else
    echo "Warning: Query failed for stop ID $SID" >&2
fi

done < "$QUERIES"

if [ ! -s "$RESP" ]; then
echo "No departures received for the selected stop IDs."
echo "Perhaps the API uses a different query parameter."
echo "The stop search itself was successful."
exit 0
fi

Normalize times for sorting.

ISO date times are sorted lexicographically; time is displayed locally.

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 "CANCELLED"
elif .realtime then "REALTIME"
else "SCHEDULE" end),
shownMode:(if .mode == "BUS" then "BUS"
elif .mode == "FERRY" then "FERRY"
elif .mode == "TRAM" then "TRAM"
elif .mode == "SUBWAY" then "SUBWAY"
elif .mode == "SUBURBAN" then "S-ROUTE"
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)
})

Prefer the explicit transport mode over OTHER for duplicate messages of the same trip at the same platform.

| 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 "THE NEXT 5 DEPARTURES IN TOTAL"
printf '%-6s | %-29s | %-5s | %-8s | %-29s | %-9s | %s\n' 
"Time" "Stop" "Type" "Line" "Destination" "Data" "Platform"
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 "No departures with usable time found."
fi

echo
echo "TRAM* means: Transitous reported OTHER at a selected tram stop ID."
echo "The 5 entries displayed are limited to the queried stop IDs."
echo "Special stops without departures in the first API search may be missing."

For an arbitrarily chosen location in Toulouse, I get the following output:

[/share/Container] # /share/Container/abfahrten-ortstest.sh 43.581972 1.448832
Searching for stops around 43.581972, 1.448832 ...
Selection: BUS - Récollets Daste (approx. 440 m)
No TRAM stop found.
No RAIL stop found.
No FERRY stop found.

Fetching departures for the selected stop IDs ...

NEXT 5 DEPARTURES IN TOTAL
Time   | Stop                      | Type  | Line     | Destination                | Data      | Platform

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


Is this correct? No idea? Brain mushy? Guaranteed… the piña colada is calling.:joy:

Thanks Tom! 10 hours straight, the Piña Colada is well deserved :tropical_drink: Enjoy and relax, the script can wait until tomorrow :grinning_face_with_smiling_eyes:

I looked at your script and reran your Toulouse test, here’s what I found (to be read with a clear mind ^^):

Your Toulouse result is correct: the L4 to Cours Dillon does indeed run at 22:49, 23:19, 23:47, 00:16, and 00:46. However, it’s displayed in UTC (20:49Z = 22:49 our time).

But your point is next to Saint-Agne: bus at 160 m, metro B at 230 m, TER station at 300 m. The script picked a bus at 440 m and didn’t find the metro or the station. Two reasons:

  • The search goes through the next 100 departures in the area. In Toulouse at 22:49, this only covers half an hour (22:49 → 23:17). A stop with nothing in that half-hour is invisible: a TER station with a train per hour, or an evening bus. To find stops without depending on schedules, /api/v1/map/stops on a rectangle around the point returns all stops in the area. Then, stoptimes per stop as you already do.
  • In cat, the metro (SUBWAY), the TGV, and the Intercités (HIGHSPEED_RAIL, LONG_DISTANCE) fall into OTHER, and the loop only goes through BUS, TRAM, RAIL, and FERRY. Out of the 95 departures around your point, 58 were B metros, all ignored.

A few details in the script:

  • Empty destination: .headsign // "?" doesn’t work on an empty string, because in jq only null and false trigger the //. Hence the TER without a destination in La Rochelle. .tripTo.name always gives the terminus.
  • The time: clock creates the text "date -d @…" but jq can’t run a command, and it’s rawtime that is displayed, not shownTime. Simpler: .[0:19] + "Z" | fromdateiso8601 | strflocaltime("%H:%M") by running jq with TZ= the time zone of the stop (field place.tz). You just need the system to have the time zones (/usr/share/zoneinfo), otherwise jq stays in UTC without saying anything.
  • Cancellations: there’s also tripCancelled (entire trip canceled), in addition to cancelled.
  • Distance: 72,000 m per degree of longitude is only accurate in Germany, but in Toulouse it’s more like 81,000. 111320 × cos(latitude) works everywhere.
  • Empty User-Agent: Transitous is a community service, they appreciate a User-Agent that identifies the app (name + link to the repository for example).

For ghost stops: with your method, they don’t bother, since they have no departures. Only filter them if you go through map/stops :wink: And the cable car, it’s not that extreme, in Toulouse it’s a real line that we take every day :joy:

The idea of letting users choose the modes and radius in the config is great :+1:

Have a good evening!