File Coverage

blib/lib/Travel/Status/DE/HAFAS.pm
Criterion Covered Total %
statement 191 392 48.7
branch 41 144 28.4
condition 34 124 27.4
subroutine 31 45 68.8
pod 13 23 56.5
total 310 728 42.5


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