summaryrefslogtreecommitdiff
path: root/lib/Travel/Status/DE/EFA.pm
diff options
context:
space:
mode:
Diffstat (limited to 'lib/Travel/Status/DE/EFA.pm')
-rw-r--r--lib/Travel/Status/DE/EFA.pm1070
1 files changed, 594 insertions, 476 deletions
diff --git a/lib/Travel/Status/DE/EFA.pm b/lib/Travel/Status/DE/EFA.pm
index 681b8b1..be08b9a 100644
--- a/lib/Travel/Status/DE/EFA.pm
+++ b/lib/Travel/Status/DE/EFA.pm
@@ -5,17 +5,63 @@ use warnings;
use 5.010;
use utf8;
-no if $] >= 5.018, warnings => 'experimental::smartmatch';
-
-our $VERSION = '1.20';
+our $VERSION = '3.13';
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::Result;
+use Travel::Status::DE::EFA::Services;
use Travel::Status::DE::EFA::Stop;
+use Travel::Status::DE::EFA::Trip;
use LWP::UserAgent;
-use XML::LibXML;
+
+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 ) = @_;
@@ -25,22 +71,49 @@ sub new {
delete $opt{timeout};
}
- 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{name} ) ) {
+ 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} ~~ [qw[stop stopID address poi]] ) ) {
+ 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('efa_url is mandatory');
+ 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)
@@ -48,7 +121,10 @@ sub new {
if ( $opt{time}
and $opt{time} =~ m{ ^ (?<hour> \d\d? ) : (?<minute> \d\d ) $ }x )
{
- @time = @+{qw{hour minute}};
+ $dt->set(
+ hour => $+{hour},
+ minute => $+{minute}
+ );
}
elsif ( $opt{time} ) {
confess('Invalid time specified');
@@ -61,10 +137,17 @@ sub new {
)
{
if ( $+{year} ) {
- @date = @+{qw{day month year}};
+ $dt->set(
+ day => $+{day},
+ month => $+{month},
+ year => $+{year}
+ );
}
else {
- @date[ 0, 1 ] = @+{qw{day month}};
+ $dt->set(
+ day => $+{day},
+ month => $+{month}
+ );
}
}
elsif ( $opt{date} ) {
@@ -72,40 +155,80 @@ sub new {
}
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 => encode( 'UTF-8', $opt{name} ),
- outputFormat => 'XML',
- ptOptionsActive => '1',
- requestID => '0',
- reset => 'neue Anfrage',
- sessionID => '0',
- submitButton => 'anfordern',
- typeInfo_dm => 'invalid',
- type_dm => $opt{type} // 'stop',
- useProxFootSearch => $opt{proximity_search} ? '1' : '0',
- useRealtime => '1',
- },
+ 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';
@@ -120,35 +243,46 @@ sub new {
bless( $self, $class );
- $ua->env_proxy;
+ 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;
+ }
- my $response = $ua->post( $opt{efa_url}, $self->{post} );
+ if ( $self->{cache} ) {
+ $self->{cache_key}
+ = $self->{efa_url} . '?'
+ . join( '&',
+ map { $_ . '=' . $self->{post}{$_} } sort keys %{ $self->{post} } );
+ }
- if ( $response->is_error ) {
- $self->{errstr} = $response->status_line;
+ if ( $opt{async} ) {
return $self;
}
- if ( $opt{efa_encoding} ) {
- $self->{xml} = encode( $opt{efa_encoding}, $response->content );
- }
- else {
- $self->{xml} = $response->decoded_content;
+ 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->{xml} ) {
+ if ( not $self->{response} ) {
+ my ( $response, $error ) = $self->post_with_cache;
- # LibXML doesn't like empty documents
- $self->{errstr} = 'Server returned nothing (empty result)';
- return $self;
- }
+ if ($error) {
+ $self->{errstr} = $error;
+ return $self;
+ }
- $self->{tree} = XML::LibXML->load_xml(
- string => $self->{xml},
- );
+ $self->{response} = $self->{json}->decode($response);
+ }
if ( $self->{developer_mode} ) {
- say $self->{tree}->toString(1);
+ say $self->{json}->pretty->encode( $self->{response} );
}
$self->check_for_ambiguous();
@@ -156,18 +290,92 @@ sub new {
return $self;
}
-sub new_from_xml {
- my ( $class, %opt ) = @_;
+sub post_with_cache {
+ my ($self) = @_;
+ my $cache = $self->{cache};
+ my $url = $self->{efa_url};
- my $self = {
- xml => $opt{xml},
- };
+ if ( $self->{developer_mode} ) {
+ say 'POST ' . ( $self->{cache_key} // $url );
+ }
- $self->{tree} = XML::LibXML->load_xml(
- string => $self->{xml},
- );
+ 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 bless( $self, $class );
+ 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 {
@@ -194,326 +402,245 @@ sub place_candidates {
return;
}
-sub sprintf_date {
- my ($e) = @_;
+sub check_for_ambiguous {
+ my ($self) = @_;
+
+ my $json = $self->{response};
- if ( $e->getAttribute('day') == -1 ) {
+ if ( $json->{departureList} ) {
return;
}
- return sprintf( '%02d.%02d.%d',
- $e->getAttribute('day'),
- $e->getAttribute('month'),
- $e->getAttribute('year'),
- );
-}
-
-sub sprintf_time {
- my ($e) = @_;
-
- if ( $e->getAttribute('minute') == -1 ) {
- 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 sprintf( '%02d:%02d',
- $e->getAttribute('hour'),
- $e->getAttribute('minute'),
- );
+ return;
}
-sub check_for_ambiguous {
+sub stop {
my ($self) = @_;
+ if ( $self->{stop} ) {
+ return $self->{stop};
+ }
- 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 $point = $self->{response}{dm}{points}{point};
+ my $place = $point->{ref}{place};
- my $xp_place_elem = XML::LibXML::XPathExpression->new('./odvPlaceElem');
- my $xp_name_elem = XML::LibXML::XPathExpression->new('./odvNameElem');
+ $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},
+ );
- my $e_place = ( $xml->findnodes($xp_place) )[0];
- my $e_name = ( $xml->findnodes($xp_name) )[0];
- my @e_mesg = $xml->findnodes($xp_mesg);
+ return $self->{stop};
+}
- if ( not( $e_place and $e_name ) ) {
+sub stops {
+ my ($self) = @_;
- # this should not happen[tm]
- cluck('skipping ambiguity check- itdOdvPlace/itdOdvName missing');
- return;
+ if ( $self->{stops} ) {
+ return @{ $self->{stops} };
}
- my $s_place = $e_place->getAttribute('state');
- my $s_name = $e_name->getAttribute('state');
+ my $stops = $self->{response}{dm}{itdOdvAssignedStops} // [];
- if ( $s_place eq 'list' ) {
- $self->{place_candidates} = [ map { $_->textContent }
- @{ $e_place->findnodes($xp_place_elem) } ];
- $self->{errstr} = 'ambiguous place parameter';
- return;
+ if ( ref($stops) eq 'HASH' ) {
+ $stops = [$stops];
}
- if ( $s_name eq 'list' ) {
- $self->{name_candidates}
- = [ map { $_->textContent } @{ $e_name->findnodes($xp_name_elem) } ];
- $self->{errstr} = 'ambiguous name parameter';
- 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;
+ 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},
+ )
+ );
}
- return;
+ $self->{stops} = \@stops;
+ return @stops;
}
-sub identified_data {
+sub infos {
my ($self) = @_;
- if ( not $self->{tree} ) {
- return;
+ if ( $self->{infos} ) {
+ return @{ $self->{infos} };
}
- my $xp_place
- = XML::LibXML::XPathExpression->new('//itdOdv/itdOdvPlace/odvPlaceElem');
- my $xp_name
- = XML::LibXML::XPathExpression->new('//itdOdv/itdOdvName/odvNameElem');
-
- my $e_place = ( $self->{tree}->findnodes($xp_place) )[0];
- my $e_name = ( $self->{tree}->findnodes($xp_name) )[0];
+ for my $info ( @{ $self->{response}{dm}{points}{point}{infos} // [] } ) {
+ push(
+ @{ $self->{infos} },
+ Travel::Status::DE::EFA::Info->new( json => $info )
+ );
+ }
- return ( $e_place->textContent, $e_name->textContent );
+ return @{ $self->{infos} // [] };
}
sub lines {
my ($self) = @_;
- my @lines;
if ( $self->{lines} ) {
return @{ $self->{lines} };
}
- if ( not $self->{tree} ) {
- return;
+ for my $line ( @{ $self->{response}{servingLines}{lines} // [] } ) {
+ push( @{ $self->{lines} }, $self->parse_line($line) );
}
- my $xp_element
- = XML::LibXML::XPathExpression->new('//itdServingLines/itdServingLine');
+ return @{ $self->{lines} // [] };
+}
- 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');
+sub parse_line {
+ my ( $self, $line ) = @_;
- for my $e ( $self->{tree}->findnodes($xp_element) ) {
+ my $mode = $line->{mode} // {};
- my $e_info = ( $e->findnodes($xp_info) )[0];
- my $e_route = ( $e->findnodes($xp_route) )[0];
- my $e_oper = ( $e->findnodes($xp_oper) )[0];
+ 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},
- if ( not($e_info) ) {
- 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 $mot = $e->getAttribute('motType');
- my $route = ( $e_route ? $e_route->textContent : undef );
- my $operator = ( $e_oper ? $e_oper->textContent : undef );
- my $identifier = $e->getAttribute('stateless');
+sub results {
+ my ($self) = @_;
- push(
- @lines,
- Travel::Status::DE::EFA::Line->new(
- name => $line,
- direction => $direction,
- valid => $valid,
- type => $type,
- mot => $mot,
- route => $route,
- operator => $operator,
- identifier => $identifier,
- )
- );
+ if ( $self->{results} ) {
+ return @{ $self->{results} };
}
- $self->{lines} = \@lines;
-
- return @lines;
+ if ( $self->{post}{coord} ) {
+ return $self->results_coord;
+ }
+ elsif ( $self->{post}{name_sf} ) {
+ return $self->results_stopfinder;
+ }
+ else {
+ return $self->results_dm;
+ }
}
-sub parse_route {
- my ( $self, @nodes ) = @_;
- my $xp_routepoint_date
- = XML::LibXML::XPathExpression->new('./itdDateTime/itdDate');
- my $xp_routepoint_time
- = XML::LibXML::XPathExpression->new('./itdDateTime/itdTime');
-
- my @ret;
-
- for my $e (@nodes) {
- my @dates = $e->findnodes($xp_routepoint_date);
- my @times = $e->findnodes($xp_routepoint_time);
+sub results_coord {
+ my ($self) = @_;
+ my $json = $self->{response};
- # note that the first stop has an arrival node with an invalid
- # timestamp and the terminal stop has a departure node with an
- # invalid timestamp. sprintf_{date,time} return undef in these
- # cases.
+ my @results;
+ for my $stop ( @{ $json->{locations} // [] } ) {
push(
- @ret,
+ @results,
Travel::Status::DE::EFA::Stop->new(
- arr_date => sprintf_date( $dates[0] ),
- arr_time => sprintf_time( $times[0] ),
- dep_date => sprintf_date( $dates[-1] ),
- dep_time => sprintf_time( $times[-1] ),
- name => $e->getAttribute('name'),
- name_suf => $e->getAttribute('nameWO'),
- platform => $e->getAttribute('platformName'),
+ place => $stop->{parent}{name},
+ full_name => $stop->{properties}{STOP_NAME_WITH_PLACE},
+ distance_m => $stop->{properties}{distance},
+ name => $stop->{name},
+ id_code => $stop->{id},
)
);
}
- return @ret;
+ $self->{results} = \@results;
+
+ return @results;
}
-sub results {
+sub results_stopfinder {
my ($self) = @_;
+ my $json = $self->{response};
+
my @results;
- if ( $self->{results} ) {
- return @{ $self->{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} ];
}
- if ( not $self->{tree} ) {
- return;
+ 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},
+ )
+ );
}
- 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');
- my $xp_prev_route
- = XML::LibXML::XPathExpression->new('./itdPrevStopSeq/itdPoint');
- my $xp_next_route
- = XML::LibXML::XPathExpression->new('./itdOnwardStopSeq/itdPoint');
-
- $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;
- }
+ $self->{results} = \@results;
- 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 $platform_name = $e->getAttribute('platformName');
- my $countdown = $e->getAttribute('countdown');
- my $occupancy = $e->getAttribute('occupancy');
- my $line = $e_line->getAttribute('number');
- my $train_no = $e_line->getAttribute('trainNum');
- my $dest = $e_line->getAttribute('direction');
- my $info = $e_info->textContent;
- my $key = $e_line->getAttribute('key');
- my $delay = $e_info->getAttribute('delay');
- my $type = $e_info->getAttribute('name');
- my $mot = $e_line->getAttribute('motType');
-
- my $platform_is_db = 0;
-
- my @prev_route;
- my @next_route;
-
- if ( $self->{want_full_routes} ) {
- @prev_route
- = $self->parse_route( @{ [ $e->findnodes($xp_prev_route) ] } );
- @next_route
- = $self->parse_route( @{ [ $e->findnodes($xp_next_route) ] } );
- }
+ return @results;
+}
- my @line_obj
- = grep { $_->{identifier} eq $e_line->getAttribute('stateless') }
- @{ $self->{lines} };
-
- # platform / platformName are inconsistent. The following cases are
- # known:
- #
- # * platform="int", platformName="" : non-DB platform
- # * platform="int", platformName="Bstg. int" : non-DB platform
- # * platform="#int", platformName="Gleis int" : non-DB platform
- # * platform="#int", platformName="Gleis int" : DB platform?
- # * platform="", platformName="Gleis int" : DB platform
- # * platform="DB", platformName="Gleis int" : DB platform
- # * platform="gibberish", platformName="Gleis int" : DB platform
-
- if ( ( $platform_name and $platform_name =~ m{ ^ Gleis }ox )
- and not( $platform and $platform =~ s{ ^ \# }{}ox ) )
- {
- $platform_is_db = 1;
- }
+sub results_dm {
+ my ($self) = @_;
+ my $json = $self->{response};
- if ( $platform_name and $platform_name =~ m{ ^ (Gleis | Bstg[.])}ox ) {
- $platform = ( split( / /, $platform_name ) )[1];
- }
- elsif ( $platform_name and not $platform ) {
- $platform = $platform_name;
- }
+ # 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::Result->new(
- date => $rdate,
- time => $rtime,
- platform => $platform,
- platform_db => $platform_is_db,
- platform_name => $platform_name,
- key => $key,
- lineref => $line_obj[0] // undef,
- line => $line,
- train_no => $train_no,
- destination => $dest,
- occupancy => $occupancy,
- countdown => $countdown,
- info => $info,
- delay => $delay,
- sched_date => $date,
- sched_time => $time,
- type => $type,
- mot => $mot,
- prev_route => \@prev_route,
- next_route => \@next_route,
+ Travel::Status::DE::EFA::Departure->new(
+ json => $departure,
+ strp_stopseq => $self->{strp_stopseq},
+ strp_stopseq_s => $self->{strp_stopseq_s}
)
);
}
@@ -527,129 +654,31 @@ sub results {
return @results;
}
-# static
-sub get_efa_urls {
-
- # sorted lexically by shortname
- return (
- {
- url => 'https://bsvg.efa.de/bsvagstd/XML_DM_REQUEST',
- name => 'Braunschweiger Verkehrs-GmbH',
- shortname => 'BSVG',
- },
- {
- url => 'https://www.ding.eu/ding3/XSLT_DM_REQUEST',
- name => 'Donau-Iller Nahverkehrsverbund',
- shortname => 'DING',
- },
- {
- url => 'https://projekte.kvv-efa.de/sl3-alone/XSLT_DM_REQUEST',
- name => 'Karlsruher Verkehrsverbund',
- shortname => 'KVV',
- },
- {
- url => 'https://www.linzag.at/static/XSLT_DM_REQUEST',
- name => 'Linz AG',
- shortname => 'LinzAG',
- encoding => 'iso-8859-15',
- },
- {
- url => 'https://efa.mvv-muenchen.de/mobile/XSLT_DM_REQUEST',
- name => 'Münchner Verkehrs- und Tarifverbund',
- shortname => 'MVV',
- },
- {
- url => 'https://www.efa-bw.de/nvbw/XSLT_DM_REQUEST',
- name => 'Nahverkehrsgesellschaft Baden-Württemberg',
- shortname => 'NVBW',
- },
-
- # HTTPS not supported
- {
- url => 'http://efa.svv-info.at/sbs/XSLT_DM_REQUEST',
- name => 'Salzburger Verkehrsverbund',
- shortname => 'SVV',
- },
-
- # HTTPS: invalid certificate
- {
- url => 'http://www.travelineeastmidlands.co.uk/em/XSLT_DM_REQUEST',
- name => 'Traveline East Midlands',
- shortname => 'TLEM',
- },
- {
- url => 'https://efa.vagfr.de/vagfr3/XSLT_DM_REQUEST',
- name => 'Freiburger Verkehrs AG',
- shortname => 'VAG',
- },
-
- # HTTPS: unsupported protocol
- {
- url => 'http://mobil.vbl.ch/vblmobil/XML_DM_REQUEST',
- name => 'Verkehrsbetriebe Luzern',
- shortname => 'VBL',
- },
-
- # HTTPS not supported
- {
- url => 'http://fahrplan.verbundlinie.at/stv/XSLT_DM_REQUEST',
- name => 'Verkehrsverbund Steiermark',
- shortname => 'Verbundlinie',
- },
- {
- url => 'https://efa.vgn.de/vgnExt_oeffi/XML_DM_REQUEST',
- name => 'Verkehrsverbund Grossraum Nuernberg',
- shortname => 'VGN',
- },
+sub result {
+ my ($self) = @_;
- # HTTPS: certificate verification fails
- {
- url => 'http://efa.vmv-mbh.de/vmv/XML_DM_REQUEST',
- name => 'Verkehrsgesellschaft Mecklenburg-Vorpommern',
- shortname => 'VMV',
- },
- {
- url => 'https://efa.vor.at/wvb/XSLT_DM_REQUEST',
- name => 'Verkehrsverbund Ost-Region',
- shortname => 'VOR',
- encoding => 'iso-8859-15',
- },
+ return Travel::Status::DE::EFA::Trip->new( json => $self->{response} );
+}
- # HTTPS not supported
- {
- url => 'http://fahrplanauskunft.vrn.de/vrn/XML_DM_REQUEST',
- name => 'Verkehrsverbund Rhein-Neckar',
- shortname => 'VRN',
- },
- {
- url => 'https://efa.vrr.de/vrr/XSLT_DM_REQUEST',
- name => 'Verkehrsverbund Rhein-Ruhr',
- shortname => 'VRR',
- },
- {
- url => 'https://app.vrr.de/standard/XML_DM_REQUEST',
- name => 'Verkehrsverbund Rhein-Ruhr (alternative)',
- shortname => 'VRR2',
- },
- {
- url => 'https://efa.vrr.de/rbgstd3/XML_DM_REQUEST',
- name => 'Verkehrsverbund Rhein-Ruhr (alternative alternative)',
- shortname => 'VRR3',
- },
+# static
+sub get_service_ids {
+ return Travel::Status::DE::EFA::Services::get_service_ids(@_);
+}
- # HTTPS not supported
- {
- url => 'http://efa.vvo-online.de:8080/dvb/XSLT_DM_REQUEST',
- name => 'Verkehrsverbund Oberelbe',
- shortname => 'VVO',
- },
- {
- url => 'https://www2.vvs.de/vvs/XSLT_DM_REQUEST',
- name => 'Verkehrsverbund Stuttgart',
- shortname => 'VVS',
- },
+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;
@@ -665,27 +694,30 @@ Travel::Status::DE::EFA - unofficial EFA departure monitor
use Travel::Status::DE::EFA;
my $status = Travel::Status::DE::EFA->new(
- efa_url => 'https://efa.vrr.de/vrr/XSLT_DM_REQUEST',
+ service => 'VRR',
name => 'Essen Helenenstr'
);
for my $d ($status->results) {
printf(
"%s %-8s %-5s %s\n",
- $d->time, $d->platform_name, $d->line, $d->destination
+ $d->datetime->strftime('%H:%M'),
+ $d->platform_name, $d->line, $d->destination
);
}
=head1 VERSION
-version 1.20
+version 3.13
=head1 DESCRIPTION
Travel::Status::DE::EFA is an unofficial interface to EFA-based departure
monitors.
-It reports all upcoming tram/bus/train departures at a given place.
+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
@@ -693,20 +725,39 @@ It reports all upcoming tram/bus/train departures at a given place.
=item my $status = Travel::Status::DE::EFA->new(I<%opt>)
-Requests the departures as specified by I<opts> and returns a new
-Travel::Status::DE::EFA object. B<efa_url> and B<name> are
-mandatory. Dies if the wrong I<opts> were passed.
+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<efa_url> => I<url>
+=item B<service> => I<name>
-URL to the EFA service. See C<< efa-m --list >> for known URLs.
-If you found a URL not listed there, please notify
+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
@@ -716,9 +767,10 @@ Name of the place/city
Type of the following I<name>. B<poi> means "point of interest". Defaults to
B<stop> (stop/station name).
-=item B<name> => I<name>
+=item B<datetime> => I<DateTime object>
-address / poi / stop name to list departures for.
+Request departures for the date/time specified by I<DateTime object>.
+Default: now.
=item B<efa_encoding> => I<encoding>
@@ -730,7 +782,7 @@ iso-8859-15.
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::Result(3pm).
+Travel::Status::DE::EFA::Departure(3pm).
=item B<proximity_search> => B<0>|B<1>
@@ -744,17 +796,37 @@ 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->identified_data
-
-Returns a list of the identified values for I<place> and I<name>.
-For instance, when requesting data for "E", "MartinSTR", B<identified_data>
-will return ("Essen", "Martinstr.").
-
=item $status->lines
Returns a list of Travel::Status::DE::EFA::Line(3pm) objects, each one
@@ -770,28 +842,70 @@ nothing (undef / empty list) otherwise.
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
-Returns a list of Travel::Status::DE::EFA::Result(3pm) objects, each one describing
-one departure.
+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
-=item Travel::Status::DE::EFA::get_efa_urls()
+In stopseq mode: Returns a Travel::Status::DE::EFA::Trip(3pm) object.
-Returns a list of known EFA entry points. Each list element is a hashref with
-the following elements.
+=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<url>: service URL as passed to B<efa_url>
+=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<name>: Name of the entity operating this service
+=item B<languages> => I<arrayref> (optional)
-=item B<shortname>: Short name of the entity
+Supportde languages, e.g. de, en.
-=item B<encoding>: Server-side encoding override for B<efa_encoding> (optional)
+=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
@@ -804,23 +918,27 @@ None.
=item * Class::Accessor(3pm)
-=item * LWP::UserAgent(3pm)
+=item * DateTime(3pm)
-=item * XML::LibXML(3pm)
+=item * DateTime::Format::Strptime(3pm)
+
+=item * JSON(3pm)
+
+=item * LWP::UserAgent(3pm)
=back
=head1 BUGS AND LIMITATIONS
-Not all features of the web interface are supported.
+The API is not exposed completely.
=head1 SEE ALSO
-efa-m(1), Travel::Status::DE::EFA::Result(3pm).
+efa-m(1), Travel::Status::DE::EFA::Departure(3pm).
=head1 AUTHOR
-Copyright (C) 2011-2015 by Daniel Friesel E<lt>derf@finalrewind.orgE<gt>
+Copyright (C) 2011-2025 Birte Kristina Friesel E<lt>derf@finalrewind.orgE<gt>
=head1 LICENSE