diff options
| author | Birte Kristina Friesel <derf@finalrewind.org> | 2025-06-15 08:23:48 +0200 | 
|---|---|---|
| committer | Birte Kristina Friesel <derf@finalrewind.org> | 2025-06-15 08:23:51 +0200 | 
| commit | c250a2f2c7968966014315f76b25109b83c041ed (patch) | |
| tree | 3c6ee328e7e67beadeac44d9458a048fe3e0b473 | |
| parent | f1da50f9f18f0a2a5fd202daff4b6f0b517f35e0 (diff) | |
Add experimental EFA support
Squashed commit of the following:
commit b7457791ab7ab4859ebf4a5ce173e1aaeed4c7fb
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Sun Jun 15 08:18:46 2025 +0200
    changelog
commit 7f3d61066195cfc3c83a8fc1b2fc3743e7e6171c
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Sat Jun 14 19:55:51 2025 +0200
    Mark EFA backends as experimental for now
    For instance, VRR has very interesting issues when checking into departures
    that do not have real-time data yet.
commit 3370c0f6c25bd6b02cc4d56e9a3aba2a66d1151a
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Sat Jun 14 19:49:48 2025 +0200
    InTransit: remove debug output
commit deb5444fa2965228b537e86fce862436ef2e6e19
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Sat Jun 14 19:12:44 2025 +0200
    frontend js for checked-in view: never show fractional delays
commit d47ff9615b551bbd844a799be7717e9e74a04266
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Sat Jun 14 19:12:31 2025 +0200
    worker: add EFA support
commit 3a955c0105bf13d040a821e2c87a19694202cde6
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Sat Jun 14 17:48:46 2025 +0200
    EFA: checkin support
    worker support and cancellations are still missing
commit 19dea1ad13029d19cba38e7d1338718149c139fb
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Sat Jun 14 14:32:59 2025 +0200
    actions.js: pass on efa parameter
commit 8f18ff2c8f9f906a387dbe16d372e1c4b4a6f259
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Sat Jun 14 14:32:48 2025 +0200
    EFA: implement geolocation lookup
commit bce1139bab9aab167cdab910fa86085529d45b80
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Sat Jun 14 14:32:21 2025 +0200
    EFA: ->id is no longer supported, use ->id_num
commit e4397e6b1538ddfa71da9839d6011a73fadc528f
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Mon Jun 9 20:34:22 2025 +0200
    ... derp
commit e0c4cbf862a8f5a7bca0b1aceab3760af94093e9
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Mon Jun 9 18:28:35 2025 +0200
    database: it's dbris, not ris
commit bfb1e834ce6c3171011dc20b32117065960b8771
Merge: 42f9a00 f1da50f
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Mon Jun 9 18:20:51 2025 +0200
    Merge branch 'main' into efa-support
commit 42f9a00d98dbd675234c05b3e25c3e722cfdd7ba
Author: Birte Kristina Friesel <derf@finalrewind.org>
Date:   Wed Jan 8 18:11:28 2025 +0100
    EFA support (WiP)
