Skip to content

Implement MOTIS provider and Transitous - #713

Open
dancojocaru2000 wants to merge 7 commits into
schildbach:masterfrom
dancojocaru2000:motis
Open

dancojocaru2000 wants to merge 7 commits into
schildbach:masterfrom
dancojocaru2000:motis

Conversation

@dancojocaru2000

Copy link
Copy Markdown

I got hyperfixated so I spent the night implementing a MOTIS provider.

@dancojocaru2000
dancojocaru2000 force-pushed the motis branch 4 times, most recently from 6d757f9 to f19c9b4 Compare May 12, 2026 07:52
@traines-source

traines-source commented May 12, 2026

Copy link
Copy Markdown
Contributor

Very cool! I hope that this way we can finally get Transitous in Öffi etc.!
For the sake of completeness, there had been existing work in another fork of PTE: https://gitlab.com/jbb/public-transport-enabler/-/tree/transitous?ref_type=heads (and apparently elsewhere more recently, which I haven't found though)

@schildbach schildbach left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your contribution! I did a first review, and this generally looks very good!

It's probably too late now, but do you like the org.json API? Would you have preferred using a different JSON API?

Comment thread src/de/schildbach/pte/AbstractMotisProvider.java Outdated
Comment thread src/de/schildbach/pte/AbstractMotisProvider.java Outdated
Comment thread test/de/schildbach/pte/live/TransitousProviderLiveTest.java Outdated
Comment thread src/de/schildbach/pte/AbstractMotisProvider.java Outdated
Comment thread src/de/schildbach/pte/NetworkId.java
@schildbach

Copy link
Copy Markdown
Owner

Unit tests pass as well 😃

@dancojocaru2000

Copy link
Copy Markdown
Author

org.json is fine, I don't think I have a strong preference

@dancojocaru2000
dancojocaru2000 force-pushed the motis branch 2 times, most recently from a3f1bd9 to d0e9973 Compare May 14, 2026 15:04
@dancojocaru2000

dancojocaru2000 commented May 14, 2026

Copy link
Copy Markdown
Author

One issue that has been brought to my attention which needs to be addressed is that the Transitous API Usage has the following requirements:

Make sure your send HTTP User-Agent headers with every request, at least containing the following information:

  • Name of the application
  • Version of the client implementation
  • A way of contact (e-mail address or website URL)

I'm unsure how to handle this in the library, given that the application which uses the library (Öffi, Transportr, etc) will have to set its own user agent.

dancojocaru2000 and others added 4 commits May 14, 2026 17:18
Co-authored-by: santawho <196087559+santawho@users.noreply.github.com>
MOTIS returns only one station ID corresponding to one dataset, but
fetching the  departures of that ID only returns the departures from
that dataset.

That means departures from the other datasets would get left out.
@schildbach

Copy link
Copy Markdown
Owner

I'm unsure how to handle this in the library, given that the application which uses the library (Öffi, Transportr, etc) will have to set its own user agent.

Yes, the apps need to call TransitousProvider.setUserAgent() then. I have never added email adresses or URLs to user agents though.

I think it would be nice to mention this requirement (maybe link to it) in the class-level JavaDoc, otherwise people maybe simply miss it.

@dancojocaru2000

dancojocaru2000 commented May 14, 2026

Copy link
Copy Markdown
Author

Would it be reasonable to set a default bad user agent (something like CHANGE ME), and then throw an exception if that wasn't explicitly modified, to make sure a library user is aware of the requirement?

@schildbach

Copy link
Copy Markdown
Owner

Would it be reasonable to set a default bad user agent (something like CHANGE ME), and then throw an exception if that wasn't explicitly modified, to make sure a library user is aware of the requirement?

Rather than setting a weird user agent, we could also check against the default null. Currently though there is no getter for the user agent.

But it feels to me this is a bit over the top anyway.

@dancojocaru2000

Copy link
Copy Markdown
Author

Then I'll add the JavaDoc and undraft the PR.

@dancojocaru2000
dancojocaru2000 marked this pull request as ready for review May 14, 2026 22:24
@traines-source

Copy link
Copy Markdown
Contributor

I think over at santawho#3 people added some more tweaks that are relevant, e.g. making distance nullable.
Also it would be good for apps using this to visibly put https://transitous.org/sources/ somewhere (and probably also attribute OSM directly). The Transitous usage policy will include this soon to make these requirements clearer :)

@schildbach

schildbach commented May 30, 2026

Copy link
Copy Markdown
Owner

I just tried this branch in Öffi. See https://gitlab.com/oeffi/oeffi/-/tree/motis?ref_type=heads if interested.

