File Coverage

blib/lib/Travel/Status/DE/IRIS.pm
Criterion Covered Total %
statement 260 467 55.6
branch 72 152 47.3
condition 30 67 44.7
subroutine 30 51 58.8
pod 8 23 34.7
total 400 760 52.6


line stmt bran cond sub pod time code
1             package Travel::Status::DE::IRIS;
2              
3 7     7   5227243 use strict;
  7         22  
  7         299  
4 7     7   35 use warnings;
  7         14  
  7         338  
5 7     7   117 use 5.014;
  7         23  
6              
7             our $VERSION = '2.04';
8              
9 7     7   72 use Carp qw(confess cluck);
  7         34  
  7         486  
10 7     7   41 use DateTime;
  7         15  
  7         242  
11 7     7   4661 use DateTime::Format::Strptime;
  7         450120  
  7         51  
12 7     7   825 use List::Util qw(none first);
  7         15  
  7         556  
13 7     7   46 use List::MoreUtils qw(uniq);
  7         19  
  7         119  
14 7     7   10185 use List::UtilsBy qw(uniq_by);
  7         15509  
  7         646  
15 7     7   5389 use LWP::UserAgent;
  7         448044  
  7         467  
16 7     7   5290 use Travel::Status::DE::IRIS::Result;
  7         37  
  7         90  
17 7     7   6618 use XML::LibXML;
  7         461258  
  7         54  