| -rw-r--r-- | cpanfile | 1 | ||||
| -rwxr-xr-x | lib/Travelynx.pm | 170 | ||||
| -rw-r--r-- | lib/Travelynx/Command/database.pm | 69 | ||||
| -rw-r--r-- | lib/Travelynx/Command/work.pm | 79 | ||||
| -rw-r--r-- | lib/Travelynx/Controller/Account.pm | 43 | ||||
| -rwxr-xr-x | lib/Travelynx/Controller/Traveling.pm | 91 | ||||
| -rw-r--r-- | lib/Travelynx/Helper/DBRIS.pm | 1 | ||||
| -rw-r--r-- | lib/Travelynx/Helper/EFA.pm | 102 | ||||
| -rw-r--r-- | lib/Travelynx/Helper/HAFAS.pm | 14 | ||||
| -rw-r--r-- | lib/Travelynx/Model/InTransit.pm | 171 | ||||
| -rwxr-xr-x | lib/Travelynx/Model/Journeys.pm | 5 | ||||
| -rw-r--r-- | lib/Travelynx/Model/Stations.pm | 63 | ||||
| -rw-r--r-- | lib/Travelynx/Model/Users.pm | 3 | ||||
| -rw-r--r-- | public/static/js/travelynx-actions.js | 8 | ||||
| -rw-r--r-- | templates/_departures_efa.html.ep | 54 | ||||
| -rw-r--r-- | templates/changelog.html.ep | 15 | ||||
| -rw-r--r-- | templates/departures.html.ep | 3 | ||||
| -rw-r--r-- | templates/select_backend.html.ep | 15 | 
18 files changed, 862 insertions, 45 deletions
| @@ -17,6 +17,7 @@ requires 'Mojolicious::Plugin::OAuth2';  requires 'Mojo::Pg';  requires 'Text::CSV';  requires 'Text::Markdown'; +requires 'Travel::Status::DE::EFA';  requires 'Travel::Status::MOTIS', '>= 0.01';  requires 'Travel::Status::DE::DBRIS', '>= 0.10';  requires 'Travel::Status::DE::HAFAS', '>= 6.20'; diff --git a/lib/Travelynx.pm b/lib/Travelynx.pm index f554d08..3d892ec 100755 --- a/lib/Travelynx.pm +++ b/lib/Travelynx.pm @@ -23,6 +23,7 @@ use List::MoreUtils qw(first_index);  use Travel::Status::DE::DBRIS::Formation;  use Travelynx::Helper::DBDB;  use Travelynx::Helper::DBRIS; +use Travelynx::Helper::EFA;  use Travelynx::Helper::HAFAS;  use Travelynx::Helper::IRIS;  use Travelynx::Helper::MOTIS; @@ -160,11 +161,12 @@ sub startup {  		cache_iris_main => sub {  			my ($self) = @_; -			return Cache::File->new( +			state $cache = Cache::File->new(  				cache_root      => $self->app->config->{cache}->{schedule},  				default_expires => '6 hours',  				lock_level      => Cache::File::LOCK_LOCAL(),  			); +			return $cache;  		}  	); @@ -172,11 +174,12 @@ sub startup {  		cache_iris_rt => sub {  			my ($self) = @_; -			return Cache::File->new( +			state $cache = Cache::File->new(  				cache_root      => $self->app->config->{cache}->{realtime},  				default_expires => '70 seconds',  				lock_level      => Cache::File::LOCK_LOCAL(),  			); +			return $cache;  		}  	); @@ -194,7 +197,7 @@ sub startup {  	$self->attr(  		renamed_station => sub { -			my $legacy_to_new = JSON->new->utf8->decode( +			state $legacy_to_new = JSON->new->utf8->decode(  				scalar read_file('share/old_station_names.json') );  			return $legacy_to_new;  		} @@ -220,6 +223,20 @@ sub startup {  	);  	$self->helper( +		efa => sub { +			my ($self) = @_; +			state $efa = Travelynx::Helper::EFA->new( +				log            => $self->app->log, +				main_cache     => $self->app->cache_iris_main, +				realtime_cache => $self->app->cache_iris_rt, +				root_url       => $self->base_url_for('/')->to_abs, +				user_agent     => $self->ua, +				version        => $self->app->config->{version}, +			); +		} +	); + +	$self->helper(  		dbris => sub {  			my ($self) = @_;  			state $dbris = Travelynx::Helper::DBRIS->new( @@ -490,15 +507,18 @@ sub startup {  				return Mojo::Promise->reject('You are already checked in');  			} -			if ( $opt{motis} ) { -				return $self->_checkin_motis_p(%opt); -			}  			if ( $opt{dbris} ) {  				return $self->_checkin_dbris_p(%opt);  			} +			if ( $opt{efa} ) { +				return $self->_checkin_efa_p(%opt); +			}  			if ( $opt{hafas} ) {  				return $self->_checkin_hafas_p(%opt);  			} +			if ( $opt{motis} ) { +				return $self->_checkin_motis_p(%opt); +			}  			my $promise = Mojo::Promise->new; @@ -869,6 +889,137 @@ sub startup {  	);  	$self->helper( +		'_checkin_efa_p' => sub { +			my ( $self, %opt ) = @_; +			my $station = $opt{station}; +			my $trip_id = $opt{train_id}; +			my $ts      = $opt{ts}; +			my $uid     = $opt{uid} // $self->current_user->{id}; +			my $db      = $opt{db}  // $self->pg->db; + +			my $promise = Mojo::Promise->new; +			$self->efa->get_journey_p( +				service => $opt{efa}, +				trip_id => $trip_id +			)->then( +				sub { +					my ($journey) = @_; + +					my $found; +					for my $stop ( $journey->route ) { +						if ( $stop->id_num == $station ) { +							$found = $stop; + +							# Lines may serve the same stop several times. +							# Keep looking until the scheduled departure +							# matches the one passed while checking in. +							if ( $ts and $stop->sched_dep->epoch == $ts ) { +								last; +							} +						} +					} +					if ( not $found ) { +						$promise->reject( +"Did not find stop '$station' within journey '$trip_id'" +						); +						return; +					} + +					for my $stop ( $journey->route ) { +						$self->stations->add_or_update( +							stop => $stop, +							db   => $db, +							efa  => $opt{efa}, +						); +					} + +					eval { +						$self->in_transit->add( +							uid        => $uid, +							db         => $db, +							journey    => $journey, +							stop       => $found, +							trip_id    => $trip_id, +							backend_id => $self->stations->get_backend_id( +								efa => $opt{efa} +							), +						); +					}; +					if ($@) { +						$self->app->log->error( +							"Checkin($uid): INSERT failed: $@"); +						$promise->reject( 'INSERT failed: ' . $@ ); +						return; +					} + +					my $polyline; +					if ( $journey->polyline ) { +						my @station_list; +						my @coordinate_list; +						for my $coord ( $journey->polyline ) { +							if ( $coord->{stop} ) { +								push( +									@coordinate_list, +									[ +										$coord->{lon}, $coord->{lat}, +										$coord->{stop}->id_num +									] +								); +								push( @station_list, +									$coord->{stop}->full_name ); +							} +							else { +								push( @coordinate_list, +									[ $coord->{lon}, $coord->{lat} ] ); +							} +						} + +						# equal length → polyline only consists of straight +						# lines between stops. that's not helpful. +						if ( @station_list == @coordinate_list ) { +							$self->log->debug( 'Ignoring polyline for ' +								  . $journey->line +								  . ' as it only consists of straight lines between stops.' +							); +						} +						else { +							$polyline = { +								from_eva => ( $journey->route )[0]->id_num, +								to_eva   => ( $journey->route )[-1]->id_num, +								coords   => \@coordinate_list, +							}; +						} +					} + +					if ($polyline) { +						$self->in_transit->set_polyline( +							uid      => $uid, +							db       => $db, +							polyline => $polyline, +						); +					} + +					# mustn't be called during a transaction +					if ( not $opt{in_transaction} ) { +						$self->run_hook( $uid, 'checkin' ); +					} + +					$promise->resolve($journey); + +					return; +				} +			)->catch( +				sub { +					my ($err) = @_; +					$promise->reject($err); +					return; +				} +			)->wait; +			return $promise; +		} +	); + +	$self->helper(  		'_checkin_hafas_p' => sub {  			my ( $self, %opt ) = @_; @@ -877,7 +1028,6 @@ sub startup {  			my $ts       = $opt{ts};  			my $uid      = $opt{uid} // $self->current_user->{id};  			my $db       = $opt{db}  // $self->pg->db; -			my $hafas;  			my $promise = Mojo::Promise->new; @@ -1136,7 +1286,11 @@ sub startup {  				return $promise->resolve( 0, 'race condition' );  			} -			if ( $user->{is_dbris} or $user->{is_hafas} or $user->{is_motis} ) { +			if (   $user->{is_dbris} +				or $user->{is_efa} +				or $user->{is_hafas} +				or $user->{is_motis} ) +			{  				return $self->_checkout_journey_p(%opt);  			} diff --git a/lib/Travelynx/Command/database.pm b/lib/Travelynx/Command/database.pm index e264c89..1385389 100644 --- a/lib/Travelynx/Command/database.pm +++ b/lib/Travelynx/Command/database.pm @@ -10,6 +10,7 @@ use DateTime;  use File::Slurp qw(read_file);  use List::Util  qw();  use JSON; +use Travel::Status::DE::EFA;  use Travel::Status::DE::HAFAS;  use Travel::Status::DE::IRIS::Stations;  use Travel::Status::MOTIS; @@ -3023,6 +3024,19 @@ qq{select distinct checkout_station_id from in_transit where backend_id = 0;}  			}  		);  	}, + +	# v62 -> v63 +	# Add EFA backend support +	sub { +		my ($db) = @_; +		$db->query( +			qq{ +				alter table schema_version add column efa varchar(12); +				update schema_version set version = 63; +				update schema_version set efa = '0'; +			} +		); +	},  );  sub sync_stations { @@ -3213,6 +3227,37 @@ sub sync_stations {  	}  } +sub sync_backends_efa { +	my ($db) = @_; +	for my $service ( Travel::Status::DE::EFA::get_services() ) { +		my $present = $db->select( +			'backends', +			'count(*) as count', +			{ +				efa  => 1, +				name => $service->{shortname} +			} +		)->hash->{count}; +		if ( not $present ) { +			$db->insert( +				'backends', +				{ +					dbris => 0, +					efa   => 1, +					hafas => 0, +					iris  => 0, +					motis => 0, +					name  => $service->{shortname}, +				}, +				{ on_conflict => undef } +			); +		} +	} + +	$db->update( 'schema_version', +		{ efa => $Travel::Status::DE::EFA::VERSION } ); +} +  sub sync_backends_hafas {  	my ($db) = @_;  	for my $service ( Travel::Status::DE::HAFAS::get_services() ) { @@ -3228,10 +3273,11 @@ sub sync_backends_hafas {  			$db->insert(  				'backends',  				{ -					iris  => 0, -					hafas => 1, -					efa   => 0,  					dbris => 0, +					efa   => 0, +					hafas => 1, +					iris  => 0, +					motis => 0,  					name  => $service->{shortname},  				},  				{ on_conflict => undef } @@ -3258,10 +3304,10 @@ sub sync_backends_motis {  			$db->insert(  				'backends',  				{ -					iris  => 0, -					hafas => 0, -					efa   => 0,  					dbris => 0, +					efa   => 0, +					hafas => 0, +					iris  => 0,  					motis => 1,  					name  => $service->{shortname},  				}, @@ -3361,6 +3407,17 @@ sub migrate_db {  		}  	} +	my $efa_version = get_schema_version( $db, 'efa' ); +	say "Found backend table for EFA v${efa_version}"; +	if ( $efa_version eq $Travel::Status::DE::EFA::VERSION ) { +		say 'Backend table is up-to-date'; +	} +	else { +		say +"Synchronizing with Travel::Status::DE::EFA $Travel::Status::DE::EFA::VERSION"; +		sync_backends_efa($db); +	} +  	my $hafas_version = get_schema_version( $db, 'hafas' );  	say "Found backend table for HAFAS v${hafas_version}";  	if ( $hafas_version eq $Travel::Status::DE::HAFAS::VERSION ) { diff --git a/lib/Travelynx/Command/work.pm b/lib/Travelynx/Command/work.pm index 2b01cb2..5ea1810 100644 --- a/lib/Travelynx/Command/work.pm +++ b/lib/Travelynx/Command/work.pm @@ -185,6 +185,85 @@ sub run {  			next;  		} +		if ( $entry->{is_efa} ) { +			eval { +				$self->app->efa->get_journey_p( +					trip_id => $train_id, +					service => $entry->{backend_name} +				)->then( +					sub { +						my ($journey) = @_; + +						my $found_dep; +						my $found_arr; +						for my $stop ( $journey->route ) { +							if ( $stop->id_num == $dep ) { +								$found_dep = $stop; +							} +							if ( $arr and $stop->id_num == $arr ) { +								$found_arr = $stop; +								last; +							} +						} +						if ( not $found_dep ) { +							$self->app->log->debug( +								"Did not find $dep within journey $train_id"); +							return; +						} + +						if ( $found_dep->rt_dep ) { +							$self->app->in_transit->update_departure_efa( +								uid     => $uid, +								journey => $journey, +								stop    => $found_dep, +								dep_eva => $dep, +								arr_eva => $arr, +								trip_id => $train_id, +							); +						} + +						if ( $found_arr and $found_arr->rt_arr ) { +							$self->app->in_transit->update_arrival_efa( +								uid     => $uid, +								journey => $journey, +								stop    => $found_arr, +								dep_eva => $dep, +								arr_eva => $arr, +								trip_id => $train_id, +							); +						} +					} +				)->catch( +					sub { +						my ($err) = @_; +						$backend_issues += 1; +						$self->app->log->error( +"work($uid) @ EFA $entry->{backend_name}: journey: $err" +						); +					} +				)->wait; + +				if (    $arr +					and $entry->{real_arr_ts} +					and $now->epoch - $entry->{real_arr_ts} > 600 ) +				{ +					$self->app->checkout_p( +						station => $arr, +						force   => 2, +						dep_eva => $dep, +						arr_eva => $arr, +						uid     => $uid +					)->wait; +				} +			}; +			if ($@) { +				$errors += 1; +				$self->app->log->error( +					"work($uid) @ EFA $entry->{backend_name}: $@"); +			} +			next; +		} +  		if ( $entry->{is_motis} ) {  			eval { diff --git a/lib/Travelynx/Controller/Account.pm b/lib/Travelynx/Controller/Account.pm index 033b270..0978c88 100644 --- a/lib/Travelynx/Controller/Account.pm +++ b/lib/Travelynx/Controller/Account.pm @@ -1077,6 +1077,49 @@ sub backend_form {  			$backend->{homepage}    = 'https://www.bahn.de';  			$backend->{recommended} = 1;  		} +		elsif ( $backend->{efa} ) { +			if ( my $s = $self->efa->get_service( $backend->{name} ) ) { +				$type                = 'EFA'; +				$backend->{longname} = $s->{name}; +				$backend->{homepage} = $s->{homepage}; +				$backend->{regions}  = [ map { $place_map{$_} // $_ } +					  @{ $s->{coverage}{regions} // [] } ]; +				$backend->{has_area}     = $s->{coverage}{area} ? 1 : 0; +				$backend->{experimental} = 1; + +				if ( +					    $s->{coverage}{area} +					and $s->{coverage}{area}{type} eq 'Polygon' +					and $self->lonlat_in_polygon( +						$s->{coverage}{area}{coordinates}, +						[ $user_lon, $user_lat ] +					) +				  ) +				{ +					push( @suggested_backends, $backend ); +				} +				elsif ( $s->{coverage}{area} +					and $s->{coverage}{area}{type} eq 'MultiPolygon' ) +				{ +					for my $s_poly ( +						@{ $s->{coverage}{area}{coordinates} // [] } ) +					{ +						if ( +							$self->lonlat_in_polygon( +								$s_poly, [ $user_lon, $user_lat ] +							) +						  ) +						{ +							push( @suggested_backends, $backend ); +							last; +						} +					} +				} +			} +			else { +				$type = undef; +			} +		}  		elsif ( $backend->{hafas} ) {  			# These backends lack a journey endpoint or are no longer diff --git a/lib/Travelynx/Controller/Traveling.pm b/lib/Travelynx/Controller/Traveling.pm index 0cfccb1..9826211 100755 --- a/lib/Travelynx/Controller/Traveling.pm +++ b/lib/Travelynx/Controller/Traveling.pm @@ -562,11 +562,14 @@ sub geolocation {  		return;  	} -	my ( $dbris_service, $hafas_service, $motis_service ); +	my ( $dbris_service, $efa_service, $hafas_service, $motis_service );  	my $backend = $self->stations->get_backend( backend_id => $backend_id );  	if ( $backend->{dbris} ) {  		$dbris_service = $backend->{name};  	} +	if ( $backend->{efa} ) { +		$efa_service = $backend->{name}; +	}  	elsif ( $backend->{hafas} ) {  		$hafas_service = $backend->{name};  	} @@ -617,6 +620,50 @@ sub geolocation {  		)->wait;  		return;  	} +	elsif ($efa_service) { +		$self->render_later; + +		Travel::Status::DE::EFA->new_p( +			promise    => 'Mojo::Promise', +			user_agent => Mojo::UserAgent->new, +			service    => $efa_service, +			coord      => { +				lat => $lat, +				lon => $lon +			} +		)->then( +			sub { +				my ($efa) = @_; +				my @results = map { +					{ +						name     => $_->full_name, +						eva      => $_->id_code, +						distance => 0, +						efa      => $efa_service, +					} +				} $efa->results; +				if ( @results > 10 ) { +					@results = @results[ 0 .. 9 ]; +				} +				$self->render( +					json => { +						candidates => [@results], +					} +				); +			} +		)->catch( +			sub { +				my ($err) = @_; +				$self->render( +					json => { +						candidates => [], +						warning    => $err, +					} +				); +			} +		)->wait; +		return; +	}  	elsif ($hafas_service) {  		$self->render_later; @@ -726,8 +773,6 @@ sub geolocation {  			lon      => $_->[0][3],  			lat      => $_->[0][4],  			distance => $_->[1], -			dbris    => 0, -			hafas    => 0,  		}  	} Travel::Status::DE::IRIS::Stations::get_station_by_location( $lon,  		$lat, 10 ); @@ -796,6 +841,7 @@ sub travel_action {  			sub {  				return $self->checkin_p(  					dbris        => $params->{dbris}, +					efa          => $params->{efa},  					hafas        => $params->{hafas},  					motis        => $params->{motis},  					station      => $params->{station}, @@ -832,6 +878,9 @@ sub travel_action {  					if ( $status->{is_dbris} ) {  						$station_link .= '?dbris=' . $status->{backend_name};  					} +					elsif ( $status->{is_efa} ) { +						$station_link .= '?efa=' . $status->{backend_name}; +					}  					elsif ( $status->{is_hafas} ) {  						$station_link .= '?hafas=' . $status->{backend_name};  					} @@ -871,6 +920,9 @@ sub travel_action {  				if ( $status->{is_dbris} ) {  					$station_link .= '?dbris=' . $status->{backend_name};  				} +				elsif ( $status->{is_efa} ) { +					$station_link .= '?efa=' . $status->{backend_name}; +				}  				elsif ( $status->{is_hafas} ) {  					$station_link .= '?hafas=' . $status->{backend_name};  				} @@ -929,6 +981,12 @@ sub travel_action {  					  . '?dbris='  					  . $status->{backend_name};  				} +				elsif ( $status->{is_efa} ) { +					$redir +					  = '/s/' +					  . $status->{dep_eva} . '?efa=' +					  . $status->{backend_name}; +				}  				elsif ( $status->{is_hafas} ) {  					$redir  					  = '/s/' @@ -959,6 +1017,7 @@ sub travel_action {  		$self->render_later;  		$self->checkin_p(  			dbris    => $params->{dbris}, +			efa      => $params->{efa},  			hafas    => $params->{hafas},  			motis    => $params->{motis},  			station  => $params->{station}, @@ -1091,6 +1150,8 @@ sub station {  	my $dbris_service = $self->param('dbris')  	  // ( $user->{backend_dbris} ? $user->{backend_name} : undef ); +	my $efa_service = $self->param('efa') +	  // ( $user->{backend_efa} ? $user->{backend_name} : undef );  	my $hafas_service = $self->param('hafas')  	  // ( $user->{backend_hafas} ? $user->{backend_name} : undef );  	my $motis_service = $self->param('motis') @@ -1118,6 +1179,15 @@ sub station {  			lookbehind => 30,  		);  	} +	elsif ($efa_service) { +		$promise = $self->efa->get_departures_p( +			service    => $efa_service, +			name       => $station, +			timestamp  => $timestamp, +			lookbehind => 30, +			lookahead  => 30, +		); +	}  	elsif ($hafas_service) {  		$promise = $self->hafas->get_departures_p(  			service    => $hafas_service, @@ -1209,6 +1279,16 @@ sub station {  					related_stations => [],  				};  			} +			elsif ($efa_service) { +				@results = map { $_->[0] } +				  sort { $b->[1] <=> $a->[1] } +				  map { [ $_, $_->datetime->epoch ] } $status->results; +				$status = { +					station_eva      => $status->stop->id_num, +					station_name     => $status->stop->full_name, +					related_stations => [], +				}; +			}  			elsif ($motis_service) {  				@results = map { $_->[0] }  				  sort { $b->[1] <=> $a->[1] } @@ -1268,12 +1348,14 @@ sub station {  						eva => $user_status->{cancellation}{dep_eva},  						destination_name =>  						  $user_status->{cancellation}{arr_name}, +						efa   => $efa_service,  						hafas => $hafas_service,  					);  				}  				else {  					$connections_p = $self->get_connecting_trains_p(  						eva   => $status->{station_eva}, +						efa   => $efa_service,  						hafas => $hafas_service  					);  				} @@ -1287,6 +1369,7 @@ sub station {  							'departures',  							user              => $user,  							dbris             => $dbris_service, +							efa               => $efa_service,  							hafas             => $hafas_service,  							motis             => $motis_service,  							eva               => $status->{station_eva}, @@ -1308,6 +1391,7 @@ sub station {  							'departures',  							user             => $user,  							dbris            => $dbris_service, +							efa              => $efa_service,  							hafas            => $hafas_service,  							motis            => $motis_service,  							eva              => $status->{station_eva}, @@ -1328,6 +1412,7 @@ sub station {  					'departures',  					user             => $user,  					dbris            => $dbris_service, +					efa              => $efa_service,  					hafas            => $hafas_service,  					motis            => $motis_service,  					eva              => $status->{station_eva}, diff --git a/lib/Travelynx/Helper/DBRIS.pm b/lib/Travelynx/Helper/DBRIS.pm index 0a46758..9ddaa5f 100644 --- a/lib/Travelynx/Helper/DBRIS.pm +++ b/lib/Travelynx/Helper/DBRIS.pm @@ -94,7 +94,6 @@ sub get_journey_p {  	my ( $self, %opt ) = @_;  	my $promise = Mojo::Promise->new; -	my $now     = DateTime->now( time_zone => 'Europe/Berlin' );  	my $agent = $self->{user_agent};  	my $proxy; diff --git a/lib/Travelynx/Helper/EFA.pm b/lib/Travelynx/Helper/EFA.pm new file mode 100644 index 0000000..ba11764 --- /dev/null +++ b/lib/Travelynx/Helper/EFA.pm @@ -0,0 +1,102 @@ +package Travelynx::Helper::EFA; + +# Copyright (C) 2024 Birte Kristina Friesel +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +use strict; +use warnings; +use 5.020; + +use Travel::Status::DE::EFA; + +sub new { +	my ( $class, %opt ) = @_; + +	my $version = $opt{version}; + +	$opt{header} +	  = { 'User-Agent' => +"travelynx/${version} on $opt{root_url} +https://finalrewind.org/projects/travelynx" +	  }; + +	return bless( \%opt, $class ); +} + +sub get_service { +	my ( $self, $service ) = @_; + +	return Travel::Status::DE::EFA::get_service($service); +} + +sub get_departures_p { +	my ( $self, %opt ) = @_; + +	my $when = ( +		  $opt{timestamp} +		? $opt{timestamp}->clone +		: DateTime->now( time_zone => 'Europe/Berlin' ) +	)->subtract( minutes => $opt{lookbehind} ); +	return Travel::Status::DE::EFA->new_p( +		service     => $opt{service}, +		name        => $opt{name}, +		datetime    => $when, +		full_routes => 1, +		cache       => $self->{realtime_cache}, +		promise     => 'Mojo::Promise', +		user_agent  => $self->{user_agent}->request_timeout(5), +	); +} + +sub get_journey_p { +	my ( $self, %opt ) = @_; + +	my $promise = Mojo::Promise->new; +	my $agent   = $self->{user_agent}; +	my $stopseq; + +	if ( $opt{trip_id} =~ m{ ^ ([^@]*) @ ([^@]*) [(] ([^)]*) [)] (.*)  $ }x ) { +		$stopseq = { +			stateless => $1, +			stop_id   => $2, +			date      => $3, +			key       => $4 +		}; +	} +	else { +		return $promise->reject("Invalid trip_id: $opt{trip_id}"); +	} + +	Travel::Status::DE::EFA->new_p( +		service    => $opt{service}, +		stopseq    => $stopseq, +		cache      => $self->{realtime_cache}, +		promise    => 'Mojo::Promise', +		user_agent => $agent->request_timeout(10), +	)->then( +		sub { +			my ($efa) = @_; +			my $journey = $efa->result; + +			if ($journey) { +				$self->{log}->debug("get_journey_p($opt{trip_id}): success"); +				$promise->resolve($journey); +				return; +			} +			$self->{log}->debug("get_journey_p($opt{trip_id}): no journey"); +			$promise->reject('no journey'); +			return; +		} +	)->catch( +		sub { +			my ($err) = @_; +			$self->{log}->debug("get_journey_p($opt{trip_id}): error $err"); +			$promise->reject($err); +			return; +		} +	)->wait; + +	return $promise; +} + +1; diff --git a/lib/Travelynx/Helper/HAFAS.pm b/lib/Travelynx/Helper/HAFAS.pm index ebf44d2..c35dfdb 100644 --- a/lib/Travelynx/Helper/HAFAS.pm +++ b/lib/Travelynx/Helper/HAFAS.pm @@ -35,6 +35,20 @@ sub new {  	return bless( \%opt, $class );  } +sub class_to_product { +	my ( $self, $hafas ) = @_; + +	my $bits = $hafas->get_active_service->{productbits}; +	my $ret; + +	for my $i ( 0 .. $#{$bits} ) { +		$ret->{ 2**$i } +		  = ref( $bits->[$i] ) eq 'ARRAY' ? $bits->[$i][0] : $bits->[$i]; +	} + +	return $ret; +} +  sub get_service {  	my ( $self, $service ) = @_; diff --git a/lib/Travelynx/Model/InTransit.pm b/lib/Travelynx/Model/InTransit.pm index cc943b3..470b45d 100644 --- a/lib/Travelynx/Model/InTransit.pm +++ b/lib/Travelynx/Model/InTransit.pm @@ -110,8 +110,6 @@ sub add {  	my $now  = DateTime->now( time_zone => 'Europe/Berlin' );  	if ($train) { - -		# IRIS  		$db->insert(  			'in_transit',  			{ @@ -142,9 +140,60 @@ sub add {  			}  		);  	} -	elsif ( $journey and $stop and $journey->can('product') ) { - -		# HAFAS +	elsif ( $journey +		and $stop +		and ref($journey) eq 'Travel::Status::DE::EFA::Trip' ) +	{ +		my @route; +		for my $j_stop ( $journey->route ) { +			push( +				@route, +				[ +					$j_stop->full_name, +					$j_stop->id_num, +					{ +						sched_arr => _epoch( $j_stop->sched_arr ), +						sched_dep => _epoch( $j_stop->sched_dep ), +						rt_arr    => _epoch( $j_stop->rt_arr ), +						rt_dep    => _epoch( $j_stop->rt_dep ), +						arr_delay => $j_stop->arr_delay, +						dep_delay => $j_stop->dep_delay, +						efa_load  => $j_stop->occupancy, +						lat       => $j_stop->latlon->[0], +						lon       => $j_stop->latlon->[1], +					} +				] +			); +		} +		$db->insert( +			'in_transit', +			{ +				user_id            => $uid, +				cancelled          => 0,                                  # TODO +				checkin_station_id => $stop->id_num, +				checkin_time       => $now, +				dep_platform       => $stop->platform, +				train_type         => $journey->type // q{}, +				train_line         => $journey->line, +				train_no           => $journey->number // q{}, +				train_id           => $opt{trip_id}, +				sched_departure    => $stop->sched_dep, +				real_departure     => $stop->rt_dep // $stop->sched_dep, +				route              => $json->encode( \@route ), +				data               => JSON->new->encode( +					{ +						rt => $stop->rt_dep ? 1 : 0, +						%{ $data // {} } +					} +				), +				backend_id => $backend_id, +			} +		); +	} +	elsif ( $journey +		and $stop +		and ref($journey) eq 'Travel::Status::DE::HAFAS::Journey' ) +	{  		my @route;  		my $product = $journey->product_at( $stop->loc->eva )  		  // $journey->product; @@ -198,9 +247,10 @@ sub add {  			}  		);  	} -	elsif ( $journey and $stop ) { - -		# DBRIS +	elsif ( $journey +		and $stop +		and ref($journey) eq 'Travel::Status::DE::DBRIS::Journey' ) +	{  		my $number = $journey->train_no // $journey->number // $train_suffix;  		my $line; @@ -284,9 +334,10 @@ sub add {  			}  		);  	} -	elsif ( $journey and $stopover ) { - -		# MOTIS +	elsif ( $journey +		and $stopover +		and ref($journey) eq 'Travel::Status::MOTIS::Trip' ) +	{  		my @route;  		for my $journey_stopover ( $journey->stopovers ) {  			push( @@ -340,7 +391,7 @@ sub add {  		);  	}  	else { -		die('neither train nor journey specified'); +		die('invalid arguments / argument types passed to InTransit->add');  	}  } @@ -944,6 +995,41 @@ sub update_departure_dbris {  	);  } +sub update_departure_efa { +	my ( $self, %opt ) = @_; +	my $uid     = $opt{uid}; +	my $db      = $opt{db} // $self->{pg}->db; +	my $dep_eva = $opt{dep_eva}; +	my $arr_eva = $opt{arr_eva}; +	my $journey = $opt{journey}; +	my $stop    = $opt{stop}; +	my $json    = JSON->new; + +	my $res_h = $db->select( 'in_transit', ['data'], { user_id => $uid } ) +	  ->expand->hash; +	my $ephemeral_data = $res_h ? $res_h->{data} : {}; +	if ( $stop->rt_dep ) { +		$ephemeral_data->{rt} = 1; +	} + +	# selecting on user_id and train_no avoids a race condition if a user checks +	# into a new train while we are fetching data for their previous journey. In +	# this case, the new train would receive data from the previous journey. +	$db->update( +		'in_transit', +		{ +			data           => $json->encode($ephemeral_data), +			real_departure => $stop->rt_dep, +		}, +		{ +			user_id             => $uid, +			train_id            => $opt{trip_id}, +			checkin_station_id  => $dep_eva, +			checkout_station_id => $arr_eva, +		} +	); +} +  sub update_departure_motis {  	my ( $self, %opt ) = @_;  	my $uid      = $opt{uid}; @@ -1137,6 +1223,67 @@ sub update_arrival_dbris {  	);  } +sub update_arrival_efa { +	my ( $self, %opt ) = @_; +	my $uid     = $opt{uid}; +	my $db      = $opt{db} // $self->{pg}->db; +	my $dep_eva = $opt{dep_eva}; +	my $arr_eva = $opt{arr_eva}; +	my $journey = $opt{journey}; +	my $stop    = $opt{stop}; +	my $json    = JSON->new; + +	my $res_h +	  = $db->select( 'in_transit', [ 'data', 'route' ], { user_id => $uid } ) +	  ->expand->hash; +	my $ephemeral_data = $res_h ? $res_h->{data}  : {}; +	my $old_route      = $res_h ? $res_h->{route} : []; + +	if ( $stop->rt_arr ) { +		$ephemeral_data->{rt} = 1; +	} + +	my @route; +	for my $j_stop ( $journey->route ) { +		push( +			@route, +			[ +				$j_stop->full_name, +				$j_stop->id_num, +				{ +					sched_arr => _epoch( $j_stop->sched_arr ), +					sched_dep => _epoch( $j_stop->sched_dep ), +					rt_arr    => _epoch( $j_stop->rt_arr ), +					rt_dep    => _epoch( $j_stop->rt_dep ), +					arr_delay => $j_stop->arr_delay, +					dep_delay => $j_stop->dep_delay, +					efa_load  => $j_stop->occupancy, +					lat       => $j_stop->latlon->[0], +					lon       => $j_stop->latlon->[1], +				} +			] +		); +	} + +	# selecting on user_id and train_no avoids a race condition if a user checks +	# into a new train while we are fetching data for their previous journey. In +	# this case, the new train would receive data from the previous journey. +	$db->update( +		'in_transit', +		{ +			data         => $json->encode($ephemeral_data), +			real_arrival => $stop->rt_arr, +			route        => $json->encode( [@route] ), +		}, +		{ +			user_id             => $uid, +			train_id            => $opt{trip_id}, +			checkin_station_id  => $dep_eva, +			checkout_station_id => $arr_eva, +		} +	); +} +  sub update_arrival_motis {  	my ( $self, %opt ) = @_;  	my $uid      = $opt{uid}; diff --git a/lib/Travelynx/Model/Journeys.pm b/lib/Travelynx/Model/Journeys.pm index 8efbab2..1662787 100755 --- a/lib/Travelynx/Model/Journeys.pm +++ b/lib/Travelynx/Model/Journeys.pm @@ -1735,6 +1735,8 @@ sub compute_stats {  sub get_stats {  	my ( $self, %opt ) = @_; +	$self->{log}->debug("get_stats"); +  	if ( $opt{cancelled} ) {  		$self->{log}  		  ->warn('get_journey_stats called with illegal option cancelled => 1'); @@ -1761,9 +1763,12 @@ sub get_stats {  		)  	  )  	{ +		$self->{log}->debug("got cached journey stats for $year/$month");  		return $stats;  	} +	$self->{log}->debug("computing journey stats for $year/$month"); +  	my $interval_start = DateTime->new(  		time_zone => 'Europe/Berlin',  		year      => 2000, diff --git a/lib/Travelynx/Model/Stations.pm b/lib/Travelynx/Model/Stations.pm index 174b3b4..bf35d1a 100644 --- a/lib/Travelynx/Model/Stations.pm +++ b/lib/Travelynx/Model/Stations.pm @@ -23,12 +23,15 @@ sub get_backend_id {  		# special case  		return 0;  	} -	if ( $opt{hafas} and $self->{backend_id}{hafas}{ $opt{hafas} } ) { -		return $self->{backend_id}{hafas}{ $opt{hafas} }; -	}  	if ( $opt{dbris} and $self->{backend_id}{dbris}{ $opt{dbris} } ) {  		return $self->{backend_id}{dbris}{ $opt{dbris} };  	} +	if ( $opt{efa} and $self->{backend_id}{efa}{ $opt{efa} } ) { +		return $self->{backend_id}{efa}{ $opt{efa} }; +	} +	if ( $opt{hafas} and $self->{backend_id}{hafas}{ $opt{hafas} } ) { +		return $self->{backend_id}{hafas}{ $opt{hafas} }; +	}  	if ( $opt{motis} and $self->{backend_id}{motis}{ $opt{motis} } ) {  		return $self->{backend_id}{motis}{ $opt{motis} };  	} @@ -47,6 +50,17 @@ sub get_backend_id {  		)->hash->{id};  		$self->{backend_id}{dbris}{ $opt{dbris} } = $backend_id;  	} +	elsif ( $opt{efa} ) { +		$backend_id = $db->select( +			'backends', +			['id'], +			{ +				efa  => 1, +				name => $opt{efa} +			} +		)->hash->{id}; +		$self->{backend_id}{efa}{ $opt{efa} } = $backend_id; +	}  	elsif ( $opt{hafas} ) {  		$backend_id = $db->select(  			'backends', @@ -100,7 +114,7 @@ sub get_backends {  	$opt{db} //= $self->{pg}->db;  	my $res = $opt{db}->select( 'backends', -		[ 'id', 'name', 'iris', 'hafas', 'dbris', 'motis' ] ); +		[ 'id', 'name', 'dbris', 'efa', 'hafas', 'iris', 'motis' ] );  	my @ret;  	while ( my $row = $res->hash ) { @@ -109,9 +123,10 @@ sub get_backends {  			{  				id    => $row->{id},  				name  => $row->{name}, -				iris  => $row->{iris},  				dbris => $row->{dbris}, +				efa   => $row->{efa},  				hafas => $row->{hafas}, +				iris  => $row->{iris},  				motis => $row->{motis},  			}  		); @@ -166,6 +181,44 @@ sub add_or_update {  		return;  	} +	if ( $opt{efa} ) { +		if ( +			my $s = $self->get_by_eva( +				$stop->id_num, +				db         => $opt{db}, +				backend_id => $opt{backend_id} +			) +		  ) +		{ +			$opt{db}->update( +				'stations', +				{ +					name     => $stop->full_name, +					lat      => $stop->latlon->[0], +					lon      => $stop->latlon->[1], +					archived => 0 +				}, +				{ +					eva    => $stop->id_num, +					source => $opt{backend_id} +				} +			); +			return; +		} +		$opt{db}->insert( +			'stations', +			{ +				eva      => $stop->id_num, +				name     => $stop->full_name, +				lat      => $stop->latlon->[0], +				lon      => $stop->latlon->[1], +				source   => $opt{backend_id}, +				archived => 0 +			} +		); +		return; +	} +  	if ( $opt{motis} ) {  		if (  			my $s = $self->get_by_external_id( diff --git a/lib/Travelynx/Model/Users.pm b/lib/Travelynx/Model/Users.pm index 1c3692e..a552633 100644 --- a/lib/Travelynx/Model/Users.pm +++ b/lib/Travelynx/Model/Users.pm @@ -418,7 +418,7 @@ sub get {  		  . 'extract(epoch from registered_at) as registered_at_ts, '  		  . 'extract(epoch from last_seen) as last_seen_ts, '  		  . 'extract(epoch from deletion_requested) as deletion_requested_ts, ' -		  . 'backend_id, backend_name, hafas, dbris, motis', +		  . 'backend_id, backend_name, dbris, efa, hafas, motis',  		{ id => $uid }  	)->hash;  	if ($user) { @@ -458,6 +458,7 @@ sub get {  			backend_id    => $user->{backend_id},  			backend_name  => $user->{backend_name},  			backend_dbris => $user->{dbris}, +			backend_efa   => $user->{efa},  			backend_hafas => $user->{hafas},  			backend_motis => $user->{motis},  		}; diff --git a/public/static/js/travelynx-actions.js b/public/static/js/travelynx-actions.js index d2316af..370aa33 100644 --- a/public/static/js/travelynx-actions.js +++ b/public/static/js/travelynx-actions.js @@ -79,12 +79,12 @@ function odelay(sched, rt) {  		return '';  	}  	if (sched < rt) { -		return ' (+' + ((rt - sched) / 60) + ')'; +		return ' (+' + Math.round((rt - sched) / 60) + ')';  	}  	else if (sched == rt) {  		return '';  	} -	return ' (' + ((rt - sched) / 60) + ')'; +	return ' (' + Math.round((rt - sched) / 60) + ')';  }  function tvly_run(link, req, err_callback) { @@ -195,6 +195,7 @@ function tvly_reg_handlers() {  		var req = {  			action: 'checkin',  			dbris: link.data('dbris'), +			efa: link.data('efa'),  			hafas: link.data('hafas'),  			motis: link.data('motis'),  			station: link.data('station'), @@ -210,6 +211,7 @@ function tvly_reg_handlers() {  		var req = {  			action: 'checkout',  			dbris: link.data('dbris'), +			efa: link.data('efa'),  			hafas: link.data('hafas'),  			motis: link.data('motis'),  			station: link.data('station'), @@ -243,6 +245,7 @@ function tvly_reg_handlers() {  		var req = {  			action: 'cancelled_from',  			dbris: link.data('dbris'), +			efa: link.data('efa'),  			hafas: link.data('hafas'),  			motis: link.data('motis'),  			station: link.data('station'), @@ -256,6 +259,7 @@ function tvly_reg_handlers() {  		var req = {  			action: 'cancelled_to',  			dbris: link.data('dbris'), +			efa: link.data('efa'),  			hafas: link.data('hafas'),  			motis: link.data('motis'),  			station: link.data('station'), diff --git a/templates/_departures_efa.html.ep b/templates/_departures_efa.html.ep new file mode 100644 index 0000000..6aec1c8 --- /dev/null +++ b/templates/_departures_efa.html.ep @@ -0,0 +1,54 @@ +<ul class="collection departures"> +% my $orientation_bar_shown = param('train'); +% my $now_epoch = now->epoch; +% for my $result (@{$results}) { +	% my $row_class = ''; +	% my $link_class = 'action-checkin'; +	% if ($result->is_cancelled) { +		% $row_class = "cancelled"; +		% $link_class = 'action-cancelled-from'; +	% } +	% if (not $orientation_bar_shown and $result->datetime->epoch < $now_epoch) { +		% $orientation_bar_shown = 1; +		<li class="collection-item" id="now"> +			<strong class="dep-time"> +				%= now->strftime('%H:%M') +			</strong> +			<strong>— Anfragezeitpunkt —</strong> +		</li> +	% } +	<li class="collection-item <%= $link_class %> <%= $row_class %>" +		data-efa="<%= $efa %>" +		data-station="<%= $result->stop_id_num %>" +		data-train="<%= $result->id %>" +		data-ts="<%= ($result->sched_datetime // $result->datetime)->epoch %>" +	> +		<a class="dep-time" href="#"> +			%= $result->datetime->strftime('%H:%M') +			% if ($result->delay) { +				(<%= sprintf('%+d', $result->delay) %>) +			% } +			% elsif (not defined $result->delay and not $result->is_cancelled) { +				<i class="material-icons" aria-label="Keine Echtzeitdaten vorhanden" style="font-size: 16px;">gps_off</i> +			% } +		</a> +		<span class="dep-line <%= $result->type // q{} %>"> +			%= $result->line +		</span> +		<span class="dep-dest"> +			% if ($result->is_cancelled) { +				Fahrt nach <%= $result->destination %> entfällt +			% } +			% else { +				%= $result->destination +				% for my $checkin (@{$checkin_by_train->{$result->id} // []}) { +					<span class="followee-checkin"> +						<i class="material-icons tiny" aria-label="Eine Person, der du folgst, ist hier eingecheckt">people</i> +						<%= $checkin->{followee_name} %> → <%= $checkin->{arr_name} // '???' %> +					</span> +				% } +			% } +		</span> +	</li> +% } +</ul> diff --git a/templates/changelog.html.ep b/templates/changelog.html.ep index ced431a..2fb89df 100644 --- a/templates/changelog.html.ep +++ b/templates/changelog.html.ep @@ -2,6 +2,21 @@  <div class="row">  	<div class="col s12 m1 l1"> +		2.14 +	</div> +	<div class="col s12 m11 l11"> +		<p> +			<i class="material-icons left" aria-label="Neues Feature">add</i> +			Experimentelle Unterstützung für Checkins via EFA-Backends. +			Teilweise ist ein Checkin nur bei Fahrten mit Echtzeitdaten +			möglich.  Hierbei handelt es sich nach aktuellem Stand um eine +			Einschränkung der verwendeten Backends. +		</p> +	</div> +</div> + +<div class="row"> +	<div class="col s12 m1 l1">  		2.13  	</div>  	<div class="col s12 m11 l11"> diff --git a/templates/departures.html.ep b/templates/departures.html.ep index 1745a47..917973b 100644 --- a/templates/departures.html.ep +++ b/templates/departures.html.ep @@ -160,6 +160,9 @@  			% if ($dbris) {  				%= include '_departures_dbris', results => $results, dbris => $dbris;  			% } +			% elsif ($efa) { +				%= include '_departures_efa', results => $results, efa => $efa; +			% }  			% elsif ($hafas) {  				%= include '_departures_hafas', results => $results, hafas => $hafas;  			% } diff --git a/templates/select_backend.html.ep b/templates/select_backend.html.ep index e54bcfd..e3db44d 100644 --- a/templates/select_backend.html.ep +++ b/templates/select_backend.html.ep @@ -66,19 +66,20 @@  %= end  <div class="row">  	<div class="col s12"> -		<h2 id="help">Hilfe</h2> +		<h2 id="help">Details</h2>  		<p>  			<strong>Deutsche Bahn: bahn.de</strong> ist eine gute Wahl für Fahrten des Nah-, Regional- und Fernverkehrs innerhalb Deutschlands. -			Die Implementierung ist noch recht frisch, bietet jedoch prinzipiell akkurate Echtzeit- und Kartendaten sowie Wagenreihungen. +			Dieses Backend bietet überwiegend korrekte Echtzeit- und Kartendaten sowie Wagenreihungen. +			Bei Nahverkehrsfahrten sind die Echtzeit- und Kartendaten meist nicht so gut wie bei den APIs des jeweiligen Verkehrsverbunds.  		<p> -			<strong>Deutsche Bahn: IRIS-TTS</strong> liefert Echtzeitdaten (nur am Start- und Zielbahnhof), Wagenreihungen und Verspätungsmeldungen für Regional- und Fernverkehr in Deutschland. Kartendaten sind nur teilweise verfügbar.  			<strong>ÖBB</strong> liefern Kartendaten und Wagenreihungen für Fernverkehr in Deutschland und Umgebung, jedoch keine Meldungen. Echtzeitdaten sind teilweise verfügbar.  		</p>  		<p> -			Die restlichen Backends lohnen sich für Fahrten in den zugehörigen Verkehrsverbünden bzw. Ländern. -			Im Gegensatz zu bahn.de liefern sie in vielen (aber nicht allen) Fällen auch detaillierte Kartendaten für die dem Verbund zugehörigen Verkehrsmittel. -			In Einzelfällen (z.B. BVG) sind sogar Auslastungsdaten eingepflegt. -			Bei Fahrten außerhalb von Deutschland und der Schweiz ist <strong>ÖBB</strong> zumeist die beste Wahl. +			<strong>Deutsche Bahn: IRIS-TTS</strong> liefert Echtzeitdaten (nur am Start- und Zielbahnhof), Wagenreihungen und Verspätungsmeldungen für Regional- und Fernverkehr in Deutschland. Kartendaten und Angaben zu Unterwegshalten sind nur teilweise verfügbar. Dieses Backend wird nicht mehr weiterentwickelt. Die zugehörige API wird voraussichtlich im Laufe des Jahres 2025 abgeschaltet. +		</p> +		<p> +			<strong>Transitous</strong> ist ein Aggregator für eine Vielzahl von Verkehrsunternehmen. +			Die Datenqualität variiert.  		</p>  	</div>  </div> | 
