railway-provider-motis-0.1.0/.cargo_vcs_info.json0000644000000001640000000000100154640ustar { "git": { "sha1": "0333950f69474deb120b3ba39113766a949cb0f8" }, "path_in_vcs": "railway-provider-motis" }railway-provider-motis-0.1.0/Cargo.toml0000644000000026410000000000100134640ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" name = "railway-provider-motis" version = "0.1.0" authors = ["Julian Schmidhuber "] description = "Implementation of a Motis client for Railway" readme = "README.md" keywords = [ "railway-backend", "train", "public-transport", ] license = "AGPL-3.0-or-later OR EUPL-1.2" repository = "https://gitlab.com/schmiddi-on-mobile/railway-backend" [dependencies.async-trait] version = "0.1" [dependencies.chrono] version = "0.4" [dependencies.chrono-tz] version = "0.8" [dependencies.rcore] version = "0.1" package = "railway-core" [dependencies.serde] version = "1.0" features = ["derive"] [dependencies.serde_json] version = "1.0" [dependencies.url] version = "2.5.0" [dev-dependencies.env_logger] version = "0.10.1" [dev-dependencies.tokio] version = "1.35" features = [ "rt-multi-thread", "macros", ] [features] polylines = ["rcore/polylines"] rt-multi-thread = ["rcore/rt-multi-thread"] railway-provider-motis-0.1.0/Cargo.toml.orig000064400000000000000000000020061046102023000171400ustar 00000000000000[package] name = "railway-provider-motis" version = "0.1.0" authors = ["Julian Schmidhuber "] edition = "2021" description = "Implementation of a Motis client for Railway" repository = "https://gitlab.com/schmiddi-on-mobile/railway-backend" license = "AGPL-3.0-or-later OR EUPL-1.2" keywords = ["railway-backend", "train", "public-transport"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] rcore = { package = "railway-core", path = "../railway-core", version = "0.1" } serde = { version = "1.0", features = [ "derive" ] } serde_json = "1.0" chrono = { version = "0.4" } chrono-tz = "0.8" url = "2.5.0" async-trait = "0.1" [features] rt-multi-thread = [ "rcore/rt-multi-thread" ] polylines = [ "rcore/polylines" ] [dev-dependencies] tokio = { version = "1.35", features = [ "rt-multi-thread", "macros" ] } env_logger = "0.10.1" rcore = { package = "railway-core", path = "../railway-core", features = [ "hyper-requester" ] } railway-provider-motis-0.1.0/README.md000064400000000000000000000005141046102023000155320ustar 00000000000000# Railway Motis Provider Implementation of a Motis client for Railway. This crate is part of [railway-backend](https://gitlab.com/schmiddi-on-mobile/railway-backend). This is mainly useful for the [Transitous](https://transitous.org/) instance. Documentation can be found [here](https://routing.spline.de/doc/index.html#post-/). railway-provider-motis-0.1.0/src/error.rs000064400000000000000000000007011046102023000165370ustar 00000000000000use std::fmt::Display; #[derive(Debug)] pub enum Error { Json(serde_json::Error), RefreshJourneyNotFound, } impl Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { match &self { Self::Json(e) => write!(f, "json error: {}", e), Self::RefreshJourneyNotFound => write!(f, "refresh journey not found"), } } } impl std::error::Error for Error {} railway-provider-motis-0.1.0/src/lib.rs000064400000000000000000000274171046102023000161710ustar 00000000000000#![doc = include_str!("../README.md")] mod error; mod types; use chrono::Utc; use error::*; use types::*; use async_trait::async_trait; use rcore::{ Journey, JourneysOptions, Location, Mode, Place, Provider, Requester, RequesterBuilder, }; use serde_json::json; use url::Url; use std::collections::HashMap; pub const SPLINE_URL: &str = "https://routing.spline.de/api/"; #[derive(Clone)] pub struct MotisClient { requester: R, base_url: url::Url, } impl MotisClient { pub fn new>(url: Url, requester: RB) -> Self { Self { requester: requester.build(), base_url: url, } } } #[cfg_attr(feature = "rt-multi-thread", async_trait)] #[cfg_attr(not(feature = "rt-multi-thread"), async_trait(?Send))] impl Provider for MotisClient { type Error = Error; async fn journeys( &self, from: rcore::Place, to: rcore::Place, opts: rcore::JourneysOptions, ) -> Result::Error, Self::Error>> { let interval = if let Some(departure) = opts.departure { json!({ "begin": departure.timestamp(), "end": departure.timestamp() + 1, }) } else if let Some(arrival) = opts.arrival { json!({ "begin": arrival.timestamp() - 1, "end": arrival.timestamp(), }) } else if let Some(later) = opts.later_than.as_ref().and_then(|s| s.parse::().ok()) { json!({ "begin": later, "end": later + 1, }) } else if let Some(earlier) = opts .earlier_than .as_ref() .and_then(|s| s.parse::().ok()) { json!({ "begin": earlier - 1, "end": earlier, }) } else { let now = Utc::now(); json!({ "begin": now.timestamp(), "end": now.timestamp() + 1, }) }; let is_earlier_or_later = opts.earlier_than.is_some() || opts.later_than.is_some(); let is_now = opts.departure.is_none() && opts.arrival.is_none() && opts.earlier_than.is_none() && opts.later_than.is_none(); // TODO: How to search by arrival time? // TODO: Destination type by station or lat/lon depending on availability. // Serach by "arrive by": Set interval on destination. let request = json!({ "content": { "destination":{ "id": match &to { Place::Station(s) => s.id.to_owned(), Place::Location(l) => match l { Location::Point { id, .. } => id.clone().unwrap_or_default(), Location::Address { .. } => "".to_owned() } }, "name": match &to { Place::Station(s) => s.name.clone().unwrap_or_default(), Place::Location(l) => match l { Location::Point { name, .. } => name.clone().unwrap_or_default(), Location::Address { address, .. } => address.to_owned(), } }, }, "destination_modes": [ { "mode_type": "FootPPR", "mode": { "search_options": { "profile": "default", "duration_limit": 900 } } } ], "destination_type": "InputStation", "router": "", "search_dir": if opts.arrival.is_some() { "Backward" } else { "Forward" }, "search_type": "Default", "start": { "extend_interval_earlier": (opts.arrival.is_some() && !is_earlier_or_later) || opts.earlier_than.is_some(), "extend_interval_later": (opts.departure.is_some() && !is_earlier_or_later) || opts.later_than.is_some() || is_now, "interval": interval, "min_connection_count": opts.results, "station": { "id": match &from { Place::Station(s) => s.id.to_owned(), Place::Location(l) => match l { Location::Point { id, .. } => id.clone().unwrap_or_default(), Location::Address { .. } => "".to_owned() } }, "name": match &from { Place::Station(ref s) => s.name.clone().unwrap_or_default(), Place::Location(l) => match l { Location::Point { name, .. } => name.clone().unwrap_or_default(), Location::Address { address, .. } => address.to_owned(), } }, }, }, "start_modes": [ { "mode_type": "FootPPR", "mode": { "search_options": { "profile": "default", "duration_limit": 900 } } } ], "start_type": "PretripStart", "allowed_claszes": std::collections::HashSet::::from(opts.products).into_iter().flat_map(MotisClasz::from_mode).map(|m| *m as u8).collect::>(), }, "content_type": "IntermodalRoutingRequest", "destination": { "target": "/intermodal", "type": "Module", } }) .to_string(); let response = self .requester .post( &self.base_url, request.as_bytes(), HashMap::from([ ("Accept", "application/json"), ("Content-Type", "application/json"), ]), ) .await .map_err(rcore::Error::Request)?; let response: MotisJourneysResponse = serde_json::from_slice(&response) .map_err(|e| rcore::Error::Provider(Error::Json(e)))?; Ok(response.into()) } async fn locations( &self, opts: rcore::LocationsOptions, ) -> Result::Error, Self::Error>> { // TODO: Also do address search. let request = json!({ "content_type": "StationGuesserRequest", "content": { "guess_count": opts.results, "input": opts.query }, "destination": { "target": "/guesser", "type": "Module" } }) .to_string(); let response = self .requester .post( &self.base_url, request.as_bytes(), HashMap::from([ ("Accept", "application/json"), ("Content-Type", "application/json"), ]), ) .await .map_err(rcore::Error::Request)?; let response: MotisLocationsResponse = serde_json::from_slice(&response) .map_err(|e| rcore::Error::Provider(Error::Json(e)))?; Ok(response.into()) } // Note: This method is not guaranteed to find the same journey. But I think this is the best we can do as search.ch does not provide a refresh-API. // TODO: Use `trip_to_connection` module instead. async fn refresh_journey( &self, journey: &Journey, opts: rcore::RefreshJourneyOptions, ) -> Result::Error, Self::Error>> { // Note: Each journey should have at least one leg. let from = &journey.legs[0].origin; let to = &journey.legs[journey.legs.len() - 1].destination; let jopts = JourneysOptions { // In most cases, the journey to refresh is likely the first one. Relax this requirement a bit. results: 3, departure: journey.legs[0].planned_departure, stopovers: opts.stopovers, tickets: opts.tickets, tariff_class: opts.tariff_class, language: opts.language, #[cfg(feature = "polylines")] polylines: opts.polylines, ..Default::default() }; self.journeys(from.clone(), to.clone(), jopts) .await? .journeys .into_iter() .find(|j| j.id == journey.id) .clone() .ok_or(rcore::Error::Provider(Error::RefreshJourneyNotFound)) } } #[cfg(test)] mod test { use rcore::{HyperRustlsRequesterBuilder, JourneysOptions, LocationsOptions, Station}; use super::*; pub async fn check_search>( search: S, expected: S, ) -> Result<(), Box> { let client = MotisClient::new( Url::parse(SPLINE_URL).expect("Failed to parse SPLINE_URL"), HyperRustlsRequesterBuilder::default(), ); let locations = client .locations(LocationsOptions { query: search.as_ref().to_string(), ..Default::default() }) .await?; let results = locations .into_iter() .flat_map(|p| match p { Place::Station(s) => s.name, Place::Location(Location::Address { address, .. }) => Some(address), Place::Location(Location::Point { name, .. }) => name, }) .collect::>(); assert!( results.iter().find(|s| s == &expected.as_ref()).is_some(), "expected {} to be contained in {:#?}", expected.as_ref(), results ); Ok(()) } pub async fn check_journey>( from: S, to: S, ) -> Result<(), Box> { let client = MotisClient::new( Url::parse(SPLINE_URL).expect("Failed to parse SPLINE_URL"), HyperRustlsRequesterBuilder::default(), ); let journeys = client .journeys( Place::Station(Station { id: from.as_ref().to_string(), ..Default::default() }), Place::Station(Station { id: to.as_ref().to_string(), ..Default::default() }), JourneysOptions::default(), ) .await?; assert!( !journeys.journeys.is_empty(), "expected journey from {} to {} to exist", from.as_ref(), to.as_ref() ); Ok(()) } #[tokio::test] async fn search_munich() -> Result<(), Box> { check_search("Münch", "München Hbf").await } #[tokio::test] async fn search_vienna() -> Result<(), Box> { check_search("Wien", "Wien Hbf").await } #[tokio::test] async fn journey_munich_nuremberg() -> Result<(), Box> { check_journey("de-DELFI_de:09162:100", "de-DELFI_de:09564:510").await } } railway-provider-motis-0.1.0/src/types.rs000064400000000000000000000323241046102023000165600ustar 00000000000000use chrono::{DateTime, Utc}; use chrono_tz::UTC; use rcore::{ IntermediateLocation, Journey, JourneysResponse, Leg, Line, Location, Mode, Operator, Place, Product, Station, Stop, }; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MotisLocationsResponse { content: MotisLocationsResponseContent, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MotisLocationsResponseContent { guesses: Vec, } impl From for Vec { fn from(locations: MotisLocationsResponse) -> Self { locations .content .guesses .into_iter() .map(Place::from) .collect() } } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MotisStation { id: String, name: String, pos: MotisPosition, } impl From for Place { fn from(station: MotisStation) -> Self { Place::Station(Station { id: station.id.clone(), name: Some(station.name.clone()), location: Some(Location::Point { id: Some(station.id), name: Some(station.name), poi: None, latitude: station.pos.lat as f32, longitude: station.pos.lng as f32, }), products: vec![], }) } } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MotisPosition { lat: f64, lng: f64, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MotisJourneysResponse { content: MotisJourneysResponseContent, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MotisJourneysResponseContent { connections: Vec, // TODO: direct_connection? } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MotisConnection { stops: Vec, transports: Vec, trips: Vec, attributes: Vec, free_texts: Vec, // problems: Vec, // TODO: status? } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MotisStop { station: MotisStation, arrival: MotisEventInfo, departure: MotisEventInfo, enter: bool, exit: bool, } impl From for IntermediateLocation { fn from(stop: MotisStop) -> IntermediateLocation { IntermediateLocation::Stop(Stop { place: stop.station.into(), departure: DateTime::::from_timestamp(stop.departure.time.try_into().unwrap(), 0) .as_ref() .map(|t| t.with_timezone(&UTC)), planned_departure: DateTime::::from_timestamp( stop.departure.schedule_time.try_into().unwrap(), 0, ) .as_ref() .map(|t| t.with_timezone(&UTC)), arrival: DateTime::::from_timestamp(stop.arrival.time.try_into().unwrap(), 0) .as_ref() .map(|t| t.with_timezone(&UTC)), planned_arrival: DateTime::::from_timestamp( stop.arrival.schedule_time.try_into().unwrap(), 0, ) .as_ref() .map(|t| t.with_timezone(&UTC)), arrival_platform: Some(stop.arrival.track), planned_arrival_platform: Some(stop.arrival.schedule_track), departure_platform: Some(stop.departure.track), planned_departure_platform: Some(stop.departure.schedule_track), cancelled: false, remarks: vec![], }) } } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MotisEventInfo { time: u64, schedule_time: u64, track: String, schedule_track: String, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "move_type", content = "move")] pub enum MotisMove { Transport(MotisTransport), Walk(MotisWalk), } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MotisTransport { range: MotisRange, clasz: u8, line_id: String, name: String, provider: String, direction: String, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MotisWalk { range: MotisRange, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct MotisRange { from: u64, to: u64, } #[derive(Serialize, Deserialize, Debug, Clone, Hash)] pub struct MotisTrip { range: MotisRange, id: MotisTripId, } #[derive(Serialize, Deserialize, Debug, Clone, Hash)] pub struct MotisTripId { id: String, station_id: String, train_nr: u64, time: i64, target_station_id: String, target_time: i64, line_id: String, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MotisAttribute { range: MotisRange, code: String, text: String, } #[derive(Serialize, Deserialize, Debug, Clone, Hash)] pub struct MotisFreeText { range: MotisRange, code: i32, text: String, r#type: String, } impl From for JourneysResponse { fn from(motis: MotisJourneysResponse) -> Self { JourneysResponse { earlier_ref: motis .content .connections .first() .and_then(|c| c.stops.first()) .map(|e| e.departure.time.to_string()), later_ref: motis .content .connections .last() .and_then(|c| c.stops.first()) .map(|e| e.departure.time.to_string()), journeys: motis .content .connections .into_iter() .map(Into::into) .collect(), } } } impl From for Journey { fn from(motis: MotisConnection) -> Self { let num_legs = motis.transports.len(); let mut legs = Vec::with_capacity(num_legs); for transport in motis.transports { let range = match &transport { MotisMove::Transport(m) => &m.range, MotisMove::Walk(m) => &m.range, }; let stops = &motis.stops[range.from as usize..=range.to as usize]; let origin_stop = &stops[0]; let destination_stop = &stops[stops.len() - 1]; let leg = Leg { origin: origin_stop.station.clone().into(), destination: destination_stop.station.clone().into(), departure: DateTime::::from_timestamp( origin_stop.departure.time.try_into().unwrap(), 0, ) .map(|t| t.with_timezone(&UTC)), planned_departure: DateTime::::from_timestamp( origin_stop.departure.schedule_time.try_into().unwrap(), 0, ) .map(|t| t.with_timezone(&UTC)), arrival: DateTime::::from_timestamp( destination_stop.arrival.time.try_into().unwrap(), 0, ) .map(|t| t.with_timezone(&UTC)), planned_arrival: DateTime::::from_timestamp( destination_stop.arrival.schedule_time.try_into().unwrap(), 0, ) .map(|t| t.with_timezone(&UTC)), reachable: true, // TODO trip_id: None, // trip.id.to_string(), // TODO line: match &transport { MotisMove::Transport(transport) => Some(Line { name: Some(transport.name.clone()), fahrt_nr: transport.name.split_once(' ').map(|(_, s)| s.to_owned()), mode: MotisClasz::from_integer(transport.clasz) .map(|c| c.to_mode()) .unwrap_or(Mode::Unknown), operator: Some(Operator { id: transport.provider.clone(), name: transport.provider.clone(), }), product_name: Some( transport .name .split_once(' ') .map(|(s, _)| s.to_owned()) .unwrap_or_else(|| transport.line_id.clone()), ), product: Product { mode: Mode::Unknown, // TODO name: Cow::from(transport.name.clone()), short: Cow::from( transport .name .split_once(' ') .map(|(s, _)| s.to_owned()) .unwrap_or_else(|| transport.line_id.clone()), ), }, }), MotisMove::Walk(_) => None, }, direction: match &transport { MotisMove::Transport(transport) => Some(transport.direction.clone()), MotisMove::Walk(_) => None, }, arrival_platform: Some(destination_stop.arrival.track.clone()), planned_arrival_platform: Some(destination_stop.arrival.schedule_track.clone()), departure_platform: Some(origin_stop.departure.track.clone()), planned_departure_platform: Some(origin_stop.departure.schedule_track.clone()), frequency: None, cancelled: false, // TODO, intermediate_locations: stops.iter().cloned().map(Into::into).collect(), load_factor: None, // TODO remarks: vec![], // TODO walking: match transport { MotisMove::Transport(_) => false, MotisMove::Walk(_) => true, }, transfer: false, // TODO distance: None, // TODO #[cfg(feature = "poylines")] polyline: None, }; legs.push(leg); } Journey { id: { let mut hasher = DefaultHasher::new(); motis.trips.hash(&mut hasher); hasher.finish().to_string() }, legs, price: None, } } } /// https://motis-project.de/docs/api/connection.html#clasz-type-integer #[derive(Debug, Clone, Copy)] pub enum MotisClasz { Flight = 0, LongDistanceHighSpeedTrain = 1, LongDistanceInterCityTrain = 2, LongDistanceBus = 3, LongDistanceNightTrain = 4, RegionalExpressTrain = 5, RegionalTrain = 6, MetroTrain = 7, SubwayTrain = 8, Tram = 9, Bus = 10, ShipFerry = 11, Other = 12, } impl MotisClasz { fn from_integer(i: u8) -> Option { match i { 0 => Some(Self::Flight), 1 => Some(Self::LongDistanceHighSpeedTrain), 2 => Some(Self::LongDistanceInterCityTrain), 3 => Some(Self::LongDistanceBus), 4 => Some(Self::LongDistanceNightTrain), 5 => Some(Self::RegionalExpressTrain), 6 => Some(Self::RegionalTrain), 7 => Some(Self::MetroTrain), 8 => Some(Self::SubwayTrain), 9 => Some(Self::Tram), 10 => Some(Self::Bus), 11 => Some(Self::ShipFerry), 12 => Some(Self::Other), _ => None, } } pub(crate) fn from_mode(mode: Mode) -> &'static [MotisClasz] { // TODO: Flight? match mode { Mode::HighSpeedTrain => &[ MotisClasz::LongDistanceHighSpeedTrain, MotisClasz::LongDistanceInterCityTrain, MotisClasz::LongDistanceNightTrain, ], Mode::RegionalTrain => &[MotisClasz::RegionalExpressTrain, MotisClasz::RegionalTrain], Mode::SuburbanTrain => &[MotisClasz::MetroTrain], Mode::Subway => &[MotisClasz::SubwayTrain], Mode::Tram => &[MotisClasz::Tram], Mode::Bus => &[MotisClasz::LongDistanceBus, MotisClasz::Bus], Mode::Ferry => &[MotisClasz::ShipFerry], Mode::Cablecar => &[MotisClasz::Other], Mode::OnDemand => &[MotisClasz::Other], Mode::Unknown => &[MotisClasz::Other], } } fn to_mode(&self) -> Mode { match self { MotisClasz::Flight => Mode::Unknown, MotisClasz::LongDistanceHighSpeedTrain => Mode::HighSpeedTrain, MotisClasz::LongDistanceInterCityTrain => Mode::HighSpeedTrain, MotisClasz::LongDistanceBus => Mode::Bus, MotisClasz::LongDistanceNightTrain => Mode::HighSpeedTrain, MotisClasz::RegionalExpressTrain => Mode::RegionalTrain, MotisClasz::RegionalTrain => Mode::RegionalTrain, MotisClasz::MetroTrain => Mode::SuburbanTrain, MotisClasz::SubwayTrain => Mode::Subway, MotisClasz::Tram => Mode::Tram, MotisClasz::Bus => Mode::Bus, MotisClasz::ShipFerry => Mode::Ferry, MotisClasz::Other => Mode::Unknown, } } }