diff --git a/README.md b/README.md index b3a675509..ef320ab1e 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,29 @@ The follow parameters are used to interact with an OTP server. | OTP_API_ROOT | This is the address of the OTP server, including the root path to the OTP API, to which all OTP related requests will be sent to. | http://otp-server.example.com/otp | | OTP_PLAN_ENDPOINT | This defines the plan endpoint part of the requesting URL. If a request is made to this, the assumption is that a plan request has been made and that the response should be processed accordingly. | /plan | +### Trip Actions + +OTP-middleware supports triggering certain actions when someone activates live tracking of a monitored trip +and reaches a location or is about to enter a path. Actions include location-sensitive API calls to notify various services. +In the context of live trip tracking, actions may include notifying transit vehicle operators or triggering traffic signals. + +#### Bus Notify Actions + +Bus notifier actions are defined in the optional file `bus-notifier-actions.yml` in the same configuration folder as `env.yml`. +The file contains a list of actions defined by an agency ID and a fully-qualified trigger class: + +```yaml +- agencyId: id1 + trigger: com.example.package.MyTriggerClass +``` + +Known trigger classes below are in package `org.opentripplanner.middleware.triptracker.interactions.busnotifiers` +and implement its `BusOperatorInteraction` interface: + +| Class | Description | +| ----- |------------------------------------------------------------------------------| +| UsRideGwinnettNotifyBusOperator | Triggers select route bus operator notifications in Gwinnett County, GA, USA | + ### Monitored Components This application allows you to monitor various system components (e.g., OTP API, OTP UI, and Data Tools) that work together @@ -273,4 +296,7 @@ The special E2E client settings should be defined in `env.yml`: | TRIP_INSTRUCTION_UPCOMING_RADIUS | integer | Optional | 10 | The radius in meters under which an upcoming instruction is given. | | TWILIO_ACCOUNT_SID | string | Optional | your-account-sid | Twilio settings available at: https://twilio.com/user/account | | TWILIO_AUTH_TOKEN | string | Optional | your-auth-token | Twilio settings available at: https://twilio.com/user/account | +| US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_URL | string | Optional | http://host.example.com | US Ride Gwinnett County bus notifier API. | +| US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_KEY | string | Optional | your-api-key | API key for the US Ride Gwinnett County bus notifier API. | +| US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_QUALIFYING_ROUTES | string | Optional | agency_id:route_id | A comma separated list of US Ride Gwinnett County routes that can be notified. | | VALIDATE_ENVIRONMENT_CONFIG | boolean | Optional | true | If set to false, the validation of the env.yml file against this schema will be skipped. | diff --git a/configurations/default/bus-notifier-actions.yml b/configurations/default/bus-notifier-actions.yml new file mode 100644 index 000000000..ed9772e5f --- /dev/null +++ b/configurations/default/bus-notifier-actions.yml @@ -0,0 +1,2 @@ +- agencyId: GCT + trigger: org.opentripplanner.middleware.triptracker.interactions.busnotifiers.UsRideGwinnettNotifyBusOperator \ No newline at end of file diff --git a/configurations/default/env.yml.tmp b/configurations/default/env.yml.tmp index aa92d0cf2..f9eae14f3 100644 --- a/configurations/default/env.yml.tmp +++ b/configurations/default/env.yml.tmp @@ -89,4 +89,8 @@ TRIP_TRACKING_RAIL_ON_TRACK_RADIUS: 200 # The radius in meters under which an immediate instruction is given. TRIP_INSTRUCTION_IMMEDIATE_RADIUS: 2 # The radius in meters under which an upcoming instruction is given. -TRIP_INSTRUCTION_UPCOMING_RADIUS: 10 \ No newline at end of file +TRIP_INSTRUCTION_UPCOMING_RADIUS: 10 + +US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_URL: https://bus.notifier.example.com +US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_KEY: your-key +US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_QUALIFYING_ROUTES: agency_id:route_id \ No newline at end of file diff --git a/src/main/java/org/opentripplanner/middleware/models/TrackedJourney.java b/src/main/java/org/opentripplanner/middleware/models/TrackedJourney.java index 2a2adf7f4..a6b4ed5eb 100644 --- a/src/main/java/org/opentripplanner/middleware/models/TrackedJourney.java +++ b/src/main/java/org/opentripplanner/middleware/models/TrackedJourney.java @@ -6,7 +6,9 @@ import java.util.ArrayList; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; @JsonIgnoreProperties(ignoreUnknown = true) @@ -22,9 +24,12 @@ public class TrackedJourney extends Model { public List locations = new ArrayList<>(); + public Map busNotificationMessages = new HashMap<>(); + public static final String TRIP_ID_FIELD_NAME = "tripId"; public static final String LOCATIONS_FIELD_NAME = "locations"; + public static final String BUS_NOTIFICATION_MESSAGES_FIELD_NAME = "busNotificationMessages"; public static final String END_TIME_FIELD_NAME = "endTime"; @@ -77,4 +82,13 @@ public int hashCode() { public TrackingLocation lastLocation() { return locations.get(locations.size() - 1); } + + public void updateNotificationMessage(String routeId, String body) { + busNotificationMessages.put(routeId, body); + Persistence.trackedJourneys.updateField( + id, + BUS_NOTIFICATION_MESSAGES_FIELD_NAME, + busNotificationMessages + ); + } } diff --git a/src/main/java/org/opentripplanner/middleware/otp/response/Leg.java b/src/main/java/org/opentripplanner/middleware/otp/response/Leg.java index 4b396de78..b60071a0d 100644 --- a/src/main/java/org/opentripplanner/middleware/otp/response/Leg.java +++ b/src/main/java/org/opentripplanner/middleware/otp/response/Leg.java @@ -27,7 +27,6 @@ public class Leg implements Cloneable { public Double distance; public Boolean pathway; public String mode; - public String route; public Boolean interlineWithPreviousLeg; public Place from; public Place to; diff --git a/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java b/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java index 9ba7f3f60..5318bd66e 100644 --- a/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java +++ b/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java @@ -20,6 +20,7 @@ import org.opentripplanner.middleware.triptracker.TripTrackingData; import org.opentripplanner.middleware.utils.ConfigUtils; import org.opentripplanner.middleware.utils.DateTimeUtils; +import org.opentripplanner.middleware.utils.I18nUtils; import org.opentripplanner.middleware.utils.ItineraryUtils; import org.opentripplanner.middleware.utils.NotificationUtils; import org.slf4j.Logger; @@ -894,8 +895,7 @@ private OtpUser getOtpUser() { * Retrieves and caches the user on first call (assuming the user for a trip does not change). */ private Locale getOtpUserLocale() { - OtpUser user = getOtpUser(); - return Locale.forLanguageTag(user == null || user.preferredLocale == null ? "en-US" : user.preferredLocale); + return I18nUtils.getOtpUserLocale(getOtpUser()); } private String getTripUrl() { diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/ManageLegTraversal.java b/src/main/java/org/opentripplanner/middleware/triptracker/ManageLegTraversal.java index d66dcdf0e..cbe62155c 100644 --- a/src/main/java/org/opentripplanner/middleware/triptracker/ManageLegTraversal.java +++ b/src/main/java/org/opentripplanner/middleware/triptracker/ManageLegTraversal.java @@ -70,11 +70,27 @@ public static Leg getExpectedLeg(Coordinates position, Itinerary itinerary) { return null; } + /** + * Get the leg following the expected leg. + */ + @Nullable + public static Leg getNextLeg(Leg expectedLeg, Itinerary itinerary) { + if (canUseTripLegs(itinerary)) { + for (int i = 0; i < itinerary.legs.size(); i++) { + Leg leg = itinerary.legs.get(i); + if (leg.equals(expectedLeg) && (i + 1 < itinerary.legs.size())) { + return itinerary.legs.get(i + 1); + } + } + } + return null; + } + /** * Get the leg that is nearest to the current position. Note, to be considered when working with transit legs: if * the trip involves traversing a cul-de-sac, the entrance and exit legs would be very close together if not * identical. In this scenario it would be possible for the current position to be attributed to the exit leg, - * therefore missing the instruction at the end of the cul-de-dac. + * therefore missing the instruction at the end of the cul-de-sac. */ private static Leg getNearestLeg(Coordinates position, Itinerary itinerary) { double shortestDistance = Double.MAX_VALUE; diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/ManageTripTracking.java b/src/main/java/org/opentripplanner/middleware/triptracker/ManageTripTracking.java index 3e94b0220..30ab3131f 100644 --- a/src/main/java/org/opentripplanner/middleware/triptracker/ManageTripTracking.java +++ b/src/main/java/org/opentripplanner/middleware/triptracker/ManageTripTracking.java @@ -3,11 +3,14 @@ import org.eclipse.jetty.http.HttpStatus; import org.opentripplanner.middleware.models.TrackedJourney; import org.opentripplanner.middleware.persistence.Persistence; +import org.opentripplanner.middleware.triptracker.interactions.busnotifiers.BusOperatorActions; +import org.opentripplanner.middleware.triptracker.interactions.busnotifiers.UsRideGwinnettNotifyBusOperator; import org.opentripplanner.middleware.triptracker.response.EndTrackingResponse; import org.opentripplanner.middleware.triptracker.response.TrackingResponse; import spark.Request; import static org.opentripplanner.middleware.utils.ConfigUtils.getConfigPropertyAsInt; +import static org.opentripplanner.middleware.utils.ItineraryUtils.removeAgencyPrefix; import static org.opentripplanner.middleware.utils.JsonUtils.logMessageAndHalt; public class ManageTripTracking { @@ -52,7 +55,8 @@ private static TrackingResponse doUpdateTracking(Request request, TripTrackingDa TravelerPosition travelerPosition = new TravelerPosition( trackedJourney, - tripData.trip.journeyState.matchingItinerary + tripData.trip.journeyState.matchingItinerary, + Persistence.otpUsers.getById(tripData.trip.userId) ); TripStatus tripStatus = TripStatus.getTripStatus(travelerPosition); trackedJourney.lastLocation().tripStatus = tripStatus; @@ -108,7 +112,7 @@ public static TrackingResponse startOrUpdateTracking(Request request) { public static EndTrackingResponse endTracking(Request request) { TripTrackingData tripData = TripTrackingData.fromRequestJourneyId(request); if (tripData != null) { - return completeJourney(tripData.journey, false); + return completeJourney(tripData, false); } return null; } @@ -122,7 +126,7 @@ public static EndTrackingResponse forciblyEndTracking(Request request) { TripTrackingData tripData = TripTrackingData.fromRequestTripId(request); if (tripData != null) { if (tripData.journey != null) { - return completeJourney(tripData.journey, true); + return completeJourney(tripData, true); } else { logMessageAndHalt(request, HttpStatus.BAD_REQUEST_400, "Journey for provided trip id does not exist!"); return null; @@ -132,9 +136,19 @@ public static EndTrackingResponse forciblyEndTracking(Request request) { } /** - * Complete a journey by defining the ending type, time and condition. + * Complete a journey by defining the ending type, time and condition. Also cancel possible upcoming bus + * notification. */ - private static EndTrackingResponse completeJourney(TrackedJourney trackedJourney, boolean isForciblyEnded) { + private static EndTrackingResponse completeJourney(TripTrackingData tripData, boolean isForciblyEnded) { + TravelerPosition travelerPosition = new TravelerPosition( + tripData.journey, + tripData.trip.journeyState.matchingItinerary, + Persistence.otpUsers.getById(tripData.trip.userId) + ); + BusOperatorActions + .getDefault() + .handleCancelNotificationAction(travelerPosition); + TrackedJourney trackedJourney = travelerPosition.trackedJourney; trackedJourney.end(isForciblyEnded); Persistence.trackedJourneys.updateField(trackedJourney.id, TrackedJourney.END_TIME_FIELD_NAME, trackedJourney.endTime); Persistence.trackedJourneys.updateField(trackedJourney.id, TrackedJourney.END_CONDITION_FIELD_NAME, trackedJourney.endCondition); diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/TrackingLocation.java b/src/main/java/org/opentripplanner/middleware/triptracker/TrackingLocation.java index c31340863..087dc6694 100644 --- a/src/main/java/org/opentripplanner/middleware/triptracker/TrackingLocation.java +++ b/src/main/java/org/opentripplanner/middleware/triptracker/TrackingLocation.java @@ -42,4 +42,9 @@ public TrackingLocation(Double lat, Double lon, Date timestamp) { public TrackingLocation(Instant instant, double lat, double lon) { this(lat, lon, new Date(instant.toEpochMilli())); } + + /** Used in testing **/ + public TrackingLocation(Date timestamp, double lat, double lon) { + this(lat, lon, timestamp); + } } diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/TravelerLocator.java b/src/main/java/org/opentripplanner/middleware/triptracker/TravelerLocator.java index 2f0f79d78..c512c15ce 100644 --- a/src/main/java/org/opentripplanner/middleware/triptracker/TravelerLocator.java +++ b/src/main/java/org/opentripplanner/middleware/triptracker/TravelerLocator.java @@ -3,23 +3,32 @@ import io.leonard.PolylineUtils; import org.opentripplanner.middleware.otp.response.Leg; import org.opentripplanner.middleware.otp.response.Step; +import org.opentripplanner.middleware.triptracker.interactions.busnotifiers.BusOperatorActions; import org.opentripplanner.middleware.utils.Coordinates; +import org.opentripplanner.middleware.utils.DateTimeUtils; import javax.annotation.Nullable; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.stream.Collectors; import static org.opentripplanner.middleware.triptracker.TripInstruction.NO_INSTRUCTION; import static org.opentripplanner.middleware.triptracker.TripInstruction.TRIP_INSTRUCTION_UPCOMING_RADIUS; import static org.opentripplanner.middleware.utils.GeometryUtils.getDistance; import static org.opentripplanner.middleware.utils.GeometryUtils.isPointBetween; +import static org.opentripplanner.middleware.utils.ItineraryUtils.isBusLeg; /** * Locate the traveler in relation to the nearest step or destination and provide the appropriate instructions. */ public class TravelerLocator { + public static final int ACCEPTABLE_AHEAD_OF_SCHEDULE_IN_MINUTES = 15; + private TravelerLocator() { } @@ -34,14 +43,14 @@ public static String getInstruction( ) { if (hasRequiredWalkLeg(travelerPosition)) { if (hasRequiredTripStatus(tripStatus)) { - TripInstruction tripInstruction = alignTravelerToTrip(travelerPosition, isStartOfTrip); + TripInstruction tripInstruction = alignTravelerToTrip(travelerPosition, isStartOfTrip, tripStatus); if (tripInstruction != null) { return tripInstruction.build(); } } if (tripStatus.equals(TripStatus.DEVIATED)) { - TripInstruction tripInstruction = getBackOnTrack(travelerPosition, isStartOfTrip); + TripInstruction tripInstruction = getBackOnTrack(travelerPosition, isStartOfTrip, tripStatus); if (tripInstruction != null) { return tripInstruction.build(); } @@ -71,14 +80,18 @@ private static boolean hasRequiredTripStatus(TripStatus tripStatus) { * provider this, else suggest the closest street to head towards. */ @Nullable - private static TripInstruction getBackOnTrack(TravelerPosition travelerPosition, boolean isStartOfTrip) { - TripInstruction instruction = alignTravelerToTrip(travelerPosition, isStartOfTrip); - if (instruction != null && instruction.hasInstruction) { + private static TripInstruction getBackOnTrack( + TravelerPosition travelerPosition, + boolean isStartOfTrip, + TripStatus tripStatus + ) { + TripInstruction instruction = alignTravelerToTrip(travelerPosition, isStartOfTrip, tripStatus); + if (instruction != null && instruction.hasInstruction()) { return instruction; } Step nearestStep = snapToStep(travelerPosition); return (nearestStep != null) - ? new TripInstruction(nearestStep.streetName) + ? new TripInstruction(nearestStep.streetName, travelerPosition.locale) : null; } @@ -86,16 +99,30 @@ private static TripInstruction getBackOnTrack(TravelerPosition travelerPosition, * Align the traveler's position to the nearest step or destination. */ @Nullable - public static TripInstruction alignTravelerToTrip(TravelerPosition travelerPosition, boolean isStartOfTrip) { - if (isApproachingDestination(travelerPosition)) { - return new TripInstruction(getDistanceToDestination(travelerPosition), travelerPosition.expectedLeg.to.name); + public static TripInstruction alignTravelerToTrip( + TravelerPosition travelerPosition, + boolean isStartOfTrip, + TripStatus tripStatus + ) { + Locale locale = travelerPosition.locale; + + if (isApproachingEndOfLeg(travelerPosition)) { + if (isBusLeg(travelerPosition.nextLeg) && isWithinOperationalNotifyWindow(travelerPosition)) { + BusOperatorActions + .getDefault() + .handleSendNotificationAction(tripStatus, travelerPosition); + // Regardless of whether the notification is sent or qualifies, provide a 'wait for bus' instruction. + return new TripInstruction(travelerPosition.nextLeg, travelerPosition.currentTime, locale); + } + return new TripInstruction(getDistanceToEndOfLeg(travelerPosition), travelerPosition.expectedLeg.to.name, locale); } Step nextStep = snapToStep(travelerPosition); if (nextStep != null && (!isPositionPastStep(travelerPosition, nextStep) || isStartOfTrip)) { return new TripInstruction( getDistance(travelerPosition.currentPosition, new Coordinates(nextStep)), - nextStep + nextStep, + locale ); } return null; @@ -120,14 +147,42 @@ private static boolean isPositionPastStep(TravelerPosition travelerPosition, Ste /** * Is the traveler approaching the leg destination. */ - private static boolean isApproachingDestination(TravelerPosition travelerPosition) { - return getDistanceToDestination(travelerPosition) <= TRIP_INSTRUCTION_UPCOMING_RADIUS; + private static boolean isApproachingEndOfLeg(TravelerPosition travelerPosition) { + return getDistanceToEndOfLeg(travelerPosition) <= TRIP_INSTRUCTION_UPCOMING_RADIUS; + } + + /** + * Make sure the traveler is on schedule or ahead of schedule (but not too far) to be within an operational window + * for the bus service. + */ + public static boolean isWithinOperationalNotifyWindow(TravelerPosition travelerPosition) { + var busDepartureTime = getBusDepartureTime(travelerPosition.nextLeg); + return + (travelerPosition.currentTime.equals(busDepartureTime) || travelerPosition.currentTime.isBefore(busDepartureTime)) && + ACCEPTABLE_AHEAD_OF_SCHEDULE_IN_MINUTES >= getMinutesAheadOfDeparture(travelerPosition.currentTime, busDepartureTime); + } + + /** + * Get how far ahead in minutes the traveler is from the bus departure time. + */ + public static long getMinutesAheadOfDeparture(Instant currentTime, Instant busDepartureTime) { + return Duration.between(busDepartureTime, currentTime).toMinutes(); + } + + /** + * Get the bus departure time. + */ + public static Instant getBusDepartureTime(Leg busLeg) { + return ZonedDateTime.ofInstant( + busLeg.startTime.toInstant().plusSeconds(busLeg.departureDelay), + DateTimeUtils.getOtpZoneId() + ).toInstant(); } /** * Get the distance from the traveler's current position to the leg destination. */ - private static double getDistanceToDestination(TravelerPosition travelerPosition) { + private static double getDistanceToEndOfLeg(TravelerPosition travelerPosition) { Coordinates legDestination = new Coordinates(travelerPosition.expectedLeg.to); return getDistance(travelerPosition.currentPosition, legDestination); } diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/TravelerPosition.java b/src/main/java/org/opentripplanner/middleware/triptracker/TravelerPosition.java index d3b0aaf0a..43fdd988c 100644 --- a/src/main/java/org/opentripplanner/middleware/triptracker/TravelerPosition.java +++ b/src/main/java/org/opentripplanner/middleware/triptracker/TravelerPosition.java @@ -1,13 +1,17 @@ package org.opentripplanner.middleware.triptracker; +import org.opentripplanner.middleware.models.OtpUser; import org.opentripplanner.middleware.models.TrackedJourney; import org.opentripplanner.middleware.otp.response.Itinerary; import org.opentripplanner.middleware.otp.response.Leg; import org.opentripplanner.middleware.utils.Coordinates; +import org.opentripplanner.middleware.utils.I18nUtils; import java.time.Instant; +import java.util.Locale; import static org.opentripplanner.middleware.triptracker.ManageLegTraversal.getExpectedLeg; +import static org.opentripplanner.middleware.triptracker.ManageLegTraversal.getNextLeg; import static org.opentripplanner.middleware.triptracker.ManageLegTraversal.getSegmentFromPosition; public class TravelerPosition { @@ -24,12 +28,34 @@ public class TravelerPosition { /** Traveler current time. */ public Instant currentTime; - public TravelerPosition(TrackedJourney trackedJourney, Itinerary itinerary) { + /** Information held about the traveler's journey. */ + public TrackedJourney trackedJourney; + + /** The leg, if available, after the expected leg. */ + public Leg nextLeg; + + /** Traveler mobility information which is passed on to bus operators. */ + public String mobilityMode; + + /** The traveler's locale. */ + public Locale locale; + + public TravelerPosition(TrackedJourney trackedJourney, Itinerary itinerary, OtpUser otpUser) { TrackingLocation lastLocation = trackedJourney.locations.get(trackedJourney.locations.size() - 1); currentTime = lastLocation.timestamp.toInstant(); currentPosition = new Coordinates(lastLocation); expectedLeg = getExpectedLeg(currentPosition, itinerary); + if (expectedLeg != null) { + nextLeg = getNextLeg(expectedLeg, itinerary); + } legSegmentFromPosition = getSegmentFromPosition(expectedLeg, currentPosition); + this.trackedJourney = trackedJourney; + if (otpUser != null) { + if (otpUser.mobilityProfile != null) { + mobilityMode = otpUser.mobilityProfile.mobilityMode; + } + this.locale = I18nUtils.getOtpUserLocale(otpUser); + } } /** Used for unit testing. */ @@ -38,4 +64,10 @@ public TravelerPosition(Leg expectedLeg, Coordinates currentPosition) { this.currentPosition = currentPosition; legSegmentFromPosition = getSegmentFromPosition(expectedLeg, currentPosition); } + + /** Used for unit testing. */ + public TravelerPosition(Leg nextLeg, Instant currentTime) { + this.nextLeg = nextLeg; + this.currentTime = currentTime; + } } diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/TripInstruction.java b/src/main/java/org/opentripplanner/middleware/triptracker/TripInstruction.java index b43b00f24..64d7254af 100644 --- a/src/main/java/org/opentripplanner/middleware/triptracker/TripInstruction.java +++ b/src/main/java/org/opentripplanner/middleware/triptracker/TripInstruction.java @@ -1,11 +1,21 @@ package org.opentripplanner.middleware.triptracker; +import org.opentripplanner.middleware.otp.response.Leg; import org.opentripplanner.middleware.otp.response.Step; +import org.opentripplanner.middleware.utils.DateTimeUtils; + +import java.util.Date; +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; import static org.opentripplanner.middleware.utils.ConfigUtils.getConfigPropertyAsInt; +import static org.opentripplanner.middleware.utils.ItineraryUtils.getRouteShortNameFromLeg; public class TripInstruction { + public enum TripInstructionType { ON_TRACK, DEVIATED, WAIT_FOR_BUS } + /** The radius in meters under which an immediate instruction is given. */ public static final int TRIP_INSTRUCTION_IMMEDIATE_RADIUS = getConfigPropertyAsInt("TRIP_INSTRUCTION_IMMEDIATE_RADIUS", 2); @@ -37,40 +47,65 @@ public class TripInstruction { /** Name of final destination or street. */ public String locationName; - /** Instruction is for a trip that is on track. */ - private boolean tripOnTrack; + /** Provided if the next leg for the traveler will be a bus transit leg. */ + public Leg busLeg; + + /** The time provided by the traveler */ + public Instant currentTime; - /** If the traveler is within the upcoming radius an instruction will be provided. */ - public boolean hasInstruction; + /** The type of instruction to be provided to the traveler. */ + private final TripInstructionType tripInstructionType; - public TripInstruction(boolean isDestination, double distance) { + /** The traveler's locale. */ + private final Locale locale; + + public TripInstruction(boolean isDestination, double distance, Locale locale) { this.distance = distance; - this.tripOnTrack = true; + this.tripInstructionType = TripInstructionType.ON_TRACK; + this.locale = locale; setPrefix(isDestination); - hasInstruction = distance <= TRIP_INSTRUCTION_UPCOMING_RADIUS; + } + + /** + * If the traveler is within the upcoming radius an instruction will be provided. + */ + public boolean hasInstruction() { + return distance <= TRIP_INSTRUCTION_UPCOMING_RADIUS; } /** * On track instruction to step. */ - public TripInstruction(double distance, Step legStep) { - this(false, distance); + public TripInstruction(double distance, Step legStep, Locale locale) { + this(false, distance, locale); this.legStep = legStep; } /** * On track instruction to destination. */ - public TripInstruction(double distance, String locationName) { - this(true, distance); + public TripInstruction(double distance, String locationName, Locale locale) { + this(true, distance, locale); this.locationName = locationName; } /** * Deviated instruction. */ - public TripInstruction(String locationName) { + public TripInstruction(String locationName, Locale locale) { + this.tripInstructionType = TripInstructionType.DEVIATED; this.locationName = locationName; + this.locale = locale; + } + + /** + * Provide bus related trip instruction. + */ + public TripInstruction(Leg busLeg, Instant currentTime, Locale locale) { + this.tripInstructionType = TripInstructionType.WAIT_FOR_BUS; + this.busLeg = busLeg; + this.currentTime = currentTime; + this.locale = locale; } /** @@ -86,16 +121,19 @@ private void setPrefix(boolean isDestination) { } /** - * Build on track or deviated instruction. + * Build instruction based on the traveler's location. */ public String build() { - if (tripOnTrack) { - return buildOnTrackInstruction(); - } else if (locationName != null) { - // Traveler has deviated. - return String.format("Head to %s", locationName); + switch (tripInstructionType) { + case ON_TRACK: + return buildOnTrackInstruction(); + case DEVIATED: + return String.format("Head to %s", locationName); + case WAIT_FOR_BUS: + return buildWaitForBusInstruction(); + default: + return NO_INSTRUCTION; } - return NO_INSTRUCTION; } /** @@ -109,7 +147,7 @@ public String build() { */ private String buildOnTrackInstruction() { - if (hasInstruction) { + if (hasInstruction()) { if (legStep != null) { String relativeDirection = (legStep.relativeDirection.equals("DEPART")) ? "Head " + legStep.absoluteDirection @@ -121,4 +159,39 @@ private String buildOnTrackInstruction() { } return NO_INSTRUCTION; } + + /** + * Build wait for bus instruction. + */ + private String buildWaitForBusInstruction() { + String routeShortName = getRouteShortNameFromLeg(busLeg); + long delayInMinutes = busLeg.departureDelay; + long absoluteMinutes = Math.abs(delayInMinutes); + long waitInMinutes = Duration + .between(currentTime.atZone(DateTimeUtils.getOtpZoneId()), busLeg.getScheduledStartTime()) + .toMinutes(); + String delayInfo = (delayInMinutes > 0) ? "late" : "early"; + String arrivalInfo = (absoluteMinutes <= 1) + ? ", on time" + : String.format(" now%s %s", getReadableMinutes(delayInMinutes), delayInfo); + return String.format( + "Wait%s for your bus, route %s, scheduled at %s%s", + getReadableMinutes(waitInMinutes), + routeShortName, + DateTimeUtils.formatShortDate(Date.from(busLeg.getScheduledStartTime().toInstant()), locale), + arrivalInfo + ); + } + + /** + * Get the number of minutes to wait for a bus. If the wait is zero (or less than zero!) return empty string. + */ + private String getReadableMinutes(long waitInMinutes) { + if (waitInMinutes == 1) { + return String.format(" %s minute", waitInMinutes); + } else if (waitInMinutes > 1) { + return String.format(" %s minutes", waitInMinutes); + } + return ""; + } } diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/TripStatus.java b/src/main/java/org/opentripplanner/middleware/triptracker/TripStatus.java index 8a619a8cd..0f4f60459 100644 --- a/src/main/java/org/opentripplanner/middleware/triptracker/TripStatus.java +++ b/src/main/java/org/opentripplanner/middleware/triptracker/TripStatus.java @@ -62,11 +62,7 @@ public static TripStatus getTripStatus(TravelerPosition travelerPosition) { travelerPosition.legSegmentFromPosition != null && isWithinModeRadius(travelerPosition) ) { - Instant segmentStartTime = travelerPosition - .expectedLeg - .startTime - .toInstant() - .plusSeconds((long) getSegmentStartTime(travelerPosition.legSegmentFromPosition)); + Instant segmentStartTime = getSegmentStartTime(travelerPosition); Instant segmentEndTime = travelerPosition .expectedLeg .startTime @@ -83,10 +79,18 @@ public static TripStatus getTripStatus(TravelerPosition travelerPosition) { return TripStatus.DEVIATED; } - public static double getSegmentStartTime(LegSegment legSegmentFromPosition) { + public static double getLegSegmentStartTime(LegSegment legSegmentFromPosition) { return legSegmentFromPosition.cumulativeTime - legSegmentFromPosition.timeInSegment; } + public static Instant getSegmentStartTime(TravelerPosition travelerPosition) { + return travelerPosition + .expectedLeg + .startTime + .toInstant() + .plusSeconds((long) getLegSegmentStartTime(travelerPosition.legSegmentFromPosition)); + } + /** * Checks if the traveler's position is with an acceptable distance of the mode type. */ diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/TripTrackingData.java b/src/main/java/org/opentripplanner/middleware/triptracker/TripTrackingData.java index 7ac96ffbf..ae03e23b1 100644 --- a/src/main/java/org/opentripplanner/middleware/triptracker/TripTrackingData.java +++ b/src/main/java/org/opentripplanner/middleware/triptracker/TripTrackingData.java @@ -10,9 +10,12 @@ import org.opentripplanner.middleware.triptracker.payload.GeneralPayload; import spark.Request; +import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; import static com.mongodb.client.model.Filters.eq; +import static org.opentripplanner.middleware.utils.DateTimeUtils.convertDateFromSecondsToMillis; import static org.opentripplanner.middleware.utils.JsonUtils.getPOJOFromRequestBody; import static org.opentripplanner.middleware.utils.JsonUtils.logMessageAndHalt; @@ -88,12 +91,29 @@ public static TripTrackingData fromRequestTripId(Request request) { if (payload != null) { var monitoredTrip = Persistence.monitoredTrips.getById(payload.tripId); if (isTripAssociatedWithUser(request, monitoredTrip)) { - return new TripTrackingData(monitoredTrip, getOngoingTrackedJourney(payload.tripId), payload.getLocations()); + return new TripTrackingData( + monitoredTrip, + getOngoingTrackedJourney(payload.tripId), + getTrackingLocations(payload) + ); } } return null; } + /** + * Get the tracking locations and convert the timestamp from seconds (as provided by the mobile app) into + * milliseconds. + */ + private static List getTrackingLocations(GeneralPayload payload) { + List rawLocations = payload.getLocations(); + if (rawLocations == null) rawLocations = new ArrayList<>(); + return rawLocations + .stream() + .map(l -> new TrackingLocation(l.bearing, l.lat, l.lon, l.speed, convertDateFromSecondsToMillis(l.timestamp))) + .collect(Collectors.toList()); + } + /** Obtain trip, journey, and locations from the journey id contained in the request. */ public static TripTrackingData fromRequestJourneyId(Request request) { GeneralPayload payload = getPayloadFromRequest(request); diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/AgencyAction.java b/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/AgencyAction.java new file mode 100644 index 000000000..da9370e8b --- /dev/null +++ b/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/AgencyAction.java @@ -0,0 +1,19 @@ +package org.opentripplanner.middleware.triptracker.interactions.busnotifiers; + +/** Associates an agency to the correct bus notification handler. */ +public class AgencyAction { + + /** Agency id. */ + public String agencyId; + + /** The fully-qualified Java class to execute. */ + public String trigger; + + public AgencyAction() { + } + + public AgencyAction(String agencyId, String trigger) { + this.agencyId = agencyId; + this.trigger = trigger; + } +} diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/BusOperatorActions.java b/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/BusOperatorActions.java new file mode 100644 index 000000000..35502e549 --- /dev/null +++ b/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/BusOperatorActions.java @@ -0,0 +1,109 @@ +package org.opentripplanner.middleware.triptracker.interactions.busnotifiers; + +import com.fasterxml.jackson.databind.JsonNode; +import org.opentripplanner.middleware.triptracker.TravelerPosition; +import org.opentripplanner.middleware.triptracker.TripStatus; +import org.opentripplanner.middleware.utils.JsonUtils; +import org.opentripplanner.middleware.utils.YamlUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import static org.opentripplanner.middleware.utils.ItineraryUtils.getAgencyIdFromLeg; +import static org.opentripplanner.middleware.utils.ItineraryUtils.removeAgencyPrefix; + +/** Holds configured bus notification actions. */ +public class BusOperatorActions { + private static final Logger LOG = LoggerFactory.getLogger(BusOperatorActions.class); + + public static final String BUS_NOTIFIER_ACTIONS_YML = "configurations/default/bus-notifier-actions.yml"; + + private static BusOperatorActions defaultInstance; + + private final List agencyActions; + + public static BusOperatorActions getDefault() { + if (defaultInstance == null) { + try (InputStream stream = new FileInputStream(BUS_NOTIFIER_ACTIONS_YML)) { + JsonNode busNotifierActionsYml = YamlUtils.yamlMapper.readTree(stream); + defaultInstance = new BusOperatorActions( + JsonUtils.getPOJOFromJSONAsList(busNotifierActionsYml, AgencyAction.class) + ); + } catch (IOException e) { + LOG.error("Error parsing bus-notifier-actions.yml", e); + throw new RuntimeException(e); + } + } + return defaultInstance; + } + + public BusOperatorActions(List agencyActions) { + this.agencyActions = agencyActions; + } + + /** + * Get the action that matches the given agency id. + */ + public AgencyAction getAgencyAction(TravelerPosition travelerPosition) { + String agencyId = removeAgencyPrefix(getAgencyIdFromLeg(travelerPosition.nextLeg)); + if (agencyId != null) { + for (AgencyAction agencyAction : agencyActions) { + if (agencyAction.agencyId.equalsIgnoreCase(agencyId)) { + return agencyAction; + } + } + } + return null; + } + + /** + * Get the correct action for agency and send notification. + */ + public void handleSendNotificationAction(TripStatus tripStatus, TravelerPosition travelerPosition) { + AgencyAction action = getAgencyAction(travelerPosition); + if (action != null) { + BusOperatorInteraction interaction = getBusOperatorInteraction(action); + try { + interaction.sendNotification(tripStatus, travelerPosition); + } catch (Exception e) { + LOG.error("Could not trigger class {} for agency {}", action.trigger, action.agencyId, e); + throw new RuntimeException(e); + } + } + } + + /** + * Get the correct action for agency and cancel notification. + */ + public void handleCancelNotificationAction(TravelerPosition travelerPosition) { + AgencyAction action = getAgencyAction(travelerPosition); + if (action != null) { + BusOperatorInteraction interaction = getBusOperatorInteraction(action); + try { + interaction.cancelNotification(travelerPosition); + } catch (Exception e) { + LOG.error("Could not trigger class {} for agency {}", action.trigger, action.agencyId, e); + throw new RuntimeException(e); + } + } + } + + /** + * Get the bus operator class for the correct agency. + */ + private BusOperatorInteraction getBusOperatorInteraction(AgencyAction action) { + BusOperatorInteraction interaction; + try { + Class interactionClass = Class.forName(action.trigger); + interaction = (BusOperatorInteraction) interactionClass.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + LOG.error("Error instantiating class {}", action.trigger, e); + throw new RuntimeException(e); + } + return interaction; + } +} diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/BusOperatorInteraction.java b/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/BusOperatorInteraction.java new file mode 100644 index 000000000..b73d40495 --- /dev/null +++ b/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/BusOperatorInteraction.java @@ -0,0 +1,11 @@ +package org.opentripplanner.middleware.triptracker.interactions.busnotifiers; + +import org.opentripplanner.middleware.triptracker.TravelerPosition; +import org.opentripplanner.middleware.triptracker.TripStatus; + +public interface BusOperatorInteraction { + + void sendNotification(TripStatus tripStatus, TravelerPosition travelerPosition); + + void cancelNotification(TravelerPosition travelerPosition); +} diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/UsRideGwinnettBusOpNotificationMessage.java b/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/UsRideGwinnettBusOpNotificationMessage.java new file mode 100644 index 000000000..1881ad96b --- /dev/null +++ b/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/UsRideGwinnettBusOpNotificationMessage.java @@ -0,0 +1,108 @@ +package org.opentripplanner.middleware.triptracker.interactions.busnotifiers; + +import org.opentripplanner.middleware.triptracker.TravelerPosition; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.opentripplanner.middleware.utils.DateTimeUtils.getOtpZoneId; +import static org.opentripplanner.middleware.utils.ItineraryUtils.getAgencyIdFromLeg; +import static org.opentripplanner.middleware.utils.ItineraryUtils.getRouteIdFromLeg; +import static org.opentripplanner.middleware.utils.ItineraryUtils.getStopIdFromPlace; +import static org.opentripplanner.middleware.utils.ItineraryUtils.getTripIdFromLeg; +import static org.opentripplanner.middleware.utils.ItineraryUtils.removeAgencyPrefix; + +/** + * Class containing the expected notify parameters. These will be converted to JSON and make up the body content of + * the request. + *