Öffi Stations:

  • It detects Tram and Bus stations in my vicinity, but only up to 500m. That's probably not enough for more sparsely populated areas than mine (Berlin).
  • None of the 5 detected stations show any departures. I'll crosscheck with other services if that's really the case, atm I think it's probably a matter of missing data.

Öffi Directions:

  • Stations seem to be autocompleted fine, I picked a station from Berlin and one from Munich.
  • Actually querying crashed:
05-30 15:07:37.900 27110 27110 E AndroidRuntime: Caused by: java.io.NotSerializableException: okhttp3.HttpUrl
05-30 15:07:37.900 27110 27110 E AndroidRuntime: 	at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1240)
05-30 15:07:37.900 27110 27110 E AndroidRuntime: 	at java.io.ObjectOutputStream.defaultWriteFields(ObjectOutputStream.java:1620)
05-30 15:07:37.900 27110 27110 E AndroidRuntime: 	at java.io.ObjectOutputStream.writeSerialData(ObjectOutputStream.java:1581)
05-30 15:07:37.900 27110 27110 E AndroidRuntime: 	at java.io.ObjectOutputStream.writeOrdinaryObject(ObjectOutputStream.java:1490)
05-30 15:07:37.900 27110 27110 E AndroidRuntime: 	at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1234)
05-30 15:07:37.900 27110 27110 E AndroidRuntime: 	at java.io.ObjectOutputStream.defaultWriteFields(ObjectOutputStream.java:1620)
05-30 15:07:37.900 27110 27110 E AndroidRuntime: 	at java.io.ObjectOutputStream.writeSerialData(ObjectOutputStream.java:1581)
05-30 15:07:37.900 27110 27110 E AndroidRuntime: 	at java.io.ObjectOutputStream.writeOrdinaryObject(ObjectOutputStream.java:1490)
05-30 15:07:37.900 27110 27110 E AndroidRuntime: 	at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1234)
05-30 15:07:37.900 27110 27110 E AndroidRuntime: 	at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:354)
05-30 15:07:37.900 27110 27110 E AndroidRuntime: 	at android.os.Parcel.writeSerializable(Parcel.java:3086)

I'll investigate and will report back.

protected String nextPageCursor;
@Nullable
protected String previousPageCursor;
protected HttpUrl endpoint;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly the OkHttp HttpUrl class is not serializable, while QueryContext is (and Serialization is used by Öffi).

I see these options:

  • just store the URL as String – probably easiest
  • keep HttpUrl but implement custom serialization for that field – somewhat ugly
  • somehow get rid of Serialization completely (in Öffi, but probably other consumers too) – in this case, no changes necessary

@schildbach

schildbach commented May 30, 2026

Copy link
Copy Markdown
Owner

None of the 5 detected stations show any departures. I'll crosscheck with other services if that's really the case, atm I think it's probably a matter of missing data.

Does Transitious actually have departure times (live or planned)? On https://transitous.org/ I only see trip planning, not any station boards.

Edit: I just searched for "Hauptbahnhof" in Öffi Stations and got somewhat random 10 Hauptbahnhöfe, with 9 of them showing no departures. "Gera Hauptbahnhof" however shows a bunch and they are nicely formatted. However most departures are hours ago – not that useful.

@traines-source

traines-source commented May 30, 2026

Copy link
Copy Markdown
Contributor

Transitous does have departure times, see e.g. https://api.transitous.org/?stopArriveBy=false&stopId=de-DELFI_de%3A05915%3A7756_G&time=2026-05-30T17%3A29%3A00.000Z (this is a link to the web UI, which uses the stoptimes API, as does this PR). I'm not sure what issues you are facing, in Germany the coverage should be mostly complete and over in ÖffiNG everything seems to work as well. (Also it can be beneficial to use the new center param if the app remembers the coordinates of saved locations anyways, since stop ids are not very stable in the source feeds...)

For the nearby view, instead of the reverse-geocode endpoint it is probably better to also use the stoptimes endpoint with a larger radius or the /map/stops endpoint (which only includes the stops and no departures). Reverse-geocode is hardcoded to 500 meters radius indeed.

@schildbach

schildbach commented May 31, 2026

Copy link
Copy Markdown
Owner

This PR uses this endpoint for departure times:

https://api.transitous.org/api/v5/stoptimes?stopId=de-DELFI_de%3A08111%3A2492&exactRadius=false&radius=200&n=200&time=2026-05-31T12%3A34%3A00.0%2B0200

This example station (Stuttgart Hauptfriedhof) yields "no departures" in Öffi, despite including a list of 180 "stop times". However I don't actually see any date/time field in these stop times (but I'm new to the API so I just might oversee things).

Edit: Ah, it's (somewhat unintuitively) in the place sub-map: fields departure and scheduledDeparture