18              
19             sub try_load_xml {
20 47     47 0 128 my ($xml) = @_;
21              
22 47         91 my $tree;
23              
24 47         105 eval { $tree = XML::LibXML->load_xml( string => $xml ) };
  47         496  
25              
26 47 50       112072 if ($@) {
27 0         0 return ( undef, $@ );
28             }
29 47         198 return ( $tree, undef );
30             }
31              
32             # "station" parameter must be an EVA or DS100 ID.
33             sub new_p {
34 0     0 1 0 my ( $class, %opt ) = @_;
35 0         0 my $promise = $opt{promise}->new;
36              
37 0 0       0 if ( not $opt{station} ) {
38 0         0 return $promise->reject('station flag must be passed');
39             }
40              
41 0         0 my $self = $class->new( %opt, async => 1 );
42 0         0 $self->{promise} = $opt{promise};
43              
44 0         0 my $lookahead_steps = int( $self->{lookahead} / 60 );
45 0 0       0 if ( ( 60 - $self->{datetime}->minute ) < ( $self->{lookahead} % 60 ) ) {
46 0         0 $lookahead_steps++;
47             }
48 0         0 my $lookbehind_steps = int( $self->{lookbehind} / 60 );
49 0 0       0 if ( $self->{datetime}->minute < ( $self->{lookbehind} % 60 ) ) {
50 0         0 $lookbehind_steps++;
51             }
52              
53 0         0 my @candidates = $opt{get_station}( $opt{station} );
54              
55 0 0 0     0 if ( @candidates != 1 and $opt{station} =~ m{^\d+$} ) {
56             @candidates = (
57             [
58             "D$opt{station}", "Betriebsstelle nicht bekannt $opt{station}",
59             $opt{station}
60 0         0 ]
61             );
62             }
63              
64 0 0       0 if ( @candidates == 0 ) {
65 0         0 return $promise->reject('station not found');
66             }
67 0 0       0 if ( @candidates >= 2 ) {
68 0         0 return $promise->reject('station identifier is ambiguous');
69             }
70              
71             # "uic" is deprecated
72             $self->{station} = {
73 0         0 ds100 => $candidates[0][0],
74             eva => $candidates[0][2],
75             name => $candidates[0][1],
76             uic => $candidates[0][2],
77             };
78 0         0 $self->{related_stations} = [];
79              
80 0         0 my @queue = ( $self->{station}{eva} );
81 0         0 my @related_reqs;
82             my @related_stations;
83 0         0 my %seen = ( $self->{station}{eva} => 1 );
84 0         0 my $iter_depth = 0;
85              
86 0   0     0 while ( @queue and $iter_depth < 12 and $opt{with_related} ) {
      0        
87 0         0 my $eva = shift(@queue);
88 0         0 $iter_depth++;
89 0   0     0 for my $ref ( @{ $opt{meta}{$eva} // [] } ) {
  0         0  
90 0 0       0 if ( not $seen{$ref} ) {
91 0         0 push( @related_stations, $ref );
92 0         0 $seen{$ref} = 1;
93 0         0 push( @queue, $ref );
94             }
95             }
96             }
97              
98 0         0 for my $eva (@related_stations) {
99 0         0 @candidates = $opt{get_station}($eva);
100              
101 0 0       0 if ( @candidates == 1 ) {
102              
103             # "uic" is deprecated
104             push(
105 0         0 @{ $self->{related_stations} },
  0         0  
106             {
107             ds100 => $candidates[0][0],
108             eva => $candidates[0][2],
109             name => $candidates[0][1],
110             uic => $candidates[0][2],
111             }
112             );
113             }
114             }
115              
116 0         0 my $dt_req = $self->{datetime}->clone;
117             my @timetable_reqs
118 0         0 = ( $self->get_timetable_p( $self->{station}{eva}, $dt_req ) );
119              
120 0         0 for my $eva (@related_stations) {
121 0         0 push( @timetable_reqs, $self->get_timetable_p( $eva, $dt_req ) );
122             }
123              
124 0         0 for ( 1 .. $lookahead_steps ) {
125 0         0 $dt_req->add( hours => 1 );
126             push( @timetable_reqs,
127 0         0 $self->get_timetable_p( $self->{station}{eva}, $dt_req ) );
128 0         0 for my $eva (@related_stations) {
129 0         0 push( @timetable_reqs, $self->get_timetable_p( $eva, $dt_req ) );
130             }
131             }
132              
133 0         0 $dt_req = $self->{datetime}->clone;
134 0         0 for ( 1 .. $lookbehind_steps ) {
135 0         0 $dt_req->subtract( hours => 1 );
136             push( @timetable_reqs,
137 0         0 $self->get_timetable_p( $self->{station}{eva}, $dt_req ) );
138 0         0 for my $eva (@related_stations) {
139 0         0 push( @timetable_reqs, $self->get_timetable_p( $eva, $dt_req ) );
140             }
141             }
142              
143             $self->{promise}->all(@timetable_reqs)->then(
144             sub {
145             my @realtime_reqs
146 0     0   0 = ( $self->get_realtime_p( $self->{station}{eva} ) );
147 0         0 for my $eva (@related_stations) {
148 0         0 push( @realtime_reqs, $self->get_realtime_p( $eva, $dt_req ) );
149             }
150 0         0 return $self->{promise}->all_settled(@realtime_reqs);
151             }
152             )->then(
153             sub {
154 0     0   0 my @realtime_results = @_;
155              
156 0         0 for my $realtime_result (@realtime_results) {
157 0 0       0 if ( $realtime_result->{status} eq 'rejected' ) {
158 0   0     0 $self->{warnstr} //= q{};
159             $self->{warnstr}
160 0         0 .= "Realtime data request failed: $realtime_result->{reason}. ";
161             }
162             }
163              
164 0         0 $self->postprocess_results;
165 0         0 $promise->resolve($self);
166 0         0 return;
167             }
168             )->catch(
169             sub {
170 0     0   0 my ($err) = @_;
171 0         0 $promise->reject($err);
172 0         0 return;
173             }
174 0         0 )->wait;
175              
176 0         0 return $promise;
177             }
178              
179             sub new {
180 13     13 1 2413963 my ( $class, %opt ) = @_;
181              
182 13 100       81 if ( not $opt{station} ) {
183 1         356 confess('station flag must be passed');
184             }
185              
186             my $self = {
187             datetime => $opt{datetime}
188             // DateTime->now( time_zone => 'Europe/Berlin' ),
189             developer_mode => $opt{developer_mode},
190             iris_base => $opt{iris_base}
191             // 'https://iris.noncd.db.de/iris-tts/timetable',
192             keep_transfers => $opt{keep_transfers},
193             lookahead => $opt{lookahead} // ( 2 * 60 ),
194             lookbehind => $opt{lookbehind} // ( 0 * 60 ),
195             main_cache => $opt{main_cache},
196             rt_cache => $opt{realtime_cache},
197             serializable => $opt{serializable},
198             user_agent => $opt{user_agent},
199             with_related => $opt{with_related},
200             departure_by_id => {},
201 12   33     474 strptime_obj => $opt{strptime_obj} // DateTime::Format::Strptime->new(
      50        
      100        
      50        
      33        
202             pattern => '%y%m%d%H%M',
203             time_zone => 'Europe/Berlin',
204             ),
205             xp_ar => XML::LibXML::XPathExpression->new('./ar'),
206             xp_dp => XML::LibXML::XPathExpression->new('./dp'),
207             xp_tl => XML::LibXML::XPathExpression->new('./tl'),
208              
209             };
210              
211 12         30410 bless( $self, $class );
212              
213 12         96 my $lookahead_steps = int( $self->{lookahead} / 60 );
214 12 50       92 if ( ( 60 - $self->{datetime}->minute ) < ( $self->{lookahead} % 60 ) ) {
215 0         0 $lookahead_steps++;
216             }
217 12         245 my $lookbehind_steps = int( $self->{lookbehind} / 60 );
218 12 50       64 if ( $self->{datetime}->minute < ( $self->{lookbehind} % 60 ) ) {
219 0         0 $lookbehind_steps++;
220             }
221              
222 12 50       140 if ( $opt{async} ) {
223 0         0 return $self;
224             }
225              
226 12 50       48 if ( not $self->{user_agent} ) {
227 12   50     47 my %lwp_options = %{ $opt{lwp_options} // { timeout => 10 } };
  12         174  
228 12         164 $self->{user_agent} = LWP::UserAgent->new(%lwp_options);
229 12         27236 $self->{user_agent}->env_proxy;
230             }
231              
232             my ( $station, @related_stations ) = $self->get_station(
233             name => $opt{station},
234             root => 1,
235             recursive => $opt{with_related},
236 12         37745 );
237              
238 12         59 $self->{station} = $station;
239 12         40 $self->{related_stations} = \@related_stations;
240              
241 12         41 for my $ref (@related_stations) {
242              
243             # We (the parent) perform transfer processing, so child requests must not
244             # do it themselves. Otherwise, trains from child requests will be
245             # processed twice and may be lost.
246             # Similarly, child requests must not perform requests to related
247             # stations -- we're already doing that right now.
248             my $ref_status = Travel::Status::DE::IRIS->new(
249             datetime => $self->{datetime},
250             developer_mode => $self->{developer_mode},
251             iris_base => $self->{iris_base},
252             lookahead => $self->{lookahead},
253             lookbehind => $self->{lookbehind},
254             station => $ref->{eva},
255             main_cache => $self->{main_cache},
256             realtime_cache => $self->{rt_cache},
257             strptime_obj => $self->{strptime_obj},
258             user_agent => $self->{user_agent},
259 0         0 keep_transfers => 1,
260             with_related => 0,
261             );
262 0 0       0 if ( not $ref_status->errstr ) {
263 0         0 push( @{ $self->{results} }, $ref_status->results );
  0         0  
264             }
265             }
266              
267 12 100       67 if ( $self->{errstr} ) {
268 2         247 return $self;
269             }
270              
271 10         85 my $dt_req = $self->{datetime}->clone;
272 10         333 $self->get_timetable( $self->{station}{eva}, $dt_req );
273 10         53 for ( 1 .. $lookahead_steps ) {
274 20         153 $dt_req->add( hours => 1 );
275 20         30209 $self->get_timetable( $self->{station}{eva}, $dt_req );
276             }
277 10         85 $dt_req = $self->{datetime}->clone;
278 10         269 for ( 1 .. $lookbehind_steps ) {
279 0         0 $dt_req->subtract( hours => 1 );
280 0         0 $self->get_timetable( $self->{station}{eva}, $dt_req );
281             }
282              
283 10         57 $self->get_realtime;
284              
285 10         14840 $self->postprocess_results;
286              
287 10         647 return $self;
288             }
289              
290             sub postprocess_results {
291 10     10 0 41 my ($self) = @_;
292 10 50       100 if ( not $self->{keep_transfers} ) {
293              
294             # tra (transfer?) indicates a train changing its ID, so there are two
295             # results for the same train. Remove the departure-only trains from the
296             # result set and merge them with their arrival-only counterpart.
297             # This way, in case the arrival is available but the departure isn't,
298             # nothing gets lost.
299             my @merge_candidates
300 10 100       23 = grep { $_->transfer and $_->departure } @{ $self->{results} };
  1125         13451  
  10         54  
301 10         280 @{ $self->{results} }
302 1125   100     12632 = grep { not( $_->transfer and $_->departure ) }
303 10         144 @{ $self->{results} };
  10         48  
304              
305 10         43 for my $transfer (@merge_candidates) {
306             my $result
307 742 100   742   5175 = first { $_->transfer and $_->transfer eq $transfer->train_id }
308 12         46 @{ $self->{results} };
  12         35  
309 12 100       192 if ($result) {
310 10         21 $result->merge_with_departure($transfer);
311             }
312             }
313             }
314              
315 10         751 @{ $self->{results} } = grep {
316 1113   66     68875 my $d = $_->departure // $_->arrival;
317 1113   66     21821 my $s = $_->sched_arrival // $_->sched_departure // $_->arrival // $d;
      33        
      33        
318 1113         18478 $d = $d->subtract_datetime( $self->{datetime} );
319 1113         656232 $s = $s->subtract_datetime( $self->{datetime} );
320             not $d->is_negative and $s->in_units('minutes') < $self->{lookahead}
321 10 100       29 } @{ $self->{results} };
  1113         599652  
  10         40  
322              
323 10         95 @{ $self->{results} }
324 10         29 = sort { $a->{epoch} <=> $b->{epoch} } @{ $self->{results} };
  3527         5791  
  10         114  
325              
326             # wings (different departures which are coupled as one train) contain
327             # references to each other. therefore, they must be processed last.
328 10         96 $self->create_wing_refs;
329              
330             # same goes for replacement refs (the <ref> tag in the fchg document)
331 10         88 $self->create_replacement_refs;
332             }
333              
334             sub get_with_cache_p {
335 0     0 0 0 my ( $self, $cache, $url ) = @_;
336              
337 0 0       0 if ( $self->{developer_mode} ) {
338 0         0 say "GET $url";
339             }
340              
341 0         0 my $promise = $self->{promise}->new;
342              
343 0 0       0 if ($cache) {
344 0         0 my $content = $cache->thaw($url);
345 0 0       0 if ($content) {
346 0 0       0 if ( $self->{developer_mode} ) {
347 0         0 say ' cache hit';
348             }
349 0         0 return $promise->resolve($content);
350             }
351             }
352              
353 0 0       0 if ( $self->{developer_mode} ) {
354 0         0 say ' cache miss';
355             }
356              
357             my $res = $self->{user_agent}->get_p($url)->then(
358             sub {
359 0     0   0 my ($tx) = @_;
360 0 0       0 if ( my $err = $tx->error ) {
361 0         0 $promise->reject(
362             "GET $url returned HTTP $err->{code} $err->{message}");
363 0         0 return;
364             }
365 0         0 my $content = $tx->res->body;
366 0 0       0 if ($cache) {
367 0         0 $cache->freeze( $url, \$content );
368             }
369 0         0 $promise->resolve($content);
370 0         0 return;
371             }
372             )->catch(
373             sub {
374 0     0   0 my ($err) = @_;
375 0         0 $promise->reject($err);
376 0         0 return;
377             }
378 0         0 )->wait;
379              
380 0         0 return $promise;
381             }
382              
383             sub get_with_cache {
384 52     52 0 2867 my ( $self, $cache, $url ) = @_;
385              
386 52 50       193 if ( $self->{developer_mode} ) {
387 0         0 say "GET $url";
388             }
389              
390 52 50       162 if ($cache) {
391 0         0 my $content = $cache->thaw($url);
392 0 0       0 if ($content) {
393 0 0       0 if ( $self->{developer_mode} ) {
394 0         0 say ' cache hit';
395             }
396 0         0 return ( ${$content}, undef );
  0         0  
397             }
398             }
399              
400 52 50       162 if ( $self->{developer_mode} ) {
401 0         0 say ' cache miss';
402             }
403              
404 52         126 my $ua = $self->{user_agent};
405 52         369 my $res = $ua->get($url);
406              
407 52 100       407696 if ( $res->is_error ) {
408 5         65 return ( undef, $res->status_line );
409             }
410 47         720 my $content = $res->decoded_content;
411              
412 47 50       54844 if ($cache) {
413 0         0 $cache->freeze( $url, \$content );
414             }
415              
416 47         723 return ( $content, undef );
417             }
418              
419             sub get_station_p {
420 0     0 0 0 my ( $self, %opt ) = @_;
421              
422 0         0 my $promise = $self->{promise}->new;
423 0         0 my $station = $opt{name};
424              
425             $self->get_with_cache_p( $self->{main_cache},
426             $self->{iris_base} . '/station/' . $station )->then(
427             sub {
428 0     0   0 my ($raw) = @_;
429 0         0 my ( $xml_st, $xml_err ) = try_load_xml($raw);
430 0 0       0 if ($xml_err) {
431 0         0 $promise->reject('Failed to parse station data: Invalid XML');
432 0         0 return;
433             }
434 0         0 my $station_node = ( $xml_st->findnodes('//station') )[0];
435              
436 0 0       0 if ( not $station_node ) {
437 0         0 $promise->reject(
438             "Station '$station' has no associated timetable");
439 0         0 return;
440             }
441             $promise->resolve(
442             {
443 0         0 ds100 => $station_node->getAttribute('ds100'),
444             eva => $station_node->getAttribute('eva'),
445             name => $station_node->getAttribute('name'),
446             uic => $station_node->getAttribute('eva'),
447             }
448             );
449 0         0 return;
450             }
451             )->catch(
452             sub {
453 0     0   0 my ($err) = @_;
454 0         0 $promise->reject($err);
455 0         0 return;
456             }
457 0         0 )->wait;
458              
459 0         0 return $promise;
460             }
461              
462             sub get_station {
463 12     12 1 117 my ( $self, %opt ) = @_;
464              
465 12         34 my $iter_depth = 0;
466 12         51 my @ret;
467 12         52 my @queue = ( $opt{name} );
468              
469             # @seen holds station IDs which were already seen during recursive
470             # 'meta' descent. This avoids infinite loops of 'meta' references.
471             # Additionally, we use it to skip stations shat should not be referenced.
472             # This includes Norddeich / Norddeich Mole (different stations commonly used
473             # by identical trains with different departure times), and Essen-Dellwig /
474             # Essen-Dellwig Ost (different stations used by different trains, but with
475             # identical platform numbers).
476 12         39 my @seen = ( 8007768, 8004449, 8001903, 8001904 );
477              
478 12   66     133 while ( @queue and $iter_depth < 12 ) {
479 12         38 my $station = shift(@queue);
480 12         31 $iter_depth++;
481              
482             my ( $raw, $err )
483             = $self->get_with_cache( $self->{main_cache},
484 12         85 $self->{iris_base} . '/station/' . $station );
485 12 100       71 if ($err) {
486 1 50       8 if ( $opt{root} ) {
487 1         5 $self->{errstr} = "Failed to fetch station data: $err";
488 1         6 return;
489             }
490             else {
491             $self->{warnstr}
492 0         0 = "Failed to fetch station data for '$station': $err\n";
493 0         0 next;
494             }
495             }
496              
497 11         68 my ( $xml_st, $xml_err ) = try_load_xml($raw);
498 11 50       42 if ($xml_err) {
499 0         0 $self->{errstr} = 'Failed to parse station data: Invalid XML';
500 0         0 return;
501             }
502              
503 11         112 my $station_node = ( $xml_st->findnodes('//station') )[0];
504              
505 11 100       1017 if ( not $station_node ) {
506 1 50       6 if ( $self->{developer_mode} ) {
507 0         0 say ' no timetable';
508             }
509 1 50       5 if ( $opt{root} ) {
510             $self->{errstr}
511 1         5 = "Station '$station' has no associated timetable";
512 1         10 return;
513             }
514             else {
515             $self->{warnstr}
516 0         0 = "Station '$station' has no associated timetable";
517 0         0 next;
518             }
519 0         0 next;
520             }
521              
522 10         175 push( @seen, $station_node->getAttribute('eva') );
523              
524 10 50       231 if ( $station_node->getAttribute('name') =~ m{ ZOB} ) {
525              
526             # There are no departures from a ZOB ("Zentraler Omnibus-Bahnhof" /
527             # Central Omnibus Station). Ignore it entirely.
528 0         0 next;
529             }
530              
531 10 50       247 if ( $station_node->getAttribute('ds100') =~ m{ ^ D \d+ $ }x ) {
532              
533             # This used to indicate an invalid DS100 code, at least from DB
534             # perspective. It typically referred to subway stations which do not
535             # have IRIS departures.
536             # However, since Fahrplanwechsel 2022 / 2023, this does not seem
537             # to be the case anymore. There are some stations whose DS100 code
538             # IRIS does not know, for whatever reason. So for now, accept these
539             # stations as well.
540              
541             #next;
542             }
543              
544             push(
545 10         180 @ret,
546             {
547             ds100 => $station_node->getAttribute('ds100'),
548             eva => $station_node->getAttribute('eva'),
549             name => $station_node->getAttribute('name'),
550             uic => $station_node->getAttribute('eva'),
551             }
552             );
553              
554 10 50       386 if ( $self->{developer_mode} ) {
555 0         0 printf( " -> %s (%s / %s)\n", @{ $ret[-1] }{qw{name eva ds100}} );
  0         0  
556             }
557              
558 10 50 33     79 if ( $opt{recursive} and defined $station_node->getAttribute('meta') ) {
559             my @refs
560 0         0 = uniq( split( m{ \| }x, $station_node->getAttribute('meta') ) );
561 0         0 for my $ref (@refs) {
562 0 0 0 0   0 if ( none { $_ == $ref } @seen and none { $_ == $ref } @queue )
  0         0  
  0         0  
563             {
564 0         0 push( @queue, $ref );
565             }
566             }
567 0         0 $opt{root} = 0;
568             }
569             }
570              
571 10 50       327 if (@queue) {
572 0         0 cluck( "Reached $iter_depth iterations when tracking station IDs. "
573             . "This is probably a bug" );
574             }
575              
576 10     10   114 @ret = uniq_by { $_->{eva} } @ret;
  10         108  
577              
578 10         176 return @ret;
579             }
580              
581             sub add_result {
582 1140     1140 0 3342 my ( $self, $station_name, $station_eva, $s ) = @_;
583              
584 1140         3181 my $id = $s->getAttribute('id');
585 1140         17703 my $e_tl = ( $s->findnodes( $self->{xp_tl} ) )[0];
586 1140         41817 my $e_ar = ( $s->findnodes( $self->{xp_ar} ) )[0];
587 1140         14394 my $e_dp = ( $s->findnodes( $self->{xp_dp} ) )[0];
588              
589 1140 50       14287 if ( not $e_tl ) {
590 0         0 return;
591             }
592              
593             my %data = (
594             raw_id => $id,
595             classes => $e_tl->getAttribute('f'), # D N S F
596             operator => $e_tl->getAttribute('o'), # coded operator: 03/80/R2/...
597             train_no => $e_tl->getAttribute('n'), # dep number
598             type => $e_tl->getAttribute('c'), # S/ICE/ERB/...
599             station => $station_name,
600             station_eva => $station_eva + 0, # EVA IDs are numbers
601             station_uic => $station_eva + 0, # deprecated
602             strptime_obj => $self->{strptime_obj},
603              
604             #unknown_t => $e_tl->getAttribute('t'), # p
605 1140         8128 );
606              
607 1140 100       42758 if ($e_ar) {
608 985         6147 $data{arrival_ts} = $e_ar->getAttribute('pt');
609 985         9930 $data{line_no} = $e_ar->getAttribute('l');
610 985         8908 $data{platform} = $e_ar->getAttribute('pp'); # string, not number!
611 985         8700 $data{route_pre} = $e_ar->getAttribute('ppth');
612 985         9547 $data{route_start} = $e_ar->getAttribute('pde');
613 985         8143 $data{transfer} = $e_ar->getAttribute('tra');
614 985         7890 $data{arrival_hidden} = $e_ar->getAttribute('hi');
615 985         8209 $data{arrival_wing_ids} = $e_ar->getAttribute('wings');
616             }
617              
618 1140 100       10816 if ($e_dp) {
619 939         5394 $data{departure_ts} = $e_dp->getAttribute('pt');
620 939         9492 $data{line_no} = $e_dp->getAttribute('l');
621 939         7559 $data{platform} = $e_dp->getAttribute('pp'); # string, not number!
622 939         7747 $data{route_post} = $e_dp->getAttribute('ppth');
623 939         8794 $data{route_end} = $e_dp->getAttribute('pde');
624 939         7848 $data{transfer} = $e_dp->getAttribute('tra');
625 939         7323 $data{departure_hidden} = $e_dp->getAttribute('hi');
626 939         7412 $data{departure_wing_ids} = $e_dp->getAttribute('wings');
627             }
628              
629 1140 100       9235 if ( $data{arrival_wing_ids} ) {
630 20         136 $data{arrival_wing_ids} = [ split( /\|/, $data{arrival_wing_ids} ) ];
631             }
632 1140 100       2508 if ( $data{departure_wing_ids} ) {
633             $data{departure_wing_ids}
634 13         72 = [ split( /\|/, $data{departure_wing_ids} ) ];
635             }
636              
637 1140         9065 my $result = Travel::Status::DE::IRIS::Result->new(%data);
638              
639             # if scheduled departure and current departure are not within the
640             # same hour, trains are reported twice. Don't add duplicates in
641             # that case.
642 1140 100       8011 if ( not $self->{departure_by_id}{$id} ) {
643 1125         1750 push( @{ $self->{results} }, $result, );
  1125         3084  
644 1125         3727 $self->{departure_by_id}{$id} = $result;
645             }
646              
647 1140         11302 return $result;
648             }
649              
650             sub get_timetable_p {
651 0     0 0 0 my ( $self, $eva, $dt ) = @_;
652              
653 0         0 my $promise = $self->{promise}->new;
654              
655             $self->get_with_cache_p( $self->{main_cache},
656             $dt->strftime( $self->{iris_base} . "/plan/${eva}/%y%m%d/%H" ) )->then(
657             sub {
658 0     0   0 my ($raw) = @_;
659 0         0 my ( $xml, $xml_err ) = try_load_xml($raw);
660 0 0       0 if ($xml_err) {
661 0         0 $promise->reject(
662             'Failed to parse a schedule part: Invalid XML');
663 0         0 return;
664             }
665 0         0 my $station
666             = ( $xml->findnodes('/timetable') )[0]->getAttribute('station');
667              
668 0         0 for my $s ( $xml->findnodes('/timetable/s') ) {
669              
670 0         0 $self->add_result( $station, $eva, $s );
671             }
672 0         0 $promise->resolve;
673 0         0 return;
674             }
675             )->catch(
676             sub {
677 0     0   0 my ($err) = @_;
678 0         0 $promise->reject($err);
679 0         0 return;
680             }
681 0         0 )->wait;
682 0         0 return $promise;
683             }
684              
685             sub get_timetable {
686 30     30 0 131 my ( $self, $eva, $dt ) = @_;
687              
688             my ( $raw, $err )
689             = $self->get_with_cache( $self->{main_cache},
690 30         246 $dt->strftime( $self->{iris_base} . "/plan/${eva}/%y%m%d/%H" ) );
691              
692 30 100       211 if ($err) {
693 4         16 $self->{warnstr} = "Failed to fetch a schedule part: $err";
694 4         13 return $self;
695             }
696              
697 26         122 my ( $xml, $xml_err ) = try_load_xml($raw);
698              
699 26 50       88 if ($xml_err) {
700 0         0 $self->{warnstr} = 'Failed to parse a schedule part: Invalid XML';
701 0         0 return $self;
702             }
703              
704 26         133 my $station = ( $xml->findnodes('/timetable') )[0]->getAttribute('station');
705              
706 26         1314 for my $s ( $xml->findnodes('/timetable/s') ) {
707              
708 1019         35567 $self->add_result( $station, $eva, $s );
709             }
710              
711 26 50 33     813 if ( $self->{developer_mode}
712             and not scalar $xml->findnodes('/timetable/s') )
713             {
714 0         0 say ' no scheduled trains';
715             }
716              
717 26         11093 return $self;
718             }
719              
720             sub get_realtime_p {
721 0     0 0 0 my ( $self, $eva ) = @_;
722              
723 0         0 my $promise = $self->{promise}->new;
724              
725             $self->get_with_cache_p( $self->{rt_cache},
726             $self->{iris_base} . "/fchg/${eva}" )->then(
727             sub {
728 0     0   0 my ($raw) = @_;
729 0         0 my ( $xml, $xml_err ) = try_load_xml($raw);
730 0 0       0 if ($xml_err) {
731 0         0 $promise->reject(
732             'Failed to parse a schedule part: Invalid XML');
733 0         0 return;
734             }
735 0         0 $self->parse_realtime( $eva, $xml );
736 0         0 $promise->resolve;
737 0         0 return;
738             }
739             )->catch(
740             sub {
741 0     0   0 my ($err) = @_;
742 0         0 $promise->reject("Failed to fetch realtime data: $err");
743 0         0 return;
744             }
745 0         0 )->wait;
746 0         0 return $promise;
747             }
748              
749             sub get_realtime {
750 10     10 0 31 my ($self) = @_;
751              
752 10         64 my $eva = $self->{station}{eva};
753              
754             my ( $raw, $err )
755             = $self->get_with_cache( $self->{rt_cache},
756 10         86 $self->{iris_base} . "/fchg/${eva}" );
757              
758 10 50       48 if ($err) {
759 0         0 $self->{warnstr} = "Failed to fetch realtime data: $err";
760 0         0 return $self;
761             }
762              
763 10         55 my ( $xml, $xml_err ) = try_load_xml($raw);
764              
765 10 50       52 if ($xml_err) {
766 0         0 $self->{warnstr} = 'Failed to parse realtime data: Invalid XML';
767 0         0 return $self;
768             }
769              
770 10         68 $self->parse_realtime( $eva, $xml );
771             }
772              
773             sub parse_realtime {
774 10     10 0 38 my ( $self, $eva, $xml ) = @_;
775 10         112 my $station = ( $xml->findnodes('/timetable') )[0]->getAttribute('station');
776              
777 10         640 for my $s ( $xml->findnodes('/timetable/s') ) {
778 2547         120991 my $id = $s->getAttribute('id');
779 2547         36858 my $e_ar = ( $s->findnodes( $self->{xp_ar} ) )[0];
780 2547         46997 my $e_dp = ( $s->findnodes( $self->{xp_dp} ) )[0];
781 2547         31223 my @e_refs = $s->findnodes('./ref/tl');
782 2547         39384 my @e_ms = $s->findnodes('.//m');
783              
784 2547         40111 my %messages;
785              
786 2547         8144 my $result = $self->{departure_by_id}{$id};
787              
788             # add_result will return nothing if no ./tl node is present. The ./tl
789             # check here is for optimization purposes.
790 2547 100 100     9988 if ( not $result and ( $s->findnodes( $self->{xp_tl} ) )[0] ) {
791 121         2919 $result = $self->add_result( $station, $eva, $s );
792 121 50       4454 if ($result) {
793 121         651 $result->set_unscheduled(1);
794             }
795             }
796 2547 100       25097 if ( not $result ) {
797 1720         6253 next;
798             }
799              
800 827 50       3469 if ( not $self->{serializable} ) {
801 827         3198 $result->set_realtime($s);
802             }
803              
804 827         2003 for my $e_m (@e_ms) {
805 3945         8428 my $type = $e_m->getAttribute('t');
806 3945         36800 my $value = $e_m->getAttribute('c');
807 3945         31934 my $msgid = $e_m->getAttribute('id');
808 3945         32391 my $ts = $e_m->getAttribute('ts');
809              
810             # 0 and 1 (with key "f") are related to canceled trains and
811             # do not appear to hold information (or at least none we can access).
812             # All observed cases of message ID 900 were related to bus
813             # connections ("Anschlussbus wartet"). We can't access which bus
814             # it refers to, so we don't show that either.
815             # ID 1000 is a generic free text message, which (as we lack access
816             # to the text itself) is not helpful either.
817 3945 100 100     41988 if ( defined $value and $value > 1 and $value < 100 ) {
      66        
818 3069         13261 $messages{$msgid} = [ $ts, $type, $value ];
819             }
820             }
821              
822 827         5231 $result->set_messages(%messages);
823              
824             # note: A departure may also have a ./tl attribute. However, we do
825             # not need to process it because it only matters for departures which
826             # are not planned (or not in the plans we requested). However, in
827             # those cases we already called add_result earlier, which reads ./tl
828             # by itself.
829 827         1893 for my $e_ref (@e_refs) {
830 1         5 $result->add_raw_ref(
831             class => $e_ref->getAttribute('f'), # D N S F
832             train_no => $e_ref->getAttribute('n'), # dep number
833             type => $e_ref->getAttribute('c'), # S/ICE/ERB/...
834             line_no => $e_ref->getAttribute('l'), # 1 -> S1, ...
835              
836             #unknown_t => $e_ref->getAttribute('t'), # p
837             #unknown_o => $e_ref->getAttribute('o'), # owner: 03/80/R2/...
838             # TODO ps='a' -> rerouted and normally unscheduled train?
839             );
840             }
841 827 100       3159 if ($e_ar) {
842 760         5226 $result->set_ar(
843             arrival_ts => $e_ar->getAttribute('ct'),
844             plan_arrival_ts => $e_ar->getAttribute('pt'),
845             platform => $e_ar->getAttribute('cp'),
846             route_pre => $e_ar->getAttribute('cpth'),
847             sched_route_pre => $e_ar->getAttribute('ppth'),
848             status => $e_ar->getAttribute('cs'),
849             status_since => $e_ar->getAttribute('clt'),
850             arrival_hidden => $e_ar->getAttribute('hi'),
851              
852             # TODO ps='a' -> rerouted and normally unscheduled train?
853             );
854             }
855 827 100       5030 if ($e_dp) {
856 686         5343 $result->set_dp(
857             departure_ts => $e_dp->getAttribute('ct'),
858             plan_departure_ts => $e_dp->getAttribute('pt'),
859             platform => $e_dp->getAttribute('cp'),
860             route_post => $e_dp->getAttribute('cpth'),
861             sched_route_post => $e_dp->getAttribute('ppth'),
862             status => $e_dp->getAttribute('cs'),
863             departure_hidden => $e_dp->getAttribute('hi'),
864             );
865             }
866              
867             }
868              
869 10         372 return $self;
870             }
871              
872             sub get_result_by_id {
873 22     22 0 102 my ( $self, $id ) = @_;
874              
875 22     1090   146 my $res = first { $_->wing_id eq $id } @{ $self->{results} };
  1090         9377  
  22         150  
876 22         308 return $res;
877             }
878              
879             sub get_result_by_train {
880 0     0 0 0 my ( $self, $type, $train_no ) = @_;
881              
882 0 0   0   0 my $res = first { $_->type eq $type and $_->train_no eq $train_no }
883 0         0 @{ $self->{results} };
  0         0  
884 0         0 return $res;
885             }
886              
887             sub create_wing_refs {
888 10     10 0 41 my ($self) = @_;
889              
890 10         55 for my $r ( $self->results ) {
891 743 100       1613 if ( $r->{departure_wing_ids} ) {
892 7         15 for my $wing_id ( @{ $r->{departure_wing_ids} } ) {
  7         24  
893 8         29 my $wingref = $self->get_result_by_id($wing_id);
894 8 50       50 if ($wingref) {
895 8         65 $r->add_departure_wingref($wingref);
896             }
897             }
898             }
899 743 100       1598 if ( $r->{arrival_wing_ids} ) {
900 13         54 for my $wing_id ( @{ $r->{arrival_wing_ids} } ) {
  13         72  
901 14         59 my $wingref = $self->get_result_by_id($wing_id);
902 14 50       88 if ($wingref) {
903 14         74 $r->add_arrival_wingref($wingref);
904             }
905             }
906             }
907             }
908              
909             }
910              
911             sub create_replacement_refs {
912 10     10 0 45 my ($self) = @_;
913              
914 10         38 for my $r ( $self->results ) {
915 743   50     938 for my $ref_hash ( @{ $r->{refs} // [] } ) {
  743         2167  
916             my $ref = $self->get_result_by_train( $ref_hash->{type},
917 0         0 $ref_hash->{train_no} );
918 0 0       0 if ($ref) {
919 0         0 $r->add_reference($ref);
920             }
921             }
922             }
923             }
924              
925             sub station {
926 0     0 1 0 my ($self) = @_;
927              
928 0         0 return $self->{station};
929             }
930              
931             sub related_stations {
932 0     0 1 0 my ($self) = @_;
933              
934 0         0 return @{ $self->{related_stations} };
  0         0  
935             }
936              
937             sub errstr {
938 3     3 1 3737 my ($self) = @_;
939              
940 3         34 return $self->{errstr};
941             }
942              
943             sub results {
944 28     28 1 3709 my ($self) = @_;
945              
946 28   50     50 return @{ $self->{results} // [] };
  28         267  
947             }
948              
949             sub warnstr {
950 1     1 1 4796 my ($self) = @_;
951              
952 1         11 return $self->{warnstr};
953             }
954              
955             1;
956              
957             __END__
958              
959             =head1 NAME
960              
961             Travel::Status::DE::IRIS - Interface to IRIS based web departure monitors.
962              
963             =head1 SYNOPSIS
964              
965             Blocking variant:
966              
967             use Travel::Status::DE::IRIS;
968            
969             my $status = Travel::Status::DE::IRIS->new(station => "Essen Hbf");
970             for my $r ($status->results) {
971             printf(
972             "%s %s +%-3d %10s -> %s\n",
973             $r->date, $r->time, $r->delay || 0, $r->line, $r->destination
974             );
975             }
976              
977             Non-blocking variant (EXPERIMENTAL):
978              
979             use Mojo::Promise;
980             use Mojo::UserAgent;
981             use Travel::Status::DE::IRIS;
982             use Travel::Status::DE::IRIS::Stations;
983            
984             Travel::Status::DE::IRIS->new_p(station => "Essen Hbf",
985             promise => 'Mojo::Promise', user_agent => Mojo::UserAgent->new,
986             get_station => \&Travel::Status::DE::IRIS::Stations::get_station,
987             meta => Travel::Status::DE::IRIS::Stations::get_meta())->then(sub {
988             my ($status) = @_;
989             for my $r ($status->results) {
990             printf(
991             "%s %s +%-3d %10s -> %s\n",
992             $r->date, $r->time, $r->delay || 0, $r->line, $r->destination
993             );
994             }
995             })->wait;
996              
997             =head1 VERSION
998              
999             version 2.04
1000              
1001             =head1 DEPRECATION NOTICE
1002              
1003             As of May 2024, the backend service that this module relies on is deprecated
1004             and may cease operation in the near future. There is no successor with feature
1005             parity. Travel::Status::DE::IRIS is no longer actively maintained. There is
1006             no promise that issues and merge requests will be reviewed or merged.
1007              
1008             The Travel::Status::DE::DBRIS(3pm) module provides similar features.
1009              
1010             =head1 DESCRIPTION
1011              
1012             Travel::Status::DE::IRIS is an unofficial interface to IRIS based web
1013             departure monitors such as
1014             L<https://iris.noncd.db.de/wbt/js/index.html?typ=ab&style=qrab&bhf=EE&SecLang=&Zeilen=20&footer=0&disrupt=0>.
1015              
1016             =head1 METHODS
1017              
1018             =over
1019              
1020             =item my $status = Travel::Status::DE::IRIS->new(I<%opt>)
1021              
1022             Requests schedule and realtime data for a specific station at a specific
1023             point in time. Returns a new Travel::Status::DE::IRIS object.
1024              
1025             Arguments:
1026              
1027             =over
1028              
1029             =item B<datetime> => I<datetime-obj>
1030              
1031             A DateTime(3pm) object specifying the point in time. Optional, defaults to the
1032             current date and time.
1033              
1034             =item B<iris_base> => I<url>
1035              
1036             IRIS base url, defaults to C<< http://iris.noncd.db.de/iris-tts/timetable >>.
1037              
1038             =item B<keep_transfers> => I<bool>
1039              
1040             A train may change its ID and number at a station, indicating that although the
1041             previous logical train ends here, the physical train will continue its journey
1042             under a new number to a new destination. A notable example is the Berlin
1043             Ringbahn, which travels round and round from Berlin SE<uuml>dkreuz to Berlin
1044             SE<uuml>dkreuz. Each train number corresponds to a single revolution, but the
1045             actual trains just keep going.
1046              
1047             The IRIS backend returns two results for each transfer train: An arrival-only
1048             result using the old ID (linked to the new one) and a departure-only result
1049             using the new ID (linked to the old one). By default, this library merges these
1050             into a single result with both arrival and departure time. Train number, ID,
1051             and route are taken from the departure only. The original train ID and number
1052             are available using the B<old_train_id> and B<old_train_no> accessors.
1053              
1054             In case this is not desirable (e.g. because you intend to track a single
1055             train to its destination station and do not want to implement special cases
1056             for transfer trains), set B<keep_transfers> to a true value. In this case,
1057             backend data will be reported as-is and transfer trains will not be merged.
1058              
1059             =item B<lookahead> => I<int>
1060              
1061             Compute only results which are scheduled less than I<int> minutes in the
1062             future.
1063             Default: 120 (2 hours).
1064              
1065             Note that the DeutscheBahn IRIS backend only provides schedules up to four to
1066             five hours into the future. So in most cases, setting this to a value above 240
1067             minutes will have little effect. However, as the IRIS occasionally contains
1068             unscheduled departures or qos messages known far in advance (e.g. 12 hours from
1069             now), any non-negative integer is accepted.
1070              
1071             =item B<lookbehind> => I<int>
1072              
1073             Also check trains whose scheduled departure lies up to I<int> minutes in the
1074             past. Default: 0.
1075              
1076             This is useful when requesting departures shortly after a full hour. If,
1077             for example, a train was scheduled to depart on 11:59 and has 5 minutes delay,
1078             it will not be shown when requesting departures on or after 12:00 unless
1079             B<lookbehind> is set to a value greater than zero.
1080              
1081             Note that trains with significant delay (e.g. +30) may still be shown in this
1082             case regardless of the setting of B<lookbehind>, since these receive special
1083             treatment by the IRIS backend.
1084              
1085             =item B<lwp_options> => I<\%hashref>
1086              
1087             Passed on to C<< LWP::UserAgent->new >>. Defaults to C<< { timeout => 10 } >>,
1088             you can use an empty hashref to unset the default.
1089              
1090             =item B<main_cache> => I<$ojj>
1091              
1092             A Cache::File(3pm) object used to cache station and timetable requests. Optional.
1093              
1094             =item B<realtime_cache> => I<$ojj>
1095              
1096             A Cache::File(3pm) object used to cache realtime data requests. Optional.
1097              
1098             =item B<station> => I<stationcode>
1099              
1100             Mandatory: Which station to return departures for. Note that this is not a
1101             station name, but a station code, such as "EE" (for Essen Hbf) or "KA"
1102             (for Aachen Hbf). See Travel::Status::DE::IRIS::Stations(3pm) for a
1103             name to code mapping.
1104              
1105             =item B<with_related> => I<bool>
1106              
1107             Sometimes, Deutsche Bahn splits up major stations in the IRIS interface. For
1108             instance, "KE<ouml>ln Messe/Deutz" actually consists of "KE<ouml>ln
1109             Messe/Deutz" (KKDZ), "KE<ouml>ln Messe/Deutz Gl. 9-10" (KKDZB) and "KE<ouml>ln
1110             Messe/Deutz (tief)" (KKDT).
1111              
1112             By default, Travel::Status::DE::IRIS only returns departures for the specified
1113             station. When this option is set to a true value, it will also return
1114             departures for all related stations.
1115              
1116             =back
1117              
1118             =item my $promise = Travel::Status::DE::IRIS->new_p(I<%opt>) (B<EXPERIMENTAL>)
1119              
1120             Return a promise yielding a Travel::Status::DE::IRIS instance (C<< $status >>)
1121             on success, or an error message (same as C<< $status->errstr >>) on failure.
1122             This function is experimental and may be changed or remove without warning.
1123              
1124             In addition to the arguments of B<new>, the following mandatory arguments must
1125             be set:
1126              
1127             =over
1128              
1129             =item B<promise> => I<promises module>
1130              
1131             Promises implementation to use for internal promises as well as B<new_p> return
1132             value. Recommended: Mojo::Promise(3pm).
1133              
1134             =item B<get_station> => I<get_station ref>
1135              
1136             Reference to Travel::Status::DE::IRIS::Stations::get_station().
1137              
1138             =item B<meta> => I<meta dict>
1139              
1140             The dictionary returned by Travel::Status::DE::IRIS::Stations::get_meta().
1141              
1142             =item B<user_agent> => I<user agent>
1143              
1144             User agent instance to use for asynchronous requests. The object must support
1145             promises (i.e., it must implement a C<< get_p >> function). Recommended:
1146             Mojo::UserAgent(3pm).
1147              
1148             =back
1149              
1150             =item $status->errstr
1151              
1152             In case of a fatal HTTP request or IRIS error, returns a string describing it.
1153             Returns undef otherwise.
1154              
1155             =item $status->related_stations
1156              
1157             Returns a list of hashes describing related stations whose
1158             arrivals/departures are included in B<results>. Only useful when setting
1159             B<with_related> to a true value, see its documentation above for details.
1160              
1161             Each hash contains the keys B<eva> (EVA number; often same as UIC station ID),
1162             B<name> (station name), and B<ds100> (station code). Note that stations
1163             returned by B<related_stations> are not necessarily known to
1164             Travel::Status::DE::IRIS::Stations(3pm).
1165              
1166             =item $status->results
1167              
1168             Returns a list of Travel::Status::DE::IRIS::Result(3pm) objects, each one describing
1169             one arrival and/or departure.
1170              
1171             =item $status->warnstr
1172              
1173             In case of a (probably) non-fatal HTTP request or IRIS error, returns a string
1174             describing it. Returns undef otherwise.
1175              
1176             =back
1177              
1178             =head1 DIAGNOSTICS
1179              
1180             None.
1181              
1182             =head1 DEPENDENCIES
1183              
1184             =over
1185              
1186             =item * DateTime(3pm)
1187              
1188             =item * List::Util(3pm)
1189              
1190             =item * LWP::UserAgent(3pm)
1191              
1192             =item * XML::LibXML(3pm)
1193              
1194             =back
1195              
1196             =head1 BUGS AND LIMITATIONS
1197              
1198             Some backend features are not yet exposed.
1199              
1200             =head1 SEE ALSO
1201              
1202             db-iris(1), Travel::Status::DE::IRIS::Result(3pm),
1203             Travel::Status::DE::IRIS::Stations(3pm)
1204              
1205             =head1 REPOSITORY
1206              
1207             L<https://github.com/derf/Travel-Status-DE-IRIS>
1208              
1209             =head1 AUTHOR
1210              
1211             Copyright (C) 2013-2026 by Birte Kristina Friesel E<lt>derf@finalrewind.orgE<gt>
1212              
1213             =head1 LICENSE
1214              
1215             This module is licensed under the same terms as Perl itself.