File Coverage

blib/lib/Travel/Status/DE/HAFAS.pm
Criterion Covered Total %
statement 197 413 47.7
branch 44 160 27.5
condition 37 133 27.8
subroutine 32 46 69.5
pod 13 23 56.5
total 323 775 41.6


line stmt bran cond sub pod time code
1             package Travel::Status::DE::HAFAS;
2              
3             # vim:foldmethod=marker
4              
5 5     5   1041835 use strict;
  5         11  
  5         206  
6 5     5   27 use warnings;
  5         10  
  5         315  
7 5     5   108 use 5.014;
  5         19  
8 5     5   28 use utf8;
  5         22  
  5         64  
9              
10 5     5   260 use Carp qw(confess);
  5         12  
  5         430  
11 5     5   5499 use DateTime;
  5         3152226  
  5         327  
12 5     5   4403 use DateTime::Format::Strptime;
  5         379373  
  5         44  
13 5     5   726 use Digest::MD5 qw(md5_hex);
  5         19  
  5         533  
14 5     5   3683 use Encode qw(decode encode);
  5         74085  
  5         773  
15 5     5   5439 use IO::Socket::SSL;
  5         495795  
  5         48  
16 5     5   1244 use JSON;
  5         13  
  5         53  
17 5     5   4785 use LWP::UserAgent;
  5         246917  
  5         250  
18 5     5   3404 use Travel::Status::DE::HAFAS::Journey;
  5         22  
  5         35  
19 5     5   2820 use Travel::Status::DE::HAFAS::Location;
  5         37  
  5         28  
20 5     5   2520 use Travel::Status::DE::HAFAS::Message;
  5         15  
  5         26  
21 5     5   2425 use Travel::Status::DE::HAFAS::Polyline qw(decode_polyline);
  5         14  
  5         414  
22 5     5   2357 use Travel::Status::DE::HAFAS::Product;
  5         16  
  5         47  
23 5     5   4982 use Travel::Status::DE::HAFAS::Services;
  5         79  
  5         484  
24 5     5   3960 use Travel::Status::DE::HAFAS::StopFinder;
  5         19  
  5         32116  