+ * 'To' fields omitted as they are not needed for requests for single transit legs. + */ +public class UsRideGwinnettBusOpNotificationMessage { + + public UsRideGwinnettBusOpNotificationMessage() { + // Required for JSON deserialization. + } + + /** This is the date format required by the API. The date/time must be provided in UTC. */ + private static final DateTimeFormatter BUS_OPERATOR_NOTIFIER_API_DATE_FORMAT = DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + + /** This is the time format required by the API. */ + public static final DateTimeFormatter BUS_OPERATOR_NOTIFIER_API_TIME_FORMAT = DateTimeFormatter + .ofPattern("HH:mm:ss") + .withZone(getOtpZoneId()); + + private static final Map MOBILITY_CODES_LOOKUP = createMobilityCodesLookup(); + + /** + * Create a relationship between mobility modes and mobility codes. + */ + private static Map createMobilityCodesLookup() { + HashMap codes = new HashMap<>(); + codes.put("Device", 1); + codes.put("MScooter", 2); + codes.put("WChairE", 3); + codes.put("WChairM", 4); + codes.put("Some", 5); + codes.put("LowVision", 6); + codes.put("Blind", 7); + codes.put("Device-LowVision", 8); + codes.put("MScooter-LowVision", 9); + codes.put("WChairE-LowVision", 10); + codes.put("WChairM-LowVision", 11); + codes.put("Some-LowVision", 12); + codes.put("Device-Blind", 13); + codes.put("MScooter-Blind", 14); + codes.put("WChairE-Blind", 15); + codes.put("WChairM-Blind", 16); + codes.put("Some-Blind", 17); + return codes; + } + + public String timestamp; + public String agency_id; + public String from_route_id; + public String from_trip_id; + public String from_stop_id; + public String to_stop_id; + public String from_arrival_time; + public Integer msg_type; + public List mobility_codes; + public boolean trusted_companion; + + public UsRideGwinnettBusOpNotificationMessage(Instant currentTime, TravelerPosition travelerPosition) { + var nextLeg = travelerPosition.nextLeg; + this.timestamp = BUS_OPERATOR_NOTIFIER_API_DATE_FORMAT.format(currentTime.atZone(ZoneOffset.UTC)); + this.agency_id = removeAgencyPrefix(getAgencyIdFromLeg(nextLeg)); + this.from_route_id = removeAgencyPrefix(getRouteIdFromLeg(nextLeg)); + this.from_trip_id = removeAgencyPrefix(getTripIdFromLeg(nextLeg)); + this.from_stop_id = removeAgencyPrefix(getStopIdFromPlace(nextLeg.from)); + this.to_stop_id = removeAgencyPrefix(getStopIdFromPlace(nextLeg.to)); + this.from_arrival_time = BUS_OPERATOR_NOTIFIER_API_TIME_FORMAT.format( + nextLeg.getScheduledStartTime() + ); + // 1 = Notify, 0 = Cancel. + this.msg_type = 1; + this.mobility_codes = getMobilityCode(travelerPosition.mobilityMode); + this.trusted_companion = false; + } + + /** + * Get the mobility code that matches the mobility mode. The API can accept multiple codes (probably to cover + * multiple travelers at the same stop), but the OTP middleware currently only provides one. + */ + private static List getMobilityCode(String mobilityMode) { + List mobilityCodes = new ArrayList<>(); + Integer code = MOBILITY_CODES_LOOKUP.get(mobilityMode); + if (code != null) { + mobilityCodes.add(code); + } + return mobilityCodes; + } +} diff --git a/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/UsRideGwinnettNotifyBusOperator.java b/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/UsRideGwinnettNotifyBusOperator.java new file mode 100644 index 000000000..319226acc --- /dev/null +++ b/src/main/java/org/opentripplanner/middleware/triptracker/interactions/busnotifiers/UsRideGwinnettNotifyBusOperator.java @@ -0,0 +1,183 @@ +package org.opentripplanner.middleware.triptracker.interactions.busnotifiers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.opentripplanner.middleware.models.TrackedJourney; +import org.opentripplanner.middleware.triptracker.TravelerPosition; +import org.opentripplanner.middleware.triptracker.TripStatus; +import org.opentripplanner.middleware.utils.HttpUtils; +import org.opentripplanner.middleware.utils.JsonUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.opentripplanner.middleware.utils.ConfigUtils.getConfigPropertyAsText; +import static org.opentripplanner.middleware.utils.ItineraryUtils.getRouteIdFromLeg; +import static org.opentripplanner.middleware.utils.ItineraryUtils.isBusLeg; + +/** + * If conditions are correct, notify a bus operator of a traveler waiting to board at a given stop. + */ +public class UsRideGwinnettNotifyBusOperator implements BusOperatorInteraction { + + public static boolean IS_TEST = false; + + private static final Logger LOG = LoggerFactory.getLogger(UsRideGwinnettNotifyBusOperator.class); + + private static final String US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_URL + = getConfigPropertyAsText("US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_URL", "not-provided"); + + private static final String US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_KEY + = getConfigPropertyAsText("US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_KEY", "not-provided"); + + public static List US_RIDE_GWINNETT_QUALIFYING_BUS_NOTIFIER_ROUTES = getBusOperatorNotifierQualifyingRoutes(); + + /** + * Headers that are required for each request. + */ + private static final Map BUS_OPERATOR_NOTIFIER_API_HEADERS = Map.of( + "Ocp-Apim-Subscription-Key", US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_KEY, + "Content-Type", "application/json" + ); + + + /** + * Get the routes which qualify for bus operator notifying from the configuration. The configuration value is + * expected to be a comma separate list of agency id (as provided by OTP not agency) and route id e.g. + *

+ * GwinnettCountyTransit:360,GwinnettCountyTransit:40,GwinnettCountyTransit:25 + */ + private static List getBusOperatorNotifierQualifyingRoutes() { + String busOperatorNotifierQualifyingRoutes + = getConfigPropertyAsText("US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_QUALIFYING_ROUTES"); + if (busOperatorNotifierQualifyingRoutes != null) { + return Arrays.asList(busOperatorNotifierQualifyingRoutes.split(",")); + } + return new ArrayList<>(); + } + + /** + * Stage notification to bus operator by making sure all required conditions are met. + */ + public void sendNotification(TripStatus tripStatus, TravelerPosition travelerPosition) { + var routeId = getRouteIdFromLeg(travelerPosition.nextLeg); + try { + if ( + hasNotSentNotificationForRoute(travelerPosition.trackedJourney, routeId) && + supportsBusOperatorNotification(routeId) + ) { + // Immediately set the notification state to pending, so that subsequent calls don't initiate another + // request before this one completes. + travelerPosition.trackedJourney.updateNotificationMessage(routeId, "pending"); + var body = createPostBody(travelerPosition); + var httpStatus = doPost(body); + if (httpStatus == HttpStatus.OK_200) { + travelerPosition.trackedJourney.updateNotificationMessage(routeId, body); + } else { + LOG.error("Error {} while trying to initiate Ride Gwinnett notification to bus operator.", httpStatus); + } + } + } catch (Exception e) { + LOG.error("Could not initiate Ride Gwinnett notification to bus operator.", e); + } + } + + /** + * Cancel a previously sent notification for the next bus leg. + */ + public void cancelNotification(TravelerPosition travelerPosition) { + var routeId = getRouteIdFromLeg(travelerPosition.nextLeg); + try { + if ( + isBusLeg(travelerPosition.nextLeg) && routeId != null && + hasNotCancelledNotificationForRoute(travelerPosition.trackedJourney, routeId) + ) { + Map busNotificationRequests = travelerPosition.trackedJourney.busNotificationMessages; + if (busNotificationRequests.containsKey(routeId)) { + UsRideGwinnettBusOpNotificationMessage body = JsonUtils.getPOJOFromJSON( + busNotificationRequests.get(routeId), + UsRideGwinnettBusOpNotificationMessage.class + ); + // Changed the saved message type from notify to cancel. + body.msg_type = 0; + var httpStatus = doPost(JsonUtils.toJson(body)); + if (httpStatus == HttpStatus.OK_200) { + travelerPosition.trackedJourney.updateNotificationMessage(routeId, JsonUtils.toJson(body)); + } else { + LOG.error("Error {} while trying to cancel Ride Gwinnett notification to bus operator.", httpStatus); + } + } + } + } catch (Exception e) { + LOG.error("Could not cancel Ride Gwinnett notification to bus operator.", e); + } + } + + /** + * Send notification and provide response. The service only provides the HTTP status as a response. + */ + public static int doPost(String body) { + if (IS_TEST) { + return HttpStatus.OK_200; + } + var httpResponse = HttpUtils.httpRequestRawResponse( + URI.create(US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_URL), + 1000, + HttpMethod.POST, + BUS_OPERATOR_NOTIFIER_API_HEADERS, + body + ); + return httpResponse.status; + } + + /** + * Create post body that will be sent to bus notification API. + */ + public static String createPostBody(TravelerPosition travelerPosition) { + return JsonUtils.toJson(new UsRideGwinnettBusOpNotificationMessage(travelerPosition.currentTime, travelerPosition)); + } + + /** + * Make sure the bus route associated with this leg supports notifying the bus operator. The 'gtfsId' is expected in + * the format agency_id:route_id e.g. GwinnettCountyTransit:360. If no routes are defined it is assumed that all + * routes support notification. + */ + public static boolean supportsBusOperatorNotification(String routeId) { + return + US_RIDE_GWINNETT_QUALIFYING_BUS_NOTIFIER_ROUTES.isEmpty() || + US_RIDE_GWINNETT_QUALIFYING_BUS_NOTIFIER_ROUTES.contains(routeId); + } + + /** + * Has the bus driver already been notified or in the process of being notified for this journey. + * The driver must only be notified once. + */ + public static boolean hasNotSentNotificationForRoute(TrackedJourney trackedJourney, String routeId) { + return !trackedJourney.busNotificationMessages.containsKey(routeId); + } + + /** + * Has a previous notification already been cancelled. + */ + public static boolean hasNotCancelledNotificationForRoute( + TrackedJourney trackedJourney, + String routeId + ) throws JsonProcessingException { + String messageBody = trackedJourney.busNotificationMessages.get(routeId); + if (messageBody == null) { + throw new IllegalStateException("A notification must exist before it can be cancelled!"); + } + UsRideGwinnettBusOpNotificationMessage message = getNotificationMessage(messageBody); + return message.msg_type != 1; + } + + public static UsRideGwinnettBusOpNotificationMessage getNotificationMessage(String body) throws JsonProcessingException { + return JsonUtils.getPOJOFromJSON(body, UsRideGwinnettBusOpNotificationMessage.class); + } +} \ No newline at end of file diff --git a/src/main/java/org/opentripplanner/middleware/utils/DateTimeUtils.java b/src/main/java/org/opentripplanner/middleware/utils/DateTimeUtils.java index c748450a9..fd989ecb9 100644 --- a/src/main/java/org/opentripplanner/middleware/utils/DateTimeUtils.java +++ b/src/main/java/org/opentripplanner/middleware/utils/DateTimeUtils.java @@ -294,4 +294,11 @@ public static List getHoursBetween(LocalDateTime start, LocalDate public static LocalDateTime getPreviousWholeHourFromNow() { return LocalDateTime.now().truncatedTo(ChronoUnit.HOURS).minusHours(1); } + + /** + * Converts a date provided in seconds to a date in milliseconds. + */ + public static Date convertDateFromSecondsToMillis(Date date) { + return Date.from(Instant.ofEpochSecond(date.getTime())); + } } diff --git a/src/main/java/org/opentripplanner/middleware/utils/I18nUtils.java b/src/main/java/org/opentripplanner/middleware/utils/I18nUtils.java index 3e031a168..c5c523844 100644 --- a/src/main/java/org/opentripplanner/middleware/utils/I18nUtils.java +++ b/src/main/java/org/opentripplanner/middleware/utils/I18nUtils.java @@ -1,6 +1,7 @@ package org.opentripplanner.middleware.utils; import org.opentripplanner.middleware.i18n.Message; +import org.opentripplanner.middleware.models.OtpUser; import java.util.Collection; import java.util.Locale; @@ -22,4 +23,12 @@ public static String label(String labelText, String content, Locale locale) { public static String label(String labelText, Locale locale) { return label(labelText, "", locale); } + + /** + * Get the OTP user's locale. + */ + public static Locale getOtpUserLocale(OtpUser user) { + return Locale.forLanguageTag(user == null || user.preferredLocale == null ? "en-US" : user.preferredLocale); + } + } diff --git a/src/main/java/org/opentripplanner/middleware/utils/ItineraryUtils.java b/src/main/java/org/opentripplanner/middleware/utils/ItineraryUtils.java index 7250f795d..b7d669730 100644 --- a/src/main/java/org/opentripplanner/middleware/utils/ItineraryUtils.java +++ b/src/main/java/org/opentripplanner/middleware/utils/ItineraryUtils.java @@ -318,4 +318,54 @@ private static boolean timeOfDayMatches(ZonedDateTime zonedDateTimeA, ZonedDateT zonedDateTimeA.getMinute() == zonedDateTimeB.getMinute() && zonedDateTimeA.getSecond() == zonedDateTimeB.getSecond(); } + + /** + * Make sure the leg in question is a bus transit leg. + */ + public static boolean isBusLeg(Leg leg) { + return leg != null && leg.mode.equalsIgnoreCase("BUS") && leg.transitLeg; + } + + /** + * Get the second element from the OTP id by removing the OTP agency prefix. + * E.g. GwinnettCountyTransit:GCT will return just GCT. + */ + public static String removeAgencyPrefix(String idParts) { + return (idParts != null) ? idParts.split(":")[1] : null; + } + + /** + * Get the route id from leg. + */ + public static String getRouteIdFromLeg(Leg leg) { + return (leg != null) ? leg.routeId : null; + } + + /** + * Get the agency id from leg. + */ + public static String getAgencyIdFromLeg(Leg leg) { + return (leg != null) ? leg.agencyId : null; + } + + /** + * Get the trip id from leg. + */ + public static String getTripIdFromLeg(Leg leg) { + return (leg != null) ? leg.tripId : null; + } + + /** + * Get the stop id from place. + */ + public static String getStopIdFromPlace(Place place) { + return (place != null) ? place.stopId : null; + } + + /** + * Get the route short name from leg. + */ + public static String getRouteShortNameFromLeg(Leg leg) { + return leg.routeShortName; + } } diff --git a/src/main/java/org/opentripplanner/middleware/utils/JsonUtils.java b/src/main/java/org/opentripplanner/middleware/utils/JsonUtils.java index 56da67c75..491c149c6 100644 --- a/src/main/java/org/opentripplanner/middleware/utils/JsonUtils.java +++ b/src/main/java/org/opentripplanner/middleware/utils/JsonUtils.java @@ -1,6 +1,7 @@ package org.opentripplanner.middleware.utils; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -26,7 +27,12 @@ public class JsonUtils { private static final Logger LOG = LoggerFactory.getLogger(JsonUtils.class); - private static final ObjectMapper mapper = new ObjectMapper(); + private static final ObjectMapper mapper; + + static { + mapper = new ObjectMapper(); + mapper.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true); + } /** * Serialize an object into a JSON string representation. diff --git a/src/main/resources/env.schema.json b/src/main/resources/env.schema.json index f3cb43458..e70d7654d 100644 --- a/src/main/resources/env.schema.json +++ b/src/main/resources/env.schema.json @@ -277,6 +277,21 @@ "examples": ["your-auth-token"], "description": "Twilio settings available at: https://twilio.com/user/account" }, + "US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_URL": { + "type": "string", + "examples": ["http://host.example.com"], + "description": "US Ride Gwinnett County bus notifier API." + }, + "US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_KEY": { + "type": "string", + "examples": ["your-api-key"], + "description": "API key for the US Ride Gwinnett County bus notifier API." + }, + "US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_QUALIFYING_ROUTES": { + "type": "string", + "examples": ["agency_id:route_id"], + "description": "A comma separated list of US Ride Gwinnett County routes that can be notified." + }, "VALIDATE_ENVIRONMENT_CONFIG": { "type": "boolean", "examples": ["true"], diff --git a/src/main/resources/latest-spark-swagger-output.yaml b/src/main/resources/latest-spark-swagger-output.yaml index 99a37c317..f3acd91cf 100644 --- a/src/main/resources/latest-spark-swagger-output.yaml +++ b/src/main/resources/latest-spark-swagger-output.yaml @@ -56,10 +56,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/AdminUser" schema: $ref: "#/definitions/AdminUser" + responseSchema: + $ref: "#/definitions/AdminUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -90,10 +90,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/AdminUser" schema: $ref: "#/definitions/AdminUser" + responseSchema: + $ref: "#/definitions/AdminUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -123,10 +123,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/Job" schema: $ref: "#/definitions/Job" + responseSchema: + $ref: "#/definitions/Job" /api/admin/user: get: tags: @@ -155,10 +155,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/ResponseList" schema: $ref: "#/definitions/ResponseList" + responseSchema: + $ref: "#/definitions/ResponseList" post: tags: - "api/admin/user" @@ -178,10 +178,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/AdminUser" schema: $ref: "#/definitions/AdminUser" + responseSchema: + $ref: "#/definitions/AdminUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -220,10 +220,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/AdminUser" schema: $ref: "#/definitions/AdminUser" + responseSchema: + $ref: "#/definitions/AdminUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -269,10 +269,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/AdminUser" schema: $ref: "#/definitions/AdminUser" + responseSchema: + $ref: "#/definitions/AdminUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -309,10 +309,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/AdminUser" schema: $ref: "#/definitions/AdminUser" + responseSchema: + $ref: "#/definitions/AdminUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -356,10 +356,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/ApiUser" schema: $ref: "#/definitions/ApiUser" + responseSchema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -402,10 +402,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/ApiUser" schema: $ref: "#/definitions/ApiUser" + responseSchema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -447,10 +447,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/TokenHolder" schema: $ref: "#/definitions/TokenHolder" + responseSchema: + $ref: "#/definitions/TokenHolder" /api/secure/application/fromtoken: get: tags: @@ -464,10 +464,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/ApiUser" schema: $ref: "#/definitions/ApiUser" + responseSchema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -498,10 +498,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/ApiUser" schema: $ref: "#/definitions/ApiUser" + responseSchema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -531,10 +531,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/Job" schema: $ref: "#/definitions/Job" + responseSchema: + $ref: "#/definitions/Job" /api/secure/application: get: tags: @@ -563,10 +563,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/ResponseList" schema: $ref: "#/definitions/ResponseList" + responseSchema: + $ref: "#/definitions/ResponseList" post: tags: - "api/secure/application" @@ -586,10 +586,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/ApiUser" schema: $ref: "#/definitions/ApiUser" + responseSchema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -628,10 +628,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/ApiUser" schema: $ref: "#/definitions/ApiUser" + responseSchema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -677,10 +677,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/ApiUser" schema: $ref: "#/definitions/ApiUser" + responseSchema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -717,10 +717,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/ApiUser" schema: $ref: "#/definitions/ApiUser" + responseSchema: + $ref: "#/definitions/ApiUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -754,10 +754,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/CDPUser" schema: $ref: "#/definitions/CDPUser" + responseSchema: + $ref: "#/definitions/CDPUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -788,10 +788,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/CDPUser" schema: $ref: "#/definitions/CDPUser" + responseSchema: + $ref: "#/definitions/CDPUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -821,10 +821,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/Job" schema: $ref: "#/definitions/Job" + responseSchema: + $ref: "#/definitions/Job" /api/secure/cdp: get: tags: @@ -853,10 +853,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/ResponseList" schema: $ref: "#/definitions/ResponseList" + responseSchema: + $ref: "#/definitions/ResponseList" post: tags: - "api/secure/cdp" @@ -876,10 +876,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/CDPUser" schema: $ref: "#/definitions/CDPUser" + responseSchema: + $ref: "#/definitions/CDPUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -918,10 +918,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/CDPUser" schema: $ref: "#/definitions/CDPUser" + responseSchema: + $ref: "#/definitions/CDPUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -967,10 +967,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/CDPUser" schema: $ref: "#/definitions/CDPUser" + responseSchema: + $ref: "#/definitions/CDPUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1007,10 +1007,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/CDPUser" schema: $ref: "#/definitions/CDPUser" + responseSchema: + $ref: "#/definitions/CDPUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1050,10 +1050,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/ItineraryExistence" schema: $ref: "#/definitions/ItineraryExistence" + responseSchema: + $ref: "#/definitions/ItineraryExistence" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1102,10 +1102,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/ResponseList" schema: $ref: "#/definitions/ResponseList" + responseSchema: + $ref: "#/definitions/ResponseList" post: tags: - "api/secure/monitoredtrip" @@ -1125,10 +1125,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/MonitoredTrip" schema: $ref: "#/definitions/MonitoredTrip" + responseSchema: + $ref: "#/definitions/MonitoredTrip" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1167,10 +1167,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/MonitoredTrip" schema: $ref: "#/definitions/MonitoredTrip" + responseSchema: + $ref: "#/definitions/MonitoredTrip" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1216,10 +1216,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/MonitoredTrip" schema: $ref: "#/definitions/MonitoredTrip" + responseSchema: + $ref: "#/definitions/MonitoredTrip" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1257,10 +1257,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/MonitoredTrip" schema: $ref: "#/definitions/MonitoredTrip" + responseSchema: + $ref: "#/definitions/MonitoredTrip" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1298,10 +1298,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/TrackingResponse" schema: $ref: "#/definitions/TrackingResponse" + responseSchema: + $ref: "#/definitions/TrackingResponse" /api/secure/monitoredtrip/updatetracking: post: tags: @@ -1319,10 +1319,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/TrackingResponse" schema: $ref: "#/definitions/TrackingResponse" + responseSchema: + $ref: "#/definitions/TrackingResponse" /api/secure/monitoredtrip/track: post: tags: @@ -1340,10 +1340,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/TrackingResponse" schema: $ref: "#/definitions/TrackingResponse" + responseSchema: + $ref: "#/definitions/TrackingResponse" /api/secure/monitoredtrip/endtracking: post: tags: @@ -1361,10 +1361,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/EndTrackingResponse" schema: $ref: "#/definitions/EndTrackingResponse" + responseSchema: + $ref: "#/definitions/EndTrackingResponse" /api/secure/monitoredtrip/forciblyendtracking: post: tags: @@ -1382,10 +1382,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/EndTrackingResponse" schema: $ref: "#/definitions/EndTrackingResponse" + responseSchema: + $ref: "#/definitions/EndTrackingResponse" /api/secure/triprequests: get: tags: @@ -1430,10 +1430,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/TripRequest" schema: $ref: "#/definitions/TripRequest" + responseSchema: + $ref: "#/definitions/TripRequest" /api/secure/monitoredcomponent: get: tags: @@ -1462,10 +1462,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/ResponseList" schema: $ref: "#/definitions/ResponseList" + responseSchema: + $ref: "#/definitions/ResponseList" post: tags: - "api/secure/monitoredcomponent" @@ -1485,10 +1485,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/MonitoredComponent" schema: $ref: "#/definitions/MonitoredComponent" + responseSchema: + $ref: "#/definitions/MonitoredComponent" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1527,10 +1527,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/MonitoredComponent" schema: $ref: "#/definitions/MonitoredComponent" + responseSchema: + $ref: "#/definitions/MonitoredComponent" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1576,10 +1576,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/MonitoredComponent" schema: $ref: "#/definitions/MonitoredComponent" + responseSchema: + $ref: "#/definitions/MonitoredComponent" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1617,10 +1617,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/MonitoredComponent" schema: $ref: "#/definitions/MonitoredComponent" + responseSchema: + $ref: "#/definitions/MonitoredComponent" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1660,10 +1660,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/VerificationResult" schema: $ref: "#/definitions/VerificationResult" + responseSchema: + $ref: "#/definitions/VerificationResult" /api/secure/user/{id}/verify_sms/{code}: post: tags: @@ -1683,10 +1683,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/VerificationResult" schema: $ref: "#/definitions/VerificationResult" + responseSchema: + $ref: "#/definitions/VerificationResult" /api/secure/user/fromtoken: get: tags: @@ -1700,10 +1700,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/OtpUser" schema: $ref: "#/definitions/OtpUser" + responseSchema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1734,10 +1734,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/OtpUser" schema: $ref: "#/definitions/OtpUser" + responseSchema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1767,10 +1767,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/Job" schema: $ref: "#/definitions/Job" + responseSchema: + $ref: "#/definitions/Job" /api/secure/user: get: tags: @@ -1799,10 +1799,10 @@ paths: responses: "200": description: "successful operation" - responseSchema: - $ref: "#/definitions/ResponseList" schema: $ref: "#/definitions/ResponseList" + responseSchema: + $ref: "#/definitions/ResponseList" post: tags: - "api/secure/user" @@ -1822,10 +1822,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/OtpUser" schema: $ref: "#/definitions/OtpUser" + responseSchema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1864,10 +1864,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/OtpUser" schema: $ref: "#/definitions/OtpUser" + responseSchema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1913,10 +1913,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/OtpUser" schema: $ref: "#/definitions/OtpUser" + responseSchema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -1953,10 +1953,10 @@ paths: "200": description: "Successful operation" examples: {} - responseSchema: - $ref: "#/definitions/OtpUser" schema: $ref: "#/definitions/OtpUser" + responseSchema: + $ref: "#/definitions/OtpUser" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -2010,11 +2010,11 @@ paths: responses: "200": description: "successful operation" - responseSchema: + schema: type: "array" items: $ref: "#/definitions/ApiUsageResult" - schema: + responseSchema: type: "array" items: $ref: "#/definitions/ApiUsageResult" @@ -2041,11 +2041,11 @@ paths: responses: "200": description: "successful operation" - responseSchema: + schema: type: "array" items: $ref: "#/definitions/BugsnagEvent" - schema: + responseSchema: type: "array" items: $ref: "#/definitions/BugsnagEvent" @@ -2072,11 +2072,11 @@ paths: responses: "200": description: "successful operation" - responseSchema: + schema: type: "array" items: $ref: "#/definitions/CDPFile" - schema: + responseSchema: type: "array" items: $ref: "#/definitions/CDPFile" @@ -2097,11 +2097,11 @@ paths: responses: "200": description: "successful operation" - responseSchema: + schema: type: "array" items: $ref: "#/definitions/URL" - schema: + responseSchema: type: "array" items: $ref: "#/definitions/URL" @@ -2398,8 +2398,6 @@ definitions: type: "boolean" mode: type: "string" - route: - type: "string" interlineWithPreviousLeg: type: "boolean" from: diff --git a/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java b/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java index acd1be155..2cd0c8303 100644 --- a/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java +++ b/src/test/java/org/opentripplanner/middleware/controllers/api/TrackedTripControllerTest.java @@ -26,8 +26,10 @@ import org.opentripplanner.middleware.triptracker.ManageTripTracking; import org.opentripplanner.middleware.triptracker.TrackingLocation; import org.opentripplanner.middleware.triptracker.TripStatus; +import org.opentripplanner.middleware.triptracker.TripTrackingData; import org.opentripplanner.middleware.triptracker.payload.EndTrackingPayload; import org.opentripplanner.middleware.triptracker.payload.ForceEndTrackingPayload; +import org.opentripplanner.middleware.triptracker.payload.GeneralPayload; import org.opentripplanner.middleware.triptracker.payload.StartTrackingPayload; import org.opentripplanner.middleware.triptracker.payload.TrackPayload; import org.opentripplanner.middleware.triptracker.payload.UpdatedTrackingPayload; @@ -388,21 +390,17 @@ private StartTrackingPayload createStartTrackingPayload() { } private StartTrackingPayload createStartTrackingPayload(String monitorTripId) { - return createStartTrackingPayload(monitorTripId, 24.1111111111111, -79.2222222222222, new Date().getTime()); - } - - private StartTrackingPayload createStartTrackingPayload(String monitorTripId, double lat, double lon, long timestamp) { var payload = new StartTrackingPayload(); payload.tripId = monitorTripId; - payload.location = new TrackingLocation(90, lat, lon, 29, new Date(timestamp)); + payload.location = new TrackingLocation(90, 24.1111111111111, -79.2222222222222, 29, getDateAndConvertToSeconds()); return payload; } private static List createTrackingLocations() { return List.of( - new TrackingLocation(90, 24.1111111111111, -79.2222222222222, 29, new Date()), - new TrackingLocation(90, 28.5398938204469, -81.3772773742676, 30, new Date()), - new TrackingLocation(90, 29.5398938204469, -80.3772773742676, 31, new Date()) + new TrackingLocation(90, 24.1111111111111, -79.2222222222222, 29, getDateAndConvertToSeconds()), + new TrackingLocation(90, 28.5398938204469, -81.3772773742676, 30, getDateAndConvertToSeconds()), + new TrackingLocation(90, 29.5398938204469, -80.3772773742676, 31, getDateAndConvertToSeconds()) ); } @@ -421,7 +419,7 @@ private TrackPayload createTrackPayload(List locations) { } private TrackPayload createTrackPayload(Coordinates coords) { - return createTrackPayload(List.of(new TrackingLocation(Instant.now(), coords.lat, coords.lon))); + return createTrackPayload(List.of(new TrackingLocation(getDateAndConvertToSeconds(), coords.lat, coords.lon))); } private EndTrackingPayload createEndTrackingPayload(String journeyId) { @@ -435,4 +433,12 @@ private ForceEndTrackingPayload createForceEndTrackingPayload(String monitorTrip payload.tripId = monitorTripId; return payload; } + + /** + * The mobile app sends timestamps in seconds which is then converted into milliseconds in {@link TripTrackingData}. + * To represent this in testing, provide the time in seconds from epoch. + */ + private static Date getDateAndConvertToSeconds() { + return new Date(new Date().getTime() / 1000); + } } diff --git a/src/test/java/org/opentripplanner/middleware/models/MobilityProfileTest.java b/src/test/java/org/opentripplanner/middleware/models/MobilityProfileTest.java index 5fe9bfbaf..ea9c095ee 100644 --- a/src/test/java/org/opentripplanner/middleware/models/MobilityProfileTest.java +++ b/src/test/java/org/opentripplanner/middleware/models/MobilityProfileTest.java @@ -15,7 +15,7 @@ public class MobilityProfileTest { // The mobility modes tested are tightly coupled with algorithms in the // Georgia Tech Mobility Profile Configuration / Logical Flow document, as - // implemented in the MobilityPorfile#updateMobilityMode() method. Changes + // implemented in the MobilityProfile#updateMobilityMode() method. Changes // to that document must be reflected in that method and in these tests. private static Stream provideModes() { diff --git a/src/test/java/org/opentripplanner/middleware/controllers/api/ManageLegTraversalTest.java b/src/test/java/org/opentripplanner/middleware/triptracker/ManageLegTraversalTest.java similarity index 95% rename from src/test/java/org/opentripplanner/middleware/controllers/api/ManageLegTraversalTest.java rename to src/test/java/org/opentripplanner/middleware/triptracker/ManageLegTraversalTest.java index e9d27a2d7..f17ac54cf 100644 --- a/src/test/java/org/opentripplanner/middleware/controllers/api/ManageLegTraversalTest.java +++ b/src/test/java/org/opentripplanner/middleware/triptracker/ManageLegTraversalTest.java @@ -1,4 +1,4 @@ -package org.opentripplanner.middleware.controllers.api; +package org.opentripplanner.middleware.triptracker; import io.leonard.PolylineUtils; import io.leonard.Position; @@ -7,6 +7,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.opentripplanner.middleware.auth.RequestingUser; +import org.opentripplanner.middleware.models.OtpUser; import org.opentripplanner.middleware.models.TrackedJourney; import org.opentripplanner.middleware.otp.response.Itinerary; import org.opentripplanner.middleware.otp.response.Leg; @@ -18,6 +20,7 @@ import org.opentripplanner.middleware.triptracker.TravelerPosition; import org.opentripplanner.middleware.triptracker.TravelerLocator; import org.opentripplanner.middleware.triptracker.TripStatus; +import org.opentripplanner.middleware.utils.ConfigUtils; import org.opentripplanner.middleware.utils.Coordinates; import org.opentripplanner.middleware.utils.DateTimeUtils; import org.opentripplanner.middleware.utils.JsonUtils; @@ -28,6 +31,7 @@ import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.stream.Stream; @@ -48,8 +52,13 @@ public class ManageLegTraversalTest { private static Itinerary adairAvenueToMonroeDriveItinerary; + private static final Locale locale = Locale.US; + @BeforeAll public static void setUp() throws IOException { + // Load default env.yml configuration. + ConfigUtils.loadConfig(new String[]{}); + busStopToJusticeCenterItinerary = JsonUtils.getPOJOFromJSON( CommonTestUtils.getTestResourceAsString("controllers/api/bus-stop-justice-center-trip.json"), Itinerary.class @@ -70,7 +79,7 @@ void canTrackTrip(Instant instant, double lat, double lon, TripStatus expected, TrackedJourney trackedJourney = new TrackedJourney(); TrackingLocation trackingLocation = new TrackingLocation(instant, lat, lon); trackedJourney.locations = List.of(trackingLocation); - TravelerPosition travelerPosition = new TravelerPosition(trackedJourney, busStopToJusticeCenterItinerary); + TravelerPosition travelerPosition = new TravelerPosition(trackedJourney, busStopToJusticeCenterItinerary, new OtpUser()); TripStatus tripStatus = TripStatus.getTripStatus(travelerPosition); assertEquals(expected, tripStatus, message); } @@ -183,7 +192,7 @@ private static Stream createTurnByTurnTrace() { Arguments.of( new TurnTrace( originCoords, - new TripInstruction(10, adairAvenueNortheastStep).build(), + new TripInstruction(10, adairAvenueNortheastStep, locale).build(), true, "Just started the trip and near to the instruction for the first step. " ) @@ -191,7 +200,7 @@ private static Stream createTurnByTurnTrace() { Arguments.of( new TurnTrace( originCoords, - new TripInstruction(10, adairAvenueNortheastStep).build(), + new TripInstruction(10, adairAvenueNortheastStep, locale).build(), false, "Coming up on first instruction." ) @@ -199,7 +208,7 @@ private static Stream createTurnByTurnTrace() { Arguments.of( new TurnTrace( adairAvenueNortheastCoords, - new TripInstruction(2, adairAvenueNortheastStep).build(), + new TripInstruction(2, adairAvenueNortheastStep, locale).build(), false, "On first instruction." ) @@ -208,7 +217,7 @@ private static Stream createTurnByTurnTrace() { new TurnTrace( TripStatus.DEVIATED, createPoint(adairAvenueNortheastCoords, 12, NORTH_WEST_BEARING), - new TripInstruction(adairAvenueNortheastStep.streetName).build(), + new TripInstruction(adairAvenueNortheastStep.streetName, locale).build(), false, "Deviated to the north of east to west path. Suggest path to head towards." ) @@ -217,7 +226,7 @@ private static Stream createTurnByTurnTrace() { new TurnTrace( TripStatus.DEVIATED, createPoint(adairAvenueNortheastCoords, 12, SOUTH_WEST_BEARING), - new TripInstruction(adairAvenueNortheastStep.streetName).build(), + new TripInstruction(adairAvenueNortheastStep.streetName, locale).build(), false, "Deviated to the south of east to west path. Suggest path to head towards." ) @@ -234,7 +243,7 @@ private static Stream createTurnByTurnTrace() { new TurnTrace( TripStatus.DEVIATED, createPoint(virginiaCircleNortheastCoords, 8, NORTH_BEARING), - new TripInstruction(9, virginiaCircleNortheastStep).build(), + new TripInstruction(9, virginiaCircleNortheastStep, locale).build(), false, "Deviated from path, but within the upcoming radius of second instruction." ) @@ -242,7 +251,7 @@ private static Stream createTurnByTurnTrace() { Arguments.of( new TurnTrace( virginiaCircleNortheastCoords, - new TripInstruction(0, virginiaCircleNortheastStep).build(), + new TripInstruction(0, virginiaCircleNortheastStep, locale).build(), false, "On second instruction." ) @@ -251,7 +260,7 @@ private static Stream createTurnByTurnTrace() { new TurnTrace( TripStatus.DEVIATED, createPoint(ponceDeLeonPlaceNortheastCoords, 8, NORTH_WEST_BEARING), - new TripInstruction(10, ponceDeLeonPlaceNortheastStep).build(), + new TripInstruction(10, ponceDeLeonPlaceNortheastStep, locale).build(), false, "Deviated to the west of south to north path. Suggest path to head towards." ) @@ -260,7 +269,7 @@ private static Stream createTurnByTurnTrace() { new TurnTrace( TripStatus.DEVIATED, createPoint(ponceDeLeonPlaceNortheastCoords, 8, NORTH_EAST_BEARING), - new TripInstruction(10, ponceDeLeonPlaceNortheastStep).build(), + new TripInstruction(10, ponceDeLeonPlaceNortheastStep, locale).build(), false, "Deviated to the east of south to north path. Suggest path to head towards." ) @@ -268,7 +277,7 @@ private static Stream createTurnByTurnTrace() { Arguments.of( new TurnTrace( createPoint(pointBeforeTurn, 8, calculateBearing(pointBeforeTurn, virginiaAvenuePoint)), - new TripInstruction(10, virginiaAvenueNortheastStep).build(), + new TripInstruction(10, virginiaAvenueNortheastStep, locale).build(), false, "Approaching left turn on Virginia Avenue (Test to make sure turn is not missed)." ) @@ -276,7 +285,7 @@ private static Stream createTurnByTurnTrace() { Arguments.of( new TurnTrace( createPoint(pointBeforeTurn, 17, calculateBearing(pointBeforeTurn, virginiaAvenuePoint)), - new TripInstruction(2, virginiaAvenueNortheastStep).build(), + new TripInstruction(2, virginiaAvenueNortheastStep, locale).build(), false, "Turn left on to Virginia Avenue (Test to make sure turn is not missed)." ) @@ -292,7 +301,7 @@ private static Stream createTurnByTurnTrace() { Arguments.of( new TurnTrace( createPoint(destinationCoords, 8, SOUTH_BEARING), - new TripInstruction(10, destinationName).build(), + new TripInstruction(10, destinationName, locale).build(), false, "Coming up on destination instruction." ) @@ -300,7 +309,7 @@ private static Stream createTurnByTurnTrace() { Arguments.of( new TurnTrace( destinationCoords, - new TripInstruction(2, destinationName).build(), + new TripInstruction(2, destinationName, locale).build(), false, "On destination instruction." ) @@ -371,9 +380,8 @@ void canTrackLegWithoutDeviating() { new Date(currentTime.toInstant().toEpochMilli()) ) ); - TravelerPosition travelerPosition = new TravelerPosition(trackedJourney, busStopToJusticeCenterItinerary); + TravelerPosition travelerPosition = new TravelerPosition(trackedJourney, busStopToJusticeCenterItinerary, null); TripStatus tripStatus = TripStatus.getTripStatus(travelerPosition); - System.out.println(tripStatus.name()); assertEquals(TripStatus.ON_SCHEDULE.name(), tripStatus.name()); cumulativeTravelTime += legSegment.timeInSegment; currentTime = startOfTrip.plus( diff --git a/src/test/java/org/opentripplanner/middleware/triptracker/NotifyBusOperatorTest.java b/src/test/java/org/opentripplanner/middleware/triptracker/NotifyBusOperatorTest.java new file mode 100644 index 000000000..87456f799 --- /dev/null +++ b/src/test/java/org/opentripplanner/middleware/triptracker/NotifyBusOperatorTest.java @@ -0,0 +1,185 @@ +package org.opentripplanner.middleware.triptracker; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.opentripplanner.middleware.models.MobilityProfile; +import org.opentripplanner.middleware.models.OtpUser; +import org.opentripplanner.middleware.models.TrackedJourney; +import org.opentripplanner.middleware.otp.response.Itinerary; +import org.opentripplanner.middleware.otp.response.Leg; +import org.opentripplanner.middleware.persistence.Persistence; +import org.opentripplanner.middleware.testutils.CommonTestUtils; +import org.opentripplanner.middleware.testutils.OtpMiddlewareTestEnvironment; +import org.opentripplanner.middleware.triptracker.interactions.busnotifiers.AgencyAction; +import org.opentripplanner.middleware.triptracker.interactions.busnotifiers.BusOperatorActions; +import org.opentripplanner.middleware.triptracker.interactions.busnotifiers.UsRideGwinnettBusOpNotificationMessage; +import org.opentripplanner.middleware.triptracker.interactions.busnotifiers.UsRideGwinnettNotifyBusOperator; +import org.opentripplanner.middleware.utils.Coordinates; +import org.opentripplanner.middleware.utils.JsonUtils; + +import java.io.IOException; +import java.sql.Date; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.opentripplanner.middleware.triptracker.TravelerLocator.ACCEPTABLE_AHEAD_OF_SCHEDULE_IN_MINUTES; +import static org.opentripplanner.middleware.triptracker.TravelerLocator.getBusDepartureTime; +import static org.opentripplanner.middleware.triptracker.interactions.busnotifiers.UsRideGwinnettNotifyBusOperator.getNotificationMessage; + +class NotifyBusOperatorTest extends OtpMiddlewareTestEnvironment { + + private static Itinerary walkToBusTransition; + + private static TrackedJourney trackedJourney; + + private static final String routeId = "GwinnettCountyTransit:40"; + + private static final Locale locale = Locale.US; + + private final BusOperatorActions busOperatorActions = new BusOperatorActions(List.of( + new AgencyAction("GCT", UsRideGwinnettNotifyBusOperator.class.getName()) + )); + + @BeforeAll + public static void setUp() throws IOException { + // This itinerary is from OTP2 and has been modified to work with OTP1 to avoid breaking changes. + walkToBusTransition = JsonUtils.getPOJOFromJSON( + CommonTestUtils.getTestResourceAsString("controllers/api/walk-to-bus-transition.json"), + Itinerary.class + ); + UsRideGwinnettNotifyBusOperator.IS_TEST = true; + UsRideGwinnettNotifyBusOperator.US_RIDE_GWINNETT_QUALIFYING_BUS_NOTIFIER_ROUTES = List.of(routeId); + } + + @AfterEach + public void tearDown() { + if (trackedJourney != null) { + trackedJourney.delete(); + } + } + + @Test + void canNotifyBusOperatorForScheduledDeparture() { + Leg busLeg = walkToBusTransition.legs.get(1); + Instant busDepartureTime = getBusDepartureTime(busLeg); + trackedJourney = createAndPersistTrackedJourney(getEndOfWalkLegCoordinates(), busDepartureTime); + TravelerPosition travelerPosition = new TravelerPosition(trackedJourney, walkToBusTransition, createOtpUser()); + String tripInstruction = TravelerLocator.getInstruction(TripStatus.ON_SCHEDULE, travelerPosition, false); + TripInstruction expectInstruction = new TripInstruction(busLeg, busDepartureTime, locale); + TrackedJourney updated = Persistence.trackedJourneys.getById(trackedJourney.id); + assertTrue(updated.busNotificationMessages.containsKey(routeId)); + assertEquals(expectInstruction.build(), tripInstruction); + } + + @Test + void canNotifyBusOperatorForDelayedDeparture() throws CloneNotSupportedException { + // Copy itinerary so changes can be made to it without impacting other tests. + Itinerary itinerary = walkToBusTransition.clone(); + itinerary.legs.get(1).departureDelay = 10; + + Leg walkLeg = itinerary.legs.get(0); + Instant timeAtEndOfWalkLeg = walkLeg.endTime.toInstant(); + timeAtEndOfWalkLeg = timeAtEndOfWalkLeg.minusSeconds(120); + + trackedJourney = createAndPersistTrackedJourney(getEndOfWalkLegCoordinates(), timeAtEndOfWalkLeg); + TravelerPosition travelerPosition = new TravelerPosition(trackedJourney, itinerary, createOtpUser()); + String tripInstruction = TravelerLocator.getInstruction(TripStatus.ON_SCHEDULE, travelerPosition, false); + + Leg busLeg = itinerary.legs.get(1); + TripInstruction expectInstruction = new TripInstruction(busLeg, timeAtEndOfWalkLeg, locale); + assertEquals(expectInstruction.build(), tripInstruction); + } + + @Test + void canCancelBusOperatorNotification() throws JsonProcessingException { + trackedJourney = createAndPersistTrackedJourney(getEndOfWalkLegCoordinates()); + TravelerPosition travelerPosition = new TravelerPosition(trackedJourney, walkToBusTransition, createOtpUser()); + busOperatorActions.handleSendNotificationAction(TripStatus.ON_SCHEDULE, travelerPosition); + TrackedJourney updated = Persistence.trackedJourneys.getById(trackedJourney.id); + assertTrue(updated.busNotificationMessages.containsKey(routeId)); + busOperatorActions.handleCancelNotificationAction(travelerPosition); + updated = Persistence.trackedJourneys.getById(trackedJourney.id); + String messageBody = updated.busNotificationMessages.get(routeId); + UsRideGwinnettBusOpNotificationMessage message = getNotificationMessage(messageBody); + assertEquals(1, message.msg_type); + } + + @Test + void canNotifyBusOperatorOnlyOnce() { + trackedJourney = createAndPersistTrackedJourney(getEndOfWalkLegCoordinates()); + TravelerPosition travelerPosition = new TravelerPosition(trackedJourney, walkToBusTransition, createOtpUser()); + busOperatorActions.handleSendNotificationAction(TripStatus.ON_SCHEDULE, travelerPosition); + TrackedJourney updated = Persistence.trackedJourneys.getById(trackedJourney.id); + assertTrue(updated.busNotificationMessages.containsKey(routeId)); + assertFalse(UsRideGwinnettNotifyBusOperator.hasNotSentNotificationForRoute(trackedJourney, routeId)); + } + + @ParameterizedTest + @MethodSource("createWithinOperationalNotifyWindowTrace") + void isWithinOperationalNotifyWindow(boolean expected, TravelerPosition travelerPosition,String message) { + assertEquals(expected, TravelerLocator.isWithinOperationalNotifyWindow(travelerPosition), message); + } + + private static Stream createWithinOperationalNotifyWindowTrace() { + var busLeg = walkToBusTransition.legs.get(1); + var busDepartureTime = getBusDepartureTime(busLeg); + + return Stream.of( + Arguments.of( + true, + new TravelerPosition(busLeg, busDepartureTime), + "Traveler is on schedule, notification can be sent." + ), + Arguments.of( + false, + new TravelerPosition(busLeg, busDepartureTime.plusSeconds(60)), + "Traveler is behind schedule, notification can not be sent." + ), + Arguments.of( + true, + new TravelerPosition(busLeg, busDepartureTime.minusSeconds(60)), + "Traveler is ahead of schedule, but within the notify window." + ), + Arguments.of(false, + new TravelerPosition( + busLeg, + busDepartureTime.plusSeconds((ACCEPTABLE_AHEAD_OF_SCHEDULE_IN_MINUTES + 1) * 60) + ), + "Too far ahead of schedule to notify bus operator.") + ); + } + + private static OtpUser createOtpUser() { + MobilityProfile mobilityProfile = new MobilityProfile(); + mobilityProfile.mobilityMode = "WChairE"; + OtpUser otpUser = new OtpUser(); + otpUser.mobilityProfile = mobilityProfile; + return otpUser; + } + + private static TrackedJourney createAndPersistTrackedJourney(Coordinates legToCoords) { + return createAndPersistTrackedJourney(legToCoords, Instant.now()); + } + + private static TrackedJourney createAndPersistTrackedJourney(Coordinates legToCoords, Instant dateTime) { + trackedJourney = new TrackedJourney(); + trackedJourney.locations.add(new TrackingLocation(legToCoords.lat, legToCoords.lon, Date.from(dateTime))); + Persistence.trackedJourneys.create(trackedJourney); + return trackedJourney; + } + + private static Coordinates getEndOfWalkLegCoordinates() { + Leg walkLeg = walkToBusTransition.legs.get(0); + return new Coordinates(walkLeg.to); + } +} \ No newline at end of file diff --git a/src/test/resources/org/opentripplanner/middleware/controllers/api/walk-to-bus-transition.json b/src/test/resources/org/opentripplanner/middleware/controllers/api/walk-to-bus-transition.json new file mode 100644 index 000000000..c5038100e --- /dev/null +++ b/src/test/resources/org/opentripplanner/middleware/controllers/api/walk-to-bus-transition.json @@ -0,0 +1,391 @@ +{ + "accessibilityScore": null, + "duration": 2137, + "endTime": 1715348772000, + "legs": [ + { + "accessibilityScore": null, + "agency": null, + "alerts": [], + "arrivalDelay": 0, + "departureDelay": 0, + "distance": 1922.85, + "dropoffType": "SCHEDULED", + "duration": 1871, + "endTime": 1715348506000, + "fareProducts": [], + "from": { + "lat": 33.9567218, + "lon": -83.9829405, + "name": "Private Residence (Northeast)", + "rentalVehicle": null, + "stop": null, + "vertexType": "NORMAL" + }, + "headsign": null, + "interlineWithPreviousLeg": false, + "intermediateStops": null, + "legGeometry": { + "length": 129, + "points": "gdgnEj|q_O@fABpA@j@?D@T?L?L@f@@R?LBv@?F?F@dA@PDV@@?BAD?BAZAXA??@@B@F@D?DFvB?D?D?L?L?F@D?@?\\?`@?D?F@J@?H?H?F?Bj@FdBDj@?H?H@L?B?FD`BFvA?J?D@F@XAB?@@\\?F?D@b@@F?DB`AFlB?D?H?J@DDdADfA?B?D@PJxBJfBJt@VjA^dABJFJR`@d@n@n@p@FDFFpBjBNB^\\DDNLh@d@CFNDJJHFJHBBD?H?B?FRxBzBPFXVTRDJ~@r@RXl@n@bAbAt@~@FHHJJNt@fABB@Bb@l@BBJRT^`AtB@?" + }, + "mode": "WALK", + "pickupBookingInfo": null, + "pickupType": "SCHEDULED", + "realTime": false, + "realtimeState": null, + "rentedBike": false, + "rideHailingEstimate": null, + "route": null, + "startTime": 1715346635000, + "steps": [ + { + "absoluteDirection": "WEST", + "alerts": [], + "area": false, + "distance": 405.01, + "elevationProfile": [], + "lat": 33.9566839, + "lon": -83.9829385, + "relativeDirection": "DEPART", + "stayOn": false, + "streetName": "sidewalk 634432391 (634432391, 11177444159→11488394695) r0.125 l34.840 tmp r0.936 l32.593" + }, + { + "absoluteDirection": "SOUTH", + "alerts": [], + "area": false, + "distance": 17.43, + "elevationProfile": [], + "lat": 33.9564868, + "lon": -83.9873097, + "relativeDirection": "LEFT", + "stayOn": true, + "streetName": "sidewalk 1229990743 (1229990743, 5987163859→11406239363) ☑" + }, + { + "absoluteDirection": "WEST", + "alerts": [], + "area": false, + "distance": 926.13, + "elevationProfile": [], + "lat": 33.9563302, + "lon": -83.9873003, + "relativeDirection": "RIGHT", + "stayOn": true, + "streetName": "sidewalk 634418500 (634418500, 10052651843→5987155585) ☑" + }, + { + "absoluteDirection": "SOUTHWEST", + "alerts": [], + "area": false, + "distance": 26.49, + "elevationProfile": [], + "lat": 33.9536642, + "lon": -83.9961217, + "relativeDirection": "SLIGHTLY_RIGHT", + "stayOn": false, + "streetName": "crossing over Langley Drive (1201573779, 11140140288→11140140287) r0.335 l8.864" + }, + { + "absoluteDirection": "SOUTH", + "alerts": [], + "area": false, + "distance": 10.9, + "elevationProfile": [], + "lat": 33.9534736, + "lon": -83.9962939, + "relativeDirection": "SLIGHTLY_LEFT", + "stayOn": false, + "streetName": "crossing over turn lane (1201573778, 11142713418→11140140285) r0.754 l8.197" + }, + { + "absoluteDirection": "SOUTHWEST", + "alerts": [], + "area": false, + "distance": 109.69, + "elevationProfile": [], + "lat": 33.9533756, + "lon": -83.9962951, + "relativeDirection": "RIGHT", + "stayOn": false, + "streetName": "sidewalk 1201723672 (1201723672, 7423585497→11141711246) ☑" + }, + { + "absoluteDirection": "SOUTHWEST", + "alerts": [], + "area": false, + "distance": 33.51, + "elevationProfile": [], + "lat": 33.9526332, + "lon": -83.9970512, + "relativeDirection": "SLIGHTLY_RIGHT", + "stayOn": false, + "streetName": "crossing over service road (1201723673, 11141711246→10242855762) r0.544 l18.219" + }, + { + "absoluteDirection": "SOUTHWEST", + "alerts": [], + "area": false, + "distance": 297.38, + "elevationProfile": [], + "lat": 33.9523934, + "lon": -83.9972712, + "relativeDirection": "CONTINUE", + "stayOn": false, + "streetName": "sidewalk 1201723674 (1201723674, 11141711247→11141711257) r0.524 l100.031" + }, + { + "absoluteDirection": "SOUTHWEST", + "alerts": [], + "area": false, + "distance": 30.19, + "elevationProfile": [], + "lat": 33.9504509, + "lon": -83.9994645, + "relativeDirection": "CONTINUE", + "stayOn": false, + "streetName": "crossing over Gwinnett Drive (1201573770, 11140140268→7417511007) r0.371 l11.196" + }, + { + "absoluteDirection": "SOUTHWEST", + "alerts": [], + "area": false, + "distance": 66.11, + "elevationProfile": [], + "lat": 33.9502891, + "lon": -83.9997262, + "relativeDirection": "CONTINUE", + "stayOn": false, + "streetName": "sidewalk 1206016159 (1206016159, 7417511008→11140099970) ☑ split r0.316 l66.107" + } + ], + "to": { + "lat": 33.949949, + "lon": -84.000314, + "name": "Gwinnett Central High School IB", + "rentalVehicle": null, + "stop": { + "alerts": [], + "code": "360", + "gtfsId": "GwinnettCountyTransit:360", + "id": "U3RvcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6MzYw" + }, + "vertexType": "TRANSIT" + }, + "transitLeg": false, + "trip": null + }, + { + "accessibilityScore": null, + "agencyId": "GwinnettCountyTransit:GCT", + "agency": { + "alerts": [ + { + "alertDescriptionText": "Expect Delays - Starting on January 22, Atlanta Gas Light will be replacing natural gas pipelines along Buford Highway in Norcross and Peachtree Corners.\n", + "alertHeaderText": "Expect Delays - Starting on January 22, Atlanta Gas Light will be replacing natural gas pipelines along Buford Highway in Norcross and Peachtree Corners.\n", + "alertUrl": null, + "effectiveStartDate": 1705881600, + "id": "QWxlcnQ6R3dpbm5ldHRDb3VudHlUcmFuc2l0OjI1MzA" + }, + { + "alertDescriptionText": "This work may result in delays on routes 10A/10B, 20, and 35. Work will take place from 7PM to 5AM Saturday through Friday each week. The final phase of replacement work is expected to begin in late summer 2024.", + "alertHeaderText": "This work may result in delays on routes 10A/10B, 20, and 35. Work will take place from 7PM to 5AM Saturday through Friday each week. The final phase of replacement work is expected to begin in late summer 2024.", + "alertUrl": null, + "effectiveStartDate": 1705881600, + "id": "QWxlcnQ6R3dpbm5ldHRDb3VudHlUcmFuc2l0OjI1MzE" + }, + { + "alertDescriptionText": "Delay in Service - Due to mechanical issues, the route 35 outbound from Peachtree Pkwy at the Forum at 8:20am and inbound from Doraville Marta Station at 9:25am is delayed. We apologize for the inconvenience.", + "alertHeaderText": "Delay in Service - Due to mechanical issues, the route 35 outbound from Peachtree Pkwy at the Forum at 8:20am and inbound from Doraville Marta Station at 9:25am is delayed. We apologize for the inconvenience.", + "alertUrl": null, + "effectiveStartDate": 1715299200, + "id": "QWxlcnQ6R3dpbm5ldHRDb3VudHlUcmFuc2l0OjI4MDQ" + }, + { + "alertDescriptionText": "Delay in Service - Due to mechanical issues, the route 35 outbound from Peachtree Parkway and the Forum at 8:20apm and inbound from Doraville Marta Station at 9:25am is delayed. We apologize for the inconvenience.", + "alertHeaderText": "Delay in Service - Due to mechanical issues, the route 35 outbound from Peachtree Parkway and the Forum at 8:20apm and inbound from Doraville Marta Station at 9:25am is delayed. We apologize for the inconvenience.", + "alertUrl": null, + "effectiveStartDate": 1715299200, + "id": "QWxlcnQ6R3dpbm5ldHRDb3VudHlUcmFuc2l0OjI4MDU" + }, + { + "alertDescriptionText": "Attention Route 50 Riders: The following outbound bus stops are not active on Route 50. \n-\tWoodward Crossing Blvd & The Complex – Stop 5051\n-\tMall of GA Blvd & Nature Pkwy OB – Stop 5054", + "alertHeaderText": "Attention Route 50 Riders: The following outbound bus stops are not active on Route 50. \n-\tWoodward Crossing Blvd & The Complex – Stop 5051\n-\tMall of GA Blvd & Nature Pkwy OB – Stop 5054", + "alertUrl": null, + "effectiveStartDate": 1707350400, + "id": "QWxlcnQ6R3dpbm5ldHRDb3VudHlUcmFuc2l0OjI1ODk" + }, + { + "alertDescriptionText": "Alert – Due to a rehabilitation program at Indian Creek MARTA Station, bus bay closures will be in effect starting April 3, to June 5, 2024. All Ride Gwinnett Route 70 buses will be re-routed. Please follow MARTA signage for the temporary pick-up point.", + "alertHeaderText": "Alert – Due to a rehabilitation program at Indian Creek MARTA Station, bus bay closures will be in effect starting April 3, to June 5, 2024. All Ride Gwinnett Route 70 buses will be re-routed. Please follow MARTA signage for the temporary pick-up point.", + "alertUrl": null, + "effectiveStartDate": 1712016000, + "id": "QWxlcnQ6R3dpbm5ldHRDb3VudHlUcmFuc2l0OjI3MDc" + } + ], + "gtfsId": "GwinnettCountyTransit:GCT", + "id": "GwinnettCountyTransit:GCT", + "name": "Gwinnett County Transit", + "timezone": "America/New_York", + "url": "https://www.ridegwinnett.com/" + }, + "alerts": [ + { + "alertDescriptionText": "Alert – Due to a rehabilitation program at Indian Creek MARTA Station, bus bay closures will be in effect starting April 3, to June 5, 2024. All Ride Gwinnett Route 70 buses will be re-routed. Please follow MARTA signage for the temporary pick-up point.", + "alertHeaderText": "Alert – Due to a rehabilitation program at Indian Creek MARTA Station, bus bay closures will be in effect starting April 3, to June 5, 2024. All Ride Gwinnett Route 70 buses will be re-routed. Please follow MARTA signage for the temporary pick-up point.", + "alertUrl": null, + "effectiveStartDate": 1712016000, + "id": "QWxlcnQ6R3dpbm5ldHRDb3VudHlUcmFuc2l0OjI3MDc" + }, + { + "alertDescriptionText": "Expect Delays - Starting on January 22, Atlanta Gas Light will be replacing natural gas pipelines along Buford Highway in Norcross and Peachtree Corners.\n", + "alertHeaderText": "Expect Delays - Starting on January 22, Atlanta Gas Light will be replacing natural gas pipelines along Buford Highway in Norcross and Peachtree Corners.\n", + "alertUrl": null, + "effectiveStartDate": 1705881600, + "id": "QWxlcnQ6R3dpbm5ldHRDb3VudHlUcmFuc2l0OjI1MzA" + }, + { + "alertDescriptionText": "This work may result in delays on routes 10A/10B, 20, and 35. Work will take place from 7PM to 5AM Saturday through Friday each week. The final phase of replacement work is expected to begin in late summer 2024.", + "alertHeaderText": "This work may result in delays on routes 10A/10B, 20, and 35. Work will take place from 7PM to 5AM Saturday through Friday each week. The final phase of replacement work is expected to begin in late summer 2024.", + "alertUrl": null, + "effectiveStartDate": 1705881600, + "id": "QWxlcnQ6R3dpbm5ldHRDb3VudHlUcmFuc2l0OjI1MzE" + }, + { + "alertDescriptionText": "Attention Route 50 Riders: The following outbound bus stops are not active on Route 50. \n-\tWoodward Crossing Blvd & The Complex – Stop 5051\n-\tMall of GA Blvd & Nature Pkwy OB – Stop 5054", + "alertHeaderText": "Attention Route 50 Riders: The following outbound bus stops are not active on Route 50. \n-\tWoodward Crossing Blvd & The Complex – Stop 5051\n-\tMall of GA Blvd & Nature Pkwy OB – Stop 5054", + "alertUrl": null, + "effectiveStartDate": 1707350400, + "id": "QWxlcnQ6R3dpbm5ldHRDb3VudHlUcmFuc2l0OjI1ODk" + } + ], + "arrivalDelay": 0, + "departureDelay": 0, + "distance": 1998.77, + "dropoffType": "SCHEDULED", + "duration": 219, + "endTime": 1715348725000, + "from": { + "lat": 33.949949, + "lon": -84.000314, + "name": "Gwinnett Central High School IB", + "rentalVehicle": null, + "stopId": "GwinnettCountyTransit:U3RvcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6MzYw", + "stop": { + "alerts": [], + "code": "360", + "gtfsId": "GwinnettCountyTransit:360", + "id": "U3RvcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6MzYw" + }, + "vertexType": "TRANSIT" + }, + "headsign": "Transit Center", + "interlineWithPreviousLeg": false, + "intermediateStops": [ + { + "lat": 33.948037, + "locationType": "STOP", + "lon": -83.99822, + "name": "Gwinnett Dr & HS Football Field", + "stopCode": "355", + "stopId": "U3RvcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6MzU1" + }, + { + "lat": 33.946601, + "locationType": "STOP", + "lon": -83.997147, + "name": "Gwinnett Dr & Police Security Bldg", + "stopCode": "354", + "stopId": "U3RvcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6MzU0" + }, + { + "lat": 33.943999, + "locationType": "STOP", + "lon": -83.993521, + "name": "Gwinnett Dr & Stone Mountain St", + "stopCode": "358", + "stopId": "U3RvcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6MzU4" + }, + { + "lat": 33.943243, + "locationType": "STOP", + "lon": -83.992318, + "name": "Gwinnett Dr & Bridgestone Tire", + "stopCode": "350", + "stopId": "U3RvcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6MzUw" + }, + { + "lat": 33.941479, + "locationType": "STOP", + "lon": -83.990338, + "name": "Gwinnett Dr & PNC Bank", + "stopCode": "356", + "stopId": "U3RvcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6MzU2" + } + ], + "legGeometry": { + "length": 31, + "points": "szenEjiu_OACO[Wg@s@uAVMlE{A|FsD????zGwE@???~EwD`HwO@A??tCqF??DGrCaGnAsA|Bu@??nAg@hAiAz@oBN}@OiIp@cGGsD" + }, + "mode": "BUS", + "pickupBookingInfo": null, + "pickupType": "SCHEDULED", + "realTime": true, + "realtimeState": null, + "rentedBike": null, + "rideHailingEstimate": null, + "routeId": "GwinnettCountyTransit:40", + "routeShortName": "40", + "route": { + "alerts": [], + "color": "AB0634", + "gtfsId": "GwinnettCountyTransit:40", + "id": "GwinnettCountyTransit:40", + "longName": "Lawrenceville - GTC - Sugarloaf Mills", + "shortName": "40", + "textColor": "FFFFFF", + "type": 3 + }, + "startTime": 1715348506000, + "steps": [], + "to": { + "lat": 33.940172, + "lon": -83.984931, + "name": "Gwinnett Dr & Kroger Shopping IB", + "rentalVehicle": null, + "stop": { + "alerts": [], + "code": "353", + "gtfsId": "GwinnettCountyTransit:353", + "id": "U3RvcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6MzUz" + }, + "vertexType": "TRANSIT" + }, + "transitLeg": true, + "tripId": "GwinnettCountyTransit:VHJpcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6dDNBNC1iMTMwLXNsNg", + "trip": { + "arrivalStoptime": { + "stop": { + "gtfsId": "GwinnettCountyTransit:3", + "id": "U3RvcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6Mw" + }, + "stopPosition": 3480 + }, + "departureStoptime": { + "stop": { + "gtfsId": "GwinnettCountyTransit:16", + "id": "U3RvcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6MTY" + }, + "stopPosition": 0 + }, + "gtfsId": "GwinnettCountyTransit:t3A4-b130-sl6", + "id": "VHJpcDpHd2lubmV0dENvdW50eVRyYW5zaXQ6dDNBNC1iMTMwLXNsNg" + } + } + ], + "startTime": 1715346635000, + "transfers": 0, + "waitingTime": 0, + "walkTime": 1918 +} \ No newline at end of file