summaryrefslogtreecommitdiff
path: root/lib/Travel/Status
diff options
context:
space:
mode:
Diffstat (limited to 'lib/Travel/Status')
-rw-r--r--lib/Travel/Status/DE/EFA.pm945
-rw-r--r--lib/Travel/Status/DE/EFA/Departure.pm528
-rw-r--r--lib/Travel/Status/DE/EFA/Info.pm127
-rw-r--r--lib/Travel/Status/DE/EFA/Line.pm (renamed from lib/Travel/Status/DE/VRR/Line.pm)63
-rw-r--r--lib/Travel/Status/DE/EFA/Services.pm.PL147
-rw-r--r--lib/Travel/Status/DE/EFA/Stop.pm228
-rw-r--r--lib/Travel/Status/DE/EFA/Trip.pm375
-rw-r--r--lib/Travel/Status/DE/VRR.pm405
-rw-r--r--lib/Travel/Status/DE/VRR/Result.pm195
9 files changed, 2418 insertions, 595 deletions
diff --git a/lib/Travel/Status/DE/EFA.pm b/lib/Travel/Status/DE/EFA.pm
new file mode 100644
index 0000000..25754f7
--- /dev/null
+++ b/lib/Travel/Status/DE/EFA.pm
@@ -0,0 +1,945 @@
+package Travel::Status::DE::EFA;
+
+use strict;
+use warnings;
+use 5.010;
+use utf8;
+
+our $VERSION = '3.15';
+
+use Carp qw(confess cluck);
+use DateTime;
+use DateTime::Format::Strptime;
+use Encode qw(encode);
+use JSON;
+use Travel::Status::DE::EFA::Departure;
+use Travel::Status::DE::EFA::Info;
+use Travel::Status::DE::EFA::Line;
+use Travel::Status::DE::EFA::Services;
+use Travel::Status::DE::EFA::Stop;
+use Travel::Status::DE::EFA::Trip;
+use LWP::UserAgent;
+
+sub new_p {
+ my ( $class, %opt ) = @_;
+ my $promise = $opt{promise}->new;
+
+ my $self;
+
+ eval { $self = $class->new( %opt, async => 1 ); };
+ if ($@) {
+ return $promise->reject($@);
+ }
+
+ $self->{promise} = $opt{promise};
+
+ $self->post_with_cache_p->then(
+ sub {
+ my ($content) = @_;
+ $self->{response} = $self->{json}->decode($content);
+
+ if ( $self->{developer_mode} ) {
+ say $self->{json}->pretty->encode( $self->{response} );
+ }
+
+ $self->check_for_ambiguous();
+
+ if ( $self->{errstr} ) {
+ $promise->reject( $self->{errstr}, $self );
+ return;
+ }
+
+ $promise->resolve($self);
+ return;
+ }
+ )->catch(
+ sub {
+ my ( $err, $self ) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub new {
+ my ( $class, %opt ) = @_;
+
+ $opt{timeout} //= 10;
+ if ( $opt{timeout} <= 0 ) {
+ delete $opt{timeout};
+ }
+
+ if (
+ not( $opt{coord}
+ or $opt{name}
+ or $opt{stopfinder}
+ or $opt{stopseq}
+ or $opt{from_json} )
+ )
+ {
+ confess('You must specify a name');
+ }
+ if ( $opt{type}
+ and not( $opt{type} =~ m{ ^ (?: stop | stopID | address | poi ) $ }x ) )
+ {
+ confess('type must be stop, stopID, address, or poi');
+ }
+
+ if ( $opt{service} ) {
+ if ( my $service
+ = Travel::Status::DE::EFA::Services::get_service( $opt{service} ) )
+ {
+ $opt{efa_url} = $service->{url};
+ if ( $opt{coord} ) {
+ $opt{efa_url} .= '/XML_COORD_REQUEST';
+ }
+ elsif ( $opt{stopfinder} ) {
+ $opt{efa_url} .= '/XML_STOPFINDER_REQUEST';
+ }
+ elsif ( $opt{stopseq} ) {
+ $opt{efa_url} .= '/XML_STOPSEQCOORD_REQUEST';
+ }
+ else {
+ $opt{efa_url} .= '/XML_DM_REQUEST';
+ }
+ $opt{time_zone} //= $service->{time_zone};
+ }
+ }
+
+ $opt{time_zone} //= 'Europe/Berlin';
+
+ if ( not $opt{efa_url} ) {
+ confess('service or efa_url must be specified');
+ }
+ my $dt = $opt{datetime} // DateTime->now( time_zone => $opt{time_zone} );
+
+ ## no critic (RegularExpressions::ProhibitUnusedCapture)
+ ## no critic (Variables::ProhibitPunctuationVars)
+
+ if ( $opt{time}
+ and $opt{time} =~ m{ ^ (?<hour> \d\d? ) : (?<minute> \d\d ) $ }x )
+ {
+ $dt->set(
+ hour => $+{hour},
+ minute => $+{minute}
+ );
+ }
+ elsif ( $opt{time} ) {
+ confess('Invalid time specified');
+ }
+
+ if (
+ $opt{date}
+ and $opt{date} =~ m{ ^ (?<day> \d\d? ) [.] (?<month> \d\d? ) [.]
+ (?<year> \d{4} )? $ }x
+ )
+ {
+ if ( $+{year} ) {
+ $dt->set(
+ day => $+{day},
+ month => $+{month},
+ year => $+{year}
+ );
+ }
+ else {
+ $dt->set(
+ day => $+{day},
+ month => $+{month}
+ );
+ }
+ }
+ elsif ( $opt{date} ) {
+ confess('Invalid date specified');
+ }
+
+ my $self = {
+ cache => $opt{cache},
+ response => $opt{from_json},
+ developer_mode => $opt{developer_mode},
+ efa_url => $opt{efa_url},
+ service => $opt{service},
+ strp_stopseq => DateTime::Format::Strptime->new(
+ pattern => '%Y%m%d %H:%M',
+ time_zone => $opt{time_zone},
+ ),
+ strp_stopseq_s => DateTime::Format::Strptime->new(
+ pattern => '%Y%m%d %H:%M:%S',
+ time_zone => $opt{time_zone},
+ ),
+
+ json => JSON->new->utf8,
+ };
+
+ if ( $opt{coord} ) {
+
+ # outputFormat => 'JSON' returns invalid JSON
+ $self->{post} = {
+ coord => sprintf( '%.7f:%.7f:%s',
+ $opt{coord}{lon}, $opt{coord}{lat}, 'WGS84[DD.ddddd]' ),
+ radius_1 => 1320,
+ type_1 => 'STOP',
+ coordListOutputFormat => 'list',
+ max => 30,
+ inclFilter => 1,
+ outputFormat => 'rapidJson',
+ };
+ }
+ elsif ( $opt{stopfinder} ) {
+
+ # filter: 2 (stop) | 4 (street) | 8 (address) | 16 (crossing) | 32 (poi) | 64 (postcod)
+ $self->{post} = {
+ locationServerActive => 1,
+ type_sf => 'any',
+ name_sf => $opt{stopfinder}{name},
+ anyObjFilter_sf => 2,
+ coordOutputFormat => 'WGS84[DD.DDDDD]',
+ outputFormat => 'JSON',
+ };
+ }
+ elsif ( $opt{stopseq} ) {
+
+ # outputFormat => 'JSON' also works; leads to different output
+ $self->{post} = {
+ line => $opt{stopseq}{stateless},
+ stop => $opt{stopseq}{stop_id},
+ tripCode => $opt{stopseq}{key},
+ date => $opt{stopseq}{date},
+ time => $opt{stopseq}{time},
+ coordOutputFormat => 'WGS84[DD.DDDDD]',
+ outputFormat => 'rapidJson',
+ useRealtime => '1',
+ };
+ }
+ else {
+ $self->{post} = {
+ language => 'de',
+ mode => 'direct',
+ outputFormat => 'JSON',
+ type_dm => $opt{type} // 'stop',
+ useProxFootSearch => $opt{proximity_search} ? '1' : '0',
+ useRealtime => '1',
+ itdDateDay => $dt->day,
+ itdDateMonth => $dt->month,
+ itdDateYear => $dt->year,
+ itdTimeHour => $dt->hour,
+ itdTimeMinute => $dt->minute,
+ name_dm => encode( 'UTF-8', $opt{name} ),
+ };
+ }
+
+ if ( $opt{place} ) {
+ $self->{post}{placeInfo_dm} = 'invalid';
+ $self->{post}{placeState_dm} = 'empty';
+ $self->{post}{place_dm} = encode( 'UTF-8', $opt{place} );
+ }
+
+ if ( $opt{full_routes} ) {
+ $self->{post}->{depType} = 'stopEvents';
+ $self->{post}->{includeCompleteStopSeq} = 1;
+ $self->{want_full_routes} = 1;
+ }
+
+ bless( $self, $class );
+
+ if ( $opt{user_agent} ) {
+ $self->{ua} = $opt{user_agent};
+ }
+ else {
+ my %lwp_options = %{ $opt{lwp_options} // { timeout => 10 } };
+ $self->{ua} = LWP::UserAgent->new(%lwp_options);
+ $self->{ua}->env_proxy;
+ }
+
+ if ( $self->{cache} ) {
+ $self->{cache_key}
+ = $self->{efa_url} . '?'
+ . join( '&',
+ map { $_ . '=' . $self->{post}{$_} } sort keys %{ $self->{post} } );
+ }
+
+ if ( $opt{async} ) {
+ return $self;
+ }
+
+ if ( $self->{developer_mode} ) {
+ say 'POST ' . $self->{efa_url};
+ while ( my ( $key, $value ) = each %{ $self->{post} } ) {
+ printf( "%30s = %s\n", $key, $value );
+ }
+ }
+
+ if ( not $self->{response} ) {
+ my ( $response, $error ) = $self->post_with_cache;
+
+ if ($error) {
+ $self->{errstr} = $error;
+ return $self;
+ }
+
+ $self->{response} = $self->{json}->decode($response);
+ }
+
+ if ( $self->{developer_mode} ) {
+ say $self->{json}->pretty->encode( $self->{response} );
+ }
+
+ $self->check_for_ambiguous();
+
+ return $self;
+}
+
+sub post_with_cache {
+ my ($self) = @_;
+ my $cache = $self->{cache};
+ my $url = $self->{efa_url};
+
+ if ( $self->{developer_mode} ) {
+ say 'POST ' . ( $self->{cache_key} // $url );
+ }
+
+ if ($cache) {
+ my $content = $cache->thaw( $self->{cache_key} );
+ if ($content) {
+ if ( $self->{developer_mode} ) {
+ say ' cache hit';
+ }
+ return ( ${$content}, undef );
+ }
+ }
+
+ if ( $self->{developer_mode} ) {
+ say ' cache miss';
+ }
+
+ my $reply = $self->{ua}->post( $url, $self->{post} );
+
+ if ( $reply->is_error ) {
+ return ( undef, $reply->status_line );
+ }
+ my $content = $reply->content;
+
+ if ($cache) {
+ $cache->freeze( $self->{cache_key}, \$content );
+ }
+
+ return ( $content, undef );
+}
+
+sub post_with_cache_p {
+ my ($self) = @_;
+ my $cache = $self->{cache};
+ my $url = $self->{efa_url};
+
+ if ( $self->{developer_mode} ) {
+ say 'POST ' . ( $self->{cache_key} // $url );
+ }
+
+ my $promise = $self->{promise}->new;
+
+ if ($cache) {
+ my $content = $cache->thaw( $self->{cache_key} );
+ if ($content) {
+ if ( $self->{developer_mode} ) {
+ say ' cache hit';
+ }
+ return $promise->resolve( ${$content} );
+ }
+ }
+
+ if ( $self->{developer_mode} ) {
+ say ' cache miss';
+ }
+
+ $self->{ua}->post_p( $url, form => $self->{post} )->then(
+ sub {
+ my ($tx) = @_;
+ if ( my $err = $tx->error ) {
+ $promise->reject(
+ "POST $url returned HTTP $err->{code} $err->{message}");
+ return;
+ }
+ my $content = $tx->res->body;
+ if ($cache) {
+ $cache->freeze( $self->{cache_key}, \$content );
+ }
+ $promise->resolve($content);
+ return;
+ }
+ )->catch(
+ sub {
+ my ($err) = @_;
+ $promise->reject($err);
+ return;
+ }
+ )->wait;
+
+ return $promise;
+}
+
+sub errstr {
+ my ($self) = @_;
+
+ return $self->{errstr};
+}
+
+sub name_candidates {
+ my ($self) = @_;
+
+ if ( $self->{name_candidates} ) {
+ return @{ $self->{name_candidates} };
+ }
+ return;
+}
+
+sub place_candidates {
+ my ($self) = @_;
+
+ if ( $self->{place_candidates} ) {
+ return @{ $self->{place_candidates} };
+ }
+ return;
+}
+
+sub check_for_ambiguous {
+ my ($self) = @_;
+
+ my $json = $self->{response};
+
+ if ( $json->{departureList} ) {
+ return;
+ }
+
+ for my $m ( @{ $json->{dm}{message} // [] } ) {
+ if ( $m->{name} eq 'error' and $m->{value} eq 'name list' ) {
+ $self->{errstr} = "ambiguous name parameter";
+ $self->{name_candidates} = [];
+ for my $point ( @{ $json->{dm}{points} // [] } ) {
+ my $place = $point->{ref}{place};
+ push(
+ @{ $self->{name_candidates} },
+ Travel::Status::DE::EFA::Stop->new(
+ place => $place,
+ full_name => $point->{name},
+ name => $point->{name} =~ s{\Q$place\E,? ?}{}r,
+ id_num => $point->{ref}{id},
+ )
+ );
+ }
+ return;
+ }
+ if ( $m->{name} eq 'error' and $m->{value} eq 'place list' ) {
+ $self->{errstr} = "ambiguous name parameter";
+ $self->{place_candidates} = [];
+ for my $point ( @{ $json->{dm}{points} // [] } ) {
+ my $place = $point->{ref}{place};
+ push(
+ @{ $self->{place_candidates} },
+ Travel::Status::DE::EFA::Stop->new(
+ place => $place,
+ full_name => $point->{name},
+ name => $point->{name} =~ s{\Q$place\E,? ?}{}r,
+ id_num => $point->{ref}{id},
+ )
+ );
+ }
+ return;
+ }
+ }
+
+ return;
+}
+
+sub stop {
+ my ($self) = @_;
+ if ( $self->{stop} ) {
+ return $self->{stop};
+ }
+
+ my $point = $self->{response}{dm}{points}{point};
+ my $place = $point->{ref}{place};
+
+ $self->{stop} = Travel::Status::DE::EFA::Stop->new(
+ place => $place,
+ full_name => $point->{name},
+ name => $point->{name} =~ s{\Q$place\E,? ?}{}r,
+ id_num => $point->{ref}{id},
+ id_code => $point->{ref}{gid},
+ );
+
+ return $self->{stop};
+}
+
+sub stops {
+ my ($self) = @_;
+
+ if ( $self->{stops} ) {
+ return @{ $self->{stops} };
+ }
+
+ my $stops = $self->{response}{dm}{itdOdvAssignedStops} // [];
+
+ if ( ref($stops) eq 'HASH' ) {
+ $stops = [$stops];
+ }
+
+ my @stops;
+ for my $stop ( @{$stops} ) {
+ push(
+ @stops,
+ Travel::Status::DE::EFA::Stop->new(
+ place => $stop->{place},
+ name => $stop->{name},
+ full_name => $stop->{nameWithPlace},
+ id_num => $stop->{stopID},
+ id_code => $stop->{gid},
+ )
+ );
+ }
+
+ $self->{stops} = \@stops;
+ return @stops;
+}
+
+sub infos {
+ my ($self) = @_;
+
+ if ( $self->{infos} ) {
+ return @{ $self->{infos} };
+ }
+
+ for my $info ( @{ $self->{response}{dm}{points}{point}{infos} // [] } ) {
+ push(
+ @{ $self->{infos} },
+ Travel::Status::DE::EFA::Info->new( json => $info )
+ );
+ }
+
+ return @{ $self->{infos} // [] };
+}
+
+sub lines {
+ my ($self) = @_;
+
+ if ( $self->{lines} ) {
+ return @{ $self->{lines} };
+ }
+
+ for my $line ( @{ $self->{response}{servingLines}{lines} // [] } ) {
+ push( @{ $self->{lines} }, $self->parse_line($line) );
+ }
+
+ return @{ $self->{lines} // [] };
+}
+
+sub parse_line {
+ my ( $self, $line ) = @_;
+
+ my $mode = $line->{mode} // {};
+
+ return Travel::Status::DE::EFA::Line->new(
+ type => $mode->{product},
+ name => $mode->{name},
+ number => $mode->{number},
+ direction => $mode->{destination},
+ valid => $mode->{timetablePeriod},
+ mot => $mode->{product},
+ operator => $mode->{diva}{operator},
+ identifier => $mode->{diva}{globalId},
+
+ );
+}
+
+sub results {
+ my ($self) = @_;
+
+ if ( $self->{results} ) {
+ return @{ $self->{results} };
+ }
+
+ if ( $self->{post}{coord} ) {
+ return $self->results_coord;
+ }
+ elsif ( $self->{post}{name_sf} ) {
+ return $self->results_stopfinder;
+ }
+ else {
+ return $self->results_dm;
+ }
+}
+
+sub results_coord {
+ my ($self) = @_;
+ my $json = $self->{response};
+
+ my @results;
+ for my $stop ( @{ $json->{locations} // [] } ) {
+ push(
+ @results,
+ Travel::Status::DE::EFA::Stop->new(
+ place => $stop->{parent}{name},
+ full_name => $stop->{properties}{STOP_NAME_WITH_PLACE},
+ distance_m => $stop->{properties}{distance},
+ name => $stop->{name},
+ id_code => $stop->{id},
+ )
+ );
+ }
+
+ $self->{results} = \@results;
+
+ return @results;
+}
+
+sub results_stopfinder {
+ my ($self) = @_;
+ my $json = $self->{response};
+
+ my @results;
+
+ # Edge case: there is just a single result.
+ # Oh EFA, you so silly.
+ if ( ref( $json->{stopFinder}{points} ) eq 'HASH'
+ and exists $json->{stopFinder}{points}{point} )
+ {
+ $json->{stopFinder}{points} = [ $json->{stopFinder}{points}{point} ];
+ }
+
+ for my $stop ( @{ $json->{stopFinder}{points} // [] } ) {
+ push(
+ @results,
+ Travel::Status::DE::EFA::Stop->new(
+ place => $stop->{ref}{place},
+ full_name => $stop->{name},
+ name => $stop->{object},
+ id_num => $stop->{ref}{id},
+ id_code => $stop->{ref}{gid},
+ )
+ );
+ }
+
+ $self->{results} = \@results;
+
+ return @results;
+}
+
+sub results_dm {
+ my ($self) = @_;
+ my $json = $self->{response};
+
+ # Oh EFA, you so silly
+ if ( $json->{departureList} and ref( $json->{departureList} ) eq 'HASH' ) {
+ $json->{departureList} = [ $json->{departureList}{departure} ];
+ }
+
+ my @results;
+ for my $departure ( @{ $json->{departureList} // [] } ) {
+ push(
+ @results,
+ Travel::Status::DE::EFA::Departure->new(
+ json => $departure,
+ strp_stopseq => $self->{strp_stopseq},
+ strp_stopseq_s => $self->{strp_stopseq_s}
+ )
+ );
+ }
+
+ @results = map { $_->[0] }
+ sort { $a->[1] <=> $b->[1] }
+ map { [ $_, $_->countdown ] } @results;
+
+ $self->{results} = \@results;
+
+ return @results;
+}
+
+sub result {
+ my ($self) = @_;
+
+ return Travel::Status::DE::EFA::Trip->new( json => $self->{response} );
+}
+
+# static
+sub get_service_ids {
+ return Travel::Status::DE::EFA::Services::get_service_ids(@_);
+}
+
+sub get_services {
+ my @services;
+ for my $service ( Travel::Status::DE::EFA::Services::get_service_ids() ) {
+ my %desc
+ = %{ Travel::Status::DE::EFA::Services::get_service($service) };
+ $desc{shortname} = $service;
+ push( @services, \%desc );
+ }
+ return @services;
+}
+
+# static
+sub get_service {
+ return Travel::Status::DE::EFA::Services::get_service(@_);
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Travel::Status::DE::EFA - unofficial EFA departure monitor
+
+=head1 SYNOPSIS
+
+ use Travel::Status::DE::EFA;
+
+ my $status = Travel::Status::DE::EFA->new(
+ service => 'VRR',
+ name => 'Essen Helenenstr'
+ );
+
+ for my $d ($status->results) {
+ printf(
+ "%s %-8s %-5s %s\n",
+ $d->datetime->strftime('%H:%M'),
+ $d->platform_name, $d->line, $d->destination
+ );
+ }
+
+=head1 VERSION
+
+version 3.15
+
+=head1 DESCRIPTION
+
+Travel::Status::DE::EFA is an unofficial interface to EFA-based departure
+monitors.
+
+It can serve as a departure monitor, request details about a specific
+trip/journey, and look up public transport stops by name or geolocation.
+The operating mode depends on its constructor arguments.
+
+=head1 METHODS
+
+=over
+
+=item my $status = Travel::Status::DE::EFA->new(I<%opt>)
+
+Requests data as specified by I<opts> and returns a new Travel::Status::DE::EFA
+object. B<service> and exactly one of B<coord>, B<stopfinder>, B<stopseq> or
+B<name> are mandatory. Dies if the wrong I<opts> were passed.
+
+Arguments:
+
+=over
+
+=item B<service> => I<name>
+
+EFA service. See C<< efa-m --list >> for known services.
+If you found a service not listed there, please notify
+E<lt>derf+efa@finalrewind.orgE<gt>.
+
+=item B<coord> => I<hashref>
+
+Look up stops in the vicinity of the given coordinates. I<hashref> must
+contain a B<lon> and a B<lat> element providing WGS84 longitude/latitude.
+
+=item B<stopfinder> => { B<name> => I<name> }
+
+Look up stops matching I<name>.
+
+=item B<stopseq> => I<hashref>
+
+Look up trip details. I<hashref> must provide B<stateless> (line ID),
+B<stop_id> (stop ID used as start for the reported route), B<key> (line trip
+number), and B<date> (departure date as YYYYMMDD string).
+
+=item B<name> => I<name>
+
+List departure for address / point of interest / stop I<name>.
+
+=item B<place> => I<place>
+
+Name of the place/city
+
+=item B<type> => B<address>|B<poi>|B<stop>|B<stopID>
+
+Type of the following I<name>. B<poi> means "point of interest". Defaults to
+B<stop> (stop/station name).
+
+=item B<datetime> => I<DateTime object>
+
+Request departures for the date/time specified by I<DateTime object>.
+Default: now.
+
+=item B<efa_encoding> => I<encoding>
+
+Some EFA servers do not correctly specify their response encoding. If you
+observe encoding issues, you can manually specify it here. Example:
+iso-8859-15.
+
+=item B<full_routes> => B<0>|B<1>
+
+If true: Request full routes for all departures from the backend. This
+enables the B<route_pre>, B<route_post> and B<route_interesting> accessors in
+Travel::Status::DE::EFA::Departure(3pm).
+
+=item B<proximity_search> => B<0>|B<1>
+
+If true: Show departures for stops in the proximity of the requested place
+as well.
+
+=item B<timeout> => I<seconds>
+
+Request timeout, the argument is passed on to LWP::UserAgent(3pm).
+Default: 10 seconds. Set to 0 or a negative value to disable it.
+
+=back
+
+=item my $status_p = Travel::Status::DE::EFA->new_p(I<%opt>)
+
+Returns a promise that resolves into a Travel::Status::DE::EFA instance
+($status) on success and rejects with an error message on failure. In case
+the error occured after construction of the Travel::Status::DE::EFA object
+(e.g. due to an ambiguous name/place parameter), the second argument of the
+rejected promise holds a Travel::Status::DE::EFA instance that can be used
+to query place/name candidates (see name_candidates and place_candidates).
+
+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 implement
+a B<post_p> function. Recommended: Mojo::UserAgent(3pm).
+
+=back
+
+=item $status->errstr
+
+In case of an HTTP request or EFA error, returns a string describing it. If
+none occured, returns undef.
+
+=item $status->lines
+
+Returns a list of Travel::Status::DE::EFA::Line(3pm) objects, each one
+describing one line servicing the selected station.
+
+=item $status->name_candidates
+
+Returns a list of B<name> candidates if I<name> is ambiguous. Returns
+nothing (undef / empty list) otherwise.
+
+=item $status->place_candidates
+
+Returns a list of B<place> candidates if I<place> is ambiguous. Returns
+nothing (undef / empty list) otherwise.
+
+=item $status->stop
+
+Returns a Travel::Status::DE::EFA::Stop(3pm) instance describing the requested
+stop.
+
+=item $status->stops
+
+In case the requested place/name is served by multiple stops and the backend
+provides a list of those: returns a list of Travel::Status::DE::EFA::Stop(3pm)
+instances describing each of them. Returns an empty list otherwise.
+
+=item $status->results
+
+In departure monitor mode: returns a list of
+Travel::Status::DE::EFA::Departure(3pm) objects, each one describing one
+departure.
+
+In coord or stopfinder mode: returns a list of
+Travel::Status::DE::EFA::Stop(3pm) objects.
+
+=item $status->result
+
+In stopseq mode: Returns a Travel::Status::DE::EFA::Trip(3pm) object.
+
+=item Travel::Status::DE::EFA::get_service_ids()
+
+Returns the list of supported services (backends).
+
+=item Travel::Status::DE::EFA::get_service(I<service>)
+
+Returns a hashref describing the requested I<service> ID with the following keys.
+
+=over
+
+=item B<name> => I<string>
+
+Provider name, e.g. Verkehrsverbund Oberelbe.
+
+=item B<url> => I<string>
+
+Backend base URL.
+
+=item B<homepage> => I<string> (optional)
+
+Provider homepage.
+
+=item B<languages> => I<arrayref> (optional)
+
+Supportde languages, e.g. de, en.
+
+=item B<coverage> => I<hashref>
+
+Area in which the service provides near-optimal coverage. Typically, this
+means a (nearly) complete list of departures and real-time data. The
+hashref contains two optional keys: B<area> (GeoJSON) and B<regions> (list of
+strings, e.g. "DE" or "CH-BE").
+
+=back
+
+=item Travel::Status::DE::EFA::get_services()
+
+Returns a list of hashrefs describing all supported services. In addition
+to the keys listed above, each service contains a B<shortname> (service ID).
+
+=back
+
+=head1 DIAGNOSTICS
+
+None.
+
+=head1 DEPENDENCIES
+
+=over
+
+=item * Class::Accessor(3pm)
+
+=item * DateTime(3pm)
+
+=item * DateTime::Format::Strptime(3pm)
+
+=item * JSON(3pm)
+
+=item * LWP::UserAgent(3pm)
+
+=back
+
+=head1 BUGS AND LIMITATIONS
+
+The API is not exposed completely.
+
+=head1 SEE ALSO
+
+efa-m(1), Travel::Status::DE::EFA::Departure(3pm).
+
+=head1 AUTHOR
+
+Copyright (C) 2011-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/DE/EFA/Departure.pm b/lib/Travel/Status/DE/EFA/Departure.pm
new file mode 100644
index 0000000..5570532
--- /dev/null
+++ b/lib/Travel/Status/DE/EFA/Departure.pm
@@ -0,0 +1,528 @@
+package Travel::Status::DE::EFA::Departure;
+
+use strict;
+use warnings;
+use 5.010;
+
+use DateTime;
+use List::Util qw(any);
+use Travel::Status::DE::EFA::Stop;
+
+use parent 'Class::Accessor';
+
+our $VERSION = '3.15';
+
+Travel::Status::DE::EFA::Departure->mk_ro_accessors(
+ qw(countdown datetime delay destination is_cancelled key line lineref mot
+ occupancy operator origin platform platform_db platform_name rt_datetime
+ sched_datetime stateless stop_id_num train_type train_name train_no type)
+);
+
+my @mot_mapping = qw{
+ zug s-bahn u-bahn stadtbahn tram stadtbus regionalbus
+ schnellbus seilbahn schiff ast sonstige
+};
+
+sub parse_departure {
+ my ( $self, $departure ) = @_;
+}
+
+sub new {
+ my ( $obj, %conf ) = @_;
+
+ my $departure = $conf{json};
+ my ( $sched_dt, $real_dt );
+
+ if ( my $dt = $departure->{dateTime} ) {
+ $sched_dt = DateTime->new(
+ year => $dt->{year},
+ month => $dt->{month},
+ day => $dt->{day},
+ hour => $dt->{hour},
+ minute => $dt->{minute},
+ second => $dt->{second} // 0,
+ time_zone => 'Europe/Berlin',
+ );
+ }
+
+ if ( my $dt = $departure->{realDateTime} ) {
+ $real_dt = DateTime->new(
+ year => $dt->{year},
+ month => $dt->{month},
+ day => $dt->{day},
+ hour => $dt->{hour},
+ minute => $dt->{minute},
+ second => $dt->{second} // 0,
+ time_zone => 'Europe/Berlin',
+ );
+ }
+
+ my @hints
+ = map { $_->{content} } @{ $departure->{servingLine}{hints} // [] };
+
+ my $ref = {
+ strp_stopseq_s => $conf{strp_stopseq_s},
+ strp_stopseq => $conf{strp_stopseq},
+ rt_datetime => $real_dt,
+ platform => $departure->{platform},
+ platform_name => $departure->{platformName},
+ platform_type => $departure->{pointType},
+ key => $departure->{servingLine}{key},
+ stateless => $departure->{servingLine}{stateless},
+ stop_id_num => $departure->{stopID},
+ line => $departure->{servingLine}{symbol},
+ train_type => $departure->{servingLine}{trainType},
+ train_name => $departure->{servingLine}{trainName},
+ train_no => $departure->{servingLine}{trainNum},
+ origin => $departure->{servingLine}{directionFrom},
+ destination => $departure->{servingLine}{direction},
+ occupancy => $departure->{occupancy},
+ countdown => $departure->{countdown},
+ delay => $departure->{servingLine}{delay},
+ sched_datetime => $sched_dt,
+ type => $departure->{servingLine}{name},
+ mot => $departure->{servingLine}{motType},
+ hints => \@hints,
+ };
+
+ if ( defined $ref->{delay} and $ref->{delay} eq '-9999' ) {
+ $ref->{delay} = 0;
+ $ref->{is_cancelled} = 1;
+ }
+ else {
+ $ref->{is_cancelled} = 0;
+ }
+
+ $ref->{datetime} = $ref->{rt_datetime} // $ref->{sched_datetime};
+
+ bless( $ref, $obj );
+
+ if ( $departure->{prevStopSeq} ) {
+ $ref->{prev_route} = $ref->parse_route( $departure->{prevStopSeq},
+ $departure->{stopID} );
+ }
+ if ( $departure->{onwardStopSeq} ) {
+ $ref->{next_route} = $ref->parse_route( $departure->{onwardStopSeq},
+ $departure->{stopID} );
+ }
+
+ return $ref;
+}
+
+sub parse_route {
+ my ( $self, $stop_seq, $requested_id ) = @_;
+ my @ret;
+
+ if ( not $stop_seq ) {
+ return \@ret;
+ }
+
+ # Oh EFA, you so silly
+ if ( ref($stop_seq) eq 'HASH' ) {
+
+ # For lines that start or terminate at the requested stop, onwardStopSeq / prevStopSeq includes the requested stop.
+ if ( $stop_seq->{ref}{id} eq $requested_id ) {
+ return \@ret;
+ }
+ $stop_seq = [$stop_seq];
+ }
+
+ for my $stop ( @{ $stop_seq // [] } ) {
+ my $ref = $stop->{ref};
+ my ( $arr, $dep );
+
+ if ( $ref->{arrDateTimeSec} ) {
+ $arr = $self->{strp_stopseq_s}
+ ->parse_datetime( $ref->{arrDateTimeSec} );
+ }
+ elsif ( $ref->{arrDateTime} ) {
+ $arr = $self->{strp_stopseq}->parse_datetime( $ref->{arrDateTime} );
+ }
+
+ if ( $ref->{depDateTimeSec} ) {
+ $dep = $self->{strp_stopseq_s}
+ ->parse_datetime( $ref->{depDateTimeSec} );
+ }
+ elsif ( $ref->{depDateTime} ) {
+ $dep = $self->{strp_stopseq}->parse_datetime( $ref->{depDateTime} );
+ }
+
+ push(
+ @ret,
+ Travel::Status::DE::EFA::Stop->new(
+ sched_arr => $arr,
+ sched_dep => $dep,
+ arr_delay => $ref->{arrValid} ? $ref->{arrDelay} : undef,
+ dep_delay => $ref->{depValid} ? $ref->{depDelay} : undef,
+ id_num => $ref->{id},
+ id_code => $ref->{gid},
+ full_name => $stop->{name},
+ place => $stop->{place},
+ name => $stop->{nameWO},
+ occupancy => $stop->{occupancy},
+ platform => $ref->{platform} || $stop->{platformName} || undef,
+ )
+ );
+ }
+
+ return \@ret;
+}
+
+sub id {
+ my ($self) = @_;
+
+ if ( $self->{id} ) {
+ return $self->{id};
+ }
+
+ return $self->{id} = sprintf( '%s@%d(%s)%d',
+ $self->stateless =~ s{ }{}gr,
+ scalar $self->route_pre ? ( $self->route_pre )[0]->id_num
+ : $self->stop_id_num,
+ ( scalar $self->route_pre and ( $self->route_pre )[0]->sched_dep )
+ ? ( $self->route_pre )[0]->sched_dep->strftime('%Y%m%dT%H:%M')
+ : $self->sched_datetime->strftime('%Y%m%dT%H:%M'),
+ $self->key );
+}
+
+sub hints {
+ my ($self) = @_;
+
+ return @{ $self->{hints} // [] };
+}
+
+sub mot_name {
+ my ($self) = @_;
+
+ return $mot_mapping[ $self->{mot} ] // 'sonstige';
+}
+
+sub route_pre {
+ my ($self) = @_;
+
+ return @{ $self->{prev_route} // [] };
+}
+
+sub route_post {
+ my ($self) = @_;
+
+ return @{ $self->{next_route} // [] };
+}
+
+sub route_interesting {
+ my ( $self, $max_parts ) = @_;
+
+ my @via = $self->route_post;
+ my ( @via_main, @via_show, $last_stop );
+ $max_parts //= 3;
+
+ for my $stop (@via) {
+ if (
+ $stop->name =~ m{ Bf | Hbf | Flughafen | [Bb]ahnhof
+ | Krankenhaus | Klinik | (?: S $ ) }ox
+ )
+ {
+ push( @via_main, $stop );
+ }
+ }
+ $last_stop = pop(@via);
+
+ if ( @via_main and $via_main[-1] == $last_stop ) {
+ pop(@via_main);
+ }
+ if ( @via and $via[-1] == $last_stop ) {
+ pop(@via);
+ }
+
+ if ( @via_main and @via and $via[0] == $via_main[0] ) {
+ shift(@via_main);
+ }
+
+ if ( @via < $max_parts ) {
+ @via_show = @via;
+ }
+ else {
+ if ( @via_main >= $max_parts ) {
+ @via_show = ( $via[0] );
+ }
+ else {
+ @via_show = splice( @via, 0, $max_parts - @via_main );
+ }
+
+ while ( @via_show < $max_parts and @via_main ) {
+ my $stop = shift(@via_main);
+ if ( any { $_->name eq $stop->name } @via_show
+ or $stop->name eq $last_stop->name )
+ {
+ next;
+ }
+ push( @via_show, $stop );
+ }
+ }
+
+ return @via_show;
+}
+
+sub TO_JSON {
+ my ($self) = @_;
+
+ # compute on-demand keys
+ $self->id;
+
+ my $ret = { %{$self} };
+
+ delete $ret->{strp_stopseq};
+ delete $ret->{strp_stopseq_s};
+
+ for my $k (qw(datetime rt_datetime sched_datetime)) {
+ if ( $ret->{$k} ) {
+ $ret->{$k} = $ret->{$k}->epoch;
+ }
+ }
+
+ return $ret;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Travel::Status::DE::EFA::Departure - Information about a single
+departure received by Travel::Status::DE::EFA
+
+=head1 SYNOPSIS
+
+ for my $departure ($status->results) {
+ printf(
+ "At %s: %s to %s from platform %d\n",
+ $departure->datetime->strftime('%H:%M'), $departure->line,
+ $departure->destination, $departure->platform
+ );
+ }
+
+=head1 VERSION
+
+version 3.15
+
+=head1 DESCRIPTION
+
+Travel::Status::DE::EFA::Departure describes a single departure as obtained by
+Travel::Status::DE::EFA. It contains information about the time, platform,
+line number and destination.
+
+=head1 METHODS
+
+=head2 ACCESSORS
+
+=over
+
+=item $departure->countdown
+
+Time in minutes from now until the tram/bus/train will depart, including
+realtime data if available.
+
+If delay information is available, it is already included.
+
+=item $departure->datetime
+
+DateTime(3pm) object for departure date and time. Realtime data if available,
+schedule data otherwise.
+
+=item $departure->delay
+
+Expected delay from scheduled departure time in minutes. A delay of 0
+indicates departure on time. undef when no realtime information is available.
+
+=item $departure->destination
+
+Destination name.
+
+=item $departure->hints
+
+Additional information related to the departure (list of strings). If
+departures for an address were requested, this is the stop name, otherwise it
+may be recent news related to the line's schedule.
+
+=item $departure->id
+
+Stringified unique(?) identifier of this departure; suitable for passing to
+Travel::Status::DE::EFA->new(stopseq) after decomposing it again.
+The returned string combines B<stateless>, B<stop_id_num> (or the ID of the first
+stop in B<route_pre>, if present), B<sched_datetime>, and B<key>.
+
+=item $departure->is_cancelled
+
+1 if the departure got cancelled, 0 otherwise.
+
+=item $departure->key
+
+Key of this departure of the corresponding line. Unique for a given day when
+combined with B<stateless>.
+
+=item $departure->line
+
+The name/number of the line.
+
+=item $departure->lineref
+
+Travel::Status::DE::EFA::Line(3pm) object describing the departing line in
+detail.
+
+=item $departure->mot
+
+Returns the "mode of transport" number. This is usually an integer between 0
+and 11.
+
+=item $departure->mot_name
+
+Returns the "mode of transport", for instance "zug", "s-bahn", "tram" or
+"sonstige".
+
+=item $departure->occupancy
+
+Returns expected occupancy, if available, undef otherwise.
+
+Occupancy values are passed from the backend as-is. Known values are
+"MANY_SEATS" (low occupation), "FEW_SEATS" (high occupation),
+"STANDING_ONLY" (very high occupation), and "FULL" (boarding not advised).
+
+=item $departure->origin
+
+Origin name.
+
+=item $departure->platform
+
+Departure platform number (may not be a number).
+
+=item $departure->platform_db
+
+true if the platform number is operated by DB ("Gleis x"), false ("Bstg. x")
+otherwise.
+
+Unfortunately, there is no distinction between tram and bus platforms yet,
+which may also have the same numbers.
+
+=item $departure->route_interesting
+
+List of up to three "interesting" stations served by this departure. Is a
+subset of B<route_post>. Each station is a Travel::Status::DE::EFA::Stop(3pm)
+object.
+
+=item $departure->route_pre
+
+List of stations the vehicle passed (or will have passed) before this stop.
+Each station is a Travel::Status::DE::EFA::Stop(3pm) object.
+
+=item $departure->route_post
+
+List of stations the vehicle will pass after this stop.
+Each station is a Travel::Status::DE::EFA::Stop(3pm) object.
+
+=item $departure->rt_datetime
+
+DateTime(3pm) object holding the departure date and time according to
+realtime data. Undef if unknown / unavailable.
+
+=item $departure->sched_datetime
+
+DateTime(3pm) object holding the scheduled departure date and time.
+
+=item $departure->stateless
+
+Unique line identifier.
+
+=item $departure->train_type
+
+Train type, e.g. "ICE". Typically only defined for long-distance trains.
+
+=item $departure->train_name
+
+Train name, e.g. "ICE International" or "InterCityExpresS" or "Deichgraf".
+Typically only defined for long-distance trains.
+
+=item $departure->train_no
+
+Train number. Only defined if departure is a train.
+
+=item $departure->type
+
+Type of the departure. Note that efa.vrr.de sometimes puts bogus data in this
+field. See L</DEPARTURE TYPES>.
+
+=back
+
+=head2 INTERNAL
+
+=over
+
+=item $departure = Travel::Status::DE::EFA::Departure->new(I<%data>)
+
+Returns a new Travel::Status::DE::EFA::Departure object. You should not need to
+call this.
+
+=item $departure->TO_JSON
+
+Allows the object data to be serialized to JSON.
+
+=back
+
+=head1 DEPARTURE TYPES
+
+The following are known so far:
+
+=over
+
+=item * Abellio-Zug
+
+=item * Bus
+
+=item * Eurocity
+
+=item * Intercity-Express
+
+=item * NE (NachtExpress / night bus)
+
+=item * Niederflurbus
+
+=item * R-Bahn (RE / RegionalExpress)
+
+=item * S-Bahn
+
+=item * SB (Schnellbus)
+
+=item * StraE<szlig>enbahn
+
+=item * U-Bahn
+
+=back
+
+=head1 DIAGNOSTICS
+
+None.
+
+=head1 DEPENDENCIES
+
+=over
+
+=item Class::Accessor(3pm)
+
+=back
+
+=head1 BUGS AND LIMITATIONS
+
+C<< $result->type >> may contain bogus data. This comes from the efa.vrr.de
+interface.
+
+=head1 SEE ALSO
+
+Travel::Status::DE::EFA(3pm).
+
+=head1 AUTHOR
+
+Copyright (C) 2011-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/DE/EFA/Info.pm b/lib/Travel/Status/DE/EFA/Info.pm
new file mode 100644
index 0000000..c4e7ce4
--- /dev/null
+++ b/lib/Travel/Status/DE/EFA/Info.pm
@@ -0,0 +1,127 @@
+package Travel::Status::DE::EFA::Info;
+
+use strict;
+use warnings;
+use 5.010;
+
+use parent 'Class::Accessor';
+
+our $VERSION = '3.15';
+
+Travel::Status::DE::EFA::Info->mk_ro_accessors(
+ qw(link_url link_text subject content subtitle additional_text));
+
+sub new {
+ my ( $obj, %opt ) = @_;
+
+ my $json = $opt{json};
+
+ my $ref = {
+ param => {},
+ link_url => $json->{infoLinkURL},
+ link_text => $json->{infoLinkText},
+ subject => $json->{infoText}{subject},
+ content => $json->{infoText}{content},
+ subtitle => $json->{infoText}{subtitle},
+ additional_text => $json->{infoText}{additionalText},
+ };
+
+ for my $param ( @{ $json->{paramList} // [] } ) {
+ $ref->{param}{ $param->{name} } = $param->{value};
+ }
+
+ return bless( $ref, $obj );
+}
+
+sub TO_JSON {
+ my ($self) = @_;
+
+ return { %{$self} };
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Travel::Status::DE::EFA::Info - Information about a public transit stop
+
+=head1 SYNOPSIS
+
+ if ( $info->subject and $info->subtitle ne $info->subject ) {
+ printf( "# %s\n%s\n", $info->subtitle, $info->subject );
+ }
+ else {
+ printf( "# %s\n", $info->subtitle );
+ }
+
+=head1 VERSION
+
+version 3.15
+
+=head1 DESCRIPTION
+
+Travel::Status::DE::EFA::Info holds a single information message related to
+a specific public transit stop.
+
+=head1 ACCESSORS
+
+All accessors may return undef.
+Individual accessors may return identical strings.
+Strings may contain HTML elements.
+
+=over
+
+=item $info->additional_text
+
+=item $info->content
+
+=item $info->link_url
+
+URL to a site related to this information message.
+The site may or may not hold additional data.
+
+=item $info->link_text
+
+Text for linking to link_url.
+
+=item $info->param
+
+Hashref of parameters, e.g. C<< incidentDateTime >> (string describing the
+date/time range during which this message is valid).
+
+=item $info->subject
+
+=item $info->subtitle
+
+=back
+
+=head1 DIAGNOSTICS
+
+None.
+
+=head1 DEPENDENCIES
+
+=over
+
+=item Class::Accessor(3pm)
+
+=back
+
+=head1 BUGS AND LIMITATIONS
+
+This module is a Work in Progress.
+Its API may change between minor versions.
+
+=head1 SEE ALSO
+
+Travel::Status::DE::EFA(3pm).
+
+=head1 AUTHOR
+
+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/DE/VRR/Line.pm b/lib/Travel/Status/DE/EFA/Line.pm
index bc6967f..888ea6b 100644
--- a/lib/Travel/Status/DE/VRR/Line.pm
+++ b/lib/Travel/Status/DE/EFA/Line.pm
@@ -1,4 +1,4 @@
-package Travel::Status::DE::VRR::Line;
+package Travel::Status::DE::EFA::Line;
use strict;
use warnings;
@@ -6,10 +6,15 @@ use 5.010;
use parent 'Class::Accessor';
-our $VERSION = '1.01';
+our $VERSION = '3.15';
-Travel::Status::DE::VRR::Line->mk_ro_accessors(
- qw(direction name operator route type valid));
+Travel::Status::DE::EFA::Line->mk_ro_accessors(
+ qw(direction mot name number operator route type valid));
+
+my @mot_mapping = qw{
+ zug s-bahn u-bahn stadtbahn tram stadtbus regionalbus
+ schnellbus seilbahn schiff ast sonstige
+};
sub new {
my ( $obj, %conf ) = @_;
@@ -18,13 +23,26 @@ sub new {
return bless( $ref, $obj );
}
+
+sub mot_name {
+ my ($self) = @_;
+
+ return $mot_mapping[ $self->{mot} ] // 'sonstige';
+}
+
+sub TO_JSON {
+ my ($self) = @_;
+
+ return { %{$self} };
+}
+
1;
__END__
=head1 NAME
-Travel::Status::DE::VRR::Line - Information about a line departing at the
+Travel::Status::DE::EFA::Line - Information about a line departing at the
requested station
=head1 SYNOPSIS
@@ -39,13 +57,13 @@ requested station
=head1 VERSION
-version 1.01
+version 3.15
=head1 DESCRIPTION
-Travel::Status::DE::VRR::Line describes a tram/bus/train line departing at the
-stop requested by Travel::Status::DE::VRR. Note that it only covers one
-direction, so in most cases, you get two Travel::Status::DE::VRR::Line objects
+Travel::Status::DE::EFA::Line describes a tram/bus/train line departing at the
+stop requested by Travel::Status::DE::EFA. Note that it only covers one
+direction, so in most cases, you get two Travel::Status::DE::EFA::Line objects
per actual line.
=head1 METHODS
@@ -58,6 +76,16 @@ per actual line.
Direction of the line. Name of either the destination stop or one on the way.
+=item $line->mot
+
+Returns the "mode of transport" number for this line. This is usually an
+integer between 0 and 11.
+
+=item $line->mot_name
+
+Returns the "mode of transport" for this line, for instance "zug", "s-bahn",
+"tram" or "sonstige".
+
=item $line->name
Name of the line, e.g. "U11", "SB15", "107".
@@ -65,14 +93,15 @@ Name of the line, e.g. "U11", "SB15", "107".
=item $line->operator
Operator of the line, as in the local transit company responsible for it.
+May be undefined.
=item $line->route
Partial route of the line (as string), usually start and destination with two
-stops in between.
+stops in between. May be undefined.
Note that start means the actual start of the line, the stop requested by
-Travel::Status::DE::VRR::Line may not even be included in this listing.
+Travel::Status::DE::EFA::Line may not even be included in this listing.
=item $line->type
@@ -89,11 +118,15 @@ When / how long above information is valid.
=over
-=item $line = Travel::Status::DE::VRR::Line->new(I<%data>)
+=item $line = Travel::Status::DE::EFA::Line->new(I<%data>)
-Returns a new Travel::Status::DE::VRR::Line object. You should not need to
+Returns a new Travel::Status::DE::EFA::Line object. You should not need to
call this.
+=item $line->TO_JSON
+
+Allows the object data to be serialized to JSON.
+
=back
=head1 DIAGNOSTICS
@@ -114,11 +147,11 @@ The B<route> accessor returns a simple string, an array might be better suited.
=head1 SEE ALSO
-Travel::Status::DE::VRR(3pm).
+Travel::Status::DE::EFA(3pm).
=head1 AUTHOR
-Copyright (C) 2011 by Daniel Friesel E<lt>derf@finalrewind.orgE<gt>
+Copyright (C) 2011-2025 Birte Kristina Friesel E<lt>derf@finalrewind.orgE<gt>
=head1 LICENSE
diff --git a/lib/Travel/Status/DE/EFA/Services.pm.PL b/lib/Travel/Status/DE/EFA/Services.pm.PL
new file mode 100644
index 0000000..964c9be
--- /dev/null
+++ b/lib/Travel/Status/DE/EFA/Services.pm.PL
@@ -0,0 +1,147 @@
+#!/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}-efa.json") );
+ my %ret = (
+ name => $opt{name} // $data->{name} =~ s{ *[(][^)]+[)]}{}r,
+ homepage => $data->{attribution}{homepage},
+ url => $opt{url} // $data->{options}{endpoint} =~ s{ / $ }{}rx,
+ time_zone => $data->{timezone},
+ languages => $data->{supportedLanguages},
+ coverage => {
+ area => $data->{coverage}{realtimeCoverage}{area},
+ regions => $data->{coverage}{realtimeCoverage}{region} // []
+ },
+ );
+
+ return %ret;
+}
+
+# GVH: 403
+# VRT: Encoding issues
+# VVSt: NXDOMAIN
+my %efa_instance = (
+ BEG => {
+ url => 'https://bahnland-bayern.de/efa',
+ name => 'Bayerische Eisenbahngesellschaft',
+ },
+ BSVG => {
+ url => 'https://bsvg.efa.de/bsvagstd',
+ name => 'Braunschweiger Verkehrs-GmbH',
+ },
+ bwegt => { load_instance('de/bwegt') },
+ DING => {
+ url => 'https://www.ding.eu/ding3',
+ name => 'Donau-Iller Nahverkehrsverbund',
+ },
+ KVV => { load_instance('de/kvv') },
+ LinzAG => {
+ url => 'https://www.linzag.at/static',
+ name => 'Linz AG',
+ encoding => 'iso-8859-15',
+ },
+ MVV => { load_instance('de/mvv') },
+ NVBW => {
+ url => 'https://www.efa-bw.de/nvbw',
+ name => 'Nahverkehrsgesellschaft Baden-Württemberg',
+ },
+ NWL => {
+ url => 'https://westfalenfahrplan.de/nwl-efa',
+ name => 'Nahverkehr Westfalen-Lippe',
+ },
+ Rolph => { load_instance('de/rolph') },
+ RVV => {
+ url => 'https://efa.rvv.de/efa',
+ name => 'Regensburger Verkehrsverbund',
+ },
+ VAG => {
+ url => 'https://efa.vagfr.de/vagfr3',
+ name => 'Freiburger Verkehrs AG',
+ },
+ VGN =>
+ { load_instance( 'de/vgn', url => 'https://efa.vgn.de/vgnExt_oeffi' ) },
+ VMV => { load_instance('de/vmv') },
+ VRN => {
+ url => 'https://www.vrn.de/mngvrn/',
+ name => 'Verkehrsverbund Rhein-Neckar',
+ },
+ VRR => {
+ load_instance(
+ 'de/vrr',
+ url => 'https://efa.vrr.de/vrr',
+ ),
+ },
+ VRR2 => { load_instance('de/vrr') },
+ VRR3 => { load_instance( 'de/vrr', url => 'https://efa.vrr.de/rbgstd3' ) },
+ VVO => {
+ url => 'https://efa.vvo-online.de/VMSSL3',
+ name => 'Verkehrsverbund Oberelbe',
+ },
+ VVS => { load_instance('de/vvs') },
+
+);
+
+my $buf = <<'__EOF__';
+package Travel::Status::DE::EFA::Services;
+
+# vim:readonly
+# This package has been automatically generated
+# by lib/Travel/Status/DE/EFA/Services.pm.PL.
+# Do not edit, changes will be lost.
+
+use strict;
+use warnings;
+use 5.014;
+use utf8;
+
+our $VERSION = '3.15';
+
+# Most of these have been adapted from
+# <https://github.com/public-transport/transport-apis> and
+# <https://github.com/public-transport/hafas-client/tree/main/p>.
+# Many thanks to Jannis R / @derhuerst and all contributors for maintaining
+# these resources.
+
+__EOF__
+
+my $perlobj = Data::Dumper->new( [ \%efa_instance ], ['efa_instance'] );
+
+$buf .= 'my ' . $perlobj->Sortkeys(1)->Indent(0)->Dump;
+
+$buf .= <<'__EOF__';
+
+sub get_service_ids {
+ return sort keys %{$efa_instance};
+}
+
+sub get_service {
+ my ($service) = @_;
+ return $efa_instance->{$service};
+}
+
+sub get_service_ref {
+ return $efa_instance;
+}
+
+sub get_service_map {
+ return %{$efa_instance};
+}
+
+1;
+__EOF__
+
+write_file( $ARGV[0], { binmode => ':utf8' }, $buf );
diff --git a/lib/Travel/Status/DE/EFA/Stop.pm b/lib/Travel/Status/DE/EFA/Stop.pm
new file mode 100644
index 0000000..ef570eb
--- /dev/null
+++ b/lib/Travel/Status/DE/EFA/Stop.pm
@@ -0,0 +1,228 @@
+package Travel::Status::DE::EFA::Stop;
+
+use strict;
+use warnings;
+use 5.010;
+
+use parent 'Class::Accessor';
+
+our $VERSION = '3.15';
+
+Travel::Status::DE::EFA::Stop->mk_ro_accessors(
+ qw(sched_arr rt_arr arr arr_delay
+ sched_dep rt_dep dep dep_delay
+ occupancy delay distance_m is_cancelled
+ place name full_name id_num id_code latlon
+ platform niveau)
+);
+
+sub new {
+ my ( $obj, %conf ) = @_;
+
+ my $ref = \%conf;
+
+ if ( $ref->{sched_arr} and $ref->{arr_delay} and not $ref->{rt_arr} ) {
+ $ref->{rt_arr}
+ = $ref->{sched_arr}->clone->add( minutes => $ref->{arr_delay} );
+ }
+
+ if ( $ref->{sched_dep} and $ref->{dep_delay} and not $ref->{rt_dep} ) {
+ $ref->{rt_dep}
+ = $ref->{sched_dep}->clone->add( minutes => $ref->{dep_delay} );
+ }
+
+ $ref->{arr} //= $ref->{rt_arr} // $ref->{sched_arr};
+ $ref->{dep} //= $ref->{rt_dep} // $ref->{sched_dep};
+
+ if ( $ref->{rt_arr}
+ and $ref->{sched_arr}
+ and not defined $ref->{arr_delay} )
+ {
+ $ref->{arr_delay}
+ = $ref->{rt_arr}->subtract_datetime( $ref->{sched_arr} )
+ ->in_units('minutes');
+ }
+
+ if ( $ref->{rt_dep}
+ and $ref->{sched_dep}
+ and not defined $ref->{dep_delay} )
+ {
+ $ref->{dep_delay}
+ = $ref->{rt_dep}->subtract_datetime( $ref->{sched_dep} )
+ ->in_units('minutes');
+ }
+
+ $ref->{delay} = $ref->{dep_delay} // $ref->{arr_delay};
+
+ return bless( $ref, $obj );
+}
+
+sub TO_JSON {
+ my ($self) = @_;
+
+ my $ret = { %{$self} };
+
+ for my $k (qw(sched_arr rt_arr arr sched_dep rt_dep dep)) {
+ if ( $ret->{$k} ) {
+ $ret->{$k} = $ret->{$k}->epoch;
+ }
+ }
+
+ return $ret;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Travel::Status::DE::EFA::Stop - Information about a stop (station) contained
+in a Travel::Status::DE::EFA::Result's route
+
+=head1 SYNOPSIS
+
+ for my $stop ($departure->route_post) {
+ printf(
+ "%s -> %s : %40s %s\n",
+ $stop->arr ? $stop->arr->strftime('%H:%M') : q{--:--},
+ $stop->dep ? $stop->dep->strftime('%H:%M') : q{--:--},
+ $stop->name, $stop->platform
+ );
+ }
+
+=head1 VERSION
+
+version 3.15
+
+=head1 DESCRIPTION
+
+Travel::Status::DE::EFA::Stop describes a single stop of a departure's
+route. It is solely based on the respective departure's schedule;
+delays or changed platforms are not taken into account.
+
+=head1 METHODS
+
+=head2 ACCESSORS
+
+Most accessors return undef if the corresponding data is not available.
+
+=over
+
+=item $stop->sched_arr
+
+DateTime(3pm) object holding scheduled arrival date and time.
+
+=item $stop->rt_arr
+
+DateTime(3pm) object holding estimated (real-time) arrival date and time.
+
+=item $stop->arr
+
+DateTime(3pm) object holding arrival date and time. Real-time data if
+available, schedule data otherwise.
+
+=item $stop->arr_delay
+
+Arrival delay in minutes.
+
+=item $stop->sched_dep
+
+DateTime(3pm) object holding scheduled departure date and time.
+
+=item $stop->rt_dep
+
+DateTime(3pm) object holding estimated (real-time) departure date and time.
+
+=item $stop->dep
+
+DateTime(3pm) object holding departure date and time. Real-time data if
+available, schedule data otherwise.
+
+=item $stop->dep_delay
+
+Departure delay in minutes.
+
+=item $stop->delay
+
+Delay in minutes. Departure delya if available, arrival delay otherwise.
+
+=item $stop->distance_m
+
+Distance from request coordinates in meters. undef if the object has not
+been obtained by means of a coord request.
+
+=item $stop->id_num
+
+Stop ID (numeric).
+
+=item $stop->id_code
+
+Stop ID (code).
+
+=item $stop->place
+
+Place or city name, for instance "Essen".
+
+=item $stop->full_name
+
+stop name with place or city prefix ("I<City> I<Stop>", for instance
+"Essen RE<uuml>ttenscheider Stern").
+
+=item $stop->name
+
+stop name without place or city prefix, for instance "RE<uuml>ttenscheider Stern".
+
+=item $stop->latlon
+
+Arrayref describing the stop's latitude and longitude in WGS84 coordinates.
+
+=item $stop->platform
+
+Platform name/number if available, empty string otherwise.
+
+=back
+
+=head2 INTERNAL
+
+=over
+
+=item $stop = Travel::Status::DE::EFA::Stop->new(I<%data>)
+
+Returns a new Travel::Status::DE::EFA::Stop object. You should not need to
+call this.
+
+=item $stop->TO_JSON
+
+Allows the object data to be serialized to JSON.
+
+=back
+
+=head1 DIAGNOSTICS
+
+None.
+
+=head1 DEPENDENCIES
+
+=over
+
+=item Class::Accessor(3pm)
+
+=back
+
+=head1 BUGS AND LIMITATIONS
+
+This module is a Work in Progress.
+Its API may change between minor versions.
+
+=head1 SEE ALSO
+
+Travel::Status::DE::EFA(3pm).
+
+=head1 AUTHOR
+
+Copyright (C) 2015-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/DE/EFA/Trip.pm b/lib/Travel/Status/DE/EFA/Trip.pm
new file mode 100644
index 0000000..fbe2643
--- /dev/null
+++ b/lib/Travel/Status/DE/EFA/Trip.pm
@@ -0,0 +1,375 @@
+package Travel::Status::DE::EFA::Trip;
+
+use strict;
+use warnings;
+use 5.010;
+
+use DateTime::Format::Strptime;
+use Travel::Status::DE::EFA::Stop;
+
+use parent 'Class::Accessor';
+
+our $VERSION = '3.15';
+
+Travel::Status::DE::EFA::Trip->mk_ro_accessors(
+ qw(operator product product_class name line number type id dest_name dest_id)
+);
+
+sub new {
+ my ( $obj, %conf ) = @_;
+
+ my $json = $conf{json}{transportation} // $conf{json}{leg}{transportation};
+
+ #say $json->{disassembledName} . ' <-> ' . $json->{number};
+
+ my $ref = {
+ operator => $json->{operator}{name},
+ product => $json->{product}{name},
+ product_class => $json->{product}{class},
+ polyline_raw => $conf{json}{leg}{coords},
+ name => $json->{name},
+ line => $json->{disassembledName},
+ number => $json->{properties}{trainNumber},
+ type => $json->{properties}{trainType} // $json->{product}{name},
+ id => $json->{id},
+ dest_name => $json->{destination}{name},
+ dest_id => $json->{destination}{id},
+ route_raw => $json->{locationSequence}
+ // $conf{json}{leg}{stopSequence},
+ strptime_obj => DateTime::Format::Strptime->new(
+ pattern => '%Y-%m-%dT%H:%M:%SZ',
+ time_zone => 'UTC'
+ ),
+ };
+ if ( ref( $ref->{polyline_raw} ) eq 'ARRAY'
+ and @{ $ref->{polyline_raw} } == 1 )
+ {
+ $ref->{polyline_raw} = $ref->{polyline_raw}[0];
+ }
+ return bless( $ref, $obj );
+}
+
+sub polyline {
+ my ( $self, %opt ) = @_;
+
+ if ( $self->{polyline} ) {
+ return @{ $self->{polyline} };
+ }
+
+ if ( not @{ $self->{polyline_raw} // [] } ) {
+ if ( $opt{fallback} ) {
+ return map {
+ {
+ lat => $_->{latlon}[0],
+ lon => $_->{latlon}[1],
+ stop => $_,
+ }
+ } $self->route;
+ }
+ return;
+ }
+
+ $self->{polyline} = [ map { { lat => $_->[0], lon => $_->[1] } }
+ @{ $self->{polyline_raw} } ];
+ my $distance;
+
+ eval {
+ require GIS::Distance;
+ $distance = GIS::Distance->new;
+ };
+
+ # Ggf. sollte die Abbildung andersrum laufen: Im zweiten Schritt durch die
+ # Polyline iterieren und Stops zuordnen (d.h. polyline_i als Key); bei
+ # Bedarf Polyline-Indexe duplizieren. Lässt sich wunderbar an der Linie
+ # 101/106 in Essen testen (3x Helenenstr, davon 2x am Anfang und 1x
+ # mittendrin).
+
+ if ($distance) {
+ my %min_dist;
+
+ # A single trip may pass the same stop multiple times, meaning that
+ # stop IDs alone are not guaranteed to be unique. So we need to use a
+ # stop's index in the trip's route as key in addition to the stop's ID.
+ my $route_i = 0;
+ for my $stop ( $self->route ) {
+ for my $polyline_index ( 0 .. $#{ $self->{polyline} } ) {
+ my $pl = $self->{polyline}[$polyline_index];
+ my $dist = $distance->distance_metal(
+ $stop->{latlon}[0],
+ $stop->{latlon}[1],
+ $pl->{lat}, $pl->{lon}
+ );
+ my $key = $route_i . ';' . $stop->{id_code};
+ if ( not $min_dist{$key}
+ or $min_dist{$key}{dist} > $dist )
+ {
+ $min_dist{$key} = {
+ dist => $dist,
+ index => $polyline_index,
+ };
+ }
+ }
+ $route_i += 1;
+ }
+ $route_i = 0;
+ for my $stop ( $self->route ) {
+ my $key = $route_i . ';' . $stop->{id_code};
+ if ( $min_dist{$key} ) {
+ if ( defined $self->{polyline}[ $min_dist{$key}{index} ]{stop} )
+ {
+ warn(
+"$key: overwriting stop ref at $min_dist{$key}{index} with $key"
+ );
+
+ # XXX experimental and untested
+ # one polyline entry maps to multiple stops → duplicate it; insert $stop after the already-present entry
+ #$min_dist{$key}{index} += 1;
+ #splice(
+ # @{ $self->{polyline} },
+ # $min_dist{$key}{index},
+ # 0, { %{ $self->{polyline}[ $min_dist{$key}{index} ] } }
+ #);
+ }
+ $self->{polyline}[ $min_dist{$key}{index} ]{stop}
+ = $stop;
+ }
+ $route_i += 1;
+ }
+ }
+
+ return @{ $self->{polyline} };
+}
+
+sub parse_dt {
+ my ( $self, $value ) = @_;
+
+ if ($value) {
+ my $dt = $self->{strptime_obj}->parse_datetime($value);
+ if ($dt) {
+ return $dt->set_time_zone('Europe/Berlin');
+ }
+ }
+ return undef;
+}
+
+sub route {
+ my ($self) = @_;
+
+ if ( $self->{route} ) {
+ return @{ $self->{route} };
+ }
+
+ for my $stop ( @{ $self->{route_raw} // [] } ) {
+ my $chain = $stop;
+ my ( $platform, $place, $name, $name_full, $id_num, $id_code );
+ while ( $chain->{type} ) {
+ if ( $chain->{type} eq 'platform' ) {
+ $platform = $chain->{properties}{platformName}
+ // $chain->{properties}{platform};
+ }
+ elsif ( $chain->{type} eq 'stop' ) {
+ $name = $chain->{disassembledName};
+ $name_full = $chain->{name};
+ $id_code = $chain->{id};
+ $id_num = $chain->{properties}{stopId};
+ }
+ elsif ( $chain->{type} eq 'locality' ) {
+ $place = $chain->{name};
+ }
+ $chain = $chain->{parent};
+ }
+ push(
+ @{ $self->{route} },
+ Travel::Status::DE::EFA::Stop->new(
+ sched_arr => $self->parse_dt( $stop->{arrivalTimePlanned} ),
+ sched_dep => $self->parse_dt( $stop->{departureTimePlanned} ),
+ rt_arr => $self->parse_dt( $stop->{arrivalTimeEstimated} ),
+ rt_dep => $self->parse_dt( $stop->{departureTimeEstimated} ),
+ occupancy => $stop->{properties}{occupancy},
+ is_cancelled => $stop->{isCancelled},
+ latlon => $stop->{coord},
+ full_name => $name_full,
+ name => $name,
+ place => $place,
+ niveau => $stop->{niveau},
+ platform => $platform,
+ id_code => $id_code,
+ id_num => $id_num,
+ )
+ );
+ }
+
+ delete $self->{route_raw};
+
+ return @{ $self->{route} // [] };
+}
+
+sub TO_JSON {
+ my ($self) = @_;
+
+ # lazy loading
+ $self->route;
+
+ # lazy loading
+ $self->polyline;
+
+ my $ret = { %{$self} };
+
+ delete $ret->{strptime_obj};
+
+ return $ret;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Travel::Status::DE::EFA::Trip - Information about an individual public transit
+trip
+
+=head1 SYNOPSIS
+
+ printf( "%s %s -> %s\n", $trip->type, $trip->line // q{}, $trip->dest_name );
+ for my $stop ( $trip->route ) {
+ ...;
+ }
+
+=head1 VERSION
+
+version 3.15
+
+=head1 DESCRIPTION
+
+Travel::Status::DE::EFA::Trip describes a single trip / journey of a public
+transport line.
+
+=head1 METHODS
+
+=head2 ACCESSORS
+
+Most accessors return undef if the corresponding data is not available.
+
+=over
+
+=item $trip->operator
+
+Operator name.
+
+=item $trip->product
+
+Product name.
+
+=item $trip->product_class
+
+Product class.
+
+=item $trip->name
+
+Trip or line name.
+
+=item $trip->line
+
+Line identifier. Note that this is not necessarily numeric.
+
+=item $trip->number
+
+Trip/journey number.
+
+=item $trip->type
+
+Transport / vehicle type, e.g. "RE" or "Bus".
+
+=item $trip->id
+
+Unique(?) trip ID
+
+=item $trip->dest_name
+
+Name of the trip's destination stop
+
+=item $trip->dest_id
+
+ID of the trip's destination stop
+
+=item $trip->route
+
+List of Travel::Status::DE::EFA::Stop(3pm) objects describing the route of this
+trip.
+
+Note: The EFA API requires a stop to be specified when requesting trip details.
+The stops returned by this accessor appear to be limited to stops after the
+requested stop; earlier ones may be missing.
+
+=item $journey->polyline(I<%opt>)
+
+List of geocoordinates that describe the trips's route.
+Each list entry is a hash with the following keys.
+
+=over
+
+=item * lon (longitude)
+
+=item * lat (latitude)
+
+=item * stop (Stop object for this location, if any. undef otherwise)
+
+=back
+
+Note that stop is not provided by the backend and instead inferred by this
+module.
+
+If the backend does not provide geocoordinates and this accessor was called
+with B< fallback > set to a true value, it returns the list of stop coordinates
+instead. Otherwise, it returns an empty list.
+
+=back
+
+=head2 INTERNAL
+
+=over
+
+=item $trip = Travel::Status::DE::EFA::Trip->new(I<%data>)
+
+Returns a new Travel::Status::DE::EFA::Trip object. You should not need to
+call this.
+
+=item $trip->TO_JSON
+
+Allows the object data to be serialized to JSON.
+
+=back
+
+=head1 DIAGNOSTICS
+
+None.
+
+=head1 DEPENDENCIES
+
+=over
+
+=item Class::Accessor(3pm)
+
+=item DateTime::Format::Strptime(3pm)
+
+=item Travel::Status::DE::EFA::Stop(3pm)
+
+=back
+
+=head1 BUGS AND LIMITATIONS
+
+This module is a Work in Progress.
+Its API may change between minor versions.
+
+=head1 SEE ALSO
+
+Travel::Status::DE::EFA(3pm).
+
+=head1 AUTHOR
+
+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/DE/VRR.pm b/lib/Travel/Status/DE/VRR.pm
index fc6e5bf..79e628e 100644
--- a/lib/Travel/Status/DE/VRR.pm
+++ b/lib/Travel/Status/DE/VRR.pm
@@ -4,354 +4,16 @@ use strict;
use warnings;
use 5.010;
-our $VERSION = '1.01';
+our $VERSION = '3.15';
-use Carp qw(confess cluck);
-use Encode qw(encode decode);
-use Travel::Status::DE::VRR::Line;
-use Travel::Status::DE::VRR::Result;
-use LWP::UserAgent;
-use XML::LibXML;
+use parent 'Travel::Status::DE::EFA';
sub new {
my ( $class, %opt ) = @_;
- my $ua = LWP::UserAgent->new(%opt);
- my @now = localtime( time() );
-
- my @time = @now[ 2, 1 ];
- my @date = ( $now[3], $now[4] + 1, $now[5] + 1900 );
-
- if ( not( $opt{place} and $opt{name} ) ) {
- confess('You need to specify a place and a name');
- }
- if ( $opt{type} and not( $opt{type} ~~ [qw[stop address poi]] ) ) {
- confess('type must be stop, address or poi');
- }
-
- ## no critic (RegularExpressions::ProhibitUnusedCapture)
- ## no critic (Variables::ProhibitPunctuationVars)
-
- if ( $opt{time}
- and $opt{time} =~ m{ ^ (?<hour> \d\d? ) : (?<minute> \d\d ) $ }x )
- {
- @time = @+{qw{hour minute}};
- }
- elsif ( $opt{time} ) {
- confess('Invalid time specified');
- }
-
- if (
- $opt{date}
- and $opt{date} =~ m{ ^ (?<day> \d\d? ) [.] (?<month> \d\d? ) [.]
- (?<year> \d{4} )? $ }x
- )
- {
- if ( $+{year} ) {
- @date = @+{qw{day month year}};
- }
- else {
- @date[ 0, 1 ] = @+{qw{day month}};
- }
- }
- elsif ( $opt{date} ) {
- confess('Invalid date specified');
- }
-
- my $self = {
- post => {
- command => q{},
- deleteAssignedStops_dm => '1',
- help => 'Hilfe',
- itdDateDay => $date[0],
- itdDateMonth => $date[1],
- itdDateYear => $date[2],
- itdLPxx_id_dm => ':dm',
- itdLPxx_mapState_dm => q{},
- itdLPxx_mdvMap2_dm => q{},
- itdLPxx_mdvMap_dm => '3406199:401077:NAV3',
- itdLPxx_transpCompany => 'vrr',
- itdLPxx_view => q{},
- itdTimeHour => $time[0],
- itdTimeMinute => $time[1],
- language => 'de',
- mode => 'direct',
- nameInfo_dm => 'invalid',
- nameState_dm => 'empty',
- name_dm => $opt{name},
- outputFormat => 'XML',
- placeInfo_dm => 'invalid',
- placeState_dm => 'empty',
- place_dm => $opt{place},
- ptOptionsActive => '1',
- requestID => '0',
- reset => 'neue Anfrage',
- sessionID => '0',
- submitButton => 'anfordern',
- typeInfo_dm => 'invalid',
- type_dm => $opt{type} // 'stop',
- useProxFootSearch => '0',
- useRealtime => '1',
- },
- };
-
- bless( $self, $class );
-
- $ua->env_proxy;
-
- my $response
- = $ua->post( 'http://efa.vrr.de/vrr/XSLT_DM_REQUEST', $self->{post} );
-
- if ( $response->is_error ) {
- $self->{errstr} = $response->status_line;
- return $self;
- }
-
- $self->{xml} = $response->decoded_content;
-
- $self->{tree} = XML::LibXML->load_xml( string => $self->{xml}, );
-
- $self->check_for_ambiguous();
-
- return $self;
-}
-
-sub new_from_xml {
- my ( $class, %opt ) = @_;
-
- my $self = { xml => $opt{xml}, };
-
- $self->{tree} = XML::LibXML->load_xml( string => $self->{xml}, );
-
- return bless( $self, $class );
-}
-
-sub errstr {
- my ($self) = @_;
-
- return $self->{errstr};
-}
-
-sub sprintf_date {
- my ($e) = @_;
-
- return sprintf( '%02d.%02d.%d',
- $e->getAttribute('day'),
- $e->getAttribute('month'),
- $e->getAttribute('year'),
- );
-}
-
-sub sprintf_time {
- my ($e) = @_;
-
- return sprintf( '%02d:%02d',
- $e->getAttribute('hour'),
- $e->getAttribute('minute'),
- );
-}
-
-sub check_for_ambiguous {
- my ($self) = @_;
-
- my $xml = $self->{tree};
-
- my $xp_place = XML::LibXML::XPathExpression->new('//itdOdv/itdOdvPlace');
- my $xp_name = XML::LibXML::XPathExpression->new('//itdOdv/itdOdvName');
- my $xp_mesg
- = XML::LibXML::XPathExpression->new('//itdMessage[@type="error"]');
-
- my $xp_place_elem = XML::LibXML::XPathExpression->new('./odvPlaceElem');
- my $xp_name_elem = XML::LibXML::XPathExpression->new('./odvNameElem');
-
- my $e_place = ( $xml->findnodes($xp_place) )[0];
- my $e_name = ( $xml->findnodes($xp_name) )[0];
- my @e_mesg = $xml->findnodes($xp_mesg);
-
- if ( not( $e_place and $e_name ) ) {
-
- # this should not happen[tm]
- cluck('skipping ambiguity check- itdOdvPlace/itdOdvName missing');
- return;
- }
-
- my $s_place = $e_place->getAttribute('state');
- my $s_name = $e_name->getAttribute('state');
-
- if ( $s_place eq 'list' ) {
- $self->{errstr} = sprintf(
- 'Ambiguous place input: %s',
- join( q{ | },
- map { decode( 'UTF-8', $_->textContent ) }
- @{ $e_place->findnodes($xp_place_elem) } )
- );
- return;
- }
- if ( $s_name eq 'list' ) {
- $self->{errstr} = sprintf(
- 'Ambiguous name input: %s',
- join( q{ | },
- map { decode( 'UTF-8', $_->textContent ) }
- @{ $e_name->findnodes($xp_name_elem) } )
- );
- return;
- }
- if ( $s_place eq 'notidentified' ) {
- $self->{errstr} = 'invalid place parameter';
- return;
- }
- if ( $s_name eq 'notidentified' ) {
- $self->{errstr} = 'invalid name parameter';
- return;
- }
- if (@e_mesg) {
- $self->{errstr} = join( q{; }, map { $_->textContent } @e_mesg );
- return;
- }
-
- return;
-}
-
-sub lines {
- my ($self) = @_;
- my @lines;
-
- my $xp_element
- = XML::LibXML::XPathExpression->new('//itdServingLines/itdServingLine');
-
- my $xp_info = XML::LibXML::XPathExpression->new('./itdNoTrain');
- my $xp_route = XML::LibXML::XPathExpression->new('./itdRouteDescText');
- my $xp_oper = XML::LibXML::XPathExpression->new('./itdOperator/name');
-
- if ( $self->{lines} ) {
- return @{ $self->{lines} };
- }
-
- for my $e ( $self->{tree}->findnodes($xp_element) ) {
-
- my $e_info = ( $e->findnodes($xp_info) )[0];
- my $e_route = ( $e->findnodes($xp_route) )[0];
- my $e_oper = ( $e->findnodes($xp_oper) )[0];
-
- if ( not( $e_info and $e_oper ) ) {
- cluck( 'node with insufficient data. This should not happen. '
- . $e->getAttribute('number') );
- next;
- }
-
- my $line = $e->getAttribute('number');
- my $direction = $e->getAttribute('direction');
- my $valid = $e->getAttribute('valid');
- my $type = $e_info->getAttribute('name');
- my $route = ( $e_route ? $e_route->textContent : undef );
- my $operator = $e_oper->textContent;
- my $identifier = $e->getAttribute('stateless');
-
- push(
- @lines,
- Travel::Status::DE::VRR::Line->new(
- name => $line,
- direction => decode( 'UTF-8', $direction ),
- valid => $valid,
- type => decode( 'UTF-8', $type ),
- route => decode( 'UTF-8', $route ),
- operator => decode( 'UTF-8', $operator ),
- identifier => $identifier,
- )
- );
- }
-
- $self->{lines} = \@lines;
-
- return @lines;
-}
+ $opt{efa_url} = 'http://efa.vrr.de/vrr/XSLT_DM_REQUEST';
-sub results {
- my ($self) = @_;
- my @results;
-
- my $xp_element = XML::LibXML::XPathExpression->new('//itdDeparture');
-
- my $xp_date = XML::LibXML::XPathExpression->new('./itdDateTime/itdDate');
- my $xp_time = XML::LibXML::XPathExpression->new('./itdDateTime/itdTime');
- my $xp_rdate = XML::LibXML::XPathExpression->new('./itdRTDateTime/itdDate');
- my $xp_rtime = XML::LibXML::XPathExpression->new('./itdRTDateTime/itdTime');
- my $xp_line = XML::LibXML::XPathExpression->new('./itdServingLine');
- my $xp_info
- = XML::LibXML::XPathExpression->new('./itdServingLine/itdNoTrain');
-
- if ( $self->{results} ) {
- return @{ $self->{results} };
- }
-
- $self->lines;
-
- for my $e ( $self->{tree}->findnodes($xp_element) ) {
-
- my $e_date = ( $e->findnodes($xp_date) )[0];
- my $e_time = ( $e->findnodes($xp_time) )[0];
- my $e_line = ( $e->findnodes($xp_line) )[0];
- my $e_info = ( $e->findnodes($xp_info) )[0];
-
- my $e_rdate = ( $e->findnodes($xp_rdate) )[0];
- my $e_rtime = ( $e->findnodes($xp_rtime) )[0];
-
- if ( not( $e_date and $e_time and $e_line ) ) {
- cluck('node with insufficient data. This should not happen');
- next;
- }
-
- my $date = sprintf_date($e_date);
- my $time = sprintf_time($e_time);
-
- my $rdate = $e_rdate ? sprintf_date($e_rdate) : $date;
- my $rtime = $e_rtime ? sprintf_time($e_rtime) : $time;
-
- my $platform = $e->getAttribute('platform');
- my $line = $e_line->getAttribute('number');
- my $dest = $e_line->getAttribute('direction');
- my $info = $e_info->textContent;
- my $countdown = $e->getAttribute('countdown');
- my $delay = $e_info->getAttribute('delay') // 0;
- my $type = $e_info->getAttribute('name');
-
- my $platform_is_db = 0;
-
- my @line_obj
- = grep { $_->{identifier} eq $e_line->getAttribute('stateless') }
- @{ $self->{lines} };
-
- if ( $platform =~ s{ ^ \# }{}ox ) {
- $platform_is_db = 1;
- }
-
- push(
- @results,
- Travel::Status::DE::VRR::Result->new(
- date => $rdate,
- time => $rtime,
- platform => $platform,
- platform_db => $platform_is_db,
- lineref => $line_obj[0] // undef,
- line => $line,
- destination => decode( 'UTF-8', $dest ),
- countdown => $countdown,
- info => decode( 'UTF-8', $info ),
- delay => $delay,
- sched_date => $date,
- sched_time => $time,
- type => $type,
- )
- );
- }
-
- @results = map { $_->[0] }
- sort { $a->[1] <=> $b->[1] }
- map { [ $_, $_->countdown ] } @results;
-
- $self->{results} = \@results;
-
- return @results;
+ return $class->SUPER::new(%opt);
}
1;
@@ -360,7 +22,7 @@ __END__
=head1 NAME
-Travel::Status::DE::VRR - unofficial VRR departure monitor
+Travel::Status::DE::VRR - unofficial VRR departure monitor.
=head1 SYNOPSIS
@@ -373,22 +35,22 @@ Travel::Status::DE::VRR - unofficial VRR departure monitor
for my $d ($status->results) {
printf(
"%s %d %-5s %s\n",
- $d->time, $d->platform, $d->line, $d->destination
+ $d->datetime->strftime('%H:%M'),
+ $d->platform, $d->line, $d->destination
);
}
+
=head1 VERSION
-version 1.01
+version 3.15
=head1 DESCRIPTION
Travel::Status::DE::VRR is an unofficial interface to the VRR departure
-monitor available at
+monitor at
L<http://efa.vrr.de/vrr/XSLT_DM_REQUEST?language=de&itdLPxx_transpCompany=vrr&>.
-It reports all upcoming tram/bus/train departures at a given place.
-
=head1 METHODS
=over
@@ -396,41 +58,12 @@ It reports all upcoming tram/bus/train departures at a given place.
=item my $status = Travel::Status::DE::VRR->new(I<%opt>)
Requests the departures as specified by I<opts> and returns a new
-Travel::Status::DE::VRR object. Dies if the wrong I<opts> were passed.
-
-Arguments:
-
-=over
-
-=item B<place> => I<place>
-
-Name of the place/city
-
-=item B<type> => B<address>|B<poi>|B<stop>
-
-Type of the following I<name>. B<poi> means "point of interest". Defaults to
-B<stop> (stop/station name).
+Travel::Status::DE::VRR object.
-=item B<name> => I<name>
+Calls Travel::Status::DE::EFA->new with the appropriate B<efa_url>.
+All I<opts> are passed on.
-address / poi / stop name to list departures for.
-
-=back
-
-=item $status->errstr
-
-In case of en HTTP request or EFA error, returns a string describing it. If
-none occured, returns undef.
-
-=item $status->lines
-
-Returns a list of Travel::Status::DE::VRR::Line(3pm) objects, each one
-describing one line servicing the selected station.
-
-=item $status->results
-
-Returns a list of Travel::Status::DE::VRR::Result(3pm) objects, each one describing
-one departure.
+See Travel::Status::DE::EFA(3pm) for the other parameters and methods.
=back
@@ -444,23 +77,25 @@ None.
=item * Class::Accessor(3pm)
+=item * DateTime(3pm)
+
=item * LWP::UserAgent(3pm)
-=item * XML::LibXML(3pm)
+=item * Travel::Status::DE::EFA(3pm)
=back
=head1 BUGS AND LIMITATIONS
-Not all features of the web interface are supported.
+Many.
=head1 SEE ALSO
-efa-m(1), Travel::Status::DE::VRR::Result(3pm).
+efa-m(1), Travel::Status::DE::EFA(3pm).
=head1 AUTHOR
-Copyright (C) 2011 by Daniel Friesel E<lt>derf@finalrewind.orgE<gt>
+Copyright (C) 2013-2023 Birte Kristina Friesel E<lt>derf@finalrewind.orgE<gt>
=head1 LICENSE
diff --git a/lib/Travel/Status/DE/VRR/Result.pm b/lib/Travel/Status/DE/VRR/Result.pm
deleted file mode 100644
index eabbba1..0000000
--- a/lib/Travel/Status/DE/VRR/Result.pm
+++ /dev/null
@@ -1,195 +0,0 @@
-package Travel::Status::DE::VRR::Result;
-
-use strict;
-use warnings;
-use 5.010;
-
-use parent 'Class::Accessor';
-
-our $VERSION = '1.01';
-
-Travel::Status::DE::VRR::Result->mk_ro_accessors(
- qw(countdown date delay destination info line lineref platform
- platform_db sched_date sched_time time type)
-);
-
-sub new {
- my ( $obj, %conf ) = @_;
-
- my $ref = \%conf;
-
- return bless( $ref, $obj );
-}
-1;
-
-__END__
-
-=head1 NAME
-
-Travel::Status::DE::VRR::Result - Information about a single
-departure received by Travel::Status::DE::VRR
-
-=head1 SYNOPSIS
-
- for my $departure ($status->results) {
- printf(
- "At %s: %s to %s from platform %s\n",
- $departure->time, $departure->line, $departure->destination,
- $departure->platform
- );
- }
-
-=head1 VERSION
-
-version 1.01
-
-=head1 DESCRIPTION
-
-Travel::Status::DE::VRR::Result describes a single departure as obtained by
-Travel::Status::DE::VRR. It contains information about the time, platform,
-line number and destination.
-
-=head1 METHODS
-
-=head2 ACCESSORS
-
-"Actual" in the description means that the delay (if available) is already
-included in the calculation, "Scheduled" means it isn't.
-
-=over
-
-=item $departure->countdown
-
-Actual time in minutes from now until the tram/bus/train will depart.
-
-If delay information is available, it is already included.
-
-=item $departure->date
-
-Actual departure date (DD.MM.YYYY).
-
-=item $departure->delay
-
-Expected delay from scheduled departure time in minutes.
-
-Note that this is only available for DB trains, in other cases it will always
-return 0.
-
-=item $departure->destination
-
-Destination name.
-
-=item $departure->info
-
-Additional information related to the departure (string). If departures for
-an address were requested, this is the stop name, otherwise it may be recent
-news related to the line's schedule. If no information is available, returns
-an empty string.
-
-=item $departure->line
-
-The name/number of the line.
-
-=item $departure->lineref
-
-Travel::Status::DE::VRR::Line(3pm) object describing the departing line in
-detail.
-
-=item $departure->platform
-
-Departure platform number.
-
-=item $departure->platform_db
-
-true if the platform number is operated by DB ("Gleis x"), false ("Bstg. x")
-otherwise.
-
-Unfortunately, there is no distinction between tram and bus platforms yet,
-which also may have the same numbers.
-
-=item $departure->sched_date
-
-Scheduled departure date (DD.MM.YYYY).
-
-=item $departure->sched_time
-
-Scheduled departure time (HH:MM).
-
-=item $departure->time
-
-Actual departure time (HH:MM).
-
-=item $departure->type
-
-Type of the departure. Note that efa.vrr.de sometimes puts bogus data in this
-field. See L</DEPARTURE TYPES>.
-
-=back
-
-=head2 INTERNAL
-
-=over
-
-=item $departure = Travel::Status::DE::VRR::Result->new(I<%data>)
-
-Returns a new Travel::Status::DE::VRR::Result object. You should not need to
-call this.
-
-=back
-
-=head1 DEPARTURE TYPES
-
-The following are known so far:
-
-=over
-
-=item * Abellio-Zug
-
-=item * Eurocity
-
-=item * Intercity-Express
-
-=item * NE (NachtExpress / night bus)
-
-=item * Niederflurbus
-
-=item * R-Bahn (RE / RegionalExpress)
-
-=item * S-Bahn
-
-=item * SB (Schnellbus)
-
-=item * StraE<szlig>enbahn
-
-=item * U-Bahn
-
-=back
-
-=head1 DIAGNOSTICS
-
-None.
-
-=head1 DEPENDENCIES
-
-=over
-
-=item Class::Accessor(3pm)
-
-=back
-
-=head1 BUGS AND LIMITATIONS
-
-C<< $result->type >> may contain bogus data. This comes from the efa.vrr.de
-interface.
-
-=head1 SEE ALSO
-
-Travel::Status::DE::VRR(3pm).
-
-=head1 AUTHOR
-
-Copyright (C) 2011 by Daniel Friesel E<lt>derf@finalrewind.orgE<gt>
-
-=head1 LICENSE
-
-This module is licensed under the same terms as Perl itself.