25              
26             our $VERSION = '6.25';
27              
28             # {{{ Endpoint Definition
29              
30             # Data sources: <https://github.com/public-transport/transport-apis> and
31             # <https://github.com/public-transport/hafas-client/tree/main/p>. Thanks to
32             # Jannis R / @derhuerst and all contributors for maintaining these.
33             my $hafas_instance = Travel::Status::DE::HAFAS::Services::get_service_ref();
34              
35             # }}}
36             # {{{ Constructors
37              
38             sub new {
39 5     5 1 1622626 my ( $obj, %conf ) = @_;
40 5         19 my $service = $conf{service};
41              
42 5         19 my $ua = $conf{user_agent};
43              
44 5 50 33     52 if ( defined $service and not exists $hafas_instance->{$service} ) {
45 0         0 confess("The service '$service' is not supported");
46             }
47              
48 5 50       20 if ( not $ua ) {
49 5   50     11 my %lwp_options = %{ $conf{lwp_options} // { timeout => 10 } };
  5         55  
50 5 50 33     50 if ( $service and $hafas_instance->{$service}{ua_string} ) {
51 0         0 $lwp_options{agent} = $hafas_instance->{$service}{ua_string};
52             }
53 5 50 33     50 if ( $service
54             and my $geoip_service = $hafas_instance->{$service}{geoip_lock} )
55             {
56 0 0       0 if ( my $proxy = $ENV{"HAFAS_PROXY_${geoip_service}"} ) {
57 0         0 $lwp_options{proxy} = [ [ 'http', 'https' ] => $proxy ];
58             }
59             }
60 5 50 33     36 if ( $service and not $hafas_instance->{$service}{tls_verify} ) {
61             $lwp_options{ssl_opts}{SSL_verify_mode}
62 0         0 = IO::Socket::SSL::SSL_VERIFY_NONE;
63 0         0 $lwp_options{ssl_opts}{verify_hostname} = 0;
64             }
65 5         72 $ua = LWP::UserAgent->new(%lwp_options);
66 5         18635 $ua->env_proxy;
67             }
68              
69 5 0 100     109025 if (
      66        
      33        
      0        
70             not( $conf{station}
71             or $conf{journey}
72             or $conf{journeyMatch}
73             or $conf{geoSearch}
74             or $conf{locationSearch} )
75             )
76             {
77 0         0 confess(
78             'station / journey / journeyMatch / geoSearch / locationSearch must be specified'
79             );
80             }
81              
82 5 50       28 if ( not defined $service ) {
83 0         0 confess("You must specify a service");
84             }
85              
86             my $now = DateTime->now( time_zone => $hafas_instance->{$service}{time_zone}
87 5   50     99 // 'Europe/Berlin' );
88             my $self = {
89             active_service => $service,
90             arrivals => $conf{arrivals},
91             cache => $conf{cache},
92             developer_mode => $conf{developer_mode},
93             exclusive_mots => $conf{exclusive_mots},
94             excluded_mots => $conf{excluded_mots},
95             messages => [],
96             results => [],
97             station => $conf{station},
98 5         96249 ua => $ua,
99             now => $now,
100             tz_offset => $now->offset / 60,
101             };
102              
103 5         605 bless( $self, $obj );
104              
105 5         21 my $req;
106              
107 5 100       59 if ( $conf{journey} ) {
    100          
    50          
    50          
108             $req = {
109             svcReqL => [
110             {
111             meth => 'JourneyDetails',
112             req => {
113             jid => $conf{journey}{id},
114             name => $conf{journey}{name} // '0',
115             getPolyline => $conf{with_polyline} ? \1 : \0,
116             },
117             }
118             ],
119 3 50 50     75 %{ $hafas_instance->{$service}{request} }
  3         121  
120             };
121             }
122             elsif ( $conf{journeyMatch} ) {
123             $req = {
124             svcReqL => [
125             {
126             meth => 'JourneyMatch',
127             req => {
128             date => ( $conf{datetime} // $now )->strftime('%Y%m%d'),
129             input => $conf{journeyMatch},
130             jnyFltrL => [
131             {
132             type => "PROD",
133             mode => "INC",
134             value => $self->mot_mask
135             }
136             ]
137             },
138             }
139             ],
140 1   33     7 %{ $hafas_instance->{$service}{request} }
  1         7  
141             };
142             }
143             elsif ( $conf{geoSearch} ) {
144             $req = {
145             svcReqL => [
146             {
147             cfg => { polyEnc => 'GPA' },
148             meth => 'LocGeoPos',
149             req => {
150             ring => {
151             cCrd => {
152             x => int( $conf{geoSearch}{lon} * 1e6 ),
153             y => int( $conf{geoSearch}{lat} * 1e6 ),
154             },
155             maxDist => -1,
156             minDist => 0,
157             },
158             locFltrL => [
159             {
160             type => "PROD",
161             mode => "INC",
162             value => $self->mot_mask
163             }
164             ],
165             getPOIs => \0,
166             getStops => \1,
167             maxLoc => $conf{results} // 30,
168             }
169             }
170             ],
171 0   0     0 %{ $hafas_instance->{$service}{request} }
  0         0  
172             };
173             }
174             elsif ( $conf{locationSearch} ) {
175             $req = {
176             svcReqL => [
177             {
178             cfg => { polyEnc => 'GPA' },
179             meth => 'LocMatch',
180             req => {
181             input => {
182             loc => {
183             type => 'S',
184             name => $conf{locationSearch},
185             },
186             maxLoc => $conf{results} // 30,
187             field => 'S',
188             },
189             }
190             }
191             ],
192 0   0     0 %{ $hafas_instance->{$service}{request} }
  0         0  
193             };
194             }
195             else {
196 1   33     15 my $date = ( $conf{datetime} // $now )->strftime('%Y%m%d');
197 1   33     106 my $time = ( $conf{datetime} // $now )->strftime('%H%M00');
198              
199 1         55 my $lid;
200 1 50       16 if ( $self->{station} =~ m{ ^ [0-9]+ $ }x ) {
201 0         0 $lid = 'A=1@L=' . $self->{station} . '@';
202             }
203             else {
204 1         4 $lid = 'A=1@O=' . $self->{station} . '@';
205             }
206              
207 1   50     10 my $maxjny = $conf{results} // 30;
208 1   50     7 my $duration = $conf{lookahead} // -1;
209              
210             $req = {
211             svcReqL => [
212             {
213             meth => 'StationBoard',
214             req => {
215             type => ( $conf{arrivals} ? 'ARR' : 'DEP' ),
216             stbLoc => { lid => $lid },
217             dirLoc => undef,
218             maxJny => $maxjny,
219             date => $date,
220             time => $time,
221             dur => $duration,
222             jnyFltrL => [
223             {
224             type => "PROD",
225             mode => "INC",
226             value => $self->mot_mask
227             }
228             ]
229             },
230             },
231             ],
232 1 50       11 %{ $hafas_instance->{$service}{request} }
  1         11  
233             };
234             }
235              
236 5 50       37 if ( $conf{language} ) {
237 0         0 $req->{lang} = $conf{language};
238             }
239              
240             $self->{strptime_obj} //= DateTime::Format::Strptime->new(
241             pattern => '%Y%m%dT%H%M%S',
242 5   50     157 time_zone => $hafas_instance->{$service}{time_zone} // 'Europe/Berlin',
      33        
243             );
244              
245 5         13558 my $json = $self->{json} = JSON->new->utf8;
246              
247             # The JSON request is the cache key, so if we have a cache we must ensure
248             # that JSON serialization is deterministic.
249 5 50       31 if ( $self->{cache} ) {
250 0         0 $json->canonical;
251             }
252              
253 5         198 $req = $json->encode($req);
254 5         37 $self->{post} = $req;
255              
256 5   33     52 my $url = $conf{url} // $hafas_instance->{$service}{mgate};
257              
258 5 50       26 if ( my $salt = $hafas_instance->{$service}{salt} ) {
259 5 50       20 if ( $hafas_instance->{$service}{micmac} ) {
260 0         0 my $mic = md5_hex( $self->{post} );
261 0         0 my $mac = md5_hex( $mic . $salt );
262 0         0 $url .= "?mic=$mic&mac=$mac";
263             }
264             else {
265 5         51 $url .= '?checksum=' . md5_hex( $self->{post} . $salt );
266             }
267             }
268              
269 5 50       29 if ( $conf{async} ) {
270 0         0 $self->{url} = $url;
271 0         0 return $self;
272             }
273              
274 5 50       21 if ( $conf{json} ) {
275 5         20 $self->{raw_json} = $conf{json};
276             }
277             else {
278 0 0       0 if ( $self->{developer_mode} ) {
279 0         0 say "requesting $req from $url";
280             }
281              
282 0         0 my ( $content, $error ) = $self->post_with_cache($url);
283              
284 0 0       0 if ($error) {
285 0         0 $self->{errstr} = $error;
286 0         0 return $self;
287             }
288              
289 0 0       0 if ( $self->{developer_mode} ) {
290 0         0 say decode( 'utf-8', $content );
291             }
292              
293 0         0 $self->{raw_json} = $json->decode($content);
294             }
295              
296 5         33 $self->check_mgate;
297              
298 5 100 33     29 if ( $conf{journey} ) {
    100          
    50          
299 3         20 $self->parse_journey;
300             }
301             elsif ( $conf{journeyMatch} ) {
302 1         5 $self->parse_journey_match;
303             }
304             elsif ( $conf{geoSearch} or $conf{locationSearch} ) {
305 0         0 $self->parse_search;
306             }
307             else {
308 1         7 $self->parse_board;
309             }
310              
311 5         38 return $self;
312             }
313              
314             sub new_p {
315 0     0 1 0 my ( $obj, %conf ) = @_;
316 0         0 my $promise = $conf{promise}->new;
317              
318 0 0 0     0 if (
      0        
      0        
      0        
319             not( $conf{station}
320             or $conf{journey}
321             or $conf{journeyMatch}
322             or $conf{geoSearch}
323             or $conf{locationSearch} )
324             )
325             {
326 0         0 return $promise->reject(
327             'station / journey / journeyMatch / geoSearch / locationSearch flag must be passed'
328             );
329             }
330              
331 0         0 my $self = $obj->new( %conf, async => 1 );
332 0         0 $self->{promise} = $conf{promise};
333              
334             $self->post_with_cache_p( $self->{url} )->then(
335             sub {
336 0     0   0 my ($content) = @_;
337 0         0 $self->{raw_json} = $self->{json}->decode($content);
338 0         0 $self->check_mgate;
339 0 0 0     0 if ( $conf{journey} ) {
    0          
    0          
340 0         0 $self->parse_journey;
341             }
342             elsif ( $conf{journeyMatch} ) {
343 0         0 $self->parse_journey_match;
344             }
345             elsif ( $conf{geoSearch} or $conf{locationSearch} ) {
346 0         0 $self->parse_search;
347             }
348             else {
349 0         0 $self->parse_board;
350             }
351 0 0       0 if ( $self->errstr ) {
352 0         0 $promise->reject( $self->errstr, $self );
353             }
354             else {
355 0         0 $promise->resolve($self);
356             }
357 0         0 return;
358             }
359             )->catch(
360             sub {
361 0     0   0 my ($err) = @_;
362 0         0 $promise->reject($err);
363 0         0 return;
364             }
365 0         0 )->wait;
366              
367 0         0 return $promise;
368             }
369              
370             # }}}
371             # {{{ Internal Helpers
372              
373             sub mot_mask {
374 2     2 0 72 my ($self) = @_;
375              
376 2         8 my $service = $self->{active_service};
377 2         4 my $mot_mask = 2**@{ $hafas_instance->{$service}{productbits} } - 1;
  2         14  
378              
379 2         4 my %mot_pos;
380 2         5 for my $i ( 0 .. $#{ $hafas_instance->{$service}{productbits} } ) {
  2         10  
381 20 50       44 if ( ref( $hafas_instance->{$service}{productbits}[$i] ) eq 'ARRAY' ) {
382 20         54 $mot_pos{ $hafas_instance->{$service}{productbits}[$i][0] } = $i;
383             }
384             else {
385 0         0 $mot_pos{ $hafas_instance->{$service}{productbits}[$i] } = $i;
386             }
387             }
388              
389 2 50 50     7 if ( my @mots = @{ $self->{exclusive_mots} // [] } ) {
  2         19  
390 0         0 $mot_mask = 0;
391 0         0 for my $mot (@mots) {
392 0 0       0 if ( exists $mot_pos{$mot} ) {
    0          
393 0         0 $mot_mask |= 1 << $mot_pos{$mot};
394             }
395             elsif ( $mot =~ m{ ^ \d+ $ }x ) {
396 0         0 $mot_mask |= 1 << $mot;
397             }
398             }
399             }
400              
401 2 50 50     4 if ( my @mots = @{ $self->{excluded_mots} // [] } ) {
  2         13  
402 0         0 for my $mot (@mots) {
403 0 0       0 if ( exists $mot_pos{$mot} ) {
    0          
404 0         0 $mot_mask &= ~( 1 << $mot_pos{$mot} );
405             }
406             elsif ( $mot =~ m{ ^ \d+ $ }x ) {
407 0         0 $mot_mask &= ~( 1 << $mot );
408             }
409             }
410             }
411              
412 2         26 return $mot_mask;
413             }
414              
415             sub post_with_cache {
416 0     0 0 0 my ( $self, $url ) = @_;
417 0         0 my $cache = $self->{cache};
418              
419 0 0       0 if ( $self->{developer_mode} ) {
420 0         0 say "POST $url $self->{post}";
421             }
422              
423 0 0       0 if ($cache) {
424 0         0 my $content = $cache->thaw( $self->{post} );
425 0 0 0     0 if ( $content
426             and not $content =~ m{ CGI_NO_SERVER | CGI_READ_FAILED }x )
427             {
428 0 0       0 if ( $self->{developer_mode} ) {
429 0         0 say ' cache hit';
430             }
431 0         0 return ( ${$content}, undef );
  0         0  
432             }
433             }
434              
435 0 0       0 if ( $self->{developer_mode} ) {
436 0         0 say ' cache miss';
437             }
438              
439             my $reply = $self->{ua}->post(
440             $url,
441             'Content-Type' => 'application/json',
442             Content => $self->{post}
443 0         0 );
444              
445 0 0       0 if ( $reply->is_error ) {
446 0         0 return ( undef, $reply->status_line );
447             }
448 0         0 my $content = $reply->content;
449              
450 0 0       0 if ($cache) {
451 0         0 $cache->freeze( $self->{post}, \$content );
452             }
453              
454 0         0 return ( $content, undef );
455             }
456              
457             sub post_with_cache_p {
458 0     0 0 0 my ( $self, $url ) = @_;
459 0         0 my $cache = $self->{cache};
460              
461 0 0       0 if ( $self->{developer_mode} ) {
462 0         0 say "POST $url";
463             }
464              
465 0         0 my $promise = $self->{promise}->new;
466              
467 0 0       0 if ($cache) {
468 0         0 my $content = $cache->thaw( $self->{post} );
469 0 0       0 if ($content) {
470 0 0       0 if ( $self->{developer_mode} ) {
471 0         0 say ' cache hit';
472             }
473 0         0 return $promise->resolve( ${$content} );
  0         0  
474             }
475             }
476              
477 0 0       0 if ( $self->{developer_mode} ) {
478 0         0 say ' cache miss';
479             }
480              
481 0         0 my $headers = {};
482 0         0 my $service_desc = $hafas_instance->{ $self->{active_service} };
483              
484 0 0       0 if ( $service_desc->{ua_string} ) {
485 0         0 $headers->{'User-Agent'} = $service_desc->{ua_string};
486             }
487 0 0       0 if ( my $geoip_service = $service_desc->{geoip_lock} ) {
488 0 0       0 if ( my $proxy = $ENV{"HAFAS_PROXY_${geoip_service}"} ) {
489 0         0 $self->{ua}->proxy->http($proxy);
490 0         0 $self->{ua}->proxy->https($proxy);
491             }
492             }
493 0 0       0 if ( not $service_desc->{tls_verify} ) {
494 0         0 $self->{ua}->insecure(1);
495             }
496              
497             $self->{ua}->post_p( $url, $headers, $self->{post} )->then(
498             sub {
499 0     0   0 my ($tx) = @_;
500 0 0       0 if ( my $err = $tx->error ) {
501 0         0 $promise->reject(
502             "POST $url returned HTTP $err->{code} $err->{message}");
503 0         0 return;
504             }
505 0         0 my $content = $tx->res->body;
506 0 0       0 if ($cache) {
507 0         0 $cache->freeze( $self->{post}, \$content );
508             }
509 0         0 $promise->resolve($content);
510 0         0 return;
511             }
512             )->catch(
513             sub {
514 0     0   0 my ($err) = @_;
515 0         0 $promise->reject($err);
516 0         0 return;
517             }
518 0         0 )->wait;
519              
520 0         0 return $promise;
521             }
522              
523             sub check_mgate {
524 5     5 0 19 my ($self) = @_;
525              
526 5 50 33     83 if ( $self->{raw_json}{err} and $self->{raw_json}{err} ne 'OK' ) {
    50 33        
    50 33        
    50          
527             $self->{errstr} = $self->{raw_json}{errTxt}
528 0   0     0 // 'error code is ' . $self->{raw_json}{err};
529 0         0 $self->{errcode} = $self->{raw_json}{err};
530             }
531             elsif ( defined $self->{raw_json}{cInfo}{code}
532             and $self->{raw_json}{cInfo}{code} ne 'OK'
533             and $self->{raw_json}{cInfo}{code} ne 'VH' )
534             {
535 0         0 $self->{errstr} = 'cInfo code is ' . $self->{raw_json}{cInfo}{code};
536 0         0 $self->{errcode} = $self->{raw_json}{cInfo}{code};
537             }
538 5   50     56 elsif ( @{ $self->{raw_json}{svcResL} // [] } == 0 ) {
539 0         0 $self->{errstr} = 'svcResL is empty';
540             }
541             elsif ( $self->{raw_json}{svcResL}[0]{err} ne 'OK' ) {
542             $self->{errstr}
543 0         0 = 'svcResL[0].err is ' . $self->{raw_json}{svcResL}[0]{err};
544 0         0 $self->{errcode} = $self->{raw_json}{svcResL}[0]{err};
545             }
546              
547 5         15 return $self;
548             }
549              
550             sub add_message {
551 32     32 0 85 my ( $self, $json, $is_him ) = @_;
552              
553 32         80 my $text = $json->{txtN};
554 32         61 my $code = $json->{code};
555              
556 32 50       80 if ($is_him) {
557 0         0 $text = $json->{text};
558 0         0 $code = $json->{hid};
559             }
560              
561             # Some backends use remL for operator information. We don't want that.
562 32 50       81 if ( $code eq 'OPERATOR' ) {
563 0         0 return;
564             }
565              
566 32         61 for my $message ( @{ $self->{messages} } ) {
  32         81  
567 148 100 100     459 if ( $code eq $message->{code} and $text eq $message->{text} ) {
568 1         4 $message->{ref_count}++;
569 1         6 return $message;
570             }
571             }
572              
573 31         135 my $message = Travel::Status::DE::HAFAS::Message->new(
574             json => $json,
575             is_him => $is_him,
576             ref_count => 1,
577             );
578 31         61 push( @{ $self->{messages} }, $message );
  31         73  
579 31         133 return $message;
580             }
581              
582             sub parse_prodL {
583 5     5 0 14 my ($self) = @_;
584              
585 5         21 my $common = $self->{raw_json}{svcResL}[0]{res}{common};
586             return [
587             map {
588 45         173 Travel::Status::DE::HAFAS::Product->new(
589             common => $common,
590             product => $_
591             )
592 5         13 } @{ $common->{prodL} }
  5         25  
593             ];
594             }
595              
596             sub parse_search {
597 0     0 0 0 my ($self) = @_;
598              
599 0         0 $self->{results} = [];
600              
601 0 0       0 if ( $self->{errstr} ) {
602 0         0 return $self;
603             }
604              
605 0   0     0 my @locL = @{ $self->{raw_json}{svcResL}[0]{res}{locL} // [] };
  0         0  
606              
607 0 0       0 if ( $self->{raw_json}{svcResL}[0]{res}{match} ) {
608 0   0     0 @locL = @{ $self->{raw_json}{svcResL}[0]{res}{match}{locL} // [] };
  0         0  
609             }
610              
611 0         0 @{ $self->{results} }
612 0         0 = map { Travel::Status::DE::HAFAS::Location->new( loc => $_ ) } @locL;
  0         0  
613              
614 0         0 return $self;
615             }
616              
617             sub parse_journey {
618 3     3 0 9 my ($self) = @_;
619              
620 3 50       13 if ( $self->{errstr} ) {
621 0         0 return $self;
622             }
623              
624 3         16 my $prodL = $self->parse_prodL;
625              
626 47         205 my @locL = map { Travel::Status::DE::HAFAS::Location->new( loc => $_ ) }
627 3   50     8 @{ $self->{raw_json}{svcResL}[0]{res}{common}{locL} // [] };
  3         24  
628 3         20 my $journey = $self->{raw_json}{svcResL}[0]{res}{journey};
629 3         7 my @polyline;
630              
631 3         9 my $poly = $journey->{poly};
632              
633             # ÖBB
634 3 50 0     37 if ( $journey->{polyG} and @{ $journey->{polyG}{polyXL} // [] } ) {
  0   33     0  
635             $poly = $self->{raw_json}{svcResL}[0]{res}{common}{polyL}
636 0         0 [ $journey->{polyG}{polyXL}[0] ];
637             }
638              
639 3 50       11 if ($poly) {
640 0         0 @polyline = decode_polyline( $poly->{crdEncYX} );
641 0   0     0 for my $ref ( @{ $poly->{ppLocRefL} // [] } ) {
  0         0  
642 0         0 my $poly = $polyline[ $ref->{ppIdx} ];
643 0         0 my $loc = $locL[ $ref->{locX} ];
644              
645 0         0 $poly->{name} = $loc->name;
646 0         0 $poly->{eva} = $loc->eva;
647             }
648             }
649              
650             $self->{result} = Travel::Status::DE::HAFAS::Journey->new(
651             common => $self->{raw_json}{svcResL}[0]{res}{common},
652 3         54 prodL => $prodL,
653             locL => \@locL,
654             journey => $journey,
655             polyline => \@polyline,
656             hafas => $self,
657             );
658              
659 3         15 return $self;
660             }
661              
662             sub parse_journey_match {
663 1     1 0 3 my ($self) = @_;
664              
665 1         2 $self->{results} = [];
666              
667 1 50       3 if ( $self->{errstr} ) {
668 0         0 return $self;
669             }
670              
671 1         6 my $prodL = $self->parse_prodL;
672              
673 2         11 my @locL = map { Travel::Status::DE::HAFAS::Location->new( loc => $_ ) }
674 1   50     1 @{ $self->{raw_json}{svcResL}[0]{res}{common}{locL} // [] };
  1         6  
675              
676 1   50     2 my @jnyL = @{ $self->{raw_json}{svcResL}[0]{res}{jnyL} // [] };
  1         4  
677              
678 1         2 for my $result (@jnyL) {
679             push(
680 1         10 @{ $self->{results} },
681             Travel::Status::DE::HAFAS::Journey->new(
682             common => $self->{raw_json}{svcResL}[0]{res}{common},
683 1         2 prodL => $prodL,
684             locL => \@locL,
685             journey => $result,
686             hafas => $self,
687             )
688             );
689             }
690 1         3 return $self;
691             }
692              
693             sub parse_board {
694 1     1 0 3 my ($self) = @_;
695              
696 1         5 $self->{results} = [];
697              
698 1 50       4 if ( $self->{errstr} ) {
699 0         0 return $self;
700             }
701              
702 1         6 my $prodL = $self->parse_prodL;
703              
704 135         321 my @locL = map { Travel::Status::DE::HAFAS::Location->new( loc => $_ ) }
705 1   50     3 @{ $self->{raw_json}{svcResL}[0]{res}{common}{locL} // [] };
  1         10  
706 1   50     4 my @jnyL = @{ $self->{raw_json}{svcResL}[0]{res}{jnyL} // [] };
  1         25  
707              
708 1         4 for my $result (@jnyL) {
709 30         48 eval {
710             push(
711 30         220 @{ $self->{results} },
712             Travel::Status::DE::HAFAS::Journey->new(
713             common => $self->{raw_json}{svcResL}[0]{res}{common},
714 30         46 prodL => $prodL,
715             locL => \@locL,
716             journey => $result,
717             hafas => $self,
718             )
719             );
720             };
721 30 50       82 if ($@) {
722 0 0       0 if ( $@ =~ m{Invalid local time for date in time zone} ) {
723              
724             # Yes, HAFAS does in fact return invalid times during DST change
725             # (as in, it returns 02:XX:XX timestamps when the time jumps from 02:00:00 to 03:00:00)
726             # It's not clear what exactly is going wrong where and whether a 2:30 or a 3:30 journey is the correct one.
727             # For now, silently discard the affected journeys.
728             }
729             else {
730 0         0 warn("Skipping $result->{jid}: $@");
731             }
732             }
733             }
734 1         59 return $self;
735             }
736              
737             # }}}
738             # {{{ Public Functions
739              
740             sub errcode {
741 5     5 1 55 my ($self) = @_;
742              
743 5         39 return $self->{errcode};
744             }
745              
746             sub errstr {
747 5     5 1 17 my ($self) = @_;
748              
749 5         34 return $self->{errstr};
750             }
751              
752             sub similar_stops {
753 0     0 1 0 my ($self) = @_;
754              
755 0         0 my $service = $self->{active_service};
756              
757 0 0 0     0 if ( $service and exists $hafas_instance->{$service}{stopfinder} ) {
758              
759             my $sf = Travel::Status::DE::HAFAS::StopFinder->new(
760             url => $hafas_instance->{$service}{stopfinder},
761             input => $self->{station},
762             ua => $self->{ua},
763             developer_mode => $self->{developer_mode},
764 0         0 );
765 0 0       0 if ( my $err = $sf->errstr ) {
766 0         0 $self->{errstr} = $err;
767 0         0 return;
768             }
769 0         0 return $sf->results;
770             }
771 0         0 return;
772             }
773              
774             sub similar_stops_p {
775 0     0 1 0 my ( $self, %opt ) = @_;
776              
777 0         0 my $service = $self->{active_service};
778              
779 0 0 0     0 if ( $service and exists $hafas_instance->{$service}{stopfinder} ) {
780 0   0     0 $opt{user_agent} //= $self->{ua};
781 0   0     0 $opt{promise} //= $self->{promise};
782             return Travel::Status::DE::HAFAS::StopFinder->new_p(
783             url => $hafas_instance->{$service}{stopfinder},
784             input => $self->{station},
785             user_agent => $opt{user_agent},
786             developer_mode => $self->{developer_mode},
787             promise => $opt{promise},
788 0         0 );
789             }
790             return $opt{promise}
791 0         0 ->reject("stopfinder not available for backend '$service'");
792             }
793              
794             sub station {
795 0     0 1 0 my ($self) = @_;
796              
797 0 0       0 if ( $self->{station_info} ) {
798 0         0 return $self->{station_info};
799             }
800              
801 0         0 my %eva_count;
802             my %name_count;
803 0         0 my %eva_by_name;
804              
805 0         0 for my $result ( $self->results ) {
806 0         0 $eva_count{ $result->station_eva } += 1;
807 0         0 $name_count{ $result->station } += 1;
808 0         0 $eva_by_name{ $result->station } = $result->station_eva;
809             }
810              
811 0         0 my @most_frequent_evas = map { $_->[0] } sort { $b->[1] <=> $a->[1] }
  0         0  
812 0         0 map { [ $_, $eva_count{$_} ] } keys %eva_count;
  0         0  
813              
814 0         0 my @most_frequent_names = map { $_->[0] } sort { $b->[1] <=> $a->[1] }
  0         0  
815 0         0 map { [ $_, $name_count{$_} ] } keys %name_count;
  0         0  
816              
817 0         0 my @shortest_names = map { $_->[0] } sort { $a->[1] <=> $b->[1] }
  0         0  
818 0         0 map { [ $_, length($_) ] } keys %name_count;
  0         0  
819              
820 0 0       0 if ( not @shortest_names ) {
821 0         0 $self->{station_info} = {};
822 0         0 return $self->{station_info};
823             }
824              
825             # The shortest name is typically the most helpful one, e.g. "Wien Hbf" vs. "Wien Hbf Süd (Sonnwendgasse)"
826             $self->{station_info} = {
827             name => $shortest_names[0],
828 0         0 eva => $eva_by_name{ $shortest_names[0] },
829             names => \@most_frequent_names,
830             evas => \@most_frequent_evas,
831             };
832              
833 0         0 return $self->{station_info};
834             }
835              
836             sub messages {
837 0     0 1 0 my ($self) = @_;
838 0         0 return @{ $self->{messages} };
  0         0  
839             }
840              
841             sub results {
842 4     4 1 10 my ($self) = @_;
843 4         6 return @{ $self->{results} };
  4         24  
844             }
845              
846             sub result {
847 3     3 1 12 my ($self) = @_;
848 3         11 return $self->{result};
849             }
850              
851             # static
852             sub get_services {
853 0     0 1 0 my @services;
854 0         0 for my $service ( sort keys %{$hafas_instance} ) {
  0         0  
855 0         0 my %desc = %{ $hafas_instance->{$service} };
  0         0  
856 0         0 $desc{shortname} = $service;
857 0         0 push( @services, \%desc );
858             }
859 0         0 return @services;
860             }
861              
862             # static
863             sub get_service {
864 0     0 1 0 my ($service) = @_;
865              
866 0 0 0     0 if ( defined $service and exists $hafas_instance->{$service} ) {
867 0         0 return $hafas_instance->{$service};
868             }
869 0         0 return;
870             }
871              
872             sub get_active_service {
873 39     39 1 87 my ($self) = @_;
874              
875 39 50       100 if ( defined $self->{active_service} ) {
876 39         267 return $hafas_instance->{ $self->{active_service} };
877             }
878 0           return;
879             }
880              
881             # }}}
882              
883             1;
884              
885             __END__
886              
887             =head1 NAME
888              
889             Travel::Status::DE::HAFAS - Interface to HAFAS-based online arrival/departure
890             monitors
891              
892             =head1 SYNOPSIS
893              
894             use Travel::Status::DE::HAFAS;
895              
896             my $status = Travel::Status::DE::HAFAS->new(
897             station => 'Essen Hbf',
898             );
899              
900             if (my $err = $status->errstr) {
901             die("Request error: ${err}\n");
902             }
903              
904             for my $departure ($status->results) {
905             printf(
906             "At %s: %s to %s from platform %s\n",
907             $departure->time,
908             $departure->line,
909             $departure->destination,
910             $departure->platform,
911             );
912             }
913              
914             =head1 VERSION
915              
916             version 6.25
917              
918             =head1 DESCRIPTION
919              
920             Travel::Status::DE::HAFAS is an interface to HAFAS-based
921             arrival/departure monitors using the mgate.exe interface.
922              
923             It can report departures/arrivals at a specific station, search for stations,
924             or provide details about a specific journey. It supports non-blocking operation
925             via promises.
926              
927             =head1 METHODS
928              
929             =over
930              
931             =item my $status = Travel::Status::DE::HAFAS->new(I<%opt>)
932              
933             Requests item(s) as specified by I<opt> and returns a new
934             Travel::Status::DE::HAFAS element with the results. Dies if the wrong
935             I<opt> were passed.
936              
937             I<opt> must contain a HAFAS service identifier:
938              
939             =over
940              
941             =item B<service> => I<service> (mandatory)
942              
943             Request results from I<service>. See B<get_services> (and C<< hafas-m --list
944             >>) for a list of supported services.
945              
946             =back
947              
948             Additionally, I<opt> must contain either a B<station>, B<geoSearch>,
949             B<locationSearch>, B<journey>, or B<journeyMatch> flag:
950              
951             =over
952              
953             =item B<station> => I<station>
954              
955             Request station board (arrivals or departures) for I<station>, e.g. "Essen HBf" or
956             "Alfredusbad, Essen (Ruhr)". The station must be specified either by name or by
957             EVA ID (e.g. 8000080 for Dortmund Hbf).
958             Results are available via C<< $status->results >>.
959              
960             =item B<geoSearch> => B<{> B<lat> => I<latitude>, B<lon> => I<longitude> B<}>
961              
962             Search for stations near I<latitude>, I<longitude>.
963             Results are available via C<< $status->results >>.
964              
965             =item B<locationSearch> => I<query>
966              
967             Search for stations whose name is similar to I<query>.
968             Results are available via C<< $status->results >>.
969              
970             =item B<journey> => B<{> B<id> => I<tripid> [, B<name> => I<line> ] B<}>
971              
972             Request details about the journey identified by I<tripid> and I<line>.
973             The result is available via C<< $status->result >>.
974              
975             =item B<journeyMatch> => I<query>
976              
977             Request journeys that match I<query> (e.g. "ICE 205" or "S 31111").
978             Results are available via C<< $status->results >>.
979             In contrast to B<journey>, the results typically only contain a minimal amount
980             of information: trip ID, train/line identifier, and first and last stop. There
981             is no real-time data.
982              
983             =back
984              
985              
986              
987             The following optional flags may be set.
988             Values in brackets indicate flags that are only relevant in certain request
989             modes, e.g. geoSearch or journey.
990              
991             =over
992              
993             =item B<arrivals> => I<bool> (station)
994              
995             Request arrivals (if I<bool> is true) rather than departures (if I<bool> is
996             false or B<arrivals> is not specified).
997              
998             =item B<cache> => I<Cache::File object>
999              
1000             Store HAFAS replies in the provided cache object. This module works with
1001             real-time data, so the object should be configured for an expiry of one to two
1002             minutes.
1003              
1004             =item B<datetime> => I<DateTime object> (station)
1005              
1006             Date and time to report for. Defaults to now.
1007              
1008             =item B<excluded_mots> => [I<mot1>, I<mot2>, ...] (geoSearch, station, journeyMatch)
1009              
1010             By default, all modes of transport (trains, trams, buses etc.) are returned.
1011             If this option is set, all modes appearing in I<mot1>, I<mot2>, ... will
1012             be excluded. The supported modes depend on B<service>, use
1013             B<get_services> or B<get_service> to get the supported values.
1014              
1015             =item B<exclusive_mots> => [I<mot1>, I<mot2>, ...] (geoSearch, station, journeyMatch)
1016              
1017             If this option is set, only the modes of transport appearing in I<mot1>,
1018             I<mot2>, ... will be returned. The supported modes depend on B<service>, use
1019             B<get_services> or B<get_service> to get the supported values.
1020              
1021             =item B<language> => I<language>
1022              
1023             Request text messages to be provided in I<language>. Supported languages depend
1024             on B<service>, use B<get_services> or B<get_service> to get the supported
1025             values. Providing an unsupported or invalid value may lead to garbage output.
1026              
1027             =item B<lookahead> => I<int> (station)
1028              
1029             Request arrivals/departures that occur up to I<int> minutes after the specified datetime.
1030             Default: -1 (do not limit results by time).
1031              
1032             =item B<lwp_options> => I<\%hashref>
1033              
1034             Passed on to C<< LWP::UserAgent->new >>. Defaults to C<< { timeout => 10 } >>,
1035             pass an empty hashref to call the LWP::UserAgent constructor without arguments.
1036              
1037             =item B<results> => I<count> (geoSearch, locationSearch, station)
1038              
1039             Request up to I<count> results.
1040             Default: 30.
1041              
1042             =item B<with_polyline> => I<bool> (journey)
1043              
1044             Request a polyline (series of geo-coordinates) indicating the train's route.
1045              
1046             =back
1047              
1048             =item my $status_p = Travel::Status::DE::HAFAS->new_p(I<%opt>)
1049              
1050             Returns a promise that resolves into a Travel::Status::DE::HAFAS instance
1051             ($status) on success and rejects with an error message on failure. If the
1052             failure occured after receiving a response from the HAFAS backend, the rejected
1053             promise contains a Travel::Status::DE::HAFAS instance as a second argument.
1054             This instance can be used e.g. to call similar_stops_p in case of an ambiguous
1055             location specifier. In addition to the arguments of B<new>, the following
1056             mandatory arguments must be set.
1057              
1058             =over
1059              
1060             =item B<promise> => I<promises module>
1061              
1062             Promises implementation to use for internal promises as well as B<new_p> return
1063             value. Recommended: Mojo::Promise(3pm).
1064              
1065             =item B<user_agent> => I<user agent>
1066              
1067             User agent instance to use for asynchronous requests. The object must implement
1068             a B<post_p> function. Recommended: Mojo::UserAgent(3pm).
1069              
1070             =back
1071              
1072             =item $status->errcode
1073              
1074             In case of an error in the HAFAS backend, returns the corresponding error code
1075             as string. If no backend error occurred, returns undef.
1076              
1077             =item $status->errstr
1078              
1079             In case of an error in the HTTP request or HAFAS backend, returns a string
1080             describing it. If no error occurred, returns undef.
1081              
1082             =item $status->results (geoSearch, locationSearch)
1083              
1084             Returns a list of stop locations. Each list element is a
1085             Travel::Status::DE::HAFAS::Location(3pm) object.
1086              
1087             If no matching results were found or the parser / http request failed, returns
1088             an empty list.
1089              
1090             =item $status->results (station)
1091              
1092             Returns a list of arrivals/departures. Each list element is a
1093             Travel::Status::DE::HAFAS::Journey(3pm) object.
1094              
1095             If no matching results were found or the parser / http request failed, returns
1096             undef.
1097              
1098             =item $status->results (journeyMatch)
1099              
1100             Returns a list of Travel::Status::DE::HAFAS::Journey(3pm) object that describe
1101             matching journeys. In general, these objects lack real-time data,
1102             intermediate stops, and more.
1103              
1104             =item $status->result (journey)
1105              
1106             Returns a single Travel::Status::DE::HAFAS::Journey(3pm) object that describes
1107             the requested journey.
1108              
1109             If no result was found or the parser / http request failed, returns undef.
1110              
1111             =item $status->messages
1112              
1113             Returns a list of Travel::Status::DE::HAFAS::Message(3pm) objects with service
1114             messages. Each message belongs to at least one arrival/departure (station,
1115             journey) or to at least stop alongside its route (journey).
1116              
1117             =item $status->station (station)
1118              
1119             Returns a hashref describing the departure stations in all requested journeys.
1120             The hashref contains four entries: B<names> (station names), B<name> (most
1121             common name), B<evas> (UIC / EVA IDs), and B<eva> (most common UIC / EVA ID).
1122             These are subject to change.
1123              
1124             Note that the most common name and ID may be different from the station for
1125             which departures were requested, as HAFAS uses different identifiers for train
1126             stations, bus stops, and other modes of transit even if they are interlinked.
1127              
1128             =item $status->similar_stops
1129              
1130             Returns a list of hashrefs describing stops whose name is similar to the one
1131             requested in the constructor's B<station> parameter. Returns nothing if
1132             the active service does not support this feature.
1133             This is most useful if B<errcode> returns 'LOCATION', which means that the
1134             HAFAS backend could not identify the stop.
1135              
1136             See Travel::Status::DE::HAFAS::StopFinder(3pm)'s B<results> method for details
1137             on the return value.
1138              
1139             =item $status->similar_stops_p(I<%opt>)
1140              
1141             Returns a promise resolving to a list of hashrefs describing stops whose name
1142             is similar to the one requested in the constructor's B<station> parameter.
1143             Returns nothing if the active service does not support this feature. This is
1144             most useful if B<errcode> returns 'LOCATION', which means that the HAFAS
1145             backend could not identify the stop.
1146              
1147             See Travel::Status::DE::HAFAS::StopFinder(3pm)'s B<results> method for details
1148             on the resolved values.
1149              
1150             If $status has been created using B<new_p>, this function does not require
1151             arguments. Otherwise, the caller must specify B<promise> and B<user_agent>
1152             (see B<new_p> above).
1153              
1154             =item $status->get_active_service
1155              
1156             Returns a hashref describing the active service when a service is active and
1157             nothing otherwise. The hashref contains the following keys.
1158              
1159             =over
1160              
1161             =item B<coverage> => I<hashref>
1162              
1163             Area in which the service provides near-optimal coverage. Typically, this means
1164             a (nearly) complete list of departures and real-time data. The hashref contains
1165             two optional keys: B<area> (GeoJSON) and B<regions> (list of strings, e.g. "DE"
1166             or "CH-BE").
1167              
1168             =item B<geoip_lock> => I<proxy_id>
1169              
1170             If present: the service filters requests based on the estimated location of the
1171             requesting IP address, and may return errors or time out when the requesting IP
1172             address does not satisfy its requirements. Set the B<HAFAS_PROXY_>I<proxy_id>
1173             environment variable to a proxy string (e.g. C<< socks://localhost:12345 >>) if
1174             needed to work around this.
1175              
1176             =item B<homepage> => I<string>
1177              
1178             Homepage URL of the service provider.
1179              
1180             =item B<languages> => I<arrayref>
1181              
1182             Languages supported by the backend; see the constructor's B<language> argument.
1183              
1184             =item B<name> => I<string>
1185              
1186             Service name, e.g. Bay Area Rapid Transit or E<Ouml>sterreichische Bundesbahnen.
1187              
1188             =item B<mgate> => I<string>
1189              
1190             HAFAS backend URL
1191              
1192             =item B<productbits> => I<arrayref>
1193              
1194             MOT bits supported by the backend. I<arrayref> contains either strings
1195             (one string per mode of transit) or arrayrefs (one string pair per mode of
1196             transit, with the first entry referring to the MOT identifier and the second
1197             one containing a slightly longer description of it).
1198              
1199             =item B<time_zone> => I<string> (optional)
1200              
1201             The time zone this service reports arrival/departure times in. If this key is
1202             not present, it is safe to assume that it uses Europe/Berlin.
1203              
1204             =back
1205              
1206             =item Travel::Status::DE::HAFAS::get_services()
1207              
1208             Returns an array containing all supported HAFAS services. Each element is a
1209             hashref and contains all keys mentioned in B<get_active_service>.
1210             It also contains a B<shortname> key, which is the service name used by
1211             the constructor's B<service> parameter, e.g. BART or NASA.
1212              
1213             =item Travel::Status::DE::HAFAS::get_service(I<$service>)
1214              
1215             Returns a hashref describing the service I<$service>. Returns nothing if
1216             I<$service> is not supported. See B<get_active_service> for the hashref layout.
1217              
1218             =back
1219              
1220             =head1 DIAGNOSTICS
1221              
1222             None.
1223              
1224             =head1 DEPENDENCIES
1225              
1226             =over
1227              
1228             =item * Class::Accessor(3pm)
1229              
1230             =item * DateTime(3pm)
1231              
1232             =item * DateTime::Format::Strptime(3pm)
1233              
1234             =item * LWP::UserAgent(3pm)
1235              
1236             =back
1237              
1238             =head1 BUGS AND LIMITATIONS
1239              
1240             Some services are not well-tested.
1241              
1242             =head1 SEE ALSO
1243              
1244             =over
1245              
1246             =item * L<https://dbf.finalrewind.org?hafas=NASA> provides a web frontend to
1247             most of this module's features. Set B<hafas=>I<service> to use a specific
1248             service.
1249              
1250             =item * Travel::Routing::DE::HAFAS(3pm) for itineraries.
1251              
1252             =item * Travel::Status::DE::DBRIS(3pm) for Deutsche Bahn services.
1253              
1254             =back
1255              
1256             =head1 AUTHOR
1257              
1258             Copyright (C) 2015-2026 Birte Kristina Friesel E<lt>derf@finalrewind.orgE<gt>
1259              
1260             =head1 LICENSE
1261              
1262             This module is licensed under the same terms as Perl itself.