/* * Copyright 2012, L. Murer. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see < http://www.gnu.org/licenses/ >. */ package de.schildbach.pte; import de.schildbach.pte.dto.*; import de.schildbach.pte.dto.Connection.Part; import de.schildbach.pte.dto.Connection.Trip; import play.Logger; import play.api.libs.json.*; import scala.collection.Iterator; import scala.collection.Seq; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.URL; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.*; /** * Created with IntelliJ IDEA. * User: Laurin * Date: 25.04.12 * Time: 18:52 */ public class OpenDataProvider extends AbstractNetworkProvider { private final static String SERVER_PRODUCT = "opendata"; private final static int MAX_CACHED_CONNECTION_DETAILS = 100; private final static char CONNECTION_NUMBER_SEPARATOR = '#'; private final Cache<String, GetConnectionDetailsResult> cachedConnectionDetails = new Cache<>(MAX_CACHED_CONNECTION_DETAILS); private final static int MAX_CACHED_REQUESTS = 500; private final static Cache<String, JsValue> cachedRequests = new Cache<>(MAX_CACHED_REQUESTS); private final String url; private final NetworkId networkId; private static final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ"); private final TimeZone timeZone; public OpenDataProvider(String url, NetworkId networkId, TimeZone timeZone) { this.url = url; this.networkId = networkId; this.timeZone = timeZone; } public NetworkId id() { return networkId; } public boolean hasCapabilities(Capability... capabilities) { for (Capability capability : capabilities) { switch (capability) { case AUTOCOMPLETE_ONE_LINE: //yes break; case CONNECTIONS: //yes break; case DEPARTURES: //yes break; case NEARBY_STATIONS: //no return false; } } return true; } /** * Determine stations near to given location. At least one of stationId or lat/lon pair must be present. * * @param location location to determine nearby stations (optional) * @param maxDistance maximum distance in meters, or {@code 0} * @param maxStations maximum number of stations, or {@code 0} * @return nearby stations * @throws IOException */ public NearbyStationsResult queryNearbyStations(Location location, int maxDistance, int maxStations) throws IOException { return queryNearbyStations(location.lat, location.lon, maxDistance, maxStations); } /** * Determine stations near to given location. * * @param lat latitude from where to determine nearby stations * @param lon longitude from where to determine nearby stations * @param maxDistance maximum distance in meters, or {@code 0} * @param maxStations maximum number of stations, or {@code 0} * @return nearby stations * @throws IOException */ public NearbyStationsResult queryNearbyStations(double lat, double lon, int maxDistance, int maxStations) throws IOException { JsValue request = performRequest("locations?x=" + lat + "&y=" + lon); request = ((JsObject) request).value().get("stations").get(); List<Location> stations = new LinkedList<>(); if (request instanceof JsArray) { Iterator<JsValue> stationsIterator = ((JsArray) request).value().iterator(); while (stationsIterator.hasNext()) { JsValue a_station = stationsIterator.next(); if (a_station instanceof JsObject) { Location foundLocation = parseLocation((JsObject) a_station); double lonDiff = lon - foundLocation.lon; double latDiff = lat - foundLocation.lat; if (maxDistance == 0 || latDiff * latDiff + lonDiff * lonDiff <= maxDistance * maxDistance) { stations.add(foundLocation); if (maxStations == 0 || stations.size() >= maxStations) { break; } } } } } return new NearbyStationsResult(new ResultHeader(SERVER_PRODUCT), stations); } /** * Get departures at a given station, probably live * * @param stationId id of the station * @param maxDepartures maximum number of departures to get or {@code 0} * @param equivs also query equivalent stations? * @return result object containing the departures * @throws IOException */ public QueryDeparturesResult queryDepartures(int stationId, int maxDepartures, boolean equivs) throws IOException { throw new UnsupportedOperationException(); } /** * Meant for auto-completion of station names * * @param constraint input by user so far * @return auto-complete suggestions * @throws IOException */ public List<Location> autocompleteStations(CharSequence constraint) throws IOException { JsValue response = performRequest("locations?type=any&query=" + constraint); if (!(response instanceof JsObject)) { throw new IOException("Bahnanbieter konnte nicht angefragt werden."); } JsObject response_o = (JsObject) response; JsValue responseStationsElement = response_o.value().get("stations").get(); if (!(responseStationsElement instanceof JsArray)) { return new ArrayList<>(0); } Iterator<JsValue> responseArray = ((JsArray) responseStationsElement).value().iterator(); List<Location> ret = new ArrayList<>(((JsArray) responseStationsElement).value().length()); while (responseArray.hasNext()) { JsValue onePossibleResponse = responseArray.next(); if (!(onePossibleResponse instanceof JsObject)) { continue; } JsObject onePossibleResponse_o = (JsObject) onePossibleResponse; final int MINIMUM_SCORE = 90; JsValue score = retrieveSubElement(onePossibleResponse_o, "score"); if (score instanceof JsString) { String score_string = ((JsString) score).value(); if (score_string.matches("\\d+")) { if (Integer.parseInt(score_string) < MINIMUM_SCORE) { continue; } } } JsValue name_js = retrieveSubElement(onePossibleResponse_o, "name"); if (!(name_js instanceof JsString)) { continue; } String name = ((JsString) name_js).value(); JsValue id_js = retrieveSubElement(onePossibleResponse_o, "id"); int id = 0; if (id_js instanceof JsString) { String id_string = ((JsString) id_js).value(); if (id_string.matches("\\d+")) { id = Integer.parseInt(id_string); } else { throw new IllegalStateException("ID (" + id_string + ") of '" + name + "' is not a number."); } } ret.add(new Location(LocationType.STATION, id, name, name)); } return ret; } private JsValue retrieveSubElement(JsValue element, String key) { if (element instanceof JsObject) { scala.collection.Map<String, JsValue> value = ((JsObject) element).value(); if (value.contains(key)) { return value.get(key).get(); } else { return null; } } else if (element instanceof JsArray) { throw new UnsupportedOperationException(); } else { return null; } } /** * Query connections, asking for any ambiguousnesses * * @param from location to route from, mandatory * @param via location to route via, may be {@code null} * @param to location to route to, mandatory * @param date desired date for departing, mandatory * @param dep date is departure date? {@code true} for departure, {@code false} for arrival * @param products products to take into account * @param walkSpeed how fast can you walk? * @param accessibility how accessible do you need the route to be? * @return result object that can contain alternatives to clear up ambiguousnesses, or contains possible connections * @throws IOException */ public QueryConnectionsResult queryConnections(Location from, Location via, Location to, Date date, boolean dep, String products, WalkSpeed walkSpeed, Accessibility accessibility) throws IOException { return queryConnections(from, via, to, date, dep, products, false, true, new ArrayList<String>()); } /** * Query connections, asking for any ambiguousnesses * * @param from location to route from, mandatory * @param via location to route via, may be {@code null} * @param to location to route to, mandatory * @param date desired date for departing, mandatory * @param dep date is departure date? {@code true} for departure, {@code false} for arrival * @param products products to take into account * @param direct If the connection has to be direct * @param flexible if the match of the stations can be flexible or has to be exact (Luzern matches also Luzern, Bahnhof) * @param warnings A list of none critical error message (the critical are thrown in an exception) * @return result object that can contain alternatives to clear up ambiguousnesses, or contains possible connections * @throws IOException */ public QueryConnectionsResult queryConnections(Location from, Location via, Location to, Date date, boolean dep, String products, boolean direct, boolean flexible, List<String> warnings) throws IOException { assert products == null : "Parameter 'Product' is not implemented yet."; StringBuilder request = new StringBuilder("connections?from="); try { request.append(from.id > 0 ? from.id : from.name).append("&to=").append(to.id > 0 ? to.id : to.name); } catch (Exception e) { e.printStackTrace(); } if (via != null) { request.append("&via[]=").append(via.id > 0 ? via.id : via.name); } if (date != null) { SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm"); timeFormat.setTimeZone(timeZone); request.append("&date=").append(new SimpleDateFormat("yyyy-MM-dd").format(date)).append("&time=").append(timeFormat.format(date)); } if (!dep) { request.append("&isArrivalTime=1"); } if (direct) { request.append("&direct=1"); } QueryConnectionsResult connections = queryConnections(request.toString(), from.name, to.name, !flexible, warnings); if (direct && connections.connections.size() == 0) { warnings.add("Keine direkte Verbindung von " + from + " nach " + to + " gefunden."); } return connections; } private QueryConnectionsResult queryConnections(String request, String from, String to, boolean strict, Collection<String> warnings) throws IOException { JsObject response = (JsObject) performRequest(request); List<Connection> connections = new ArrayList<>(); Location fromx = null, tox = null; int connectionNumber = 0; JsValue connections_json = response.value().get("connections").get(); if (connections_json instanceof JsArray) { Iterator<JsValue> connectionsArray = ((JsArray) connections_json).value().iterator(); while (connectionsArray.hasNext()) { JsValue connection_value = connectionsArray.next(); if (!(connection_value instanceof JsObject)) { continue; } JsObject connection = (JsObject) connection_value; Set<String> ignored_from = new HashSet<>(); Set<String> ignored_to = new HashSet<>(); JsObject from_response = (JsObject) ((JsObject) connection.value().get("from").get()).value().get("station").get(); int from_id = Integer.parseInt(((JsString) from_response.value().get("id").get()).value()); String from_name = ((JsString) from_response.value().get("name").get()).value(); if (strict && !from_name.equals(from) && !ignored_from.contains(from_name)) { ignored_from.add(from_name); warnings.add("Bei der Abfrage von " + from + " nach " + to + " wurden Verbindungen ab '" + from_name + "' ignoriert."); continue; } fromx = new Location(LocationType.STATION, from_id, from_name, from_name); JsObject to_response = (JsObject) ((JsObject) connection.value().get("to").get()).value().get("station").get(); int to_id = Integer.parseInt(((JsString) to_response.value().get("id").get()).value()); String to_name = ((JsString) to_response.value().get("name").get()).value(); if (strict && !to_name.equals(to) && !ignored_to.contains(to_name)) { ignored_to.add(to_name); warnings.add("Bei der Abfrage von " + from + " nach " + to + " wurden Verbindungen nach '" + to_name + "' ignoriert."); continue; } Seq<JsValue> section_response = ((JsArray) connection.value().get("sections").get()).value(); tox = new Location(LocationType.STATION, to_id, to_name, to_name); List<Part> parts = new ArrayList<>(section_response.length()); Iterator<JsValue> parts_iterator = section_response.iterator(); while (parts_iterator.hasNext()) { JsValue section_part_element = parts_iterator.next(); Part trip = parsePart(section_part_element); if (trip == null) { continue; } parts.add(trip); } String connection_id = request + CONNECTION_NUMBER_SEPARATOR + connectionNumber++; Connection c = new Connection(connection_id, connection_id, fromx, tox, parts, null, null); cachedConnectionDetails.put(connection_id, new GetConnectionDetailsResult(new Date(), c)); connections.add(c); addErrorsForIgnoredConnections(from, to, ignored_from, ignored_to, warnings); } } return new QueryConnectionsResult(new ResultHeader(SERVER_PRODUCT), request, fromx, null, tox, "", connections); } /** * Adds one single error message containing information about all (from and to) ignored stations * * @param from The name of the given from-station * @param to The name of the to-station * @param ignored_from A list of found but ignored from-stations * @param ignored_to A list of found but ignored to-stations * @param warnings A list of none critical error message (the critical are thrown in an exception) */ private static void addErrorsForIgnoredConnections(String from, String to, Set<String> ignored_from, Set<String> ignored_to, Collection<String> warnings) { final String firstPart = "Bei der Abfrage von "" + from + "" nach "" + to + "" wurden Verbindungen "; if (ignored_from.size() > 0) { if (ignored_to.size() > 0) { warnings.add(firstPart + "von "" + join(ignored_from, "", "", "" und "") + "" sowie nach "" + join(ignored_to, "", "", "" und "") + "" ignoriert."); } else { warnings.add(firstPart + "von "" + join(ignored_from, "", "", "" und "") + "" ignoriert."); } } else { if (ignored_to.size() > 0) { warnings.add(firstPart + "nach "" + join(ignored_to, "", "", "" und "") + "" ignoriert."); } } } /** * Joins a collection of strings * * @param items some Strings to be put together * @param joinBy The String to put between the entries * @param lastJoinBy The alternative String to put between the last two entries * @return A string containing all inputstrings connected with a ";" */ private static String join(Collection<String> items, String joinBy, String lastJoinBy) { if (items.isEmpty()) { return ""; } StringBuilder sb = new StringBuilder(); boolean isFirst = true; int itemsLeft = items.size(); for (String item : items) { if (isFirst) { isFirst = false; } else if (itemsLeft > 1) { sb.append(joinBy); } else { sb.append(lastJoinBy); } sb.append(item); itemsLeft--; } return sb.toString(); } private Part parsePart(JsValue section_part_element) { if (!(section_part_element instanceof JsObject)) { return null; } scala.collection.Map<String, JsValue> section_part = ((JsObject) section_part_element).value(); JsObject section_part_departure = (JsObject) section_part.get("departure").get(); Location departure = parseLocation((JsObject) section_part_departure.value().get("station").get()); JsObject section_part_arrival = (JsObject) section_part.get("arrival").get(); Location arrival = parseLocation((JsObject) section_part_arrival.value().get("station").get()); int section_part_departure_id = Integer.parseInt(((JsString) ((JsObject) section_part_departure.value().get("station").get()).value().get("id").get()).value()); String section_part_departure_name = ((JsString) ((JsObject) section_part_departure.value().get("station").get()).value().get("name").get()).value(); int section_part_arrival_id = Integer.parseInt(((JsString) ((JsObject) section_part_arrival.value().get("station").get()).value().get("id").get()).value()); String section_part_arrival_name = ((JsString) ((JsObject) section_part_arrival.value().get("station").get()).value().get("name").get()).value(); Date departureTime; Date arrivalTime; try { departureTime = dateFormat.parse(((JsString) section_part_departure.value().get("departure").get()).value()); arrivalTime = dateFormat.parse(((JsString) section_part_arrival.value().get("arrival").get()).value()); } catch (ParseException e) { e.printStackTrace(); throw new RuntimeException("Could not parse date: " + e); } boolean isWalk = section_part.contains("walk") && (section_part.get("walk").get() instanceof JsObject); if (isWalk) { return new Connection.Footway(parseWalktime((JsObject) section_part.get("walk").get()), departure, arrival, new ArrayList<Point>(0)); } else { Style style = new Style(0, 0xFFFFFF); if (section_part.contains("journey") && section_part.get("journey").get() instanceof JsObject) { JsObject section_part_journey = (JsObject) section_part.get("journey").get(); Location destination = null; Line line = new Line(((JsString) section_part_journey.value().get("name").get()).value(), parseCategory(section_part_journey), style); if (section_part_journey.value().get("to").get() instanceof JsObject) { String destination_name = ((JsString) section_part_journey.value().get("to").get()).value(); destination = new Location(LocationType.STATION, -1, destination_name, destination_name); } Seq<JsValue> passList = ((JsArray) section_part_journey.value().get("passList").get()).value(); List<Stop> stops = new ArrayList<>(passList.length()); Iterator<JsValue> passListIterator = passList.iterator(); while (passListIterator.hasNext()) { JsValue passedStation = passListIterator.next(); if (!(passedStation instanceof JsObject)) { continue; } JsObject passedStation_o = (JsObject) passedStation; String position = ((JsString) passedStation_o.value().get("platform").get()).value(); Date timeOfPass; try { JsValue passTime = passedStation_o.value().get("departure").get(); if (passTime instanceof JsString) { timeOfPass = dateFormat.parse(((JsString) passTime).value()); } else { timeOfPass = null; } } catch (ParseException e) { e.printStackTrace(); throw new RuntimeException("Could not parse date '" + ((JsString) passedStation_o.value().get("departure").get()).value() + "'"); } stops.add(new Stop(parseLocation((JsObject) passedStation_o.value().get("station").get()), position, timeOfPass)); } return new Trip(line, destination, departureTime, departureTime, ((JsString) section_part_departure.value().get("platform").get()).value(), new Location(LocationType.STATION, section_part_departure_id, section_part_departure_name, section_part_departure_name), arrivalTime, arrivalTime, ((JsString) section_part_arrival.value().get("platform").get()).value(), new Location(LocationType.STATION, section_part_arrival_id, section_part_arrival_name, section_part_arrival_name), stops, new ArrayList<Point>(passList.size())); } else { return new Trip(new Line("", "?", style), null, departureTime, departureTime, ((JsString) section_part_departure.value().get("platform").get()).value(), new Location(LocationType.STATION, section_part_departure_id, section_part_departure_name, section_part_departure_name), arrivalTime, arrivalTime, ((JsString) section_part_arrival.value().get("platform").get()).value(), new Location(LocationType.STATION, section_part_arrival_id, section_part_arrival_name, section_part_arrival_name), new ArrayList<Stop>(0), new ArrayList<Point>(0)); } } } private int parseWalktime(JsObject walk) { final DateFormat walktimeformat = new SimpleDateFormat("HH:mm:ss"); Calendar calendar = Calendar.getInstance(); try { calendar.setTime(walktimeformat.parse(((JsString) walk.value().get("duration").get()).value())); } catch (ParseException e) { e.printStackTrace(); throw new RuntimeException(e); } return calendar.get(Calendar.MINUTE) + 60 * calendar.get(Calendar.HOUR_OF_DAY); } /** * Transforms a JsObject containing a Location to a Location-Object * * @param location Object to convert * @return Converted object */ private Location parseLocation(JsObject location) { String station_name = ((JsString) location.value().get("name").get()).value(); return new Location(LocationType.STATION, Integer.parseInt(((JsString) location.value().get("id").get()).value()), station_name, station_name); } private String parseCategory(JsObject category) { if (category == null) { return ""; } String cat = ((JsString) category.value().get("category").get()).value().toUpperCase(); String prefix; final String[] schnellzugStrings = {"EC", "IC", "ICN", "ICE", "CNL", "EN", "THA", "TGV"}; final String[] busString = {"NBU", "TRO", "BUS", "NTO", "NFB", "MID", "MIN", "KB", "NB"}; if (cat.matches("SN?\\d*")) { //S-Bahn prefix = "S"; } else if (cat.startsWith("IR") | cat.startsWith("R") | cat.equals("EXT")) { //Regionalzug prefix = "R"; } else if (cat.equals("NTR") | cat.equals("TRA")) { //Tram prefix = "T"; } else if (Arrays.asList(schnellzugStrings).contains(cat)) { //Schnellzug prefix = "I"; } else if (Arrays.asList(busString).contains(cat)) { //Bus prefix = "B"; } else if (cat.equals("BAT") || cat.equals("BAV")) { //Schiff prefix = "F"; } else if (cat.equals("LB") | cat.equals("GB")) { //Seilbahn prefix = "L"; } else { prefix = "?"; System.err.println("Category '" + cat + "' can't be parsed (" + category.toString() + ")."); } String name = ((JsString) category.value().get("name").get()).value(); JsValue to = category.value().get("to").get(); if (to instanceof JsString) { return prefix + name + " (nach " + ((JsString) to).value() + ")"; } return prefix + name; } /** * Query more connections (e.g. earlier or later) * * @param context context to query more connections from * @param next {@code true} for get next connections, {@code false} for get previous connections * @return result object that contains possible connections * @throws IOException */ public QueryConnectionsResult queryMoreConnections(String context, boolean next) throws IOException { assert false : "Not implemented yet."; return null; } /** * Get details about a connection * * @param connectionUri uri returned via {@link NetworkProvider#queryConnections} * @return result object containing the details of the connection * @throws IOException */ public GetConnectionDetailsResult getConnectionDetails(String connectionUri) throws IOException { GetConnectionDetailsResult connectionDetailsResult = cachedConnectionDetails.get(connectionUri); assert connectionDetailsResult != null : "Connection details for connection " + connectionUri + " not stored."; return connectionDetailsResult; } private JsValue performRequest(String request) throws IOException { JsValue response = cachedRequests.get(request); if (response == null) { response = performRequest(request, 5); if (response != null) { cachedRequests.put(request, response); } } return response; } private JsValue performRequest(String request, int retries) throws IOException { request = request.replace(" ", "+"); try { String requestURL = url + request; Logger.debug("Performing request to: " + requestURL); BufferedReader in = new BufferedReader(new InputStreamReader(new URL(requestURL).openStream())); StringBuilder response = new StringBuilder(); String inputLine; while ((inputLine = in.readLine()) != null) { response.append(inputLine); } in.close(); return Json.parse(response.toString()); } catch (IOException e) { if (retries > 0) { return performRequest(request, retries - 1); } else { throw e; } } } private static class Cache<K, V> extends LinkedHashMap<K, V> { final int maxSize; public Cache(int maxSize) { super(maxSize); this.maxSize = maxSize; } @Override protected boolean removeEldestEntry(Map.Entry<K, V> kvEntry) { return size() > maxSize; } } }