#!/usr/bin/env perl use strict; use warnings; use 5.010; use utf8; our $VERSION = '3.04'; binmode( STDOUT, ':encoding(utf-8)' ); use Encode qw(decode); use Getopt::Long qw(:config no_ignore_case bundling); use JSON; use List::Util qw(first max none); use Travel::Status::DE::EFA; my $service = 'VRR'; my $efa_url; my $efa_encoding; my $use_cache = 1; my $cache; my $json_output; my ( $date, $time, $input_type, $list_lines, $offset, $relative_times ); my ( $full_routes, $filter_via, $show_jid ); my ( $timeout, $developer_mode ); my ( @grep_lines, @grep_platforms, @grep_mots ); my ( %edata, @edata_pre ); my ( $list_services, $discover_and_print, $discover ); my $efa; my %occupancy_map = ( MANY_SEATS => '.', FEW_SEATS => 'o', STANDING_ONLY => '*', FULL => '!', unknown => '?', ); @ARGV = map { decode( 'UTF-8', $_ ) } @ARGV; GetOptions( 'A|auto-url|discover-and-print' => \$discover_and_print, 'd|date=s' => \$date, 'D|discover' => \$discover, 'h|help' => sub { show_help(0) }, 'j|with-jid' => \$show_jid, 'l|line=s@' => \@grep_lines, 'L|linelist' => \$list_lines, 'list' => \$list_services, 'm|mot=s@' => \@grep_mots, 'o|offset=i' => \$offset, 'O|output=s@' => \@edata_pre, 'p|platform=s@' => \@grep_platforms, 'r|relative' => \$relative_times, 's|service=s' => \$service, 't|time=s' => \$time, 'timeout=i' => \$timeout, 'u|efa-url=s' => \$efa_url, 'v|via=s' => \$filter_via, 'V|track-via=s' => \$filter_via, 'cache!' => \$use_cache, 'json' => \$json_output, 'devmode' => \$developer_mode, 'version' => \&show_version, ) or show_help(1); if ($list_services) { show_services(); } if ( @ARGV < 1 or @ARGV > 2 ) { show_help(1); } if ($use_cache) { my $cache_path = ( $ENV{XDG_CACHE_HOME} // "$ENV{HOME}/.cache" ) . '/Travel-Status-DE-EFA'; eval { require Cache::File; $cache = Cache::File->new( cache_root => $cache_path, default_expires => '90 seconds', lock_level => Cache::File::LOCK_LOCAL(), ); }; if ($@) { $cache = undef; } } # --line=foo,bar support @edata_pre = split( qr{,}, join( q{,}, @edata_pre ) ); @grep_lines = split( qr{,}, join( q{,}, @grep_lines ) ); @grep_mots = split( qr{,}, join( q{,}, @grep_mots ) ); @grep_platforms = split( qr{,}, join( q{,}, @grep_platforms ) ); my ( $place, $input, $coord, $stopseq, $stopfinder ); if ( @ARGV == 1 ) { if ( $ARGV[0] =~ m{ ^ ([^@]*) @ ([^@]*) [(] ([^)]*) [)] (.*) $ }x ) { $stopseq = { stateless => $1, stop_id => $2, date => $3, key => $4 }; } elsif ( $ARGV[0] =~ m{ ^ [?] (? .*) $ }x ) { $stopfinder = { name => $+{name}, }; } elsif ( $ARGV[0] =~ m{ ^ (? [0-9.]+ ) : (? [0-9].+ ) $ }x ) { $coord = { lat => $+{lat}, lon => $+{lon}, }; } else { $input = $ARGV[0]; } } else { ( $place, $input ) = @ARGV; } if ( $input and $input =~ s{ ^ (? address|poi|stop|stopID) : }{}x ) { $input_type = $+{type}; } for my $efield (@edata_pre) { if ( $efield eq 'a' ) { $edata{route_after} = 1; $full_routes = 1 } elsif ( $efield eq 'b' ) { $edata{route_before} = 1; $full_routes = 1 } elsif ( $efield eq 'f' ) { $edata{fullroute} = 1; $full_routes = 1 } elsif ( $efield eq 'r' ) { $edata{route} = 1; $full_routes = 1 } elsif ( $efield eq 'i' ) { $edata{info} = 1 } elsif ( $efield eq 'm' ) { $edata{messages} = 1 } else { $edata{$efield} = 1 } } if ($filter_via) { $full_routes = 1; } if ($efa_url) { $service = undef; } elsif ($service) { my $service_ref = Travel::Status::DE::EFA::get_service($service); if ( not $service_ref ) { printf STDERR ( "Error: Unknown service '%s'. See 'efa-m --list' for a " . "list of supported service names\n", $service ); exit 1; } $efa_encoding = $service_ref->{encoding}; $efa_url = undef; } sub new_efa { my ( $s, $u ) = @_; my $res = Travel::Status::DE::EFA->new( service => $s, efa_url => $u, cache => $cache, date => $date, developer_mode => $developer_mode, efa_encoding => $efa_encoding, full_routes => $full_routes, place => $place, name => $input, coord => $coord, stopfinder => $stopfinder, stopseq => $stopseq, time => $time, type => $input_type, timeout => $timeout, ); return $res; } sub show_help { my ($code) = @_; print "Usage: efa-m [-d ] [-t ] [place] \n" . "See also: man efa-m\n"; exit $code; } sub show_services { printf( "%-45s %-14s %s\n\n", 'service', 'abbr. (-s)', 'url (-u)' ); for my $shortname ( Travel::Status::DE::EFA::get_service_ids() ) { my $service = Travel::Status::DE::EFA::get_service($shortname); printf( "%-45s %-14s %s\n", $service->{name}, $shortname, $service->{url} ); } exit 0; } sub show_version { say "efa-m version ${VERSION}"; exit 0; } sub format_delay { my ( $delay, $len ) = @_; if ( $delay and $len ) { return sprintf( "(%+${len}d)", $delay ); } return q{}; } sub format_route { my (@route) = @_; my $output = q{}; for my $stop (@route) { if ( not $stop ) { say 'BUG'; next; } my $occupancy = $stop->occupancy ? format_occupancy( $stop->occupancy ) : q{ }; my $delay = q{ }; if ( $stop->delay ) { $delay = sprintf( '(%+3d)', $stop->delay ); } if ( defined $stop->arr and defined $stop->dep ) { if ( $stop->arr->epoch == $stop->dep->epoch ) { $output .= sprintf( " %5s %s %s %35s %s\n", $stop->arr->strftime('%H:%M'), $delay, $occupancy, $stop->full_name, $stop->platform // q{}, ); } else { $output .= sprintf( "%5s → %5s %s %s %35s %s\n", $stop->arr->strftime('%H:%M'), $stop->dep->strftime('%H:%M'), $delay, $occupancy, $stop->full_name, $stop->platform // q{}, ); } } elsif ( defined $stop->arr ) { $output .= sprintf( "%5s %s %s %35s %s\n", $stop->arr->strftime('%H:%M'), $delay, $occupancy, $stop->full_name, $stop->platform, ); } elsif ( defined $stop->dep ) { $output .= sprintf( " %5s %s %s %35s %s\n", $stop->dep->strftime('%H:%M'), $delay, $occupancy, $stop->full_name, $stop->platform, ); } elsif ( $stop->full_name ) { $output .= sprintf( " %s %s %35s %s\n", $delay, $occupancy, $stop->full_name, $stop->platform, ); } else { $output .= "?\n"; } } return $output; } sub display_result { my (@lines) = @_; my @line_length; if ( not @lines ) { die("Nothing to show\n"); } for my $i ( 0 .. 4 ) { $line_length[$i] = max map { length( $_->[$i] ) } @lines; } for my $line (@lines) { if ( $edata{messages} and $line->[5]->hints ) { print "\n"; for my $hint ( $line->[5]->hints ) { $hint =~ tr{\n\x0d}{ }s; chomp $hint; say "# ${hint}"; } } printf( join( q{ }, ( map { "%-${_}s" } @line_length ) ) . "\n", @{$line}[ 0 .. 4 ] ); if ( $line->[6] ) { say $line->[6]; } } return; } sub show_coord { my $max_len = max map { length( $_->full_name ) } $efa->results; for my $stop ( $efa->results ) { printf( "%5.1f km %-${max_len}s %s\n", $stop->distance_m * 1e-3, $stop->full_name, $stop->id ); } } sub show_stopfinder { my $max_len = max map { length( $_->full_name ) } $efa->results; for my $stop ( $efa->results ) { printf( "%-${max_len}s %s\n", $stop->full_name, $stop->id ); } } sub show_stopseq { my $trip = $efa->result; printf( "%s %s → %s\n", $trip->type, $trip->line // q{}, $trip->dest_name ); printf( "Fahrt %s am %s\n", $trip->number || $stopseq, ( $trip->route )[0]->sched_dep->strftime('%d.%m.%Y'), ); say q{}; my $delay_len = 0; my $inner_delay_len = 0; my $max_delay = max map { abs( $_->delay // 0 ) } $trip->route; if ($max_delay) { $inner_delay_len = length($max_delay) + 1; $delay_len = length( sprintf( '(%+d)', $max_delay ) ) + 1; } for my $stop ( $trip->route ) { printf( "%s → %s%${delay_len}s %s (%s) %s\n", $stop->arr ? $stop->arr->strftime('%H:%M') : q{ }, $stop->dep ? $stop->dep->strftime('%H:%M') : q{ }, $stop->delay ? sprintf( " (%+${inner_delay_len}d)", $stop->delay ) : q{}, $stop->full_name, $stop->niveau, $stop->platform ); } } sub show_lines { my @output; for my $l ( $efa->lines ) { if ( ( @grep_lines and none { $l->number eq $_ } @grep_lines ) or ( @grep_mots and none { $l->mot_name eq $_ } @grep_mots ) ) { next; } if ( @grep_mots and none { $l->mot_name eq $_ } @grep_mots ) { next; } push( @output, [ $l->type, $l->number, $l->direction // q{}, q{}, $l->route // q{} ] ); } display_result(@output); return; } sub format_occupancy { my ($occupancy) = @_; return $occupancy_map{$occupancy} // $occupancy_map{unknown}; } sub show_results { my @output; my $delay_len = 0; my $delay_fmt = 0; for my $d ( $efa->results ) { if ( $d->delay ) { $delay_len = max( $delay_len, length( $d->delay ) + 1 ); } } if ($delay_len) { $delay_fmt = $delay_len + 3; } if ( scalar $efa->stops > 1 ) { for my $stop ( $efa->stops ) { say $stop->full_name; } } elsif ( $efa->stop->full_name ) { say $efa->stop->full_name; } for my $d ( $efa->results ) { my @output_line; my $platform = $d->platform; my $dtime = ( $relative_times ? sprintf( '%2d min', $d->countdown ) : $d->datetime->strftime('%H:%M') ); if ( ( @grep_lines and none { $d->line eq $_ } @grep_lines ) or ( @grep_mots and none { $d->mot_name eq $_ } @grep_mots ) or ( @grep_platforms and none { $platform eq $_ } @grep_platforms ) or ( $offset and $d->countdown < $offset ) or ( $filter_via and not( first { $_->name =~ m{$filter_via}io } $d->route_post ) ) ) { next; } if ( $d->is_cancelled ) { if ($relative_times) { next; } else { $dtime = '--:--'; } } elsif ($filter_via) { my $via = first { $_->name =~ m{$filter_via}io } $d->route_post; $dtime .= ' → ' . $via->arr->clone->add( minutes => $d->delay // 0 ) ->strftime('%H:%M'); } if ( $d->delay ) { $dtime .= ' ' . format_delay( $d->delay, $delay_len ); } my $line = $d->line; if ( ( length($line) > 10 or not $line ) and $d->train_type and $d->train_no ) { $line = $d->train_type . ' ' . $d->train_no; } @output_line = ( $dtime, $platform, $line, q{}, $d->destination, $d ); if ($show_jid) { $output_line[2] .= sprintf( ' %s@%d(%s)%d', $d->stateless =~ s{ }{}gr, scalar $d->route_pre ? ( $d->route_pre )[0]->id : $d->stop_id, $d->sched_datetime->strftime('%Y%m%d'), $d->key ); } if ( $edata{route} ) { $output_line[3] = join( q{ }, map { $_->name } $d->route_interesting ); } elsif ( $d->occupancy ) { $output_line[3] = format_occupancy( $d->occupancy ); } if ( $edata{fullroute} ) { $output_line[6] = format_route( $d->route_pre ) . ' -' x 30 . "\n" . format_route( $d->route_post ); } elsif ( $edata{route_after} ) { $output_line[6] = format_route( $d->route_post ); } elsif ( $edata{route_before} ) { $output_line[6] = format_route( reverse $d->route_pre ); } push( @output, \@output_line ); } display_result(@output); if ( $edata{info} ) { for my $info ( $efa->infos ) { say q{}; if ( $info->subject and $info->subtitle ne $info->subject ) { printf( "# %s\n%s\n", $info->subtitle, $info->subject ); } else { printf( "# %s\n", $info->subtitle ); } } } return; } if ( $discover or $discover_and_print ) { for my $shortname ( Travel::Status::DE::EFA::get_service_ids() ) { $efa = new_efa($shortname); if ( $efa and not $efa->errstr ) { if ($discover_and_print) { last; } my $service_ref = Travel::Status::DE::EFA::get_service($shortname); printf( "%s / %s (%s)\n -> efa-m -s %s %s\n\n", $service_ref->{name}, $shortname, $service_ref->{url}, $shortname, join( q{ }, map { "'$_'" } @ARGV ), ); } } if ($discover) { exit 0; } } $efa = new_efa( $service, $efa_url ); if ( my $err = $efa->errstr ) { say STDERR "Request error: ${err}"; if ( $efa->place_candidates ) { say 'You might want to try one of the following places:'; say join( "\n", $efa->place_candidates ); } elsif ( $efa->name_candidates ) { say 'You might want to try one of the following names:'; say join( "\n", $efa->name_candidates ); } exit 2; } if ($json_output) { if ($stopseq) { say JSON->new->convert_blessed->encode( $efa->result ); } else { say JSON->new->convert_blessed->encode( [ $efa->results ] ); } } elsif ($coord) { show_coord(); } elsif ($stopfinder) { show_stopfinder(); } elsif ($stopseq) { show_stopseq(); } elsif ($list_lines) { show_lines(); } else { show_results(); } __END__ =head1 NAME efa-m - Unofficial interface to the efa.vrr.de departure and trip monitor =head1 SYNOPSIS B [B<-jLr>] [B<-d> I] [B<-t> I] [B<-l> I] [B<-p> I] [B<-s> I] [I] [IB<:>]I B [B<-s> I] BI|IB<:>I B [B<-s> I] I =head1 VERSION version 3.04 =head1 DESCRIPTION B is an interface to EFA public transport services. 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 the contents of its mandatory argument. =head2 Departure Monitor (I) Shows departures it I or I I. For each departure, B shows =over =item * estimated departure time (including delay, if available), =item * delay in minutes, =item * platform, =item * line, =item * expected occupation (range . o * !, if available), and =item * destination. =back If I is specified, I refers to a location within I. Otherwise, I must be self-contained. I.e., both C<< efa Essen Hbf >> and C<< efa "Essen Hbf" >> are valid. Note, however, than C<< efa E Hbf >> works, but C<< efa "E Hbf" >> does not. By default, I refers to a stop, this can be changed by specifying I. Supported types are B
and B (point of interest). =head2 Location Search (BI|IB<:>I) List stops that match I or that are located in the vicinity of IB<:>I geocoordinates. =head2 Trip Details (I) List trip information as well as arrival and departure time, name, and platform of each stop on the trip's route. =head1 OPTIONS Values in brackets indicate options that only apply to the corresponding operating mode(s). =over =item B<-A>, B<--auto-url>, B<--discover-and-print> (monitor) Probe all known EFA entry points for the specified stop. Print the first result which was not an error. Note that this may take a while and will not necessarily return the best result. Also, using thi option by default is not recommended, as it puts EFA services under considerable additional load. =item B<-d>, B<--date> I (monitor) Show departures for I instead of today. May also be specified as I =item B<-D>, B<--discover> (monitor) Probe all known EFA entry points for the specified stop. Print the URLs and names of all entry points which did not return an error. =item B<-j>, B<--with-jid> (monitor) Show journey ID for each departure. The ID can be used to query details with a subsequent B invocation (trip details mode). =item B<--json> Print result(s) as JSON and exit. This is a dump of internal data structures and not guaranteed to remain stable between minor versions. Please use the Travel::Status::DE::EFA(3pm) module if you need a proper API. =item B<-L>, B<--linelist> (monitor) Do not show departures. Instead, list all lines serving the specified place. Note that this information may be incomplete -- only lines which are in service either at the time of the B call or at the time specifed using B<--date> and B<--time> are guaranteed to be included. =item B<-l>, B<--line> I (monitor) Only show departures of I (comma-separatad list, option may be repeated) =item B<--list> List supported EFA services with their URLs (see B<-u>) and abbreviations (see B<-s>). =item B<-m>, B<--mot> I (monitor) Only show departures whose type appears in I (comma-separated list, this option may be repeated). The following departure types ("modes of transport") are supported: zug, s-bahn, u-bahn, stadtbahn, tram, stadtbus, regionalbus, schnellbus, seilbahn, schiff, ast, sonstige =item B<-o>, B<--offset> I (monitor) Ignore departures which are less than I from now. =item B<-O>, B<--output> I (monitor) For each result, show additional information as specified by I. I is a comma-separated list, the B<-O>/B<--output> option may also be repeated. Each output type has both a short and long form, for instance both "-Or" and "--output=route" are valid. The following output types are supported: =over =item a / route_after Show each departure's full route (timestamps and stop names) after the requested station. =item b / route_before Show each departure's full route (timestamps and stop names) before the requested station. =item f / fullroute Show each departure's full route (timestamps and stop names) before and after the requested station. =item i / info Show station-specific information messages. These typically relate to construction work, broken elevators or escalators, or special announcements for large-scale events. =item r / route Show up to three stops between the requested station and the departure's destination. B tries to display the three most important stops, however these are heuristically determined and may not be optimal. =item m / messages Show free-text messages associated with individual departures. These can include generic information such is bicycle transportation options or Wi-Fi availability, delay reasons, and more. =back =item B<-p>, B<--platform> I (monitor) Only show departures at I (comma-separated list, option may be repeated). Note that the C<< Bstg. >> / C<< Gleis >> prefix must be omitted. =item B<-r>, B<--relative> (monitor) Show relative departure times in minutes (i.e. the time difference between the departure and the time of the request). In this case, realtime data is already included. =item B<-s>, B<--service> I Short name of the EFA entry point. See Travel::Status::DE::EFA(3pm) and the B<--list> option for a list of services. =item B<-t>, B<--time> I (monitor) Show departures starting at I