summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/Travel/Status/MOTIS.pm595
-rw-r--r--lib/Travel/Status/MOTIS/Polyline.pm98
-rw-r--r--lib/Travel/Status/MOTIS/Services.pm28
-rw-r--r--lib/Travel/Status/MOTIS/Services.pm.PL135
-rw-r--r--lib/Travel/Status/MOTIS/Stop.pm59
-rw-r--r--lib/Travel/Status/MOTIS/Stopover.pm123
-rw-r--r--lib/Travel/Status/MOTIS/Trip.pm186
-rw-r--r--lib/Travel/Status/MOTIS/TripAtStopover.pm78
8 files changed, 1302 insertions, 0 deletions
diff --git a/lib/Travel/Status/MOTIS.pm b/lib/Travel/Status/MOTIS.pm
new file mode 100644
index 0000000..23cc55e
--- /dev/null
+++ b/lib/Travel/Status/MOTIS.pm
@@ -0,0 +1,595 @@
+package Travel::Status::MOTIS;
+
+# vim:foldmethod=marker
+
+use strict;
+use warnings;
+use 5.020;
+use utf8;
+
+use Carp qw(confess);
+use DateTime;
+use DateTime::Format::ISO8601;
+use Encode qw(decode encode);
+use JSON;
+
+use LWP::UserAgent;
+
+use URI;
+
+use Travel::Status::MOTIS::Services;
+use Travel::Status::MOTIS::TripAtStopover;
+use Travel::Status::MOTIS::Trip;
+use Travel::Status::MOTIS::Stopover;
+use Travel::Status::MOTIS::Stop;
+
+our $VERSION = '0.01';
+
+# {{{ Endpoint Definition
+
+# Data sources: <https://github.com/public-transport/transport-apis>.
+# Thanks to Jannis R / @derhuerst and all contributors for maintaining these.
+my $motis_instance = Travel::Status::MOTIS::Services::get_service_ref();
+
+# {{{ Constructors
+
+sub new {
+ my ( $obj, %conf ) = @_;
+ my $service = $conf{service};
+
+ if ( not defined $service ) {
+ confess("You must specify a service");
+ }
+
+ if ( defined $service and not exists $motis_instance->{$service} ) {
+ confess("The service '$service' is not supported");
+ }
+
+ my $user_agent = $conf{user_agent};
+
+ if ( not $user_agent ) {
+ $user_agent = LWP::UserAgent->new(%{
+ $conf{lwp_options} // { timeout => 10 }
+ });
+ }
+
+ my $self = {
+ cache => $conf{cache},
+ developer_mode => $conf{developer_mode},
+ results => [],
+ station => $conf{station},
+ user_agent => $user_agent,
+ };
+
+ bless( $self, $obj );
+
+ my $request_url = URI->new;
+
+ if ( my $stop_id = $conf{stop_id} ) {
+ my $timestamp = $conf{timestamp} // DateTime->now;
+
+ my @modes_of_transit = ( qw(TRANSIT) );
+
+ if ( $conf{modes_of_transit} ) {
+ @modes_of_transit = @{ $conf{modes_of_transit} // [] };
+ }
+
+ $request_url->path('api/v1/stoptimes');
+ $request_url->query_form(
+ time => DateTime::Format::ISO8601->format_datetime( $timestamp ),
+ stopId => $stop_id,
+ n => $conf{results} // 10,
+ mode => join( ',', @modes_of_transit ),
+ );
+ }
+ elsif ( my $trip_id = $conf{trip_id} ) {
+ $request_url->path('api/v1/trip');
+ $request_url->query_form(
+ tripId => $trip_id,
+ );
+ }
+ elsif ( my $coordinates = $conf{stops_by_coordinate} ) {
+ my $lat = $coordinates->{lat};
+ my $lon = $coordinates->{lon};
+
+ $request_url->path('api/v1/reverse-geocode');
+ $request_url->query_form(
+ type => 'STOP',
+ place => "$lat,$lon,0",
+ );
+ }
+ elsif ( my $query = $conf{stops_by_query} ) {
+ $request_url->path('api/v1/geocode');
+ $request_url->query_form(
+ text => $query,
+ );
+ }
+ else {
+ confess('stop_id / trip_id / stops_by_coordinate / stops_by_query must be specified');
+ }
+
+ my $json = $self->{json} = JSON->new->utf8;
+
+ $request_url = $request_url->abs( $motis_instance->{$service}{endpoint} )->as_string;
+
+ if ( $conf{async} ) {
+ $self->{request_url} = $request_url;
+ return $self;
+ }
+
+ if ( $conf{json} ) {
+ $self->{raw_json} = $conf{json};
+ }
+ else {
+ if ( $self->{developer_mode} ) {
+ say "requesting $request_url";
+ }
+
+ my ( $content, $error ) = $self->get_with_cache($request_url);
+
+ if ($error) {
+ $self->{errstr} = $error;
+ return $self;
+ }
+
+ if ( $self->{developer_mode} ) {
+ say decode( 'utf-8', $content );
+ }
+
+ $self->{raw_json} = $json->decode($content);
+ }
+
+ if ( $conf{stop_id} ) {
+ $self->parse_trips_at_stopover;
+ }
+ elsif ( $conf{trip_id} ) {
+ $self->parse_trip;
+ }
+ elsif ( $conf{stops_by_query} or $conf{stops_by_coordinate} ) {
+ $self->parse_stops_by;
+ }
+
+ return $self;
+}
+
+sub new_p {
+ my ( $obj, %conf ) = @_;
+
+ my $promise = $conf{promise}->new;
+
+ if (not($conf{stop_id}
+ or $conf{trip_id}
+ or $conf{stops_by_coordinate}
+ or $conf{stops_by_query}
+ )) {
+ return $promise->reject(
+ 'stop_id / trip_id / stops_by_coordinate / stops_by_query flag must be passed'
+ );
+ }
+
+ my $self = $obj->new( %conf, async => 1 );
+
+ $self->{promise} = $conf{promise};
+
+ $self->get_with_cache_p( $self->{request_url} )->then(
+ sub {
+ my ($content) = @_;
+ $self->{raw_json} = $self->{json}->decode($content);
+
+ if ( $conf{stop_id} ) {
+ $self->parse_trips_at_stopover;
+ }
+ elsif ( $conf{trip_id} ) {
+ $self->parse_trip;
+ }
+ elsif ( $conf{stops_by_query} or $conf{stops_by_coordinate} ) {
+ $self->parse_stops_by;
+ }
+
+ if ( $self->errstr ) {
+ $promise->reject( $self->errstr, $self );
+ }
+ else {
+ $promise->resolve($self);
+ }
+
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+# }}}
+# {{{ Internal Helpers
+
+sub get_with_cache {
+ my ( $self, $url ) = @_;
+ my $cache = $self->{cache};
+
+ if ( $self->{developer_mode} ) {
+ say "GET $url";
+ }
+
+ if ($cache) {
+ my $content = $cache->thaw($url);
+ if ($content) {
+ if ( $self->{developer_mode} ) {
+ say ' cache hit';
+ }
+
+ return ( ${$content}, undef );
+ }
+ }
+
+ if ( $self->{developer_mode} ) {
+ say ' cache miss';
+ }
+
+ my $reply = $self->{user_agent}->get($url);
+
+ if ( $reply->is_error ) {
+ return ( undef, $reply->status_line );
+ }
+
+ my $content = $reply->content;
+
+ if ($cache) {
+ $cache->freeze( $url, \$content );
+ }
+
+ return ( $content, undef );
+}
+
+sub get_with_cache_p {
+ my ( $self, $url ) = @_;
+
+ my $cache = $self->{cache};
+
+ if ( $self->{developer_mode} ) {
+ say "GET $url";
+ }
+
+ my $promise = $self->{promise}->new;
+
+ if ($cache) {
+ my $content = $cache->thaw($url);
+ if ($content) {
+ if ( $self->{developer_mode} ) {
+ say ' cache hit';
+ }
+
+ return $promise->resolve( ${$content} );
+ }
+ }
+
+ if ( $self->{developer_mode} ) {
+ say ' cache miss';
+ }
+
+ $self->{user_agent}->get_p($url)->then(
+ sub {
+ my ($tx) = @_;
+ if ( my $err = $tx->error ) {
+ $promise->reject("GET $url returned HTTP $err->{code} $err->{message}");
+
+ return;
+ }
+
+ my $content = $tx->res->body;
+
+ if ($cache) {
+ $cache->freeze( $url, \$content );
+ }
+
+ $promise->resolve($content);
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub parse_trip {
+ my ( $self, %opt ) = @_;
+
+ $self->{result} = Travel::Status::MOTIS::Trip->new( json => $self->{raw_json} );
+}
+
+sub parse_stops_by {
+ my ($self) = @_;
+
+ @{ $self->{results} } = map {
+ $_->{type} eq 'STOP' ? Travel::Status::MOTIS::Stop->from_match( json => $_ ) : ()
+ } @{ $self->{raw_json} // [] };
+
+ return $self;
+}
+
+sub parse_trips_at_stopover {
+ my ($self) = @_;
+
+ @{ $self->{results} } = map {
+ Travel::Status::MOTIS::TripAtStopover->new( json => $_ )
+ } @{ $self->{raw_json}{stopTimes} // [] };
+
+ return $self;
+}
+
+# }}}
+# {{{ Public Functions
+
+sub errstr {
+ my ($self) = @_;
+
+ return $self->{errstr};
+}
+
+sub results {
+ my ($self) = @_;
+ return @{ $self->{results} };
+}
+
+sub result {
+ my ($self) = @_;
+ return $self->{result};
+}
+
+# static
+sub get_services {
+ my @services;
+ for my $service ( sort keys %{$motis_instance} ) {
+ my %desc = %{ $motis_instance->{$service} };
+ $desc{shortname} = $service;
+ push( @services, \%desc );
+ }
+ return @services;
+}
+
+# static
+sub get_service {
+ my ($service) = @_;
+
+ if ( defined $service and exists $motis_instance->{$service} ) {
+ return $motis_instance->{$service};
+ }
+ return;
+}
+
+# }}}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Travel::Status::MOTIS - An interface to the MOTIS routing service
+
+=head1 SYNOPSIS
+
+Blocking variant:
+
+ use Travel::Status::MOTIS;
+
+ my $status = Travel::Status::MOTIS->new(
+ service => 'RNV',
+ stop_id => 'rnv_241721',
+ );
+
+ for my $result ($status->results) {
+ printf(
+ "%s +%-3d %10s -> %s\n",
+ $result->stopover->departure->strftime('%H:%M'),
+ $result->stopover->delay,
+ $result->route_name,
+ $result->headsign,
+ );
+ }
+
+Non-blocking variant;
+
+ use Mojo::Promise;
+ use Mojo::UserAgent;
+ use Travel::Status::MOTIS;
+
+ Travel::Status::MOTIS->new_p(
+ service => 'RNV',
+ stop_id => 'rnv_241721',
+ promise => 'Mojo::Promise',
+ user_agent => Mojo::UserAgent->new
+ )->then(sub {
+ my ($status) = @_;
+ for my $result ($status->results) {
+ printf(
+ "%s +%-3d %10s -> %s\n",
+ $result->stopover->departure->strftime('%H:%M'),
+ $result->stopover->delay,
+ $result->route_name,
+ $result->headsign,
+ );
+ }
+ })->wait;
+
+=head1 VERSION
+
+version 0.01
+
+=head1 DESCRIPTION
+
+Travel::Status::MOTIS is an interface to the departures and trips
+provided by MOTIS routing services
+
+=head1 METHODS
+
+=over
+
+=item my $status = Travel::Status::MOTIS->new(I<%opt>)
+
+Requests item(s) as specified by I<opt> and returns a new
+Travel::Status::MOTIS element with the results. Dies if the wrong
+I<opt> were passed.
+
+I<opt> must contain exactly one of the following keys:
+
+=over
+
+=item B<stop_id> => I<$stop_id>
+
+Request stop board (departures) for the stop specified by I<$stop_id>.
+Use B<stops_by_coordinate> or B<stops_by_query> to obtain a stop id.
+Results are available via C<< $status->results >>.
+
+=item B<stops_by_coordinate> => B<{> B<lat> => I<latitude>, B<lon> => I<longitude> B<}>
+
+Search for stops near I<latitude>, I<longitude>.
+Results are available via C<< $status->results >>.
+
+=item B<stops_by_query> => I<$query>
+
+Search for stops whose name is equal or similar to I<query>. Results are
+available via C<< $status->results >> and include the stop id needed for
+stop board requests.
+
+=item B<trip_id> => I<$trip_id>
+
+Request trip details for I<$trip_id>.
+The result is available via C<< $status->result >>.
+
+=back
+
+The following optional keys may be set.
+Values in brackets indicate keys that are only relevant in certain request
+modes, e.g. stops_by_coordinate or stop_id.
+
+=over
+
+=item B<cache> => I<$obj>
+
+A Cache::File(3pm) object used to cache realtime data requests. It should be
+configured for an expiry of one to two minutes.
+
+=item B<lwp_options> => I<\%hashref>
+
+Passed on to C<< LWP::UserAgent->new >>. Defaults to C<< { timeout => 10 } >>,
+you can use an empty hashref to unset the default.
+
+=item B<modes_of_transit> => I<\@arrayref> (stop_id)
+
+Only consider the modes of transit given in I<arrayref> when listing
+departures. Accepted modes of transit are:
+TRANSIT (same as RAIL, SUBWAY, TRAM, BUS, FERRY, AIRPLANE, COACH),
+TRAM,
+SUBWAY,
+FERRY,
+AIRPLANE,
+BUS,
+COACH,
+RAIL (same as HIGHSPEED_RAIL, LONG_DISTANCE_RAIL, NIGHT_RAIL, REGIONAL_RAIL, REGIONAL_FAST_RAIL),
+METRO,
+HIGHSPEED_RAIL,
+LONG_DISTANCE,
+NIGHT_RAIL,
+REGIONAL_FAST_RAIL,
+REGIONAL_RAIL.
+
+By default, Travel::Status::MOTIS uses TRANSIT.
+
+=item B<json> => I<\%json>
+
+Do not perform a request to MOTIS; load the prepared response provided in
+I<json> instead. Note that you still need to specify B<stop_id>, B<trip_id>,
+etc. as appropriate.
+
+=back
+
+=item my $promise = Travel::Status::MOTIS->new_p(I<%opt>)
+
+Return a promise yielding a Travel::Status::MOTIS instance (C<< $status >>)
+on success, or an error message (same as C<< $status->errstr >>) on failure.
+
+In addition to the arguments of B<new>, the following mandatory arguments must
+be set:
+
+=over
+
+=item B<promise> => I<promises module>
+
+Promises implementation to use for internal promises as well as B<new_p> return
+value. Recommended: Mojo::Promise(3pm).
+
+=item B<user_agent> => I<user agent>
+
+User agent instance to use for asynchronous requests. The object must support
+promises (i.e., it must implement a C<< get_p >> function). Recommended:
+Mojo::UserAgent(3pm).
+
+=back
+
+=item $status->errstr
+
+In case of a fatal HTTP request or backend error, returns a string describing
+it. Returns undef otherwise.
+
+=item $status->results (stop_id, stops_by_query, stops_by_coordinate)
+
+Returns a list of Travel::Status::MOTIS::Stop(3pm) or Travel::Status::MOTIS::TripAtStopover(3pm) objects, depending on the arguments passed to B<new>.
+
+=item $status->result (trip_id)
+
+Returns a Travel::Status::MOTIS::Trip(3pm) object, depending on the arguments passed to B<new>.
+
+=back
+
+=head1 DIAGNOSTICS
+
+Calling B<new> or B<new_p> with the B<developer_mode> key set to a true value
+causes this module to print MOTIS requests and responses on the standard
+output.
+
+=head1 DEPENDENCIES
+
+=over
+
+=item * DateTime(3pm)
+
+=item * DateTime::Format::ISO8601(3pm)
+
+=item * LWP::UserAgent(3pm)
+
+=item * URI(3pm)
+
+=back
+
+=head1 BUGS AND LIMITATIONS
+
+This module is designed for use in travelynx (L<https://finalrewind.org/projects/travelynx/>) and
+might not contain functionality needed otherwise.
+
+=head1 REPOSITORY
+
+L<TBD>
+
+=head1 AUTHOR
+
+Copyright (C) 2025 networkException E<lt>git@nwex.deE<gt>
+
+Based on Travel::Status::DE::DBRIS
+
+Copyright (C) 2024-2025 Birte Kristina Friesel E<lt>derf@finalrewind.orgE<gt>
+
+=head1 LICENSE
+
+This module is licensed under the same terms as Perl itself.
diff --git a/lib/Travel/Status/MOTIS/Polyline.pm b/lib/Travel/Status/MOTIS/Polyline.pm
new file mode 100644
index 0000000..ea46526
--- /dev/null
+++ b/lib/Travel/Status/MOTIS/Polyline.pm
@@ -0,0 +1,98 @@
+package Travel::Status::MOTIS::Polyline;
+
+use strict;
+use warnings;
+use 5.014;
+
+# Adapted from code by Slaven Rezic
+#
+# Copyright (C) 2009,2010,2012,2017,2018 Slaven Rezic. All rights reserved.
+# This package is free software; you can redistribute it and/or
+# modify it under the same terms as Perl itself.
+#
+# Mail: slaven@rezic.de
+# WWW: http://www.rezic.de/eserte/
+
+use parent 'Exporter';
+
+our @EXPORT_OK = qw(decode_polyline);
+
+our $VERSION = '0.01';
+
+# Translated this php script
+# <http://unitstep.net/blog/2008/08/02/decoding-google-maps-encoded-polylines-using-php/>
+# to perl
+sub decode_polyline {
+ my ($encoded) = @_;
+
+ my $length = length $encoded;
+ my $index = 0;
+ my @points;
+ my $lat = 0;
+ my $lon = 0;
+
+ while ( $index < $length ) {
+
+ # The encoded polyline consists of a latitude value followed
+ # by a longitude value. They should always come in pairs. Read
+ # the latitude value first.
+ for my $val ( \$lat, \$lon ) {
+ my $shift = 0;
+ my $result = 0;
+
+ # Temporary variable to hold each ASCII byte.
+ my $b;
+ do {
+ # The `ord(substr($encoded, $index++))` statement returns
+ # the ASCII code for the character at $index. Subtract 63
+ # to get the original value. (63 was added to ensure
+ # proper ASCII characters are displayed in the encoded
+ # polyline string, which is `human` readable)
+ $b = ord( substr( $encoded, $index++, 1 ) ) - 63;
+
+ # AND the bits of the byte with 0x1f to get the original
+ # 5-bit `chunk. Then left shift the bits by the required
+ # amount, which increases by 5 bits each time. OR the
+ # value into $results, which sums up the individual 5-bit
+ # chunks into the original value. Since the 5-bit chunks
+ # were reversed in order during encoding, reading them in
+ # this way ensures proper summation.
+ $result |= ( $b & 0x1f ) << $shift;
+ $shift += 5;
+ }
+
+ # Continue while the read byte is >= 0x20 since the last
+ # `chunk` was not OR'd with 0x20 during the conversion
+ # process. (Signals the end)
+ while ( $b >= 0x20 );
+
+ # see last paragraph of "Integer Arithmetic" in perlop.pod
+ use integer;
+
+ # Check if negative, and convert. (All negative values have the last bit
+ # set)
+ my $dtmp
+ = ( ( $result & 1 ) ? ~( $result >> 1 ) : ( $result >> 1 ) );
+
+ # Compute actual latitude (resp. longitude) since value is
+ # offset from previous value.
+ $$val += $dtmp;
+ }
+
+ # The actual latitude and longitude values were multiplied by
+ # 1e5 before encoding so that they could be converted to a 32-bit
+ # integer representation. (With a decimal accuracy of 7 places)
+ # Convert back to original values.
+ push(
+ @points,
+ {
+ lat => $lat * 1e-7,
+ lon => $lon * 1e-7
+ }
+ );
+ }
+
+ return @points;
+}
+
+1;
diff --git a/lib/Travel/Status/MOTIS/Services.pm b/lib/Travel/Status/MOTIS/Services.pm
new file mode 100644
index 0000000..71ec12b
--- /dev/null
+++ b/lib/Travel/Status/MOTIS/Services.pm
@@ -0,0 +1,28 @@
+package Travel::Status::MOTIS::Services;
+
+# vim:readonly
+# This module has been automatically generated
+# by lib/Travel/Status/MOTIS/Services.pm.PL.
+# Do not edit, changes will be lost.
+
+use strict;
+use warnings;
+use 5.014;
+use utf8;
+
+our $VERSION = '0.01';
+
+# Source <https://github.com/public-transport/transport-apis>.
+# Many thanks to Jannis R / @derhuerst and all contributors for maintaining
+# these resources.
+
+my $motis_instance = {'RNV' => {'coverage' => {'area' => {'coordinates' => [[['8.66147483636217','49.5668219936431'],['8.64642540920443','49.5568382715344'],['8.61042656597024','49.5503084660978'],['8.5819681642929','49.5577725222357'],['8.55635172724939','49.5487563121438'],['8.54881233439468','49.5248230928757'],['8.53276821729179','49.536837593048'],['8.49568358055447','49.5727690805899'],['8.47596243926148','49.5848639899874'],['8.44614801871398','49.5910126926984'],['8.42198404713383','49.5842342192271'],['8.41736989283368','49.5487563121438'],['8.40346035903804','49.5452129786743'],['8.37114730950125','49.555406853935'],['8.33286120248991','49.5529790328013'],['8.33137693421639','49.5375718561748'],['8.33494334548936','49.5221286699925'],['8.33690677795104','49.4988964466165'],['8.30678042064534','49.4885500590892'],['8.26784895688698','49.4838521585405'],['8.25423585654025','49.4735369859369'],['8.20632998266032','49.4760094013827'],['8.14462323584891','49.4734833746128'],['8.13045619910952','49.4443930041389'],['8.18420778670873','49.442720268909'],['8.27254930011','49.4496939870607'],['8.32155433680973','49.4591951125256'],['8.34370942136059','49.4513729891719'],['8.3668669374886','49.4323454360553'],['8.40277547897756','49.4209418648904'],['8.43005534345036','49.4239778970603'],['8.45940926837977','49.4431216465173'],['8.4917354449758','49.44388447979'],['8.51038774276287','49.4335868229186'],['8.49878755048184','49.417042238655'],['8.49553985226339','49.3996923273633'],['8.52037431311788','49.4046918305137'],['8.53117721917823','49.3953565625151'],['8.52699948591896','49.3835271603866'],['8.56649226595519','49.388735051347'],['8.60276909446185','49.3757445454189'],['8.60885726020382','49.3468487087811'],['8.64130741433121','49.3401811679553'],['8.65302897048286','49.3230983004625'],['8.70909291220218','49.312292313098'],['8.71188049820543','49.3403826797658'],['8.75572569226915','49.3430562267034'],['8.82165874069668','49.3630985437148'],['8.83347847335665','49.3927451052107'],['8.82975787417305','49.4998502106185'],['8.80422336940282','49.5074733892921'],['8.74840593219253','49.5017997361833'],['8.73188936131828','49.4590738976289'],['8.70928817502508','49.4483252857173'],['8.69685148074061','49.4688987841802'],['8.70045176672548','49.5065198846691'],['8.71373125714393','49.5462937942235'],['8.69365314006183','49.5626169448028'],['8.66147483636217','49.5668219936431']]],'type' => 'Polygon'},'regions' => ['DE-HE','DE-BW','DE-RP']},'endpoint' => 'https://directions.nwex.de/api/providers/rhein-neckar-verkehr/','homepage' => 'https://www.opendata-oepnv.de/ht/de/organisation/verkehrsunternehmen/rnv/openrnv/datensaetze','languages' => ['de'],'license' => 'https://www.govdata.de/dl-de/by-2-0','name' => 'Rhein-Neckar-Verkehr'},'transitous' => {'coverage' => {'area' => {'coordinates' => [[[['5.85','49.48'],['5.88','49.49'],['5.95','49.6'],['5.97','49.6'],['5.99','49.69'],['5.83','49.82'],['5.84','49.93'],['5.99','50.09'],['6.13','50.06'],['6.19','50.05'],['6.23','50.18'],['6.47','50.28'],['6.48','50.34'],['6.43','50.48'],['6.47','50.53'],['6.31','50.57'],['6.36','50.66'],['6.3','50.7'],['6.12','50.79'],['6.17','50.91'],['6.16','50.98'],['6.01','51.02'],['6.21','51.09'],['6.32','51.17'],['6.19','51.23'],['6.27','51.3'],['6.3','51.32'],['6.3','51.5'],['6.09','51.76'],['6.16','51.77'],['6.26','51.74'],['6.25','51.8'],['6.37','51.76'],['6.4','51.74'],['6.48','51.8'],['6.48','51.81'],['6.75','51.83'],['6.77','51.83'],['6.9','51.95'],['6.91','52.02'],['6.85','52.06'],['7.11','52.18'],['7.15','52.23'],['7.06','52.51'],['7.02','52.54'],['6.84','52.54'],['6.84','52.57'],['7.07','52.56'],['7.12','52.6'],['7.28','53.12'],['7.32','53.13'],['7.26','53.34'],['7.24','53.41'],['7.03','53.4'],['6.8','53.58'],['6.4','53.78'],['6.37','53.82'],['5.04','53.65'],['5.02','53.65'],['4.55','53.42'],['4.53','53.41'],['4.28','53.08'],['4.27','53.06'],['4.09','52.41'],['3.25','51.87'],['3.24','51.87'],['3.03','51.63'],['3.01','51.6'],['2.45','51.36'],['2.27','51.31'],['2.55','50.93'],['2.53','50.87'],['2.52','50.82'],['2.69','50.71'],['2.67','50.69'],['2.9','50.62'],['2.92','50.62'],['3.12','50.7'],['3.23','50.49'],['3.24','50.46'],['3.56','50.43'],['3.66','50.25'],['3.7','50.23'],['4.02','50.28'],['4.11','50.24'],['4.07','50.18'],['4.01','50.12'],['4.09','50.08'],['4.02','50.03'],['4.15','49.91'],['4.17','49.88'],['4.69','49.92'],['4.72','49.92'],['4.77','49.98'],['4.78','49.76'],['4.82','49.73'],['5.23','49.63'],['5.43','49.44'],['5.46','49.42'],['5.85','49.48']]],[[['9.05','45.76'],['9.19','45.91'],['9.14','45.95'],['9.09','46.02'],['9.33','46.22'],['9.32','46.25'],['9.34','46.43'],['9.42','46.44'],['9.51','46.22'],['9.57','46.24'],['9.92','46.31'],['10','46.17'],['10.04','46.16'],['10.19','46.16'],['10.2','46.21'],['10.24','46.43'],['10.2','46.47'],['10.11','46.49'],['10.11','46.5'],['10.22','46.55'],['10.26','46.49'],['10.29','46.48'],['10.5','46.48'],['10.56','46.53'],['10.46','46.69'],['10.57','46.96'],['10.52','46.99'],['10.39','47.07'],['10.35','47.06'],['10.1','46.91'],['9.94','46.98'],['9.94','47.08'],['9.88','47.09'],['9.56','47.11'],['9.59','47.23'],['9.74','47.35'],['9.74','47.42'],['9.54','47.6'],['9.27','47.72'],['9.25','47.72'],['8.92','47.71'],['8.83','47.8'],['8.75','47.8'],['8.75','47.78'],['8.57','47.89'],['8.53','47.86'],['8.32','47.69'],['8.34','47.66'],['8.21','47.69'],['8.2','47.69'],['7.76','47.61'],['7.77','47.65'],['7.7','47.67'],['7.55','47.65'],['7.54','47.63'],['7.38','47.5'],['7.28','47.49'],['7.24','47.55'],['7.21','47.56'],['6.94','47.56'],['6.93','47.53'],['6.8','47.36'],['6.83','47.29'],['6.87','47.29'],['6.9','47.28'],['6.68','47.11'],['6.34','46.96'],['6.37','46.91'],['6.38','46.81'],['5.99','46.58'],['6.04','46.55'],['5.98','46.38'],['6.04','46.36'],['6.06','46.35'],['5.85','46.1'],['5.95','46.06'],['6.33','46.17'],['6.39','46.22'],['6.31','46.31'],['6.36','46.34'],['6.43','46.35'],['6.68','46.39'],['6.75','46.37'],['6.73','46.11'],['6.76','46.07'],['6.81','46.07'],['6.79','46.02'],['6.87','45.98'],['6.91','45.99'],['7.07','45.79'],['7.12','45.79'],['7.57','45.92'],['7.87','45.85'],['7.9','45.86'],['8.22','46.12'],['8.22','46.17'],['8.18','46.24'],['8.38','46.35'],['8.38','46.19'],['8.42','46.19'],['8.59','46.06'],['8.6','46.06'],['8.74','46.03'],['8.66','45.95'],['8.77','45.93'],['8.84','45.91'],['8.86','45.75'],['8.92','45.76'],['9.02','45.75'],['9.05','45.76']]],[[['-62.79','17.53'],['-62.72','17.63'],['-63.04','17.83'],['-62.93','17.96'],['-62.88','18.03'],['-62.98','18.09'],['-62.99','18.1'],['-63.14','18.1'],['-63.17','18.09'],['-63.34','17.89'],['-63.3','17.86'],['-63.43','17.82'],['-63.45','17.8'],['-63.51','17.56'],['-63.5','17.53'],['-63.08','17.24'],['-63.04','17.22'],['-62.79','17.53']]],[[['-69.62','12.37'],['-69.62','12.39'],['-69.64','12.55'],['-69.65','12.56'],['-69.88','12.81'],['-70.02','12.87'],['-70.06','12.87'],['-70.2','12.82'],['-70.22','12.81'],['-70.31','12.49'],['-70.31','12.47'],['-70.21','12.33'],['-70.19','12.31'],['-69.79','12.23'],['-69.71','12.21'],['-69.62','12.37']]],[[['-68.53','11.76'],['-68.51','11.77'],['-68.43','11.87'],['-68.34','11.79'],['-68.32','11.79'],['-68.15','11.8'],['-68.13','11.81'],['-67.95','12.06'],['-67.95','12.08'],['-67.99','12.36'],['-68.01','12.38'],['-68.32','12.55'],['-68.34','12.55'],['-68.58','12.48'],['-68.6','12.46'],['-68.65','12.3'],['-69.06','12.62'],['-69.08','12.63'],['-69.26','12.61'],['-69.28','12.6'],['-69.4','12.46'],['-69.41','12.44'],['-69.4','12.22'],['-69.39','12.2'],['-69.26','12.03'],['-69.25','12.02'],['-68.72','11.74'],['-68.7','11.74'],['-68.53','11.76']]]],'type' => 'MultiPolygon'},'regions' => ['BE','CH','NL']},'endpoint' => 'https://api.transitous.org/','homepage' => 'https://transitous.org/','languages' => [],'license' => undef,'name' => 'Transitous open public transport routing'}};
+sub get_service_ref {
+ return $motis_instance;
+}
+
+sub get_service_map {
+ return %{$motis_instance};
+}
+
+1;
diff --git a/lib/Travel/Status/MOTIS/Services.pm.PL b/lib/Travel/Status/MOTIS/Services.pm.PL
new file mode 100644
index 0000000..dc86963
--- /dev/null
+++ b/lib/Travel/Status/MOTIS/Services.pm.PL
@@ -0,0 +1,135 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+use 5.014;
+use utf8;
+use Data::Dumper;
+use Encode qw(encode);
+use File::Slurp qw(read_file write_file);
+use JSON;
+
+my $json = JSON->new->utf8;
+
+sub load_instance {
+ my ( $path, %opt ) = @_;
+
+ my $data = $json->decode( scalar read_file("ext/transport-apis/data/${path}.json") );
+ my %ret = (
+ name => $data->{name} =~ s{ *[(][^)]+[)]}{}r,
+ license => $data->{attribution}{license},
+ homepage => $data->{attribution}{homepage},
+ languages => $data->{supportedLanguages},
+ endpoint => $data->{options}{endpoint},
+ coverage => {
+ area => $data->{coverage}{realtimeCoverage}{area},
+ regions => $data->{coverage}{realtimeCoverage}{region} // []
+ },
+ );
+
+ my %bitmask_to_product;
+ for my $product ( @{ $data->{options}{products} // [] } ) {
+ for my $bitmask ( @{ $product->{bitmasks} // [] } ) {
+ $bitmask_to_product{$bitmask} = $product;
+ }
+ }
+
+ my $skipped = 0;
+ for my $bit ( 0 .. 15 ) {
+ if ( my $p = $bitmask_to_product{ 2**$bit } ) {
+ for ( 1 .. $skipped ) {
+ push( @{ $ret{productbits} }, [ "_", undef ] );
+ }
+ if ( $p->{name} ) {
+ push( @{ $ret{productbits} }, [ $p->{id}, $p->{name} ] );
+ }
+ else {
+ push( @{ $ret{productbits} }, $p->{id} );
+ }
+ }
+ else {
+ $skipped += 1;
+ }
+ }
+
+ if ( $data->{options}{ext} ) {
+ $ret{request}{ext} = $data->{options}{ext};
+ }
+ if ( $data->{options}{ver} ) {
+ $ret{request}{ver} = $data->{options}{ver};
+ }
+ elsif ( $data->{options}{version} ) {
+ $ret{request}{ver} = $data->{options}{version};
+ }
+
+ if ( $opt{geoip_lock} ) {
+ $ret{geoip_lock} = $opt{geoip_lock};
+ }
+
+ if ( $opt{lang} ) {
+ $ret{request}{lang} = $opt{lang};
+ }
+ if ( $opt{ua_string} ) {
+ $ret{ua_string} = $opt{ua_string};
+ }
+ if ( $opt{ver} ) {
+ $ret{request}{ver} = $opt{ver};
+ }
+
+ return %ret;
+}
+
+my %motis_instance = (
+ RNV => {
+ load_instance('de/rnv-motis')
+ },
+ transitous => {
+ load_instance('un/transitous')
+ },
+);
+
+my $perlobj = Data::Dumper->new( [ \%motis_instance ], ['motis_instance'] );
+
+my $buf = <<'__EOF__';
+package Travel::Status::MOTIS::Services;
+
+# vim:readonly
+# This module has been automatically generated
+# by lib/Travel/Status/MOTIS/Services.pm.PL.
+# Do not edit, changes will be lost.
+
+use strict;
+use warnings;
+use 5.014;
+use utf8;
+
+our $VERSION = '0.01';
+
+# Source <https://github.com/public-transport/transport-apis>.
+# Many thanks to Jannis R / @derhuerst and all contributors for maintaining
+# these resources.
+
+__EOF__
+
+$buf .= 'my ' . $perlobj->Sortkeys(1)->Indent(0)->Dump;
+
+$buf =~ s{\Q\x{d6}\E}{Ö}g;
+$buf =~ s{\Q\x{c9}\E}{É}g;
+$buf =~ s{\Q\x{f3}\E}{ó}g;
+$buf =~ s{\Q\x{f6}\E}{ö}g;
+$buf =~ s{\Q\x{fc}\E}{ü}g;
+
+$buf .= <<'__EOF__';
+
+sub get_service_ref {
+ return $motis_instance;
+}
+
+sub get_service_map {
+ return %{$motis_instance};
+}
+
+1;
+__EOF__
+
+write_file( $ARGV[0], { binmode => ':utf8' }, $buf );
diff --git a/lib/Travel/Status/MOTIS/Stop.pm b/lib/Travel/Status/MOTIS/Stop.pm
new file mode 100644
index 0000000..85348bf
--- /dev/null
+++ b/lib/Travel/Status/MOTIS/Stop.pm
@@ -0,0 +1,59 @@
+package Travel::Status::MOTIS::Stop;
+
+use strict;
+use warnings;
+use 5.020;
+
+use parent 'Class::Accessor';
+
+our $VERSION = '0.01';
+
+Travel::Status::MOTIS::Stop->mk_ro_accessors(qw(
+ id
+ name
+ type
+ lat
+ lon
+));
+
+sub from_match {
+ my ( $obj, %opt ) = @_;
+
+ my $json = $opt{json};
+
+ my $ref = {
+ id => $json->{id},
+ name => $json->{name},
+ lat => $json->{lat},
+ lon => $json->{lon},
+ };
+
+ bless( $ref, $obj );
+
+ return $ref;
+}
+
+sub from_stopover {
+ my ( $obj, %opt ) = @_;
+
+ my $json = $opt{json};
+
+ my $ref = {
+ id => $json->{stopId},
+ name => $json->{name},
+ lat => $json->{lat},
+ lon => $json->{lon},
+ };
+
+ bless( $ref, $obj );
+
+ return $ref;
+}
+
+sub TO_JSON {
+ my ($self) = @_;
+
+ return { %{$self} };
+}
+
+1;
diff --git a/lib/Travel/Status/MOTIS/Stopover.pm b/lib/Travel/Status/MOTIS/Stopover.pm
new file mode 100644
index 0000000..e0b03df
--- /dev/null
+++ b/lib/Travel/Status/MOTIS/Stopover.pm
@@ -0,0 +1,123 @@
+package Travel::Status::MOTIS::Stopover;
+
+use strict;
+use warnings;
+use 5.020;
+
+use parent 'Class::Accessor';
+
+use DateTime::Format::ISO8601;
+
+our $VERSION = '0.01';
+
+Travel::Status::MOTIS::Stopover->mk_ro_accessors(qw(
+ stop
+
+ is_cancelled
+ is_realtime
+
+ arrival
+ scheduled_arrival
+ realtime_arrival
+
+ departure
+ scheduled_departure
+ realtime_departure
+
+ delay
+ arrival_delay
+ departure_delay
+
+ track
+ scheduled_track
+ realtime_track
+));
+
+sub new {
+ my ( $obj, %opt ) = @_;
+
+ my $json = $opt{json};
+ my $realtime = $opt{realtime} // 0;
+ my $cancelled = $opt{cancelled};
+
+ my $ref = {
+ stop => Travel::Status::MOTIS::Stop->from_stopover( json => $json ),
+
+ is_realtime => $realtime,
+ is_cancelled => $json->{cancelled} // $cancelled,
+ };
+
+ if ( $json->{scheduledArrival} ) {
+ $ref->{scheduled_arrival} = DateTime::Format::ISO8601->parse_datetime( $json->{scheduledArrival} );
+ $ref->{scheduled_arrival}->set_time_zone('local');
+ }
+
+ if ( $json->{arrival} and $realtime ) {
+ $ref->{realtime_arrival} = DateTime::Format::ISO8601->parse_datetime( $json->{arrival} );
+ $ref->{realtime_arrival}->set_time_zone('local');
+ }
+
+ if ( $json->{scheduledDeparture} ) {
+ $ref->{scheduled_departure} = DateTime::Format::ISO8601->parse_datetime( $json->{scheduledDeparture} );
+ $ref->{scheduled_departure}->set_time_zone('local');
+ }
+
+ if ( $json->{departure} and $realtime ) {
+ $ref->{realtime_departure} = DateTime::Format::ISO8601->parse_datetime( $json->{departure} );
+ $ref->{realtime_departure}->set_time_zone('local');
+ }
+
+ if ( $ref->{scheduled_arrival} and $ref->{realtime_arrival} ) {
+ $ref->{arrival_delay} = $ref->{realtime_arrival}
+ ->subtract_datetime( $ref->{scheduled_arrival} )
+ ->in_units('minutes');
+ }
+
+ if ( $ref->{scheduled_departure} and $ref->{realtime_departure} ) {
+ $ref->{departure_delay} = $ref->{realtime_departure}
+ ->subtract_datetime( $ref->{scheduled_departure} )
+ ->in_units('minutes');
+ }
+
+ if ( $json->{scheduledTrack} ) {
+ $ref->{scheduled_track} = $json->{scheduledTrack};
+ }
+
+ if ( $json->{track} ) {
+ $ref->{realtime_track} = $json->{track};
+ }
+
+ $ref->{delay} = $ref->{arrival_delay} // $ref->{departure_delay};
+
+ $ref->{arrival} = $ref->{realtime_arrival} // $ref->{scheduled_arrival};
+ $ref->{departure} = $ref->{realtime_departure} // $ref->{scheduled_departure};
+ $ref->{track} = $ref->{realtime_track} // $ref->{scheduled_track};
+
+ bless( $ref, $obj );
+
+ return $ref;
+}
+
+sub TO_JSON {
+ my ($self) = @_;
+
+ my $ret = { %{$self} };
+
+ for my $timestamp_key (qw(
+ arrival
+ scheduled_arrival
+ realtime_arrival
+
+ departure
+ scheduled_departure
+ realtime_departure
+ )) {
+ if ( $ret->{$timestamp_key} ) {
+ $ret->{$timestamp_key} = $ret->{$timestamp_key}->epoch;
+ }
+ }
+
+ return $ret;
+}
+
+1;
diff --git a/lib/Travel/Status/MOTIS/Trip.pm b/lib/Travel/Status/MOTIS/Trip.pm
new file mode 100644
index 0000000..c879bee
--- /dev/null
+++ b/lib/Travel/Status/MOTIS/Trip.pm
@@ -0,0 +1,186 @@
+package Travel::Status::MOTIS::Trip;
+
+use strict;
+use warnings;
+use 5.020;
+
+use parent 'Class::Accessor';
+
+use DateTime::Format::ISO8601;
+
+use Travel::Status::MOTIS::Stop;
+use Travel::Status::MOTIS::Polyline qw(decode_polyline);
+
+our $VERSION = '0.01';
+
+Travel::Status::MOTIS::Trip->mk_ro_accessors(qw(
+ id
+ mode
+ agency
+ route_name
+ route_color
+ headsign
+
+ is_realtime
+ is_cancelled
+
+ arrival
+ scheduled_arrival
+ realtime_arrival
+
+ departure
+ scheduled_departure
+ realtime_departure
+));
+
+sub new {
+ my ( $obj, %opt ) = @_;
+
+ my $json = $opt{json}{legs}[0];
+
+ my $ref = {
+ id => $json->{tripId},
+ mode => $json->{mode},
+ agency => $json->{agencyName},
+ route_name => $json->{routeShortName},
+ route_color => $json->{routeColor},
+ headsign => $json->{headsign},
+
+ is_cancelled => $json->{cancelled},
+ is_realtime => $json->{realTime},
+
+ raw_stopovers => [ $json->{from}, @{ $json->{intermediateStops} }, $json->{to} ],
+ raw_polyline => $json->{legGeometry}->{points},
+ };
+
+ $ref->{scheduled_departure} = DateTime::Format::ISO8601->parse_datetime( $json->{scheduledStartTime} );
+ $ref->{scheduled_departure}->set_time_zone('local');
+
+ if ( $json->{realTime} ) {
+ $ref->{realtime_departure} = DateTime::Format::ISO8601->parse_datetime( $json->{startTime} );
+ $ref->{realtime_departure}->set_time_zone('local');
+ }
+
+ $ref->{departure} = $ref->{realtime_departure} // $ref->{scheduled_departure};
+
+ $ref->{scheduled_arrival} = DateTime::Format::ISO8601->parse_datetime( $json->{scheduledEndTime} );
+ $ref->{scheduled_arrival}->set_time_zone('local');
+
+ if ( $json->{realTime} ) {
+ $ref->{realtime_arrival} = DateTime::Format::ISO8601->parse_datetime( $json->{endTime} );
+ $ref->{realtime_arrival}->set_time_zone('local');
+ }
+
+ $ref->{arrival} = $ref->{realtime_arrival} // $ref->{scheduled_arrival};
+
+ bless( $ref, $obj );
+
+ return $ref;
+}
+
+sub polyline {
+ my ($self) = @_;
+
+ if ( not $self->{raw_polyline} ) {
+ return;
+ }
+
+ if ( $self->{polyline} ) {
+ return @{ $self->{polyline} };
+ }
+
+ my $polyline = [ decode_polyline( $self->{raw_polyline} ) ];
+
+ my $gis_distance;
+
+ eval {
+ require GIS::Distance;
+ $gis_distance = GIS::Distance->new;
+ };
+
+ if ($gis_distance) {
+ my %minimum_distances;
+
+ for my $stopover ( $self->stopovers ) {
+ my $stop = $stopover->stop;
+
+ for my $polyline_index ( 0 .. $#{$polyline} ) {
+ my $coordinate = $polyline->[$polyline_index];
+ my $distance = $gis_distance->distance_metal(
+ $stop->{lat},
+ $stop->{lon},
+ $coordinate->{lat},
+ $coordinate->{lon},
+ );
+
+ if ( not $minimum_distances{ $stop->id }
+ or $minimum_distances{ $stop->id }{distance} > $distance
+ ) {
+ $minimum_distances{ $stop->id } = {
+ distance => $distance,
+ index => $polyline_index,
+ };
+ }
+ }
+ }
+
+ for my $stopover ( $self->stopovers ) {
+ my $stop = $stopover->stop;
+
+ if ( $minimum_distances{ $stop->id } ) {
+ $polyline->[ $minimum_distances{ $stop->id }{index} ]{stop} = $stop;
+ }
+ }
+ }
+
+ $self->{polyline} = $polyline;
+
+ return @{ $self->{polyline} };
+}
+
+sub stopovers {
+ my ($self) = @_;
+
+ if ( $self->{stopovers} ) {
+ return @{ $self->{stopovers} };
+ }
+
+ @{ $self->{stopovers} } = map {
+ Travel::Status::MOTIS::Stopover->new(
+ json => $_,
+ realtime => $self->{is_realtime}
+ )
+ } ( @{ $self->{raw_stopovers} // [] } );
+
+ return @{ $self->{stopovers} };
+}
+
+sub TO_JSON {
+ my ($self) = @_;
+
+ # transform raw_route into route (lazy accessor)
+ $self->route;
+
+ # transform raw_polyline into polyline (lazy accessor)
+ $self->polyline;
+
+ my $ret = { %{$self} };
+
+ for my $timestamp_key (qw(
+ arrival
+ scheduled_arrival
+ realtime_arrival
+
+ departure
+ scheduled_departure
+ realtime_departure
+ )) {
+ if ( $ret->{$timestamp_key} ) {
+ $ret->{$timestamp_key} = $ret->{$timestamp_key}->epoch;
+ }
+ }
+
+ return $ret;
+}
+
+1;
diff --git a/lib/Travel/Status/MOTIS/TripAtStopover.pm b/lib/Travel/Status/MOTIS/TripAtStopover.pm
new file mode 100644
index 0000000..3d8d390
--- /dev/null
+++ b/lib/Travel/Status/MOTIS/TripAtStopover.pm
@@ -0,0 +1,78 @@
+package Travel::Status::MOTIS::TripAtStopover;
+
+use strict;
+use warnings;
+use 5.020;
+
+use DateTime::Format::ISO8601;
+
+use parent 'Class::Accessor';
+
+our $VERSION = '0.01';
+
+Travel::Status::MOTIS::TripAtStopover->mk_ro_accessors(qw(
+ id
+ mode
+ agency
+ route_name
+ route_color
+ headsign
+
+ is_cancelled
+ is_realtime
+
+ stopover
+));
+
+sub new {
+ my ( $obj, %opt ) = @_;
+
+ my $json = $opt{json};
+
+ my $ref = {
+ id => $json->{tripId},
+ mode => $json->{mode},
+ agency => $json->{agencyName},
+ route_name => $json->{routeShortName},
+ route_color => $json->{routeColor},
+ headsign => $json->{headsign},
+
+ is_cancelled => $json->{cancelled},
+ is_realtime => $json->{realTime},
+
+ stopover => Travel::Status::MOTIS::Stopover->new(
+ json => $json->{place},
+
+ # NOTE: $json->{place}->{cancelled} isn't set, we just override this here.
+ cancelled => $json->{cancelled},
+ realtime => $json->{realTime},
+ ),
+ };
+
+ bless( $ref, $obj );
+
+ return $ref;
+}
+
+sub TO_JSON {
+ my ($self) = @_;
+
+ my $ret = { %{$self} };
+
+ for my $timestamp_key (qw(
+ scheduled_departure
+ realtime_departure
+ departure
+ scheduled_arrival
+ realtime_arrival
+ arrival
+ )) {
+ if ( $ret->{$timestamp_key} ) {
+ $ret->{$timestamp_key} = $ret->{$timestamp_key}->epoch;
+ }
+ }
+
+ return $ret;
+}
+
+1;