@traines-source

Copy link
Copy Markdown
Contributor

The times are contained in place.{arrival|scheduledArrival|departure|scheduledDeparture}.
Maybe boolean equivs is the culprit? In Transitous, depending on the source feed, individual stops are usually platforms/bays. If you filter for really just the stop you requested, this will lead to unexpected results. You may use the parentId to group stops or remove the radius param and rely on Transitous/MOTIS to do the equivalence logic for you, which will also include departures from different feeds (e.g. when the local public transport stems from a different source feed than the long distance transport, they will never have the same parentId, but hopefully be returned by the stoptimes endpoint even without radius.

final JSONObject place = stopTime.getJSONObject("place");
final String departureStopId = place.getString("stopId");

if (!equivs && !stationId.equals(departureStopId)) {

@schildbach schildbach May 31, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this conditional fails way more often than it should, leading to no departures being returned if equivs is false (always false in Öffi Departures).

Would it be an option to also test againt parentId, or would that lead to too many results?

@schildbach

schildbach commented May 31, 2026

Copy link
Copy Markdown
Owner

The times are contained in place.{arrival|scheduledArrival|departure|scheduledDeparture}. Maybe boolean equivs is the culprit?

Yes, I just came to the same conclusions.

In Transitous, depending on the source feed, individual stops are usually platforms/bays. If you filter for really just the stop you requested, this will lead to unexpected results. You may use the parentId to group stops or remove the radius param and rely on Transitous/MOTIS to do the equivalence logic for you, which will also include departures from different feeds (e.g. when the local public transport stems from a different source feed than the long distance transport, they will never have the same parentId, but hopefully be returned by the stoptimes endpoint even without radius.

I'd be happy to remove the client-side filter, if that were possible. It would also save resources.

So are you saying that if we remove the radius and exactRadius URL parameters this would lead to a sensible "no equivs" behaviour?

endpointBuilder.setQueryParameter("window", String.valueOf(3600));
}
if (time != null) {
endpointBuilder.addQueryParameter("time", new SimpleDateFormat("yyyy-MM-dd'T'h:m:ss.SZ").format(time));

@schildbach schildbach May 31, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The time format is incorrect. Please use HH for hours to use 24 hour format. And mm for minutes so that it's always two digits.

About the S: Does motis really support milliseconds? Their examples don't imply so. I'd consider removing it.

About the Z: Have you considered passing in time as UTC always? In that case, you'd need to pass a literal Z as timezone.

throw new IllegalArgumentException("to needs to be stop or have coordinates: " + to);
}

endpointBuilder.addQueryParameter("time", new SimpleDateFormat("yyyy-MM-dd'T'h:m:ss.SZ").format(date));

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

@traines-source

Copy link
Copy Markdown
Contributor

Yes it's unfortunately a bit complicated. And just today with the new MOTIS release deployed to Transitous, the equivalence logic has been considerably improved.

I've thought about it again. For both equivs=false and equivs=true I think you should use

  • (optionally) stopId
  • (more importantly, if possible) center (stopIds can change, not very often, but they do, the position of the stops hopefully less so, so if the stops are stored somewhere as favorites etc. better to not rely on the stopId – if you provide both stopId and center, the latter will only be used as a fallback
  • exactRadius=false
  • and then for equivs=false a radius like 50 meters. Als long as exactRadius is false, this will return stops outside of the radius that MOTIS/Transitous considers equivalent in the narrower sense, i.e. very similar stop name and essentially the same station, i.e. can be shown in a departure view without absolutely having to name the stop for each entry. This is of course a heuristic and the quality might vary depending on region.
  • for equivs=true a radius like 500 meters. Meaning for each entry in the UI, you need to show the actual departure station, and you might want to group by parentId.

@schildbach

Copy link
Copy Markdown
Owner

I just worked on some patches to queryDepartures() that finally made it to show departures in my vicinity.

However, I now seriously consider removing equivs from the PTE queryDepartures() API, and make the result structure simpler (basically, replace the ID→StationDepartures map by just one StationDepartures). It has always been a pain, and never been used by Öffi afair.

Any thoughts about this change? It would not affect queryTrips() btw.

@traines-source

Copy link
Copy Markdown
Contributor

Idk I think it is a useful distinction, depending on the usecase, and something that all timetable information systems do in one way or another. Just that the definition of "equivalent" and "same" is of course very vague and depending on use case as well. Also in some sense it would be a breaking change to PTE.

@schildbach

schildbach commented May 31, 2026

Copy link
Copy Markdown
Owner
  • (optionally) stopId

  • (more importantly, if possible) center (stopIds can change, not very often, but they do, the position of the stops hopefully less so, so if the stops are stored somewhere as favorites etc. better to not rely on the stopId – if you provide both stopId and center, the latter will only be used as a fallback

I know the pain of changing stop IDs. It has always been a problem with the other APIs as well. However, geo-coordinates change too.

As long as https://api.transitous.org/api/v1/reverse-geocode?place=…&type=STOP returns up-to-date stop IDs, there is only one problem with changing stop IDs: station favorites. But users can migrate them, and Öffi could even help them with that (currently it doesn't).

At the moment I suspect it might be a problem to use complex structures like a map with a geo-coordinates as a station ID in Öffi.

@traines-source

traines-source commented May 31, 2026

Copy link
Copy Markdown
Contributor

The detour via reverse-geocode would be unnecessary, since stoptimes already supports querying by coordinates (reason why I think also for the nearby feature it or /map/stops is the better option).
You could just consider the comma-separated coordinates as the stop id string. But yeah thats of course a tradeoff. I would have said if the coordinates change by more than 50 meters, it's essentially a different stop. But using the actual stopId should also work in most cases, it's not like they change every other day (but probably more often than in a typical HAFAS system).

@schildbach

schildbach commented May 31, 2026

Copy link
Copy Markdown
Owner
  • and then for equivs=false a radius like 50 meters. Als long as exactRadius is false, this will return stops outside of the radius that MOTIS/Transitous considers equivalent in the narrower sense, i.e. very similar stop name and essentially the same station, i.e. can be shown in a departure view without absolutely having to name the stop for each entry. This is of course a heuristic and the quality might vary depending on region.

I fear this will fail in too many ways, and be a pain to maintain – especially world-wide.

I just tested removing the radius and exactRadius for the equivs=false case (on top of some other necessary patches), and it seemed to work fine for Berlin:

  • simple bi-directional and uni-directional Bus/Tram stops
  • crossings of two Bus or Tram lines (typically resulting in 4 positions)
  • larger crossings of three or more modes
  • crossings with products from different public transport companies
  • huge multi-mode hubs like Berlin Hauptbahnhof

So I wonder what's the specific reason for including a 50 meters radius in the query?

@traines-source

traines-source commented May 31, 2026

Copy link
Copy Markdown
Contributor

50 meter radius would just be needed when using center. It would still include all the equivalences you mentioned above also beyond the radius (as long as exactRadius is not set to true), i.e. you could also set it to 1 meter – it is just to enable the "coordinates-as-stopid" workaround while allowing for some small coordinate change hopefully without including any unrelated stations that are not the "same" according to MOTIS.

@schildbach

Copy link
Copy Markdown
Owner

I propose PR #723 which removes the equivs concept from queryDepartures(). Feel free to comment there.

If/when it is merged, I can add the necessary changes to this PR.

@cyroxx

cyroxx commented Sep 13, 2026

Copy link
Copy Markdown

Hi @dancojocaru2000, hi @schildbach,

I'd like to help move this PR forward. I'm particularly interested in using Transitous/MOTIS for Seattle, so I wanted to test the provider against a region outside Europe and report back with something reproducible.

Based on this thread, I backported a few small fixes on top of this branch, verified with unit tests and a live run against api.transitous.org:

  • Serializable QueryTripsContext: AbstractMotisProvider.QueryContext stored the MOTIS endpoint as okhttp3.HttpUrl, which is not Serializable. This is exactly the java.io.NotSerializableException: okhttp3.HttpUrl @schildbach ran into testing this in Öffi. I now store it as a String and re-parse it with HttpUrl.parse(...) in queryMoreTrips(), plus added an explicit serialVersionUID and a round-trip serialization unit test.
  • ISO-8601 UTC time formatting: replaced the SimpleDateFormat("yyyy-MM-dd'T'h:m:ss.SZ") pattern (12-hour h, un-padded m, not thread-safe) with DateTimeFormatter.ISO_INSTANT on the UTC instant, used both in queryDepartures() and queryTrips().
  • Nullable walking-leg distance: parseMotisItinerary() required a distance field on individual (e.g. walking) legs via getDouble(...). MOTIS can omit it; switched to optDouble(..., 0.0), with a unit test covering a leg JSON without distance.

I also added two live test cases to TransitousProviderLiveTest for Seattle (Westlake Station ↔ SeaTac Airport), verified locally against the live API — they exercise Sound Transit Link light rail and King County Metro bus data via Transitous.

I deliberately did not touch queryDepartures()'s equivs handling, since that's already being discussed in #723.

See this branch for details: https://github.com/cyroxx/public-transport-enabler/tree/motis
It contains 4 small commits on top of this PR's motis branch.

Please let me know how you'd like to proceed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants